diff --git a/ROMFS/px4fmu_common/init.d-posix/airframes/1011_gazebo-classic_iris_irlock b/ROMFS/px4fmu_common/init.d-posix/airframes/1011_gazebo-classic_iris_irlock index 2f064e2c5ff0..29776d8f9d33 100644 --- a/ROMFS/px4fmu_common/init.d-posix/airframes/1011_gazebo-classic_iris_irlock +++ b/ROMFS/px4fmu_common/init.d-posix/airframes/1011_gazebo-classic_iris_irlock @@ -35,4 +35,7 @@ param set-default PLD_HACC_RAD 0.1 param set-default RTL_PLD_MD 2 # Start up Landing Target Estimator module -landing_target_estimator start +if param compare VTE_EN 0 +then + landing_target_estimator start +fi diff --git a/ROMFS/px4fmu_common/init.d/rc.mc_apps b/ROMFS/px4fmu_common/init.d/rc.mc_apps index 24a3f81ed705..e826cacf791b 100644 --- a/ROMFS/px4fmu_common/init.d/rc.mc_apps +++ b/ROMFS/px4fmu_common/init.d/rc.mc_apps @@ -25,6 +25,15 @@ then mc_autotune_attitude_control start fi +# +# Start Vision Target Estimator. +# + +if param greater -s VTE_EN 0 +then + vision_target_estimator start +fi + # # Start Multicopter Position Controller. # diff --git a/ROMFS/px4fmu_common/init.d/rc.vtol_apps b/ROMFS/px4fmu_common/init.d/rc.vtol_apps index 16fb6ee5d660..2efa29709d9d 100644 --- a/ROMFS/px4fmu_common/init.d/rc.vtol_apps +++ b/ROMFS/px4fmu_common/init.d/rc.vtol_apps @@ -25,6 +25,15 @@ then mc_autotune_attitude_control start fi +# +# Start Vision Target Estimator. +# + +if param greater -s VTE_EN 0 +then + vision_target_estimator start +fi + fw_rate_control start vtol fw_att_control start vtol fw_mode_manager start diff --git a/boards/px4/fmu-v6c/visionTargetEst.px4board b/boards/px4/fmu-v6c/visionTargetEst.px4board new file mode 100644 index 000000000000..1031765cb533 --- /dev/null +++ b/boards/px4/fmu-v6c/visionTargetEst.px4board @@ -0,0 +1,95 @@ +CONFIG_BOARD_TOOLCHAIN="arm-none-eabi" +CONFIG_BOARD_ARCHITECTURE="cortex-m7" +CONFIG_BOARD_SERIAL_GPS1="/dev/ttyS0" +CONFIG_BOARD_SERIAL_GPS2="/dev/ttyS6" +CONFIG_BOARD_SERIAL_TEL1="/dev/ttyS5" +CONFIG_BOARD_SERIAL_TEL2="/dev/ttyS3" +CONFIG_BOARD_SERIAL_TEL3="/dev/ttyS1" +CONFIG_DRIVERS_ADC_BOARD_ADC=y +CONFIG_DRIVERS_BAROMETER_MS5611=y +CONFIG_DRIVERS_BATT_SMBUS=y +CONFIG_DRIVERS_CAMERA_CAPTURE=y +CONFIG_DRIVERS_CAMERA_TRIGGER=y +CONFIG_COMMON_DIFFERENTIAL_PRESSURE=y +CONFIG_COMMON_DISTANCE_SENSOR=y +CONFIG_DRIVERS_DSHOT=y +CONFIG_DRIVERS_GPS=y +CONFIG_DRIVERS_HEATER=y +CONFIG_COMMON_INS=n +CONFIG_COMMON_LIGHT=y +CONFIG_COMMON_MAGNETOMETER=y +CONFIG_COMMON_OPTICAL_FLOW=y +CONFIG_DRIVERS_POWER_MONITOR_INA226=y +CONFIG_DRIVERS_POWER_MONITOR_INA228=y +CONFIG_DRIVERS_POWER_MONITOR_INA238=y +CONFIG_DRIVERS_PWM_OUT=y +CONFIG_DRIVERS_PX4IO=y +CONFIG_COMMON_TELEMETRY=y +CONFIG_DRIVERS_TONE_ALARM=y +CONFIG_DRIVERS_UAVCAN=y +CONFIG_BOARD_UAVCAN_TIMER_OVERRIDE=2 +CONFIG_MODULES_AIRSPEED_SELECTOR=y +CONFIG_MODULES_BATTERY_STATUS=y +CONFIG_MODULES_COMMANDER=y +CONFIG_MODULES_CONTROL_ALLOCATOR=y +CONFIG_MODULES_DATAMAN=y +CONFIG_MODULES_EKF2=y +CONFIG_MODULES_ESC_BATTERY=y +CONFIG_MODULES_EVENTS=y +CONFIG_MODULES_FLIGHT_MODE_MANAGER=y +CONFIG_MODULES_FW_ATT_CONTROL=y +CONFIG_MODULES_FW_AUTOTUNE_ATTITUDE_CONTROL=y +CONFIG_MODULES_FW_POS_CONTROL=y +CONFIG_MODULES_FW_RATE_CONTROL=y +CONFIG_MODULES_GYRO_CALIBRATION=y +CONFIG_MODULES_GYRO_FFT=y +CONFIG_MODULES_LAND_DETECTOR=y +CONFIG_MODULES_LOAD_MON=y +CONFIG_MODULES_LOGGER=y +CONFIG_MODULES_MAG_BIAS_ESTIMATOR=y +CONFIG_MODULES_MANUAL_CONTROL=y +CONFIG_MODULES_MAVLINK=y +CONFIG_MODULES_MC_ATT_CONTROL=y +CONFIG_MODULES_MC_AUTOTUNE_ATTITUDE_CONTROL=y +CONFIG_MODULES_MC_HOVER_THRUST_ESTIMATOR=y +CONFIG_MODULES_MC_POS_CONTROL=y +CONFIG_MODULES_MC_RATE_CONTROL=y +CONFIG_MODULES_NAVIGATOR=y +CONFIG_MODULES_RC_UPDATE=y +CONFIG_MODULES_SENSORS=y +CONFIG_MODULES_SIMULATION_SIMULATOR_SIH=y +CONFIG_MODULES_TEMPERATURE_COMPENSATION=y +CONFIG_MODULES_VTOL_ATT_CONTROL=y +CONFIG_SYSTEMCMDS_ACTUATOR_TEST=y +CONFIG_SYSTEMCMDS_BSONDUMP=y +CONFIG_SYSTEMCMDS_DMESG=y +CONFIG_SYSTEMCMDS_HARDFAULT_LOG=y +CONFIG_SYSTEMCMDS_I2CDETECT=y +CONFIG_SYSTEMCMDS_LED_CONTROL=y +CONFIG_SYSTEMCMDS_MFT=y +CONFIG_SYSTEMCMDS_MTD=y +CONFIG_SYSTEMCMDS_NSHTERM=y +CONFIG_SYSTEMCMDS_PARAM=y +CONFIG_SYSTEMCMDS_PERF=y +CONFIG_SYSTEMCMDS_REBOOT=y +CONFIG_SYSTEMCMDS_SD_BENCH=y +CONFIG_SYSTEMCMDS_SYSTEM_TIME=y +CONFIG_SYSTEMCMDS_TOP=y +CONFIG_SYSTEMCMDS_TOPIC_LISTENER=y +CONFIG_SYSTEMCMDS_TUNE_CONTROL=y +CONFIG_SYSTEMCMDS_UORB=y +CONFIG_SYSTEMCMDS_VER=y +CONFIG_SYSTEMCMDS_WORK_QUEUE=y + +CONFIG_MAVLINK_DIALECT="development" +CONFIG_MODULES_VISION_TARGET_ESTIMATOR=y +CONFIG_MODULES_LANDING_TARGET_ESTIMATOR=n +CONFIG_MODULES_ROVER_POS_CONTROL=n +CONFIG_MODULES_CAMERA_FEEDBACK=n +CONFIG_MODULES_GIMBAL=n +CONFIG_MODULES_UXRCE_DDS_CLIENT=y +CONFIG_UXRCE_DDS_CLIENT_SERIAL=n +CONFIG_MODULES_SIMULATION_SIMULATOR_SIH=n +CONFIG_DRIVERS_GNSS_SEPTENTRIO=n +CONFIG_DRIVERS_IMU_BOSCH_BMI055=n +CONFIG_DRIVERS_IMU_INVENSENSE_ICM42688P=y diff --git a/boards/px4/sitl/default.px4board b/boards/px4/sitl/default.px4board index e2a3996ca364..3e2edf7ea26c 100644 --- a/boards/px4/sitl/default.px4board +++ b/boards/px4/sitl/default.px4board @@ -28,7 +28,7 @@ CONFIG_MODULES_GIMBAL=y CONFIG_MODULES_GYRO_CALIBRATION=y CONFIG_MODULES_GYRO_FFT=y CONFIG_MODULES_LAND_DETECTOR=y -CONFIG_MODULES_LANDING_TARGET_ESTIMATOR=y +CONFIG_MODULES_LANDING_TARGET_ESTIMATOR=n CONFIG_MODULES_LOAD_MON=y CONFIG_MODULES_LOCAL_POSITION_ESTIMATOR=y CONFIG_MODULES_LOGGER=y @@ -59,6 +59,7 @@ CONFIG_MODULES_TEMPERATURE_COMPENSATION=y CONFIG_MODULES_UUV_ATT_CONTROL=y CONFIG_MODULES_UUV_POS_CONTROL=y CONFIG_MODULES_UXRCE_DDS_CLIENT=y +CONFIG_MODULES_VISION_TARGET_ESTIMATOR=y CONFIG_MODULES_VTOL_ATT_CONTROL=y CONFIG_SYSTEMCMDS_ACTUATOR_TEST=y CONFIG_SYSTEMCMDS_BSONDUMP=y @@ -84,3 +85,4 @@ CONFIG_EXAMPLES_PX4_MAVLINK_DEBUG=y CONFIG_EXAMPLES_PX4_SIMPLE_APP=y CONFIG_EXAMPLES_WORK_ITEM=y CONFIG_MODULES_SPACECRAFT=n +CONFIG_MAVLINK_DIALECT="development" diff --git a/docs/assets/vision_target_estimator/VtestAllObservations.png b/docs/assets/vision_target_estimator/VtestAllObservations.png new file mode 100644 index 000000000000..6724c710d4bd Binary files /dev/null and b/docs/assets/vision_target_estimator/VtestAllObservations.png differ diff --git a/docs/assets/vision_target_estimator/VtestInnovations.png b/docs/assets/vision_target_estimator/VtestInnovations.png new file mode 100644 index 000000000000..1cac7ea2e8be Binary files /dev/null and b/docs/assets/vision_target_estimator/VtestInnovations.png differ diff --git a/docs/assets/vision_target_estimator/VtestMeasNotFused.png b/docs/assets/vision_target_estimator/VtestMeasNotFused.png new file mode 100644 index 000000000000..50c5d5b76631 Binary files /dev/null and b/docs/assets/vision_target_estimator/VtestMeasNotFused.png differ diff --git a/docs/assets/vision_target_estimator/VtestOrientation.png b/docs/assets/vision_target_estimator/VtestOrientation.png new file mode 100644 index 000000000000..0973e02934d9 Binary files /dev/null and b/docs/assets/vision_target_estimator/VtestOrientation.png differ diff --git a/docs/assets/vision_target_estimator/VtestPrecland.png b/docs/assets/vision_target_estimator/VtestPrecland.png new file mode 100644 index 000000000000..66587acdf0ab Binary files /dev/null and b/docs/assets/vision_target_estimator/VtestPrecland.png differ diff --git a/docs/en/SUMMARY.md b/docs/en/SUMMARY.md index b90c2635a238..ead812fbbe91 100644 --- a/docs/en/SUMMARY.md +++ b/docs/en/SUMMARY.md @@ -20,6 +20,8 @@ - [Offboard Mode (MC)](flight_modes_mc/offboard.md) - [Collision Prevention](computer_vision/collision_prevention.md) - [Precision Landing](advanced_features/precland.md) + - [Vision Target Estimator](advanced_features/vision_target_estimator.md) + - [Vision Target Estimator Deep Dive](advanced_features/vision_target_estimator_advanced.md) - [Terrain Following/Holding](flying/terrain_following_holding.md) - [Throw Launch](flight_modes_mc/throw_launch.md) - [Assembly](assembly/assembly_mc.md) diff --git a/docs/en/advanced_config/parameter_reference.md b/docs/en/advanced_config/parameter_reference.md index a6ed64793742..d1c6f1b407a5 100644 --- a/docs/en/advanced_config/parameter_reference.md +++ b/docs/en/advanced_config/parameter_reference.md @@ -1851,7 +1851,6 @@ PCA9685 Output Channel 6 Output Function. Select what should be output on PCA9685 Output Channel 6. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -1925,7 +1924,6 @@ PCA9685 Output Channel 7 Output Function. Select what should be output on PCA9685 Output Channel 7. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -1998,6 +1996,76 @@ PCA9685 Output Channel 8 Output Function. Select what should be output on PCA9685 Output Channel 8. +The default failsafe value is set according to the selected function: +- 'Min' for ConstantMin +- 'Max' for ConstantMax +- 'Max' for Parachute +- ('Max'+'Min')/2 for Servos +- 'Disarmed' for the rest + +**Values:** + +- `0`: Disabled +- `1`: Constant Min +- `2`: Constant Max +- `101`: Motor 1 +- `102`: Motor 2 +- `103`: Motor 3 +- `104`: Motor 4 +- `105`: Motor 5 +- `106`: Motor 6 +- `107`: Motor 7 +- `108`: Motor 8 +- `109`: Motor 9 +- `110`: Motor 10 +- `111`: Motor 11 +- `112`: Motor 12 +- `201`: Servo 1 +- `202`: Servo 2 +- `203`: Servo 3 +- `204`: Servo 4 +- `205`: Servo 5 +- `206`: Servo 6 +- `207`: Servo 7 +- `208`: Servo 8 +- `301`: Peripheral via Actuator Set 1 +- `302`: Peripheral via Actuator Set 2 +- `303`: Peripheral via Actuator Set 3 +- `304`: Peripheral via Actuator Set 4 +- `305`: Peripheral via Actuator Set 5 +- `306`: Peripheral via Actuator Set 6 +- `400`: Landing Gear +- `401`: Parachute +- `402`: RC Roll +- `403`: RC Pitch +- `404`: RC Throttle +- `405`: RC Yaw +- `406`: RC Flaps +- `407`: RC AUX 1 +- `408`: RC AUX 2 +- `409`: RC AUX 3 +- `410`: RC AUX 4 +- `411`: RC AUX 5 +- `412`: RC AUX 6 +- `420`: Gimbal Roll +- `421`: Gimbal Pitch +- `422`: Gimbal Yaw +- `430`: Gripper +- `440`: Landing Gear Wheel +- `450`: IC Engine Ignition +- `451`: IC Engine Throttle +- `452`: IC Engine Choke +- `453`: IC Engine Starter + +| Reboot | minValue | maxValue | increment | default | unit | +| ------ | -------- | -------- | --------- | ------- | ---- | +|   | | | | 0 | + +### PCA9685_FUNC9 (`INT32`) {#PCA9685_FUNC9} + +PCA9685 Output Channel 9 Output Function. + +Select what should be output on PCA9685 Output Channel 9. The default failsafe value is set according to the selected function: - 'Min' for ConstantMin @@ -2060,6 +2128,76 @@ The default failsafe value is set according to the selected function: - `452`: IC Engine Choke - `453`: IC Engine Starter +| Reboot | minValue | maxValue | increment | default | unit | +| ------ | -------- | -------- | --------- | ------- | ---- | +|   | | | | 0 | + +### PCA9685_FUNC9 (`INT32`) {#PCA9685_FUNC9} + +PCA9685 Output Channel 9 Output Function. + +Select what should be output on PCA9685 Output Channel 9. +The default failsafe value is set according to the selected function: +- 'Min' for ConstantMin +- 'Max' for ConstantMax +- 'Max' for Parachute +- ('Max'+'Min')/2 for Servos +- 'Disarmed' for the rest + +**Values:** + +- `0`: Disabled +- `1`: Constant Min +- `2`: Constant Max +- `101`: Motor 1 +- `102`: Motor 2 +- `103`: Motor 3 +- `104`: Motor 4 +- `105`: Motor 5 +- `106`: Motor 6 +- `107`: Motor 7 +- `108`: Motor 8 +- `109`: Motor 9 +- `110`: Motor 10 +- `111`: Motor 11 +- `112`: Motor 12 +- `201`: Servo 1 +- `202`: Servo 2 +- `203`: Servo 3 +- `204`: Servo 4 +- `205`: Servo 5 +- `206`: Servo 6 +- `207`: Servo 7 +- `208`: Servo 8 +- `301`: Peripheral via Actuator Set 1 +- `302`: Peripheral via Actuator Set 2 +- `303`: Peripheral via Actuator Set 3 +- `304`: Peripheral via Actuator Set 4 +- `305`: Peripheral via Actuator Set 5 +- `306`: Peripheral via Actuator Set 6 +- `400`: Landing Gear +- `401`: Parachute +- `402`: RC Roll +- `403`: RC Pitch +- `404`: RC Throttle +- `405`: RC Yaw +- `406`: RC Flaps +- `407`: RC AUX 1 +- `408`: RC AUX 2 +- `409`: RC AUX 3 +- `410`: RC AUX 4 +- `411`: RC AUX 5 +- `412`: RC AUX 6 +- `420`: Gimbal Roll +- `421`: Gimbal Pitch +- `422`: Gimbal Yaw +- `430`: Gripper +- `440`: Landing Gear Wheel +- `450`: IC Engine Ignition +- `451`: IC Engine Throttle +- `452`: IC Engine Choke +- `453`: IC Engine Starter + | Reboot | minValue | maxValue | increment | default | unit | | ------ | -------- | -------- | --------- | ------- | ---- | |   | | | | 0 | | @@ -3750,7 +3888,6 @@ PWM Aux 8 Output Function. Select what should be output on PWM Aux 8. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -3828,7 +3965,6 @@ PWM Capture 1 Output Function. Select what should be output on PWM Capture 1. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -8959,7 +9095,6 @@ When set to -1 (default), the value depends on the function (see SIM_GZ_WH_FUNC4 SIM_GZ Wheels 1 Output Function. Select what should be output on SIM_GZ Wheels 1. - The default failsafe value is set according to the selected function: - 'Min' for ConstantMin @@ -9033,7 +9168,6 @@ The default failsafe value is set according to the selected function: SIM_GZ Wheels 2 Output Function. Select what should be output on SIM_GZ Wheels 2. - The default failsafe value is set according to the selected function: - 'Min' for ConstantMin @@ -9109,7 +9243,6 @@ SIM_GZ Wheels 3 Output Function. Select what should be output on SIM_GZ Wheels 3. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -9889,7 +10022,6 @@ The default failsafe value is set according to the selected function: TAP ESC Output ESC 8 Output Function. Select what should be output on TAP ESC Output ESC 8. - The default failsafe value is set according to the selected function: - 'Min' for ConstantMin @@ -9952,6 +10084,71 @@ The default failsafe value is set according to the selected function: - `452`: IC Engine Choke - `453`: IC Engine Starter +| Reboot | minValue | maxValue | increment | default | unit | +| ------ | -------- | -------- | --------- | ------- | ---- | +|   | | | | 0 | + +### UAVCAN_EC_FUNC2 (`INT32`) {#UAVCAN_EC_FUNC2} + +UAVCAN ESC 2 Output Function. + +Select what should be output on UAVCAN ESC 2. +The default failsafe value is set according to the selected function: + +**Values:** + +- `0`: Disabled +- `1`: Constant Min +- `2`: Constant Max +- `101`: Motor 1 +- `102`: Motor 2 +- `103`: Motor 3 +- `104`: Motor 4 +- `105`: Motor 5 +- `106`: Motor 6 +- `107`: Motor 7 +- `108`: Motor 8 +- `109`: Motor 9 +- `110`: Motor 10 +- `111`: Motor 11 +- `112`: Motor 12 +- `201`: Servo 1 +- `202`: Servo 2 +- `203`: Servo 3 +- `204`: Servo 4 +- `205`: Servo 5 +- `206`: Servo 6 +- `207`: Servo 7 +- `208`: Servo 8 +- `301`: Peripheral via Actuator Set 1 +- `302`: Peripheral via Actuator Set 2 +- `303`: Peripheral via Actuator Set 3 +- `304`: Peripheral via Actuator Set 4 +- `305`: Peripheral via Actuator Set 5 +- `306`: Peripheral via Actuator Set 6 +- `400`: Landing Gear +- `401`: Parachute +- `402`: RC Roll +- `403`: RC Pitch +- `404`: RC Throttle +- `405`: RC Yaw +- `406`: RC Flaps +- `407`: RC AUX 1 +- `408`: RC AUX 2 +- `409`: RC AUX 3 +- `410`: RC AUX 4 +- `411`: RC AUX 5 +- `412`: RC AUX 6 +- `420`: Gimbal Roll +- `421`: Gimbal Pitch +- `422`: Gimbal Yaw +- `430`: Gripper +- `440`: Landing Gear Wheel +- `450`: IC Engine Ignition +- `451`: IC Engine Throttle +- `452`: IC Engine Choke +- `453`: IC Engine Starter + | Reboot | minValue | maxValue | increment | default | unit | | ------ | -------- | -------- | --------- | ------- | ---- | |   | | | | 0 | | @@ -10675,7 +10872,6 @@ UAVCAN ESC 5 Output Function. Select what should be output on UAVCAN ESC 5. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -10749,7 +10945,6 @@ UAVCAN ESC 6 Output Function. Select what should be output on UAVCAN ESC 6. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -10823,7 +11018,6 @@ UAVCAN ESC 7 Output Function. Select what should be output on UAVCAN ESC 7. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -10971,7 +11165,6 @@ UAVCAN ESC 9 Output Function. Select what should be output on UAVCAN ESC 9. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -12103,7 +12296,6 @@ UAVCAN Servo 8 Output Function. Select what should be output on UAVCAN Servo 8. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -12987,7 +13179,6 @@ UAVCANv1 ESC 14 Output Function. Select what should be output on UAVCANv1 ESC 14. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -13505,7 +13696,6 @@ UAVCANv1 ESC 6 Output Function. Select what should be output on UAVCANv1 ESC 6. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -13579,7 +13769,6 @@ UAVCANv1 ESC 7 Output Function. Select what should be output on UAVCANv1 ESC 7. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -13653,7 +13842,6 @@ UAVCANv1 ESC 8 Output Function. Select what should be output on UAVCANv1 ESC 8. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -13727,7 +13915,6 @@ UAVCANv1 ESC 9 Output Function. Select what should be output on UAVCANv1 ESC 9. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -14587,7 +14774,6 @@ VOXL2 IO Output PWM Channel 6 Output Function. Select what should be output on VOXL2 IO Output PWM Channel 6. The default failsafe value is set according to the selected function: - - 'Min' for ConstantMin - 'Max' for ConstantMax - 'Max' for Parachute @@ -21270,6 +21456,8 @@ Multi-EKF IMUs. Maximum number of IMUs to use for Multi-EKF. Set 0 to disable. Requires SENS_IMU_MODE 0. +Maximum number of IMUs to use for Multi-EKF. Set 0 to disable. Requires SENS_IMU_MODE 0. + | Reboot | minValue | maxValue | increment | default | unit | | ------- | -------- | -------- | --------- | ------- | ---- | | ✓ | 0 | 4 | | 0 | | @@ -22235,7 +22423,6 @@ Landing flare time. Multiplied by the descent rate to calculate a dynamic altitude at which to trigger the flare. - NOTE: max(FW_LND_FLALT, FW_LND_FL_TIME \* descent rate) is taken as the flare altitude | Reboot | minValue | maxValue | increment | default | unit | @@ -22758,7 +22945,6 @@ Height above ground threshold below which tighter altitude tracking gets enabled (see FW_LND_THRTC_SC). Below this height, TECS smoothly (1 sec / sec) transitions the altitude tracking time constant from FW_T_ALT_TC to FW_LND_THRTC_SC\*FW_T_ALT_TC. - -1 to disable. | Reboot | minValue | maxValue | increment | default | unit | @@ -24576,7 +24762,6 @@ is the output signal sent to the motor controller. Moment coefficient of rotor 0. The moment coefficient if defined as Torque = KM \* Thrust. - Use a positive value for a rotor with CCW rotation. Use a negative value for a rotor with CW rotation. @@ -24673,7 +24858,6 @@ is the output signal sent to the motor controller. Moment coefficient of rotor 10. The moment coefficient if defined as Torque = KM \* Thrust. - Use a positive value for a rotor with CCW rotation. Use a negative value for a rotor with CW rotation. @@ -24770,7 +24954,6 @@ is the output signal sent to the motor controller. Moment coefficient of rotor 11. The moment coefficient if defined as Torque = KM \* Thrust. - Use a positive value for a rotor with CCW rotation. Use a negative value for a rotor with CW rotation. @@ -24867,7 +25050,6 @@ is the output signal sent to the motor controller. Moment coefficient of rotor 1. The moment coefficient if defined as Torque = KM \* Thrust. - Use a positive value for a rotor with CCW rotation. Use a negative value for a rotor with CW rotation. @@ -24964,7 +25146,6 @@ is the output signal sent to the motor controller. Moment coefficient of rotor 2. The moment coefficient if defined as Torque = KM \* Thrust. - Use a positive value for a rotor with CCW rotation. Use a negative value for a rotor with CW rotation. @@ -25061,7 +25242,6 @@ is the output signal sent to the motor controller. Moment coefficient of rotor 3. The moment coefficient if defined as Torque = KM \* Thrust. - Use a positive value for a rotor with CCW rotation. Use a negative value for a rotor with CW rotation. @@ -25158,7 +25338,6 @@ is the output signal sent to the motor controller. Moment coefficient of rotor 4. The moment coefficient if defined as Torque = KM \* Thrust. - Use a positive value for a rotor with CCW rotation. Use a negative value for a rotor with CW rotation. @@ -25255,7 +25434,6 @@ is the output signal sent to the motor controller. Moment coefficient of rotor 5. The moment coefficient if defined as Torque = KM \* Thrust. - Use a positive value for a rotor with CCW rotation. Use a negative value for a rotor with CW rotation. @@ -25352,7 +25530,6 @@ is the output signal sent to the motor controller. Moment coefficient of rotor 6. The moment coefficient if defined as Torque = KM \* Thrust. - Use a positive value for a rotor with CCW rotation. Use a negative value for a rotor with CW rotation. @@ -25449,7 +25626,6 @@ is the output signal sent to the motor controller. Moment coefficient of rotor 7. The moment coefficient if defined as Torque = KM \* Thrust. - Use a positive value for a rotor with CCW rotation. Use a negative value for a rotor with CW rotation. @@ -25546,7 +25722,6 @@ is the output signal sent to the motor controller. Moment coefficient of rotor 8. The moment coefficient if defined as Torque = KM \* Thrust. - Use a positive value for a rotor with CCW rotation. Use a negative value for a rotor with CW rotation. @@ -25643,7 +25818,6 @@ is the output signal sent to the motor controller. Moment coefficient of rotor 9. The moment coefficient if defined as Torque = KM \* Thrust. - Use a positive value for a rotor with CCW rotation. Use a negative value for a rotor with CW rotation. @@ -27451,7 +27625,6 @@ Higher values result in less aggressive following of the measurement and a smoot Landing target mode. Configure the mode of the landing target. Depending on the mode, the landing target observations are used differently to aid position estimation. - Mode Moving: The landing target may be moving around while in the field of view of the vehicle. Landing target measurements are not used to aid positioning. Mode Stationary: The landing target is stationary. Measured velocity w.r.t. the landing target is used to aid velocity estimation. @@ -29757,7 +29930,6 @@ The descend speed is amended: stick full up - 0 stick centered - MPC_LAND_SPEED stick full down - 2 \* MPC_LAND_SPEED - Manual override during auto modes has to be disabled to use this feature (see COM_RC_OVERRIDE). **Values:** @@ -30126,7 +30298,7 @@ Defined as corrective acceleration in m/s^2 per m/s velocity error | ------ | -------- | -------- | --------- | ------- | ---- | |   | 1.2 | 5 | 0.1 | 1.8 | | -### MPC_Z_P (`FLOAT`) {#MPC_Z_P} +Defined as corrective velocity in m/s per m position error Proportional gain for vertical position error. @@ -30666,7 +30838,6 @@ Yaw rate proportional gain, i.e. control output for angular speed error 1 rad/s. | ------ | -------- | -------- | --------- | ------- | ---- | |   | 0.0 | 0.6 | 0.01 | 0.2 | | -### MC_YAW_TQ_CUTOFF (`FLOAT`) {#MC_YAW_TQ_CUTOFF} Low pass filter cutoff frequency for yaw torque setpoint. @@ -39696,7 +39867,6 @@ Note: certain drivers such as the GPS can determine the Baudrate automatically. Baudrate for the TELEM 2 Serial Port. Configure the Baudrate for the TELEM 2 Serial Port. - Note: certain drivers such as the GPS can determine the Baudrate automatically. **Values:** diff --git a/docs/en/advanced_features/index.md b/docs/en/advanced_features/index.md index 39820576978b..1d81d968e259 100644 --- a/docs/en/advanced_features/index.md +++ b/docs/en/advanced_features/index.md @@ -10,3 +10,5 @@ This section contains topics related to some of the more advanced features of th - [Iridium/RockBlock Satellite Communication System](../advanced_features/satcom_roadblock.md) - [Precision Landing](../advanced_features/precland.md) - [RTK GNSS (GPS)](../gps_compass/rtk_gps.md) +- [Vision Target Estimator](../advanced_features/vision_target_estimator.md) +- [Vision Target Estimator Deep Dive](../advanced_features/vision_target_estimator_advanced.md) diff --git a/docs/en/advanced_features/precland.md b/docs/en/advanced_features/precland.md index 2da2d5619bcb..d933bcde18c3 100644 --- a/docs/en/advanced_features/precland.md +++ b/docs/en/advanced_features/precland.md @@ -52,6 +52,13 @@ Landing Phases Flow Diagram A flow diagram showing the phases can be found in [landing phases flow Diagram](#landing-phases-flow-diagram) below. +### Yaw Alignment + +Precision landing can optionally align the multicopter's yaw to match the detected target orientation while the vehicle is approaching or descending above the pad. +Enable this behaviour with [PLD_YAW_EN](../advanced_config/parameter_reference.md#PLD_YAW_EN). +When enabled, PX4 uses the `vision_target_est_orientation` topic (published by the [Vision Target Estimator](../advanced_features/vision_target_estimator.md)) to command the yaw setpoint as long as the orientation data remains valid. +If the orientation feed times out (see [PLD_BTOUT](../advanced_config/parameter_reference.md#PLD_BTOUT)) or the parameter is disabled, yaw control falls back to the default mission behaviour. + ## Initiating a Precision Landing Precision landing can be used in missions, during the landing phase in _Return mode_, or by entering the _Precision Land_ mode. @@ -157,6 +164,7 @@ Some of the most useful ones are listed below. | [PLD_BTOUT](../advanced_config/parameter_reference.md#PLD_BTOUT) | Landing Target Timeout, after which the target is assumed lost. Default is 5 seconds. | | [PLD_FAPPR_ALT](../advanced_config/parameter_reference.md#PLD_FAPPR_ALT) | Final approach altitude. Default is 0.1 metres. | | [PLD_MAX_SRCH](../advanced_config/parameter_reference.md#PLD_MAX_SRCH) | Maximum number of search attempts in an required landing. | +| [PLD_YAW_EN](../advanced_config/parameter_reference.md#PLD_YAW_EN) | Enable yaw alignment during precision landing when target orientation is available. | | [RTL_PLD_MD](../advanced_config/parameter_reference.md#RTL_PLD_MD) | RTL precision land mode. `0`: disabled, `1`: [Opportunistic](#opportunistic-mode), `2`: [Required](#required-mode). | ### IR Beacon Scaling diff --git a/docs/en/advanced_features/vision_target_estimator.md b/docs/en/advanced_features/vision_target_estimator.md new file mode 100644 index 000000000000..4daa563016f8 --- /dev/null +++ b/docs/en/advanced_features/vision_target_estimator.md @@ -0,0 +1,312 @@ +# Vision Target Estimator (VTEST) + +The Vision Target Estimator (VTEST) fuses vision-based relative target measurements with absolute positioning sources (vehicle GNSS, a mission landing waypoint, and/or target-mounted GNSS). It runs alongside the primary vehicle estimator and publishes: + +- `landing_target_pose` - relative and absolute target pose in NED for precision landing. +- `vision_target_est_position` - the full position-state vector (with covariances) for logging and tuning. +- `vision_target_est_orientation` - target yaw and yaw-rate estimates with associated uncertainty. +- `vision_target_est_input` - downsampled NED acceleration and attitude quaternion used by the position prediction step. +- `vte_bias_init_status` - raw and low-pass filtered GNSS/vision bias, plus the consecutive raw-bias delta norm, during the initial bias averaging phase. +- `vte_aid_*` - innovation logs per sensor, used for observability checks and noise tuning. + +When [`VTE_EKF_AID`](../advanced_config/parameter_reference.md#VTE_EKF_AID)=1 and the target is static, the relative velocity estimate is forwarded to the main EKF2 estimator (see [EKF2 aiding](#ekf2-aiding) for the required conditions). + +> [!WARNING] +> VTEST is a beta feature, disabled in default board configurations, and should only be enabled on custom builds after careful bench and flight testing. + +For tuning workflows, log analysis guidance, and instructions on extending the estimator, see the [Vision Target Estimator deep dive](../advanced_features/vision_target_estimator_advanced.md). + +## Table of Contents +- [Vision Target Estimator (VTEST)](#vision-target-estimator-vtest) + - [Table of Contents](#table-of-contents) + - [Architecture Overview](#architecture-overview) + - [Building the Module](#building-the-module) + - [Dynamic Models](#dynamic-models) + - [Position state](#position-state) + - [Orientation state](#orientation-state) + - [Outlier detection](#outlier-detection) + - [Time alignment](#time-alignment) + - [Estimator Lifecycle](#estimator-lifecycle) + - [Measurement Sources](#measurement-sources) + - [Measurements validity](#measurements-validity) + - [Configuration](#configuration) + - [Module enable and scheduling](#module-enable-and-scheduling) + - [Task selection](#task-selection) + - [Sensor fusion selection](#sensor-fusion-selection) + - [Noise and gating](#noise-and-gating) + - [Sensor-specific settings](#sensor-specific-settings) + - [EKF2 aiding](#ekf2-aiding) + - [MAVLink Messages](#mavlink-messages) + - [TARGET\_RELATIVE (ID 511)](#target_relative-id-511) + - [TARGET\_ABSOLUTE (ID 510)](#target_absolute-id-510) + - [Moving-target projection parameters (experimental)](#moving-target-projection-parameters-experimental) + - [Gazebo Classic Simulation](#gazebo-classic-simulation) + - [Generated SymForce Functions](#generated-symforce-functions) + - [Monitoring](#monitoring) + - [Operational Notes](#operational-notes) + +## Architecture Overview + +VTEST is implemented as two tightly coupled, but independent, estimators managed by the `VisionTargetEst` work item: + +- **Position filter (`VTEPosition`)** - three decoupled 1D Kalman filters (one per NED axis) that estimate relative position, vehicle velocity, and GNSS bias. This bias is the offset between the active absolute target reference (target-mounted GNSS or the mission landing waypoint) and the vision-based target position, so the absolute reference can still point to the target if vision is lost. When the firmware is built with `CONFIG_VTEST_MOVING`, target velocity and acceleration states are also estimated (experimental). +- **Orientation filter (`VTEOrientation`)** - a planar yaw filter that estimates the target's heading and yaw rate. + +Both filters run at a fixed 20 ms interval (`kPosUpdatePeriodUs = kYawUpdatePeriodUs`), so the target rate is 50 Hz. The position loop downsamples vehicle acceleration, rotates it into NED, forwards attitude/local position/local velocity/range data to `VTEPosition`, and publishes `vision_target_est_input`. The yaw loop forwards range data to `VTEOrientation`. The work item also enforces timeouts, parameter reloads, and task activation. + +> [!WARNING] +> The moving-target mode `CONFIG_VTEST_MOVING=y` is experimental and has not yet been flight tested. + +## Building the Module + +The estimator is not part of the default PX4 board configurations. Build a board variant that enables the module, or add `CONFIG_MODULES_VISION_TARGET_ESTIMATOR=y` to a custom `.px4board` file. + +Set `CONFIG_MAVLINK_DIALECT="development"` so the [MAVLink messages](#mavlink-messages) for relative and absolute target measurements are available, and disable the landing target estimator by adding `CONFIG_MODULES_LANDING_TARGET_ESTIMATOR=n` (both modules publish `landing_target_pose` and will conflict if enabled together). + +Common build targets that already include the module are: +- `make px4_fmu-v6c_visionTargetEst` +- `make px4_sitl_default` + +Other boards provide the same `_visionTargetEst` suffix. Enable `CONFIG_VTEST_MOVING=y` in the board configuration if you need the experimental moving-target states. Orientation estimation can be enabled at runtime with [`VTE_YAW_EN`](../advanced_config/parameter_reference.md#VTE_YAW_EN), while [`VTE_POS_EN`](../advanced_config/parameter_reference.md#VTE_POS_EN) controls the position filter. + +## Dynamic Models + +### Position state + +For a static target the per-axis state is $x = [ r, v^{uav}, b ]^T$, where $r$ is the relative NED displacement (target minus vehicle), $v^{uav}$ is the vehicle velocity, and $b$ is the constant offset between GNSS-based target position and the relative target position from vision. Once this offset is known, GNSS can keep the estimate centred on the target even if vision drops out. The discrete prediction model, assuming constant NED acceleration input $a^{uav}$ over the integration interval $dt$, is + +$$ +\begin{aligned} +r_{k+1} &= r_k - dt\thinspace v^{uav}_k - \tfrac{1}{2}\thinspace dt^2\thinspace a^{uav} \\ +v^{uav}_{k+1} &= v^{uav}_k + dt\thinspace a^{uav} \\ +b_{k+1} &= b_k +\end{aligned} +$$ + +When `CONFIG_VTEST_MOVING` is enabled, two additional states capture the target dynamics: $x = [ r, v^{uav}, b, a^{t}, v^{t} ]^T$, where $a^{t}$ and $v^{t}$ are the target acceleration and velocity along the axis. The prediction model becomes + +$$ +\begin{aligned} +r_{k+1} &= r_k - dt\thinspace (v^{uav}_k - v^{t}_k) - \tfrac{1}{2}\thinspace dt^2\thinspace (a^{uav} - a^{t}_k) \\ +v^{uav}_{k+1} &= v^{uav}_k + dt\thinspace a^{uav} \\ +v^{t}_{k+1} &= v^{t}_k + dt\thinspace a^{t}_k \\ +a^{t}_{k+1} &= a^{t}_k \\ +b_{k+1} &= b_k +\end{aligned} +$$ + +Process noise is assumed diagonal. The parameters [`VTE_ACC_D_UNC`](../advanced_config/parameter_reference.md#VTE_ACC_D_UNC), [`VTE_ACC_T_UNC`](../advanced_config/parameter_reference.md#VTE_ACC_T_UNC), and [`VTE_BIAS_UNC`](../advanced_config/parameter_reference.md#VTE_BIAS_UNC) set the input variance on the vehicle acceleration, target acceleration (moving mode), and GNSS bias terms. Initial variances are seeded from [`VTE_POS_UNC_IN`](../advanced_config/parameter_reference.md#VTE_POS_UNC_IN), [`VTE_VEL_UNC_IN`](../advanced_config/parameter_reference.md#VTE_VEL_UNC_IN), [`VTE_BIA_UNC_IN`](../advanced_config/parameter_reference.md#VTE_BIA_UNC_IN), and [`VTE_ACC_UNC_IN`](../advanced_config/parameter_reference.md#VTE_ACC_UNC_IN) (moving mode). + +### Orientation state + +The yaw filter tracks $x = [ \psi, \dot{\psi} ]^T$ and propagates it with a constant-rate model: + +$$ +\begin{aligned} +\psi_{k+1} &= \text{wrap}\negthinspace\left(\psi_k + dt\thinspace\dot{\psi}_k\right) \\ +\dot{\psi}_{k+1} &= \dot{\psi}_k +\end{aligned} +$$ + +Yaw angles are wrapped to $[-\pi, \pi]$. + +### Outlier detection + +Measurement residuals are gated with the Normalized Innovation Squared (NIS) test. [`VTE_POS_NIS_THRE`](../advanced_config/parameter_reference.md#VTE_POS_NIS_THRE) applies to every position-axis update, [`VTE_YAW_NIS_THRE`](../advanced_config/parameter_reference.md#VTE_YAW_NIS_THRE) applies to yaw updates, and the defaults (3.84) correspond to a $95\%$ chi-squared confidence interval. Innovations and gate ratios are published on the `vte_aid_*` topics for log review. + +### Time alignment + +Vision and GNSS observations can arrive delayed due to transport and processing latency. The position and orientation filters therefore support an **Out-of-Sequence Measurements (OOSM)** approximation which uses a **history-consistent projected correction** strategy. More details can be found in [Vision Target Estimator deep dive](../advanced_features/vision_target_estimator_advanced.md) + +## Estimator Lifecycle + +1. **Task activation**: `VisionTargetEst` enables the position and/or orientation filters when the UAV is performing a task selected in [`VTE_TASK_MASK`](../advanced_config/parameter_reference.md#VTE_TASK_MASK). Bit 0 activates precision landing, while bit 1 keeps the estimator running continuously for debugging; future task bits can enable additional mission profiles. +2. **Initialization**: The position filter starts only after it has at least one position-like observation and a recent vehicle velocity estimate. This velocity is needed to initialize the `v^{uav}` state. The orientation filter starts on the first valid vision yaw sample and initializes yaw rate to zero. For the position state, VTEST uses the first source it can trust: + + - If the GNSS bias is not observable yet (for example, only vision is available), it starts from the best direct position source: vision first, otherwise target GNSS or mission position. + - If GNSS and vision are both available and the bias can be trusted immediately, it initializes with `r = pos_rel_vision` and `b = pos_rel_gnss(t_vision) - pos_rel_vision`. If needed, the GNSS-relative sample is propagated to the vision timestamp using UAV velocity before the subtraction. + - If GNSS is already the active position reference when vision first becomes available, it initializes from the GNSS-relative source and keeps `b = 0` until the low-pass filter finishes. Once that filtered bias is activated, the filter resets to `r = pos_rel_gnss(t_vision) - b_filtered`, `b = b_filtered`. +3. **Bias estimation**: The bias becomes observable only when a GNSS-relative position source and vision are both available. VTEST forms raw samples as `b_raw = pos_rel_gnss(t_vision) - pos_rel_vision`; if the GNSS sample is older than the vision sample but still valid, it is propagated forward with the UAV velocity estimate before the subtraction. For the design details see [Bias initialization design](../advanced_features/vision_target_estimator_advanced.md#bias-initialization-design). + + - The behavior is intentionally asymmetric. If GNSS is the active position reference first, VTEST delays vision until the bias is ready so it does not mix a GNSS-referenced state with an uncorrected vision measurement. If vision is the active position reference first, VTEST keeps trusting vision and activates the bias immediately on the first joint GNSS+vision sample, which is safer because the relative position state stays vision-based. + - The low-pass filter (LPF) phase is skipped when [`VTE_BIA_AVG_TOUT`](../advanced_config/parameter_reference.md#VTE_BIA_AVG_TOUT)=0 and also whenever the estimator is already vision-referenced before bias activation. That includes the case where vision was available first and GNSS arrives later. + - When averaging is enabled, the low-pass filter is updated at the vision rate. This only happens in the GNSS-first case. GNSS remains the active position source during this phase, and vision samples update the low-pass filter but are not fused into the position state yet. + - The low-pass filter exits when either 5 consecutive raw-bias delta norms stay below [`VTE_BIA_AVG_THR`](../advanced_config/parameter_reference.md#VTE_BIA_AVG_THR) and at least `2 * tau` has elapsed (`tau = 0.3 s`), or [`VTE_BIA_AVG_TOUT`](../advanced_config/parameter_reference.md#VTE_BIA_AVG_TOUT) expires. + - When the averaged bias is activated, the filter uses `b = b_filtered` and resets the relative position to `r = pos_rel_gnss(t_vision) - b_filtered`. + - If GNSS goes stale while averaging, VTEST aborts the averaging phase, keeps the current filtered bias, switches the position state to the current vision measurement, and starts fusing vision immediately. + - Once the bias has been activated for the current estimator run, a temporary vision dropout does not restart bias initialization when vision returns. +4. **Prediction**: Predictions run whenever the fixed 20 ms scheduler interval elapses (50 Hz). +5. **Update**: When new sensor observations are available, measurement handlers check the validity of the measurement (timestamp, value, and fusion requested in [`VTE_AID_MASK`](../advanced_config/parameter_reference.md#VTE_AID_MASK)), process it, and populate observation buffers. The observations that pass the NIS gates defined by [`VTE_POS_NIS_THRE`](../advanced_config/parameter_reference.md#VTE_POS_NIS_THRE) and [`VTE_YAW_NIS_THRE`](../advanced_config/parameter_reference.md#VTE_YAW_NIS_THRE) are then fused. +6. **Timeout handling** - if no position measurement is fused for [`VTE_BTOUT`](../advanced_config/parameter_reference.md#VTE_BTOUT), the filters are stopped; the published pose/yaw is flagged invalid once [`VTE_TGT_TOUT`](../advanced_config/parameter_reference.md#VTE_TGT_TOUT) elapses without fresh data. + +## Measurement Sources + +All measurements are fused sequentially. For each observation `z` a one-row Jacobian is formed and applied to a single axis (position filter) or to the yaw state (orientation filter). Enabled sensors are defined by the [`VTE_AID_MASK`](#sensor-fusion-selection) bitmask. + +| Source | uORB topic | H structure | Notes | +| --- | --- | --- | --- | +| Target GNSS position | `target_gnss` | $z = r + b$ once the bias is observable, otherwise $z = r$ | The vehicle GNSS sample is interpolated to the target timestamp using the vehicle velocity so the two receivers share a common epoch. Requires [`VTE_AID_MASK`](../advanced_config/parameter_reference.md#VTE_AID_MASK) bit 0. Before bias activation, this source is held back if the estimator is already vision-referenced. | +| Mission landing waypoint | `position_setpoint_triplet` | $z = r$ | Provides a fallback absolute reference when target GNSS is unavailable. Enable [`VTE_AID_MASK`](../advanced_config/parameter_reference.md#VTE_AID_MASK) bit 3 and avoid combining it with target GNSS because only one GNSS bias can be estimated. Before bias activation, this source is held back if the estimator is already vision-referenced. | +| Vision pose | `fiducial_marker_pos_report` | $z = r$ after rotating the measurement (`rel_pos`) into NED using `q` | Uses the message variances, lower-bounded by [`VTE_EVP_NOISE`](../advanced_config/parameter_reference.md#VTE_EVP_NOISE). Recent vision fusions are required for EKF aiding. During the initial GNSS/vision bias averaging phase, valid vision samples update the bias low-pass filter but are not fused into the position state yet. This averaging phase only exists when GNSS became the active reference first. | +| Vehicle GNSS velocity | `sensor_gps` | $z = v^{uav}$ | Removes rotation-induced velocity using `sensor_gps.antenna_offset_{x,y,z}`, which are populated from the vehicle GPS antenna offset parameters (`SENS_GPS0_OFF*`). Enable [`VTE_AID_MASK`](../advanced_config/parameter_reference.md#VTE_AID_MASK) bit 1. | +| Target GNSS velocity (moving mode) | `target_gnss` | $z = v^{t}$ | Available only when `CONFIG_VTEST_MOVING=y` and [`VTE_AID_MASK`](../advanced_config/parameter_reference.md#VTE_AID_MASK) bit 4 is set. | +| Vision yaw | `fiducial_marker_yaw_report` | $z = \psi$ | Only source used by the orientation filter. Requires [`VTE_YAW_EN`](../advanced_config/parameter_reference.md#VTE_YAW_EN) and [`VTE_AID_MASK`](../advanced_config/parameter_reference.md#VTE_AID_MASK) bit 2. Variance is taken from the message and lower-bounded by [`VTE_EVA_NOISE`](../advanced_config/parameter_reference.md#VTE_EVA_NOISE). | + +All innovation data are published on dedicated topics (`vte_aid_gps_pos_target`, `vte_aid_fiducial_marker`, `vte_aid_ev_yaw`, etc.), making it easy to inspect residuals and test ratios in logs. Rejected measurements still emit innovation messages with `fused=false` so tuning sessions can identify time skew or noise mismatches. + +> [!NOTE] +> UWB and IRLock are candidates for future development once representative test data is available. + +### Measurements validity + +Two time horizons guard incoming data: [`VTE_M_REC_TOUT`](../advanced_config/parameter_reference.md#VTE_M_REC_TOUT) defines how old a sample can be and still be considered for fusion, while [`VTE_M_UPD_TOUT`](../advanced_config/parameter_reference.md#VTE_M_UPD_TOUT) bounds how long cached measurements remain valid in the estimator state. The published outputs toggle `*_valid` false after [`VTE_TGT_TOUT`](../advanced_config/parameter_reference.md#VTE_TGT_TOUT) (and the estimators fully stop once [`VTE_BTOUT`](../advanced_config/parameter_reference.md#VTE_BTOUT) is exceeded). Measurements timestamped in the future relative to the latest prediction are always rejected and reported on the innovation topics. + +## Configuration + +### Module enable and scheduling + +- [`VTE_EN`](../advanced_config/parameter_reference.md#VTE_EN) - global module enable (reboot required). +- [`VTE_POS_EN`](../advanced_config/parameter_reference.md#VTE_POS_EN) / [`VTE_YAW_EN`](../advanced_config/parameter_reference.md#VTE_YAW_EN) - enable the position and orientation filters respectively (reboot required). +- [`VTE_BTOUT`](../advanced_config/parameter_reference.md#VTE_BTOUT), [`VTE_TGT_TOUT`](../advanced_config/parameter_reference.md#VTE_TGT_TOUT) - timeouts for estimator shutdown and published validity flags. +- [`VTE_M_REC_TOUT`](../advanced_config/parameter_reference.md#VTE_M_REC_TOUT), [`VTE_M_UPD_TOUT`](../advanced_config/parameter_reference.md#VTE_M_UPD_TOUT) - maximum ages for measurements to be fused or retained. + +### Task selection + +[`VTE_TASK_MASK`](../advanced_config/parameter_reference.md#VTE_TASK_MASK) selects runtime tasks during which the estimators perform computations and estimate the state of the target. + +| Bit | Value | Meaning | +| --- | --- | --- | +| 0 | `1` | precision landing | +| 1 | `2` | DEBUG, always active | + +> [!IMPORTANT] +> Precision landing yaw control is disabled by default. Enable [PLD_YAW_EN](../advanced_config/parameter_reference.md#PLD_YAW_EN) when you want the mission controller to align the vehicle with the target heading, and configure the landing waypoint for precision landing (see [Mission precision landing](../advanced_features/precland.md#mission)). Without both settings the aircraft will only track the position estimate from the Vision Target Estimator. + +### Sensor fusion selection + +[`VTE_AID_MASK`](../advanced_config/parameter_reference.md#VTE_AID_MASK) defines which measurements can be fused: + +| Bit | Value | Meaning | +| --- | --- | --- | +| 0 | `1` | Target GNSS position | +| 1 | `2` | Vehicle GNSS velocity | +| 2 | `4` | Vision-relative position | +| 3 | `8` | Mission landing waypoint | +| 4 | `16` | Target GNSS velocity (moving mode only) | + +Bit 2 also enables processing of `fiducial_marker_yaw_report` in the orientation filter. + +### Noise and gating + +- Adjust [`VTE_POS_UNC_IN`](../advanced_config/parameter_reference.md#VTE_POS_UNC_IN), [`VTE_VEL_UNC_IN`](../advanced_config/parameter_reference.md#VTE_VEL_UNC_IN), [`VTE_BIA_UNC_IN`](../advanced_config/parameter_reference.md#VTE_BIA_UNC_IN), [`VTE_ACC_UNC_IN`](../advanced_config/parameter_reference.md#VTE_ACC_UNC_IN), and [`VTE_YAW_UNC_IN`](../advanced_config/parameter_reference.md#VTE_YAW_UNC_IN) to reflect initial uncertainty. These parameters seed the estimator state at initialization. Updating them while the estimator is already running has no immediate effect; the new values are used the next time the estimator initializes or resets. Larger numbers slow initial convergence but reduce the chance of aggressive transients. +- [`VTE_ACC_D_UNC`](../advanced_config/parameter_reference.md#VTE_ACC_D_UNC), [`VTE_ACC_T_UNC`](../advanced_config/parameter_reference.md#VTE_ACC_T_UNC), [`VTE_BIAS_UNC`](../advanced_config/parameter_reference.md#VTE_BIAS_UNC), [`VTE_YAW_ACC_UNC`](../advanced_config/parameter_reference.md#VTE_YAW_ACC_UNC) - process noise for vehicle acceleration, target acceleration, GNSS bias, and yaw acceleration. Increase [`VTE_ACC_D_UNC`](../advanced_config/parameter_reference.md#VTE_ACC_D_UNC) if the estimator lags behind real motion, raise [`VTE_BIAS_UNC`](../advanced_config/parameter_reference.md#VTE_BIAS_UNC) if GNSS bias corrections should respond more quickly, and use [`VTE_YAW_ACC_UNC`](../advanced_config/parameter_reference.md#VTE_YAW_ACC_UNC) to match expected target heading dynamics. In moving-target builds, use [`VTE_ACC_T_UNC`](../advanced_config/parameter_reference.md#VTE_ACC_T_UNC) to match expected target manoeuvrability. +- [`VTE_BIA_AVG_THR`](../advanced_config/parameter_reference.md#VTE_BIA_AVG_THR), [`VTE_BIA_AVG_TOUT`](../advanced_config/parameter_reference.md#VTE_BIA_AVG_TOUT) - control the first GNSS/vision bias averaging step in the GNSS-first case. Stability requires 5 consecutive raw-bias delta norms below the threshold and at least `2 * tau` of low-pass filter runtime (`tau = 0.3 s`); the timeout is the fallback exit. Set [`VTE_BIA_AVG_TOUT`](../advanced_config/parameter_reference.md#VTE_BIA_AVG_TOUT)=0 to skip averaging and activate the bias immediately. +- [`VTE_POS_NIS_THRE`](../advanced_config/parameter_reference.md#VTE_POS_NIS_THRE), [`VTE_YAW_NIS_THRE`](../advanced_config/parameter_reference.md#VTE_YAW_NIS_THRE) - chi-squared thresholds (e.g. 3.84 corresponds to $95\%$ confidence). Runtime values are lower-bounded by `kMinNisThreshold = 0.1`. +- Measurement variance floors: [`VTE_GPS_P_NOISE`](../advanced_config/parameter_reference.md#VTE_GPS_P_NOISE), [`VTE_GPS_V_NOISE`](../advanced_config/parameter_reference.md#VTE_GPS_V_NOISE), [`VTE_EVP_NOISE`](../advanced_config/parameter_reference.md#VTE_EVP_NOISE), [`VTE_EVA_NOISE`](../advanced_config/parameter_reference.md#VTE_EVA_NOISE). Runtime values are floored at `kMinObservationNoise = 1e-2` (see `src/modules/vision_target_estimator/common.h`) to keep the Kalman gains bounded. + +### Sensor-specific settings + +- **GNSS antenna offsets** - `VisionTargetEst` reads `sensor_gps.antenna_offset_{x,y,z}` to remove position- and rotation-induced velocity offsets before forming GNSS measurements. Configure the vehicle GPS antenna location with [`SENS_GPS0_OFFX`](../advanced_config/parameter_reference.md#SENS_GPS0_OFFX), [`SENS_GPS0_OFFY`](../advanced_config/parameter_reference.md#SENS_GPS0_OFFY), and [`SENS_GPS0_OFFZ`](../advanced_config/parameter_reference.md#SENS_GPS0_OFFZ); + +### EKF2 aiding + +When [`VTE_EKF_AID`](../advanced_config/parameter_reference.md#VTE_EKF_AID)=1, `VisionTargetEst` sets `landing_target_pose.rel_vel_valid` so EKF2 can fuse the relative target velocity. This requires all of the following: + +- `landing_target_pose.is_static = true`, which is only the case when `CONFIG_VTEST_MOVING` is disabled. +- `landing_target_pose.rel_pos_valid = true` and a recent relative measurement (within [`VTE_M_REC_TOUT`](../advanced_config/parameter_reference.md#VTE_M_REC_TOUT)). +- A vision-relative position measurement has been fused. + +If any condition fails, `rel_vel_valid` is cleared and EKF2 ignores the input. + +### MAVLink Messages + +To run the vision target estimator, `CONFIG_MAVLINK_DIALECT="development"` is required to expose `TARGET_RELATIVE` for vision-based observations and `TARGET_ABSOLUTE` for target-mounted GNSS data, two PX4-specific messages that feed the estimator. Note that there are open discussions to include them into the common MAVLink dialect. + +#### TARGET_RELATIVE (ID 511) + +`TARGET_RELATIVE` extends the `LANDING_TARGET` message with a full 3D report that includes orientation and measurement uncertainty: + +- A coordinate frame selector (`TARGET_OBS_FRAME`) and a sensor quaternion (`q_sensor`) that, when provided, rotates the measurement into vehicle-carried NED. +- Target pose (`x`, `y`, `z`, `q_target`) and variances (`pos_std`, `yaw_std`), collected from onboard vision pipelines. + +`mavlink_receiver` validates the frame/type and handles the message differently depending on the Vision Target Estimator status: + +- When `VTE_EN=0`, the measurement is rotated (using `q_sensor` or the vehicle attitude for body-frame reports) and published straight to `landing_target_pose` so precision-landing can operate without the estimator. +- When `VTE_EN=1`, the message is split into `fiducial_marker_pos_report` and `fiducial_marker_yaw_report`. `VisionTargetEst` consumes these uORB topics to drive the position and orientation filters (using `fiducial_marker_pos_report.q` to rotate `fiducial_marker_pos_report.rel_pos` into NED at `timestamp_sample`). + +#### TARGET_ABSOLUTE (ID 510) + +`TARGET_ABSOLUTE` reports the target's absolute state when it carries its own GNSS (and optionally IMU). A capability bitmap advertises which fields are valid. PX4 maps the available content into the `target_gnss` uORB topic: + +- Bit 0 (position) triggers publication of latitude/longitude/altitude along with the horizontal and vertical accuracy estimates (`position_std`). +- Bit 1 (velocity) forwards the NED velocity vector (`vel`) and its standard deviations (`vel_std`). +- Additional fields (acceleration, quaternion `q_target`, rates, uncertainties) are not supported and reserved for future fusion logic once flight testing is available. + +### Moving-target projection parameters (experimental) + +When `CONFIG_VTEST_MOVING` is active, [`VTE_MOVING_T_MIN`](../advanced_config/parameter_reference.md#VTE_MOVING_T_MIN) and [`VTE_MOVING_T_MAX`](../advanced_config/parameter_reference.md#VTE_MOVING_T_MAX) determine how far ahead the target position is projected when publishing the absolute landing setpoint. The estimator computes an intersection time $\Delta t$ by constraining $|z_{rel}| / |v_{descent}|$ between the two parameters, then shifts the absolute landing target by $\Delta t$ along the estimated target velocity (with a correction from the target acceleration state). The goal is to command the vehicle towards where the moving target will be at touchdown, not where it was at the last measurement. + +## Gazebo Classic Simulation + +Run the SITL world `gazebo-classic_iris_irlock` to simulate precision landing using the VTEST fusing vision (ArUco-based) and target GNSS aiding. The world name is retained for historical reasons. The models were introduced in [PX4/PX4-SITL_gazebo-classic#950](https://github.com/PX4/PX4-SITL_gazebo-classic/pull/950). + +> [!TIP] +> The ArUco vision observation path implemented in `Tools/simulation/gazebo-classic/sitl_gazebo-classic/src/gazebo_aruco_plugin.cpp` provides a concrete example of how to obtain a vision-based observation of a target and how to publish the `TARGET_RELATIVE` message. + +1. Launch the simulator with: + + ```sh + make px4_sitl gazebo-classic_iris_irlock + ``` + +2. If CMake cannot locate the expected OpenCV version, query the version present on your system (`opencv_version --short` or `pkg-config --modversion opencv4`) and update `Tools/simulation/gazebo-classic/sitl_gazebo-classic/CMakeLists.txt` accordingly, for example: + + ```cmake + find_package(OpenCV 4.2.0 REQUIRED EXACT) + ``` + + Re-run the build after adjusting the `find_package` line. + +> [!TIP]Tips +> - **Pad visibility**: In `Tools/simulation/gazebo-classic/sitl_gazebo-classic/models/land_pad/land_pad.sdf`, increase the visual box size to `1.5 1.5 0.01` so the pad stays in view longer while the vehicle descends. +> - **Mission waypoint bias**: Enable vision and mission position aiding in [`VTE_AID_MASK`](../advanced_config/parameter_reference.md#VTE_AID_MASK) (set bits 2 and 3, disable bit 0). Place the landing waypoint 3 to 4 m away from the pad in QGroundControl to watch the UAV correct towards the pad once it is detected. In the logs, observe how the GNSS bias compensates for the distance between the land waypoint and the actual pad. +> - **Measurement noise experiments**: The ArUco plugin publishes nominal standard deviations through `set_std_x` and `set_std_y` in `Tools/simulation/gazebo-classic/sitl_gazebo-classic/src/gazebo_aruco_plugin.cpp`. Modify these assignments, and optionally the camera noise block in `.../models/aruco_cam/aruco_cam.sdf`, to see how innovation gates react to noisier vision. +> - **Moving target trials**: To emulate a moving pad, edit `` in `.../models/land_pad/land_pad.sdf` (for example `0.5 0 0`) and enable `CONFIG_VTEST_MOVING` so the estimator tracks the target velocity. + +## Generated SymForce Functions + +The symbolic models live in: + +- `src/modules/vision_target_estimator/Position/vtest_derivation/derivation.py` for the position filter +- `src/modules/vision_target_estimator/Orientation/vtest_derivation/derivation.py` for the yaw filter + +During the build: + +- By default, CMake copies the committed pre-generated source-tree files from `Position/vtest_derivation/generated` (or `generated_moving` when `CONFIG_VTEST_MOVING=y`) into `build//src/modules/vision_target_estimator/vtest_derivation/generated/`, and from `Orientation/vtest_derivation/generated` into `build//src/modules/vision_target_estimator/vte_orientation_derivation/generated/`. +- Setting `VTEST_SYMFORCE_GEN=ON` and having SymForce available regenerates both the position and orientation files at configure time. +- Developers can refresh the committed reference outputs with `-DVTEST_UPDATE_COMMITTED_DERIVATION=ON`. + +The position filter uses generated `state.h`, `predictState.h`, `predictCov.h`, `computeInnovCov.h`, `getTransitionMatrix.h`, and `applyCorrection.h`. The orientation filter uses generated `predictState.h`, `predictCov.h`, and `getTransitionMatrix.h`; innovation computation and correction remain hand-written in `Orientation/KF_orientation.cpp` so yaw innovations and corrected states can be wrapped to $[-\pi, \pi]$. + +These generated files are included from the build directory and must not be edited manually. See the [Vision Target Estimator deep dive](../advanced_features/vision_target_estimator_advanced.md#regenerating-the-symbolic-model) for regeneration prerequisites and troubleshooting tips. + +## Monitoring + +For a detailed log analysis guidance, see the [Vision Target Estimator deep dive](../advanced_features/vision_target_estimator_advanced.md). + +- `landing_target_pose.rel_pos_valid` and `.abs_pos_valid` indicate whether recent measurements support relative and absolute positioning. +- `vision_target_est_position` exposes every state component (relative position, vehicle velocity, GNSS bias, and optional target motion) together with diagonal covariance entries. +- `vision_target_est_orientation` provides yaw, yaw-rate, and their variances. +- `vision_target_est_input` records the downsampled NED acceleration and attitude quaternion actually fed to the position prediction step. +- `vte_bias_init_status` shows raw and filtered GNSS/vision bias while the initial averaging phase is active. +- Innovations published on `vte_aid_*` topics include the raw measurement, innovation, innovation variance, and chi-squared test ratio for post-flight analysis. + +## Operational Notes + +- Accurate timestamp alignment between measurement sources is critical. Large skews will cause innovations to fail the NIS gate and be rejected. +- Absolute target pose is only published when `vehicle_local_position` reports a valid local frame. +- When you expect yaw alignment during landing, enable [`PLD_YAW_EN`](../advanced_config/parameter_reference.md#PLD_YAW_EN) and configure the mission land item for precision landing as described in [Precision landing](../advanced_features/precland.md#mission). In practice this means setting the QGroundControl land waypoint `Precision landing` drop-down (or `MAV_CMD_NAV_LAND` `param2`) to Opportunistic or Required so the controller requests the estimator output. Otherwise only positional cues are used. +- For extended parameter descriptions, log analysis checklists, and developer workflows, refer to the [Vision Target Estimator deep dive](../advanced_features/vision_target_estimator_advanced.md). + +> [!WARNING] +> The moving-target mode is experimental and has not been flight tested; use it only for controlled experiments. diff --git a/docs/en/advanced_features/vision_target_estimator_advanced.md b/docs/en/advanced_features/vision_target_estimator_advanced.md new file mode 100644 index 000000000000..c2a442cec7e6 --- /dev/null +++ b/docs/en/advanced_features/vision_target_estimator_advanced.md @@ -0,0 +1,340 @@ +# Vision Target Estimator Deep Dive + +This guide expands on the [Vision Target Estimator module overview](../advanced_features/vision_target_estimator.md) and targets advanced users who want to tune, extend, and debug the estimator in detail. It documents the system architecture of the Vision Target Estimator, and outlines workflows for log analysis and sensor integration. + +**Table of Contents** +- [Vision Target Estimator Deep Dive](#vision-target-estimator-deep-dive) + - [System architecture](#system-architecture) + - [Bias initialization design](#bias-initialization-design) + - [Time alignment](#time-alignment) + - [OOSM implementation](#oosm-implementation) + - [OOSM approximation assumptions](#oosm-approximation-assumptions) + - [Log analysis and expected plots](#log-analysis-and-expected-plots) + - [Estimator outputs](#estimator-outputs) + - [Main input feeds](#main-input-feeds) + - [What to look for in logs](#what-to-look-for-in-logs) + - [Troubleshooting checklist](#troubleshooting-checklist) + - [Plot examples](#plot-examples) + - [Development and debugging tips](#development-and-debugging-tips) + - [Adding new measurement sources](#adding-new-measurement-sources) + - [Regenerating the symbolic model](#regenerating-the-symbolic-model) + - [Unit test suites](#unit-test-suites) + +## System architecture + +The implementation is split across a scheduler and two independent estimators: + +- `VisionTargetEst` (`src/modules/vision_target_estimator/VisionTargetEst.cpp`) owns the work-queue task. Its main handles vehicle inputs (`vehicle_attitude`, `vehicle_acceleration`, `vehicle_local_position`, range, and angular rates), downsamples acceleration, publishes `vision_target_est_input`, and sends the samples to the position and orientation filters every 20 ms (50 Hz). +- `VTEPosition` (`Position/VTEPosition.cpp`) implements the per-axis filters, observation buffers, timeout logic, and the publication of `landing_target_pose`, `vision_target_est_position`, and every `vte_aid_*` innovation topic. Helper unions (`SensorFusionMaskU`, `ObsValidMaskU`) mirror the bit layout of [`VTE_AID_MASK`](../advanced_features/vision_target_estimator.md#sensor-fusion-selection), making it straightforward to add new observation types. It also handles the absolute-reference/vision bias logic: this bias stores the offset between the active absolute reference (target GNSS or mission landing waypoint) and vision so the corrected absolute reference can still point to the target if vision drops out. Before that bias is trusted, the state is initialized from the best direct position source. The behavior is intentionally asymmetric. When GNSS is the active position reference first, VTE delays vision and may average the first bias samples so it does not mix a GNSS-referenced state with an uncorrected vision measurement. When vision is the active position reference first, VTE keeps trusting vision and activates the bias immediately on the first joint GNSS+vision sample. That keeps `r` aligned with the already trusted vision measurement and avoids delaying recovery for no gain. Once the filtered or immediate bias is activated, the state is reset with the chosen `r` and `b`. If GNSS goes stale during GNSS-first averaging, averaging stops, the current vision position is used, and the current filtered bias is activated. If the bias is already set and vision later drops out and returns, the estimator does not re-average it. + - `Position/KF_position.cpp` encapsulates the 1D Kalman filter math described in the [position-state model](../advanced_features/vision_target_estimator.md#position-state). The prediction, covariance, innovation-covariance, and correction routines (`sym::Predictstate`, `sym::Predictcov`, `sym::Computeinnovcov`, `sym::Applycorrection`) are generated by SymForce and copied into the build-tree `vtest_derivation/generated/` directory during the build. + - `Position/vtest_derivation/derivation.py` defines the symbolic system that SymForce expands into C++; rerun the generator via CMake whenever the model changes as detailed in [Regenerating the symbolic model](#regenerating-the-symbolic-model). +- `VTEOrientation` (`Orientation/VTEOrientation.cpp`) handles yaw fusion, using a simplified state vector but the same measurement staging helpers as the position filter. + - `Orientation/KF_orientation.cpp` implements the constant-turn-rate Kalman filter described in the [orientation-state model](../advanced_features/vision_target_estimator.md#orientation-state). State prediction, covariance propagation, and the transition matrix are generated by SymForce, while innovation computation and correction remain hand-written so yaw angles can be wrapped to $[-\pi, \pi]$. +- Shared utilities live in `common.h` and `VTEOosm.h` (templated OOSM manager). + +## Bias initialization design + +The bias state exists because the absolute target reference and the relative vision reference do not necessarily agree: + +- If [`VTE_AID_MASK`](../advanced_features/vision_target_estimator.md#sensor-fusion-selection) uses the mission landing point, that waypoint can be offset from the real pad. +- If [`VTE_AID_MASK`](../advanced_features/vision_target_estimator.md#sensor-fusion-selection) uses target GNSS, the target receiver can still be off by metres when it is not RTK-grade. +- Vision measures the target relative to the vehicle, so it can be locally accurate even when the absolute GNSS reference is biased. + +VTE therefore models GNSS-like position observations as `z = r + b` once the bias becomes observable. This lets corrected GNSS continue to point to the target when vision drops out. + +Naive bias initialization is not robust: + +- **Zero bias** assumes GNSS and vision already agree. If the real offset is large, the first vision updates can look like outliers and get rejected. +- **Instantaneous bias from the first sample** trusts a single early vision frame. If that frame is noisy and vision is lost soon after, the estimator can continue descending with the wrong corrected GNSS offset. + +The implementation therefore uses two different bias-initialization paths depending on which position source is trusted first: + +1. **Startup prerequisites**: `VTEPosition::initializeEstimator()` requires a recent local-velocity or UAV-GNSS-velocity estimate before the filter can start. This is needed to initialize the `v^{uav}` state and also to support the GNSS-to-vision time alignment used by the bias logic. +2. **Raw bias sample**: whenever a valid GNSS-relative sample exists, the code computes the initial bias from `selectBiasGnssSample(sample_time)`, where `sample_time` is the vision timestamp. If the GNSS-relative sample is older than vision but still valid, the helper propagates it forward with the UAV velocity estimate. In other words, the bias logic uses `pos_rel_gnss(t_vision)`, not the stale stored `pos_rel_gnss`. +3. **Why the behavior is asymmetric**: if GNSS is already driving the position state, feeding in raw vision before the bias is ready would mix two frames that can differ by metres. That is why VTE delays vision in the GNSS-first case. If vision is already driving the position state, the safer choice is the opposite: keep the trusted vision-based `r` and solve for the bias immediately when GNSS and vision overlap for the first time. +4. **Immediate activation case**: when the estimator is already vision-referenced, VTE computes `b = pos_rel_gnss(t_vision) - pos_rel_vision` and keeps `r = pos_rel_vision`. The same immediate-activation path is also used when [`VTE_BIA_AVG_TOUT`](../advanced_config/parameter_reference.md#VTE_BIA_AVG_TOUT)=0, even if GNSS was active first. +5. **Averaging case**: when GNSS is active first and averaging is enabled, vision updates the LPF at the vision rate while GNSS remains the active position source. The raw sample is still `pos_rel_gnss(t_vision) - pos_rel_vision`, so the rate mismatch between GNSS and vision does not force the estimator to discard intermediate vision frames. +6. **Exit condition**: the LPF is accepted only after 5 consecutive raw-bias delta norms stay below [`VTE_BIA_AVG_THR`](../advanced_config/parameter_reference.md#VTE_BIA_AVG_THR) and at least `2 * tau` has elapsed (`tau = 0.3 s`), or when [`VTE_BIA_AVG_TOUT`](../advanced_config/parameter_reference.md#VTE_BIA_AVG_TOUT) expires. +7. **Activation after averaging**: once the LPF is accepted, the state reset is `activateBiasEstimate(gnss_sample - filtered_bias, filtered_bias)`, which means `r = pos_rel_gnss(t_vision) - b_filtered`, `b = b_filtered`. +8. **Stale-GNSS fallback**: if GNSS goes stale during averaging, `selectBiasGnssSample()` fails and the code intentionally switches to the current vision position while keeping the current filtered bias. This is the only bias-initialization branch that does not use `pos_rel_gnss(t_vision)`, because no valid GNSS sample exists anymore. +9. **No re-initialization after recovery**: once the bias is set for the current estimator run, a temporary vision dropout does not restart the averaging phase. When vision returns, the existing bias stays active. + +## Time alignment + +Vision and GNSS observations can arrive delayed due to transport and processing latency. The position and orientation filters therefore support an **Out-of-Sequence Measurements (OOSM)** approximation which uses a **history-consistent projected correction** strategy. + +### OOSM implementation + +Each filter maintains a fixed-size ring buffer of recent state snapshots spanning roughly the last half second of operation (25 samples ≈ 0.5 s at 50 Hz). For the position filter the snapshot stores $(t, x, P, a^{uav})$; for the orientation filter it stores $(t, x, P)$. When a delayed scalar measurement arrives with timestamp $t_{meas}$: + +1. **Retrieve**: fetch the closest $(x_{old}, P_{old}, a^{uav}_{old})$ before $t_{meas}$. + +$$ +t_{old} ≤ t_{meas} +$$ + +1. **Predict**: use the KF model to predict the state $(x_{meas}, P_{meas})$ using $\Delta t = t_{meas} - t_{old}$ (for the orientation filter the control-input term is omitted) + + +$$ + x_{meas} = \Phi(\Delta t) x_{old} + G a^{uav}_{old} \\ + P_{meas} = \Phi(\Delta t)P_{old}\Phi^T(\Delta t) + R +$$ + + +3. **Innovate**: compute the innovation $y$ and innovation variance $S$: + +$$ +y = z - Hx_{meas} \\ +S = H P_{meas} H^T + R +$$ + +4. **Correct**: compute the optimal correction vector: + +$$ + \delta x_{meas} = Ky \\ \thickspace K = P_{meas} H^T S^{-1} +$$ +5. **Project**: project this correction forward with the state transition matrix $\Phi(\Delta t)$: + + $$ + \delta x(t) = \Phi(t - t_{meas})\thinspace \delta x_{meas} = \Phi(t - t_{meas})\thinspace Ky \\ + + $$ + +6. **Apply**: apply the projected correction to the current live state *and* to every stored history sample $i$ that occurred after $t_{meas}$ i.e. $t_{i} > t_{meas}$. + + $$ + K_{i} = \Phi(t_{i} - t_{meas})K \\ + x_{i} = x_{i} + K_{i}y \\ + P_{i} = P_{i} - K_{i}SK_{i}^T + $$ + +Updating the history buffer keeps it self-consistent for subsequent delayed measurements and provides the practical benefits of a lag-smoother while remaining deterministic (fixed buffer, bounded runtime) and avoiding matrix inversion or backward retrodiction. + +Note that the state prediction is $x_{k+1} = \Phi x_k + G u_k$, however, when projecting the correction forward we don't explicitely use $Gu_{k}$ because we're projecting the correction and thus the $Gu_{k}$ term cancels out: + +$$ +(x'_{k+1} - x_{k+1}) = (\Phi x'_k + G u_k) - (\Phi x_k + G u_k) \\ +\Delta x_{k+1} = \Phi (x'_k - x_k) = \Phi \Delta x_k +$$ + +### OOSM approximation assumptions + +In the Algorithm I of *Zhang et al. Optimal Update with Out-of-Sequence Measurements*, the optimal gain for an Out-of-Sequence Measurement (OOSM) depends on $U_{k,d}$, which represents the cross-covariance between the current state $x_k$ and the past state error $\tilde{x}_{d|k}$ + +$$ + +xÌ‚_{k|k,d} = xÌ‚_{k|k} + K_d (z_d − H_d xÌ‚_{d|k} ) \\ +K_d = U_{k,d} H_d' S_d^{−1} + +$$ + + +With the optimal recursion Eq. 5: + +$$ +U_{n+1,d} = (I − K_{n+1} H_{n+1} )F_{n+1,n} U_{n,d} +$$ + + +$F_{n+1,n}$ propagates the correlation forward in time based on system dynamics.$(I - K_{n+1}H_{n+1})$ reduces the correlation based on the information gained from intermediate measurements processed between $t_d$ and $t_k$. + +**First Approximation:** +To avoid the computational complexity and storage required to track every intermediate gain $K_n$ and measurement matrix $H_n$, we approximate the cross-covariance term. We assume that the reduction in correlation due to intermediate updates is negligible for the purpose of the OOSM projection. Mathematically, this assumes the update factor is close to the identity matrix: + +$$ +(I - K_{n}H_{n}) \approx I +$$ + +Under this assumption, the recursion in Eq. (5) simplifies to a pure dynamic propagation: + +$$U_{n+1,d} \approx F_{n+1,n} U_{n,d}$$ + +By iterating this simplified recursion from the measurement time $d$ to the current time $k$, and recognizing that the initial cross-covariance $U_{d,d}$ corresponds to the prediction covariance $P_{d|d-1}$ (or $P_{d|k-l}$ in the paper's notation Eq. 6), we obtain: + +$$U_{k,d} \approx F_{k,d}​U_{d,d} \approx \Phi_{k,d} P_{d|d-1}$$ + +Substituting this approximation back into the gain formula: +$$K_d \approx (\Phi_{k,d} P_{d|d-1}) H_d^T S_d^{-1}$$ + +Since the standard Kalman gain at time $d$ is $K = P_{d|d-1} H_d^T S_d^{-1}$, the OOSM gain simplifies to: + +$$K_d \approx \Phi_{k,d} K$$ + +Effectively, this justifies the "Project" step in the implementation: we calculate the correction vector using the snapshot at $t_{meas}$ and propagate it forward using only the state transition matrix $\Phi(\Delta t)$ ($F_{k,d}$), ignoring the complex coupling effects of intermediate measurements described in the optimal recursion. This reduces storage complexity from $O(N)$ matrices to a simple circular buffer of snapshots while ensuring the correction remains consistent with the system's motion model. + + +**Second Approximation:** No SmoothingContext: +The paper’s globally optimal update uses the "retrodicted" (or smoothed) state estimate and covariance. +- Optimal Innovation: The innovation in Eq. (3) is defined as + $$\tilde{z}_{d|k} = z_d - H_d \hat{x}_{d|k}$$ + This $\hat{x}_{d|k}$ is the estimate of the state at time $d$ conditioned on all measurements up to time $k$. +- Optimal Covariance: The innovation covariance $S_d$ in Eq. (4) uses $P_{d|k}$, which is the error covariance at time $d$ given all data up to $k$. + +The Approximation: The current implementation calculates the innovation using a "snapshot" retrieved from history ($x_{old}$) and predicted to the measurement time ($x_{meas}$). Since this snapshot was saved before the subsequent measurements ($z_{d+1} \dots z_k$) were processed, it only contains information available up to time $d$. + +- State Approximation: The predicted state $\hat{x}_{d|d^-}$ (or filtered state $\hat{x}_{d|d}$) is substituted for the smoothed state $\hat{x}_{d|k}$. + $$\hat{x}_{d|k} \approx \hat{x}_{d|d^-}$$ +- Covariance Approximation: Similarly, $S$ is calculated using $P_{meas}$ (which is $P_{d|d^-}$), substituting the prediction covariance for the smoothed covariance. +$$P_{d|k} \approx P_{d|d^-}$$ + +Justification and Impact: Calculating the exact $\hat{x}_{d|k}$ and $P_{d|k}$ would require the "retrodiction" or "backward smoothing" machinery described in the paper (Section 4.1), which necessitates storing all intermediate measurements and transition matrices. By using the stored snapshot, the implementation avoids this storage overhead. This approximation assumes that the measurements received between $t_{meas}$ and $t_{now}$ ($z_{d+1} \dots z_k$) would not have significantly altered the estimate of the state at $t_{meas}$ had they been available. + +## Log analysis and expected plots + +This section provides an overview of the topics and fields that matter during log review: the published [estimator outputs](#estimator-outputs), the upstream [input feeds](#main-input-feeds). Then log analysis guidance is provided in [What to look for in logs](#what-to-look-for-in-logs) and the [troubleshooting checklist](#troubleshooting-checklist). The [plot examples](#plot-examples) at the end illustrate the expected convergence behaviour. + +### Estimator outputs + +- Full state estimations with variance and validity flags for position and orientation filters: + - `vision_target_est_position` + - `vision_target_est_orientation` +- `vte_bias_init_status`: raw GNSS/vision bias sample, low-pass filtered bias, and the norm of the consecutive raw-bias delta while the initial bias averaging phase is running. +- `landing_target_pose`: controller-facing pose plus `rel_pos_valid`, `rel_vel_valid`, and `fused` booleans. +- `vision_target_est_input`: downsampled acceleration in NED and the quaternion fed to each prediction step. Can be used to correlate estimator behaviour with vehicle attitude changes. +- `vte_aid_*`: one topic per fused source (`vte_aid_gps_pos_target`, `vte_aid_gps_pos_mission`, `vte_aid_gps_vel_uav`, `vte_aid_gps_vel_target`, `vte_aid_fiducial_marker`, `vte_aid_ev_yaw`). Each publishes the innovation, variance, observation, observation variance, chi-squared `test_ratio`, timestamps, and the `fused` flag (true when the update passed the gate). + +### Main input feeds + +- Vision: `fiducial_marker_pos_report` / `fiducial_marker_yaw_report` +- GNSS on the target: `target_gnss` +- Vehicle GNSS: `sensor_gps` (used to convert absolute GNSS measurements to relative vehicle-carried NED measurements) +- Mission position: `position_setpoint_triplet` +- `vehicle_local_position` and `vehicle_attitude` (used for frame transforms and timeout checks) + +### What to look for in logs + +1. **Estimator output vs. observations**: In all axis directions, overlay the estimator outputs position `vision_target_est_position.rel_pos[0]`, `vision_target_est_orientation.yaw` with measurement observations `vte_aid_*.observation[0]` (e.g. `vte_aid_fiducial_marker.observation[0]` or the relevant GNSS observation). The traces should converge after a short transient. Large steady offsets point to calibration errors or incorrect measurement-frame-to-NED transforms. +2. **Initial bias averaging**: When GNSS is active before vision, inspect `vte_bias_init_status.raw_bias`, `vte_bias_init_status.filtered_bias`, and `vte_bias_init_status.delta_norm`. Here `delta_norm` is the norm between consecutive raw bias samples, so it directly reflects the stability criterion used by the low-pass filter exit. The filtered bias should settle before `vision_target_est_position.bias` is initialized. Large swings in the raw bias or a persistently large `delta_norm` point to unstable early vision data or GNSS/vision time-alignment problems. If vision is active first, you should normally see immediate bias activation instead of an averaging phase. +3. **Innovation behaviour**: `vte_aid_*.innovation` (e.g. `vte_aid_fiducial_marker.innovation`) should be centred at zero and resemble white noise. Recall that the innovation is defined as the difference between the state prediction and the measurement observation of the state. It follows that the drifting innovations can come from + - State prediction errors: + - check the estimators state outputs which correspond to the prediction of the state when no measurements are fused + - check `vision_target_est_input` (accelerations, attitude) + - check the frequency of the prediction `dt` + - State update errors: + - errors in the measurement implementation, compare the raw sensor input (e.g. `fiducial_marker_pos_report`) with the processed observation (e.g. `vte_aid_fiducial_marker`) + - time delays between the attitude of the drone and the observation (causing biases in the frame transform from sensor to NED) + - Incorrect noise assumptions +4. **Measurement acceptance**: Inspect the `fused` boolean alongside `test_ratio`. If the NIS gate rejected the measurements frequently, compare the observation variance being logged with the expected sensor accuracy and update the noise parameters. Remember that gaps longer than [`VTE_TGT_TOUT`](../advanced_config/parameter_reference.md#VTE_TGT_TOUT) clear the validity flags, and exceeding [`VTE_BTOUT`](../advanced_config/parameter_reference.md#VTE_BTOUT) stops the estimator entirely until new data arrives. +5. **Time alignment**: Compare `timestamp_sample` (measurement time of validity) with `time_last_fuse` (contains the prediction time) on the same `vte_aid_*` topic. The difference should stay within a few milliseconds of the estimator loop period. Persistent offsets are a sign of delayed sensor delivery or missing time synchronisation. +6. **Coordinate transforms**: Plot the raw measurement (e.g. `fiducial_marker_pos_report.rel_pos[0]`) together with its processed observation `vte_aid_fiducial_marker.observation`. Ensure that there is no mistake in the rotation (`fiducial_marker_pos_report.q`), covariance handling, or offsets applied in the handler. +7. **Prediction inputs**: Use `vision_target_est_input` to track the downsampled acceleration and quaternion. Compare them to `vehicle_attitude.q` and `vehicle_acceleration` to ensure the estimator sees the expected attitude, especially when diagnosing timestamp mismatches. + +### Troubleshooting checklist + +Start by confirming that the estimator is running (`vision_target_estimator status`) and that the relevant `vte_aid_*` topic is present in the log. If a topic is missing, verify that the upstream sensor message is publishing and that the fusion mask bit is enabled. + +>[!TIP] +> **Task activation**: Enable the debug bit in [`VTE_TASK_MASK`](../advanced_config/parameter_reference.md#VTE_TASK_MASK). When the debug bit is set the estimators always runs, making it ideal for bench testing. Enable **debug prints** (`PX4_DEBUG`). + +| Symptom | Likely cause | Remedy | +| --- | --- | --- | +| Vehicle misses the pad or ignores target yaw | Mission land waypoint not set to precision mode or yaw alignment disabled | In QGroundControl set the land waypoint `Precision landing` field (or `MAV_CMD_NAV_LAND` `param2`) to Opportunistic/Required as described in [Precision landing missions](../advanced_features/precland.md#mission), and enable [`PLD_YAW_EN`](../advanced_config/parameter_reference.md#PLD_YAW_EN). In logs verify that `trajectory_setpoint.x/y` converge to `landing_target_pose.x_abs/y_abs` and that `trajectory_setpoint.yaw` follows `vision_target_est_orientation.yaw`. | +| Frequent innovation rejections | Incorrect noise floors or timestamp skew | Verify sensor variances, verify the NIS threholds ([`VTE_POS_NIS_THRE`](../advanced_config/parameter_reference.md#VTE_POS_NIS_THRE) and [`VTE_YAW_NIS_THRE`](../advanced_config/parameter_reference.md#VTE_YAW_NIS_THRE)), compare timestamp_sample (measurement time of validity) with time_last_fuse (contains the prediction time) on the same vte_aid_* topic.. | +| Bias does not converge | No secondary position source, or the GNSS/vision agreement check is too strict for the available vision quality | Ensure vision fusion is enabled so the filter can observe GNSS bias. If GNSS is active before vision and the bias stays near zero, inspect the first GNSS/vision agreement window and tune [`VTE_BIA_AVG_THR`](../advanced_config/parameter_reference.md#VTE_BIA_AVG_THR) / [`VTE_BIA_AVG_TOUT`](../advanced_config/parameter_reference.md#VTE_BIA_AVG_TOUT). If vision is active first, expect the bias to appear on the first overlapping GNSS+vision sample rather than after an averaging phase. | +| Orientation estimate drifts | Missing yaw measurements or low NIS gate | Enable [`VTE_YAW_EN`](../advanced_config/parameter_reference.md#VTE_YAW_EN) and ensure vision yaw data is present. Increase [`VTE_YAW_NIS_THRE`](../advanced_config/parameter_reference.md#VTE_YAW_NIS_THRE) if legitimate data is being rejected. | +| `rel_pos_valid` toggles during descent | Position measurements arriving too slowly | Increase [`VTE_TGT_TOUT`](../advanced_config/parameter_reference.md#VTE_TGT_TOUT) or improve the measurement rate so updates remain inside [`VTE_M_REC_TOUT`](../advanced_config/parameter_reference.md#VTE_M_REC_TOUT). | +| No `vte_aid_*` topics in the log | Sensor not publishing or fusion mask disabled | Use `listener` on the raw sensor topic, confirm [`VTE_AID_MASK`](../advanced_config/parameter_reference.md#VTE_AID_MASK) includes the relevant bit, and rerun the test with the debug task active. | +| Estimator never starts | Task mask disabled or mission not requesting precision landing | Set [`VTE_TASK_MASK`](../advanced_config/parameter_reference.md#VTE_TASK_MASK)=1 (or 3 for continuous debugging) and verify that new measurements arrive with valid timestamps. | + +### Plot examples + +The next four dashboards provide hints on how to analyse logs of the Vision Target Estimator. + +>[!TIP] +> Plot Juggler (or the PX4 DevTools log viewer) is the easiest way to inspect the estimator as it allows you to group related signals into subplots that share the time axis. + +**Estimator output and observation consistency**: Quick health check that the fused sensors agree with the estimated state before diving into per-axis innovations. +- **Top row (observations and state output)**: For every axis, plot all the available observations `vte_aid_*.observation[0]` (in this example: vision/IRLock and target GNSS) alongside `vision_target_est_position`. Expect a smooth state trace following the trend of the most precise observations. +- **Second row (GNSS bias estimate)**: Plot `vision_target_est_position.bias[0]`, `vision_target_est_position.bias[1]`, `vision_target_est_position.bias[2]`. The bias should settle once both a GNSS observation and a relative observation are available. In the GNSS-first case it can remain near zero during the initial averaging phase while valid vision samples are still updating the low-pass filter. In the vision-first case it should appear immediately on the first overlapping GNSS+vision sample. If GNSS is slightly older than vision, VTEST propagates the current GNSS-relative measurement with the UAV velocity estimate to match the vision timestamp. A non-zero bias is expected after activation; what matters is that it remains stable so the corrected GNSS still points to the pad if the relative measurement temporarily drops out. +- **Third row (sensor variances)**: Compare `vte_aid_*.observation_variance[0]` across sensors. Large gaps mean one source is trusted far more than the others. Tune [`VTE_EVP_NOISE`](../advanced_config/parameter_reference.md#VTE_EVP_NOISE) or [`VTE_GPS_P_NOISE`](../advanced_config/parameter_reference.md#VTE_GPS_P_NOISE) until variances reflect the real-world accuracy. +- **Bottom row (approach context)**: `vehicle_local_position.dist_bottom` indicates the descent phase and helps correlate changes in variance or bias with altitude. + +![VTEST all observations](../../assets/vision_target_estimator/VtestAllObservations.png) + +**Innovation Consistency**: Use this view to confirm that every fused sensor produces zero-mean, white-noise innovations; large drifts reveal modelling errors, mis-scaled noise, or frame-misaligned observations. +- **Top row (x innovations)**: For every measurement available, plot all the innovations (e.g. `vte_aid_fiducial_marker.innovation[0]` and `vte_aid_gps_pos_target.innovation[0]`). The innovations should fluctuate around zero. +- **Second row (y innovations)**: The same signals for the y direction. White-noise like innovations indicate the measurements and the assumed noise matches reality. +- **Third row (z innovations)**: Index 2 is only populated by sources that provide altitude information. A persistent offset, such as the ~2 m bias from `vte_aid_fiducial_marker` in the example, shows that the camera observations are not precise at higher altitudes. The measurement variance should reflect this behaviour. +- **Bottom row (fusion decisions)**: The boolean `*.fused` arrays confirm that the Kalman update is skipped whenever the NIS exceeds the gate. If a good sensor is frequently rejected, revisit the variance floors listed earlier. + +![VTEST innovations](../../assets/vision_target_estimator/VtestInnovations.png) + +**Rejecting a Corrupted Measurement**: Demonstrates how the estimator isolates a faulty measurement dimension. The healthy axes continue to fuse while the outlier repeatedly fails the gate, making it easy to spot sensor degradations. + +- **Top row (y observation)**: `vte_aid_fiducial_marker.observation[1]` tracks `vision_target_est_position.rel_pos[1]`, showing how a healthy measurement pulls the estimate. +- **Second row (z observation)**: `vte_aid_fiducial_marker.observation[2]` deviates strongly from `vision_target_est_position.rel_pos[2]`; the estimator sensibly refuses to follow it. +- **Third row (fusion flag)**: `vte_aid_fiducial_marker.fused[2]` drops to zero whenever the vertical observation disagrees with the filter prediction, confirming that the gate is working. +- **Bottom row (test ratios)**: `vte_aid_fiducial_marker.test_ratio[1]` stays near zero, while index 2 spikes above one, breaching [`VTE_POS_NIS_THRE`](../advanced_config/parameter_reference.md#VTE_POS_NIS_THRE). Investigate the vision covariance estimate or adjust [`VTE_EVP_NOISE`](../advanced_config/parameter_reference.md#VTE_EVP_NOISE) if this pattern occurs frequently. + +![VTEST measurement not fused](../../assets/vision_target_estimator/VtestMeasNotFused.png) + +**Precision Landing Alignment**: Compare the precision landing target with the vehicle position to ensure that the vehicle navigates towards the target. It can help catch misconfigurations e.g. [`PLD_YAW_EN`](../advanced_config/parameter_reference.md#PLD_YAW_EN) disabled or it can if the controller struggles to follow, review the [multicopter position tuning guide](../config_mc/pid_tuning_guide_multicopter.md). Check that the absolute position of the target remains stable. + +- **Top row (north alignment)**: Compare `landing_target_pose.x_abs` with `vehicle_local_position.x`. The curves should overlay once precision landing is triggered. +- **Second row (east alignment)**: `landing_target_pose.y_abs` versus `vehicle_local_position.y` reveals lateral errors. +- **Third row (yaw alignment)**: `vision_target_est_orientation.yaw` should remain steady while `trajectory_setpoint.yaw` tracks it when [`PLD_YAW_EN`](../advanced_config/parameter_reference.md#PLD_YAW_EN) is enabled. +- **Bottom row (descent context)**: `vehicle_local_position.dist_bottom` indicates when the final approach begins, providing context for any transients above. + +![VTEST precision landing](../../assets/vision_target_estimator/VtestPrecland.png) + +**Orientation Filter**: Validate that yaw aiding stays smooth yet responsive; this plot highlights whether orientation noise is properly tuned and whether the gating logic keeps unreliable observations out of the state. + +- **Top row (yaw observation vs. state)**: `vte_aid_ev_yaw.observation` compared with `vision_target_est_orientation.yaw`. The smoothed state should stay close without mirroring the high-frequency noise; if it does, increase [`VTE_EVA_NOISE`](../advanced_config/parameter_reference.md#VTE_EVA_NOISE). +- **Second row (innovation)**: `vte_aid_ev_yaw.innovation` should resemble white noise. Long biases indicate an incorrect camera-to-vehicle rotation. +- **Third row (test ratio)**: `vte_aid_ev_yaw.test_ratio` highlights altitude-driven noise. Brief spikes near touchdown are acceptable, but sustained high ratios mean yaw gating might be too tight. +- **Bottom row (fusion flag)**: `vte_aid_ev_yaw.fused` shows when yaw measurements actually update the state. Loss of fusion at high altitude is common; ensure it returns before `dist_bottom` goes below the final approach threshold. + +![VTEST orientation](../../assets/vision_target_estimator/VtestOrientation.png) + +## Development and debugging tips + +- To print `PX4_DEBUG` statements from the module, launch SITL with `PX4_LOG_LEVEL=debug` (for example, `PX4_LOG_LEVEL=debug make px4_sitl_visionTargetEst`). On hardware builds, compile with the debug configuration or enable the console log level before running tests so the additional diagnostics appear on the shell. +- Keep the estimator alive on the bench by setting [`VTE_TASK_MASK`](../advanced_config/parameter_reference.md#VTE_TASK_MASK)=3; the debug bit enables the continuous update of the position and orientation estimators (if enabled via [`VTE_YAW_EN`](../advanced_config/parameter_reference.md#VTE_YAW_EN) and [`VTE_POS_EN`](../advanced_config/parameter_reference.md#VTE_POS_EN)). +- Use shell helpers while iterating: `listener landing_target_pose`, `listener vte_bias_init_status`, `listener vte_aid_fiducial_marker` (or the relevant `vte_aid_*`) to inspect bias averaging and innovations, `listener vision_target_est_input 5` for prediction inputs, and `vision_target_estimator status` to ensure both filters are running. + +### Adding new measurement sources + +To integrate a new sensor: + +1. **Define a uORB message** that carries the measurement in either the vehicle body frame or NED, complete with variance estimates and timestamps. +2. **Extend the fusion mask**: add a bit to `SensorFusionMaskU` (`src/modules/vision_target_estimator/common.h`) and update the parameter definition for [`VTE_AID_MASK`](../advanced_config/parameter_reference.md#VTE_AID_MASK) (see also [sensor fusion selection](../advanced_features/vision_target_estimator.md#sensor-fusion-selection)) in `vision_target_estimator_params.yaml`. +3. **Augment observation enums**: append the new entry to the relevant `ObsType` enum (`VTEPosition.h` or `VTEOrientation.h`), update `ObsValidMaskU`, and update helper functions such as `hasNewNonGpsPositionSensorData()` and `selectInitialPosition()` if the measurement can provide a a relative position. +4. **Subscribe and validate**: add a `uORB::Subscription` to the filter, check for finite values, and reject samples that are too old `isMeasUpdated` or timestamped in the future before marking the observation valid. +5. **Implement the handler** in `processObservations()`. Convert the measurement into NED coordinates, populate `TargetObs::meas_xyz`, `meas_unc_xyz`, and the observation Jacobian (`meas_h_xyz` or `meas_h_theta`), and set the fusion-mask flag only after the data passes validation. +6. **Provide tunable noise**: declare a parameter (e.g. `VTE__NOISE`) and clamp it with `kMinObservationNoise` so the estimator never believes a measurement is perfect. +7. **Log the innovations**: add a publication member and ORB topic (see `vte_aid_fiducial_marker` for reference) so that logs include the innovation, variance, and fused flag for the new sensor. +8. **Exercise SITL**: update the Gazebo (or other) simulation so that replay tests produce the new measurement. This keeps CI coverage intact and provides a reference data set for tuning. +9. **Document the workflow**: update this deep dive and any setup how-tos so users know how to enable the new bit, calibrate the sensor, and interpret its logs. + +>[!IMPORTANT] +> **Timeout policy**: Every measurement must be time-aligned and checked so that stale data never reaches the update step. Reject samples older than [`VTE_M_REC_TOUT`](../advanced_config/parameter_reference.md#VTE_M_REC_TOUT) (`isMeasRecent(hrt_abstime ts)`) and double-check `timestamp_sample` vs. `time_last_fuse` in logs to confirm the new sensor fits inside the estimator deadlines. If observations are stored in cache, invalidate it inside `checkMeasurementInputs` when older than [`VTE_M_UPD_TOUT`](../advanced_config/parameter_reference.md#VTE_M_UPD_TOUT) (`isMeasUpdated(hrt_abstime ts)`). + +### Regenerating the symbolic model + +The generated headers (`predictState.h`, `predictCov.h`, `computeInnovCov.h`, `getTransitionMatrix.h`, `state.h`) are copied into the build directory and should never be edited by hand. To regenerate them: + +1. Configure CMake with `-DVTEST_SYMFORCE_GEN=ON` (automatically enabled when `CONFIG_VTEST_MOVING=y`) and ensure SymForce is available in the Python environment. +2. Reconfigure (`cmake --fresh ...`) so the custom command in `src/modules/vision_target_estimator/CMakeLists.txt` runs. The generated outputs land under `build//src/modules/vision_target_estimator/vtest_derivation/generated/` for position and `build//src/modules/vision_target_estimator/vte_orientation_derivation/generated/` for orientation. +3. If you need to refresh the committed defaults, set `-DVTEST_UPDATE_COMMITTED_DERIVATION=ON` and commit the regenerated files in `Position/vtest_derivation/generated*/` once vetted. + +If the build fails during regeneration, inspect the CMake output for the SymForce invocation and rerun it manually inside `Position/vtest_derivation/` to catch Python errors. After regenerating, rebuild the module to ensure the Jacobians and code stay in sync. + +## Unit test suites + +- `TEST_VTE_KF_position`: Kalman filter math for the position state (prediction, NIS gating, bias-aware H, OOSM gold standard). Static model (state size 3) by default, moving-target tests (state size 5) build only when `CONFIG_VTEST_MOVING` is enabled. +- `TEST_VTE_KF_orientation`: Kalman filter math for yaw/yaw-rate (wrap logic, process noise, dt edge cases, covariance symmetry). +- `TEST_VTE_VTEPosition`: Module logic for vision/GNSS fusion, offsets, interpolation, ordering, and uORB innovation topics (static + moving gated by `CONFIG_VTEST_MOVING`). +- `TEST_VTE_VTEOrientation`: Module logic for yaw fusion, noise models, resets, and OOSM handling. +- `TEST_VTE_VTEOosm`: Generic OOSM manager behavior. + +Run locally: + +```sh +make tests TESTFILTER=TEST_VTE* +``` + +Timing constraints to keep in mind when exercising OOSM paths: + +- OOSM activates when measurement lag exceeds 20 ms. +- History accepts measurements up to 500 ms old before rejecting them. diff --git a/msg/CMakeLists.txt b/msg/CMakeLists.txt index 3ecb1ee59769..429874edbf72 100644 --- a/msg/CMakeLists.txt +++ b/msg/CMakeLists.txt @@ -286,6 +286,20 @@ set(msg_files ) list(SORT msg_files) +if(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) + list(APPEND msg_files + TargetGnss.msg + VisionTargetEstInput.msg + VisionTargetEstPosition.msg + VisionTargetEstOrientation.msg + VteAidSource1d.msg + VteAidSource3d.msg + VteBiasInitStatus.msg + FiducialMarkerPosReport.msg + FiducialMarkerYawReport.msg + PrecLandStatus.msg) +endif() + px4_list_make_absolute(msg_files ${CMAKE_CURRENT_SOURCE_DIR} ${msg_files}) if(NOT EXTERNAL_MODULES_LOCATION STREQUAL "") diff --git a/msg/FiducialMarkerPosReport.msg b/msg/FiducialMarkerPosReport.msg new file mode 100644 index 000000000000..931d72f1dd2a --- /dev/null +++ b/msg/FiducialMarkerPosReport.msg @@ -0,0 +1,9 @@ +# Relative position of precision land target + +uint64 timestamp # [us] time since system start +uint64 timestamp_sample # [us] timestamp of the raw observation + +float32[3] rel_pos # [m] Target position relative to vehicle, expressed in the frame defined by q +float32[3] cov_rel_pos # [m^2] Target position variance, expressed in the frame defined by q + +float32[4] q # [-] Quaternion rotation from the rel_pos frame to the NED earth frame diff --git a/msg/FiducialMarkerYawReport.msg b/msg/FiducialMarkerYawReport.msg new file mode 100644 index 000000000000..9024e7beedf8 --- /dev/null +++ b/msg/FiducialMarkerYawReport.msg @@ -0,0 +1,7 @@ +# Relative orientation of landing target in NED (North, East, Down) + +uint64 timestamp # [us] time since system start +uint64 timestamp_sample # [us] timestamp of the raw observation + +float32 yaw_ned # [rad] [@frame NED] orientation of the target relative to the NED frame [-Pi ; Pi] +float32 yaw_var_ned # [rad^2] orientation uncertainty diff --git a/msg/LandingTargetPose.msg b/msg/LandingTargetPose.msg index 875920f183b8..1c4495bb9418 100644 --- a/msg/LandingTargetPose.msg +++ b/msg/LandingTargetPose.msg @@ -1,26 +1,30 @@ # Relative position of precision land target in navigation (body fixed, north aligned, NED) and inertial (world fixed, north aligned, NED) frames -uint64 timestamp # time since system start (microseconds) +uint64 timestamp # [us] time since system start -bool is_static # Flag indicating whether the landing target is static or moving with respect to the ground +bool is_static # [-] Flag indicating whether the landing target is static or moving with respect to the ground -bool rel_pos_valid # Flag showing whether relative position is valid -bool rel_vel_valid # Flag showing whether relative velocity is valid +bool rel_pos_valid # [-] Flag showing whether relative position is valid +bool rel_vel_valid # [-] Flag showing whether relative velocity is valid -float32 x_rel # X/north position of target, relative to vehicle (navigation frame) [meters] -float32 y_rel # Y/east position of target, relative to vehicle (navigation frame) [meters] -float32 z_rel # Z/down position of target, relative to vehicle (navigation frame) [meters] +float32 x_rel # [m] X/north position of target, relative to vehicle (navigation frame) +float32 y_rel # [m] Y/east position of target, relative to vehicle (navigation frame) +float32 z_rel # [m] Z/down position of target, relative to vehicle (navigation frame) -float32 vx_rel # X/north velocity of target, relative to vehicle (navigation frame) [meters/second] -float32 vy_rel # Y/east velocity of target, relative to vehicle (navigation frame) [meters/second] +float32 vx_rel # [m/s] X/north velocity of target, relative to vehicle (navigation frame) +float32 vy_rel # [m/s] Y/east velocity of target, relative to vehicle (navigation frame) +float32 vz_rel # [m/s] Z/down velocity of target, relative to vehicle (navigation frame) -float32 cov_x_rel # X/north position variance [meters^2] -float32 cov_y_rel # Y/east position variance [meters^2] +float32 cov_x_rel # [m^2] X/north position variance +float32 cov_y_rel # [m^2] Y/east position variance +float32 cov_z_rel # [m^2] Z/down position variance -float32 cov_vx_rel # X/north velocity variance [(meters/second)^2] -float32 cov_vy_rel # Y/east velocity variance [(meters/second)^2] +float32 cov_vx_rel # [(m/s)^2] X/north velocity variance +float32 cov_vy_rel # [(m/s)^2] Y/east velocity variance +float32 cov_vz_rel # [(m/s)^2] Z/down velocity variance -bool abs_pos_valid # Flag showing whether absolute position is valid -float32 x_abs # X/north position of target, relative to origin (navigation frame) [meters] -float32 y_abs # Y/east position of target, relative to origin (navigation frame) [meters] -float32 z_abs # Z/down position of target, relative to origin (navigation frame) [meters] +bool abs_pos_valid # [-] Flag showing whether absolute position is valid + +float32 x_abs # [m] X/north position of target, relative to origin (navigation frame) +float32 y_abs # [m] Y/east position of target, relative to origin (navigation frame) +float32 z_abs # [m] Z/down position of target, relative to origin (navigation frame) diff --git a/msg/PrecLandStatus.msg b/msg/PrecLandStatus.msg new file mode 100644 index 000000000000..fc9fb988a19c --- /dev/null +++ b/msg/PrecLandStatus.msg @@ -0,0 +1,16 @@ +# PREC_LAND_STATUS message data +uint8 PREC_LAND_STATE_STOPPED = 0 +uint8 PREC_LAND_STATE_ONGOING = 1 + +uint8 PREC_LAND_NAV_STATE_START = 0 +uint8 PREC_LAND_NAV_STATE_HORIZONTAL = 1 +uint8 PREC_LAND_NAV_STATE_DESCEND = 2 +uint8 PREC_LAND_NAV_STATE_FINAL = 3 +uint8 PREC_LAND_NAV_STATE_SEARCH = 4 +uint8 PREC_LAND_NAV_STATE_FALLBACK = 5 +uint8 PREC_LAND_NAV_STATE_DONE = 6 + +uint64 timestamp # [us] time since system start + +uint8 state # [-] PX4 state of delivery (PREC_LAND_STATE_*) +uint8 nav_state # [-] Precision delivery state (PREC_LAND_NAV_STATE_*) diff --git a/msg/TargetGnss.msg b/msg/TargetGnss.msg new file mode 100644 index 000000000000..b62b2269416c --- /dev/null +++ b/msg/TargetGnss.msg @@ -0,0 +1,21 @@ +# landing target GNSS position in WGS84 coordinates. + +uint64 timestamp # [us] time since system start +uint64 timestamp_sample # [us] timestamp of the raw observation + +float64 latitude_deg # [deg] Latitude, allows centimeter level RTK precision +float64 longitude_deg # [deg] Longitude, allows centimeter level RTK precision +float32 altitude_msl_m # [m] Altitude above MSL + +float32 eph # [m] GNSS horizontal position accuracy +float32 epv # [m] GNSS vertical position accuracy + +bool abs_pos_updated # [-] True if WGS84 position is updated + +float32 vel_n_m_s # [m/s] GNSS North velocity +float32 vel_e_m_s # [m/s] GNSS East velocity +float32 vel_d_m_s # [m/s] GNSS Down velocity + +float32 s_acc_m_s # [m/s] GNSS speed accuracy estimate + +bool vel_ned_updated # [-] True if NED velocity is updated diff --git a/msg/VisionTargetEstInput.msg b/msg/VisionTargetEstInput.msg new file mode 100644 index 000000000000..b62b1de6fa03 --- /dev/null +++ b/msg/VisionTargetEstInput.msg @@ -0,0 +1,8 @@ +# Vision Target Estimator input + + +uint64 timestamp # [us] time since system start +uint64 timestamp_sample # [us] timestamp of the raw input data + +float32[3] acc_xyz # [m/s^2] [@frame NED] downsampled UAV bias-corrected acceleration (including gravity) +float32[4] q_att # [-] downsampled UAV attitude quaternion (FRD body -> NED earth) diff --git a/msg/VisionTargetEstOrientation.msg b/msg/VisionTargetEstOrientation.msg new file mode 100644 index 000000000000..29e723c9fda7 --- /dev/null +++ b/msg/VisionTargetEstOrientation.msg @@ -0,0 +1,11 @@ +# Vision Target Estimator orientation state + +uint64 timestamp # [us] time since system start + +bool orientation_valid # [-] relative orientation estimate valid + +float32 yaw # [rad] [@frame NED] target yaw angle +float32 cov_yaw # [rad^2] variance of yaw + +float32 yaw_rate # [rad/s] [@frame NED] target yaw rate +float32 cov_yaw_rate # [(rad/s)^2] variance of yaw_rate diff --git a/msg/VisionTargetEstPosition.msg b/msg/VisionTargetEstPosition.msg new file mode 100644 index 000000000000..8882208066a4 --- /dev/null +++ b/msg/VisionTargetEstPosition.msg @@ -0,0 +1,18 @@ +# Vision Target Estimator position state + +uint64 timestamp # [us] time since system start + +bool rel_pos_valid # [-] relative position estimate valid +bool rel_vel_valid # [-] relative velocity estimate valid + +float32[3] rel_pos # [m] [@frame NED] target position relative to vehicle +float32[3] vel_uav # [m/s] [@frame NED] vehicle velocity +float32[3] vel_target # [m/s] [@frame NED] target velocity +float32[3] bias # [m] [@frame NED] GNSS bias between vehicle and target receivers +float32[3] acc_target # [m/s^2] [@frame NED] target acceleration + +float32[3] cov_rel_pos # [m^2] [@frame NED] variance of rel_pos +float32[3] cov_vel_uav # [(m/s)^2] [@frame NED] variance of vel_uav +float32[3] cov_bias # [m^2] [@frame NED] variance of bias +float32[3] cov_vel_target # [(m/s)^2] [@frame NED] variance of vel_target +float32[3] cov_acc_target # [(m/s^2)^2] [@frame NED] variance of acc_target diff --git a/msg/VteAidSource1d.msg b/msg/VteAidSource1d.msg new file mode 100644 index 000000000000..4fef8ac99a60 --- /dev/null +++ b/msg/VteAidSource1d.msg @@ -0,0 +1,32 @@ +# Vision Target Estimator 1D fusion aid source + +uint64 timestamp # [us] time since system start +uint64 timestamp_sample # [us] timestamp of the raw observation +uint64 time_last_predict # [us] timestamp of last filter prediction + +# Observation & Innovation +float32 observation # [-] observation attempted to be fused +float32 observation_variance # [-] variance of observation attempted to be fused + +float32 innovation # [-] Kalman Filter innovation (y = z - Hx) +float32 innovation_variance # [-] Kalman Filter variance of the innovation + +float32 test_ratio # [-] normalized innovation squared (NIS) + +# Detailed status code for debugging "Why did it fail?" +# 0: STATUS_IDLE +# 1: STATUS_FUSED_CURRENT # Fused immediately (low latency) +# 2: STATUS_FUSED_OOSM # Fused via history buffer +# 3: STATUS_REJECT_NIS # Rejected by Normalized Innovation Squared check +# 4: STATUS_REJECT_COV # Rejected due to invalid/infinite covariance or numerical error +# 5: STATUS_REJECT_TOO_OLD # Rejected: older than buffer limit (kOosmMaxTimeUs) or oldest sample +# 6: STATUS_REJECT_TOO_NEW # Rejected: timestamp in the future (beyond tolerance) +# 7: STATUS_REJECT_STALE # Rejected: history was reset due to staleness/discontinuity +# 8: STATUS_REJECT_EMPTY # Rejected: history buffer not yet populated +uint8 fusion_status # [-] fusion status code + +# OOSM Diagnostics +float32 time_since_meas_ms # [ms] (now - timestamp_sample) +uint8 history_steps # [-] number of steps replayed in OOSM (0 if current or failed) + +# TOPICS vte_aid_ev_yaw diff --git a/msg/VteAidSource3d.msg b/msg/VteAidSource3d.msg new file mode 100644 index 000000000000..f587024cb4b1 --- /dev/null +++ b/msg/VteAidSource3d.msg @@ -0,0 +1,32 @@ +# Vision Target Estimator 3D fusion aid source + +uint64 timestamp # [us] time since system start +uint64 timestamp_sample # [us] timestamp of the raw observation +uint64 time_last_predict # [us] timestamp of last filter prediction + +# Observation & Innovation +float32[3] observation # [-] [@frame NED] sensor observation attempted to be fused +float32[3] observation_variance # [-] [@frame NED] variance of the observation attempted to be fused + +float32[3] innovation # [-] [@frame NED] Kalman Filter innovation (y = z - Hx) +float32[3] innovation_variance # [-] [@frame NED] Kalman Filter variance of the innovation + +float32[3] test_ratio # [-] normalized innovation squared (NIS) + +# 0: STATUS_IDLE +# 1: STATUS_FUSED_CURRENT # Fused immediately (low latency) +# 2: STATUS_FUSED_OOSM # Fused via history buffer +# 3: STATUS_REJECT_NIS # Rejected by Normalized Innovation Squared check +# 4: STATUS_REJECT_COV # Rejected due to invalid/infinite covariance or numerical error +# 5: STATUS_REJECT_TOO_OLD # Rejected: older than buffer limit (kOosmMaxTimeUs) or oldest sample +# 6: STATUS_REJECT_TOO_NEW # Rejected: timestamp in the future (beyond tolerance) +# 7: STATUS_REJECT_STALE # Rejected: history was reset due to staleness/discontinuity +# 8: STATUS_REJECT_EMPTY # Rejected: history buffer not yet populated +uint8[3] fusion_status # [-] Enum detailing the status code of the sensor fusion + +# OOSM Diagnostics (Shared across axes) +float32 time_since_meas_ms # [ms] (now - timestamp_sample) +uint8 history_steps # [-] number of steps replayed in OOSM (0 if current or failed) + +# TOPICS vte_aid_gps_pos_target vte_aid_gps_pos_mission vte_aid_gps_vel_target vte_aid_gps_vel_uav +# TOPICS vte_aid_fiducial_marker diff --git a/msg/VteBiasInitStatus.msg b/msg/VteBiasInitStatus.msg new file mode 100644 index 000000000000..236e6a0eda44 --- /dev/null +++ b/msg/VteBiasInitStatus.msg @@ -0,0 +1,7 @@ +# Vision Target Estimator initial GNSS bias averaging status + +uint64 timestamp # [us] time since system start + +float32[3] raw_bias # [m] [@frame NED] current GNSS-vision bias sample +float32[3] filtered_bias # [m] [@frame NED] low-pass filtered bias sample +float32 delta_norm # [m] norm(raw_bias_k - raw_bias_k-1) diff --git a/platforms/common/include/px4_platform_common/px4_work_queue/WorkQueueManager.hpp b/platforms/common/include/px4_platform_common/px4_work_queue/WorkQueueManager.hpp index 4ce4db1e7434..dbee70a8ed9e 100644 --- a/platforms/common/include/px4_platform_common/px4_work_queue/WorkQueueManager.hpp +++ b/platforms/common/include/px4_platform_common/px4_work_queue/WorkQueueManager.hpp @@ -70,6 +70,8 @@ static constexpr wq_config_t I2C4{"wq:I2C4", CONFIG_WQ_I2C_STACKSIZE, (int8_t)CO // PX4 att/pos controllers, highest priority after sensors. static constexpr wq_config_t nav_and_controllers{"wq:nav_and_controllers", CONFIG_WQ_NAV_AND_CONTROLLERS_STACKSIZE, (int8_t)CONFIG_WQ_NAV_AND_CONTROLLERS_PRIORITY}; +static constexpr wq_config_t vte{"wq:vte", CONFIG_WQ_VTE_STACKSIZE, (int8_t)CONFIG_WQ_VTE_PRIORITY}; + static constexpr wq_config_t INS0{"wq:INS0", CONFIG_WQ_INS_STACKSIZE, (int8_t)CONFIG_WQ_INS0_PRIORITY}; static constexpr wq_config_t INS1{"wq:INS1", CONFIG_WQ_INS_STACKSIZE, (int8_t)CONFIG_WQ_INS1_PRIORITY}; static constexpr wq_config_t INS2{"wq:INS2", CONFIG_WQ_INS_STACKSIZE, (int8_t)CONFIG_WQ_INS2_PRIORITY}; diff --git a/platforms/common/px4_work_queue/Kconfig b/platforms/common/px4_work_queue/Kconfig index bb320e9519c7..0f2a2ac004ce 100644 --- a/platforms/common/px4_work_queue/Kconfig +++ b/platforms/common/px4_work_queue/Kconfig @@ -138,6 +138,20 @@ config WQ_NAV_AND_CONTROLLERS_PRIORITY help Sets the relative priority for the nav_and_controllers work queue. +config WQ_VTE_STACKSIZE + int "Stack size for vte" + default 6000 + range 1000 10000 + help + Sets the stack size for the vte work queue. + +config WQ_VTE_PRIORITY + int "Relative priority for vte" + default -14 + range -255 0 + help + Sets the relative priority for the vte work queue. + menu "INS Work Queues" config WQ_INS_STACKSIZE diff --git a/src/lib/ringbuffer/CMakeLists.txt b/src/lib/ringbuffer/CMakeLists.txt index 538c1ae90717..af30832d62cf 100644 --- a/src/lib/ringbuffer/CMakeLists.txt +++ b/src/lib/ringbuffer/CMakeLists.txt @@ -38,4 +38,5 @@ px4_add_library(ringbuffer target_include_directories(ringbuffer PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) px4_add_unit_gtest(SRC RingbufferTest.cpp LINKLIBS ringbuffer) -px4_add_unit_gtest(SRC TimestampedRingBufferTest.cpp) +px4_add_unit_gtest(SRC TimestampedRingBufferDynamicTest.cpp) +px4_add_unit_gtest(SRC TimestampedRingBufferStaticTest.cpp) diff --git a/src/lib/ringbuffer/TimestampedRingBuffer.hpp b/src/lib/ringbuffer/TimestampedRingBuffer.hpp index a96f1c35a023..4548ce7b0d10 100644 --- a/src/lib/ringbuffer/TimestampedRingBuffer.hpp +++ b/src/lib/ringbuffer/TimestampedRingBuffer.hpp @@ -32,21 +32,134 @@ ****************************************************************************/ /** - * @file TimestampedRingBuffer.h - * @author Roman Bapst - * @brief Template ring buffer for timestamped samples (requires data_type::time_us). - * Note: This is not the same as `Ringbuffer` (byte FIFO) in `src/lib/ringbuffer/Ringbuffer.hpp`. - */ +* @file TimestampedRingBuffer.hpp +* @author Roman Bapst +* @brief Template ring buffer for timestamped samples (requires data_type::time_us). +* +* Note: This is not the same as `Ringbuffer` (byte FIFO) in `src/lib/ringbuffer/Ringbuffer.hpp`. +*/ #pragma once +#include #include -#include -#include -template +template +class TimestampedRingBuffer; + +/** + * Static-size ring buffer specialization (SIZE > 0). + */ +template class TimestampedRingBuffer { +public: + static_assert(SIZE > 0, "SIZE must be > 0"); + static_assert(SIZE <= UINT8_MAX, "SIZE must fit in uint8_t"); + static constexpr uint8_t kSize = static_cast(SIZE); + + TimestampedRingBuffer() = default; + ~TimestampedRingBuffer() = default; + + // no copy, assignment, move, move assignment + TimestampedRingBuffer(const TimestampedRingBuffer &) = delete; + TimestampedRingBuffer &operator=(const TimestampedRingBuffer &) = delete; + TimestampedRingBuffer(TimestampedRingBuffer &&) = delete; + TimestampedRingBuffer &operator=(TimestampedRingBuffer &&) = delete; + + bool allocate(size_t size) { return size == SIZE; } + + bool valid() const { return SIZE > 0; } + + void push(const data_type &sample) + { + _buffer[_head] = sample; + _head = next(_head); + + if (_entries < kSize) { + _entries++; + } + } + + uint8_t get_length() const { return kSize; } + + data_type &operator[](const uint8_t index) { return _buffer[index]; } + + const data_type &get_newest() const { return _buffer[get_newest_index()]; } + const data_type &get_oldest() const { return _buffer[get_oldest_index()]; } + + uint8_t get_newest_index() const { return (_entries > 0) ? prev(_head) : 0; } + uint8_t get_oldest_index() const + { + if (_entries == 0) { + return 0; + } + + const uint16_t idx = static_cast(_head) + static_cast(kSize) - static_cast(_entries); + return (idx >= kSize) ? static_cast(idx - kSize) : static_cast(idx); + } + + uint8_t next(uint8_t i) const { return (i + 1 >= kSize) ? 0 : i + 1; } + uint8_t prev(uint8_t i) const { return (i == 0) ? kSize - 1 : i - 1; } + + bool pop_first_older_than(const uint64_t ×tamp, data_type *sample) + { + // start looking from newest observation data + const uint8_t newest_idx = get_newest_index(); + const uint8_t oldest_idx = get_oldest_index(); + + for (uint8_t i = 0; i < _entries; i++) { + int index = (newest_idx - i); + index = index < 0 ? kSize + index : index; + + if (timestamp >= _buffer[index].time_us && timestamp < _buffer[index].time_us + (uint64_t)1e5) { + *sample = _buffer[index]; + + // Now we can set the tail to the item which + // comes after the one we removed since we don't + // want to have any older data in the buffer + const uint8_t index_u = static_cast(index); + const uint8_t distance = (index_u >= oldest_idx) ? (index_u - oldest_idx) : (index_u + kSize - oldest_idx); + const uint8_t discard_count = distance + 1; + _entries = (_entries > discard_count) ? (_entries - discard_count) : 0; + _buffer[index].time_us = 0; + + return true; + } + } + + return false; + } + + int get_used_size() const { return sizeof(*this) + sizeof(data_type) * entries(); } + int get_total_size() const { return sizeof(*this) + sizeof(data_type) * kSize; } + + int entries() const { return _entries; } + bool empty() const { return _entries == 0; } + + void reset() + { + for (uint8_t i = 0; i < kSize; i++) { + _buffer[i].time_us = 0; + } + + _head = 0; + _entries = 0; + } + +private: + data_type _buffer[SIZE] {}; + + uint8_t _head{0}; + uint8_t _entries{0}; +}; + +/** + * Dynamic-size ring buffer specialization (SIZE == 0). + */ +template +class TimestampedRingBuffer +{ public: explicit TimestampedRingBuffer(size_t size) { allocate(size); } TimestampedRingBuffer() = delete; @@ -58,14 +171,14 @@ class TimestampedRingBuffer TimestampedRingBuffer(TimestampedRingBuffer &&) = delete; TimestampedRingBuffer &operator=(TimestampedRingBuffer &&) = delete; - bool allocate(uint8_t size) + bool allocate(size_t size) { if (valid() && (size == _size)) { // no change return true; } - if (size == 0) { + if (size == 0 || size > UINT8_MAX) { return false; } @@ -77,7 +190,7 @@ class TimestampedRingBuffer return false; } - _size = size; + _size = static_cast(size); reset(); @@ -91,7 +204,7 @@ class TimestampedRingBuffer uint8_t head_new = _head; if (!_first_write) { - head_new = (_head + 1) % _size; + head_new = next(_head); } _buffer[head_new] = sample; @@ -99,7 +212,7 @@ class TimestampedRingBuffer // move tail if we overwrite it if (_head == _tail && !_first_write) { - _tail = (_tail + 1) % _size; + _tail = next(_tail); } else { _first_write = false; @@ -113,8 +226,12 @@ class TimestampedRingBuffer const data_type &get_newest() const { return _buffer[_head]; } const data_type &get_oldest() const { return _buffer[_tail]; } + uint8_t get_newest_index() const { return _head; } uint8_t get_oldest_index() const { return _tail; } + uint8_t next(uint8_t i) const { return (_size > 0) ? ((i + 1 >= _size) ? 0 : i + 1) : 0; } + uint8_t prev(uint8_t i) const { return (_size > 0) ? ((i == 0) ? (_size - 1) : (i - 1)) : 0; } + bool pop_first_older_than(const uint64_t ×tamp, data_type *sample) { // start looking from newest observation data @@ -133,7 +250,7 @@ class TimestampedRingBuffer _first_write = true; } else { - _tail = (index + 1) % _size; + _tail = next(static_cast(index)); } _buffer[index].time_us = 0; @@ -167,11 +284,13 @@ class TimestampedRingBuffer return count; } + bool empty() const { return entries() == 0; } + void reset() { if (_buffer) { for (uint8_t i = 0; i < _size; i++) { - _buffer[i] = {}; + _buffer[i].time_us = 0; } _head = 0; diff --git a/src/lib/ringbuffer/TimestampedRingBufferTest.cpp b/src/lib/ringbuffer/TimestampedRingBufferDynamicTest.cpp similarity index 78% rename from src/lib/ringbuffer/TimestampedRingBufferTest.cpp rename to src/lib/ringbuffer/TimestampedRingBufferDynamicTest.cpp index 83f5fa7940e6..15a64a5be9ee 100644 --- a/src/lib/ringbuffer/TimestampedRingBufferTest.cpp +++ b/src/lib/ringbuffer/TimestampedRingBufferDynamicTest.cpp @@ -41,7 +41,7 @@ struct sample { }; -class TimestampedRingBufferTest : public ::testing::Test +class TimestampedRingBufferDynamicTest : public ::testing::Test { public: @@ -67,15 +67,24 @@ class TimestampedRingBufferTest : public ::testing::Test } }; -TEST_F(TimestampedRingBufferTest, goodInitialisation) +TEST_F(TimestampedRingBufferDynamicTest, goodInitialisation) { // WHEN: buffer was allocated // THEN: allocation should have succeed - ASSERT_EQ(true, _buffer->allocate(3)); + EXPECT_TRUE(_buffer->allocate(3)); + EXPECT_TRUE(_buffer->valid()); + EXPECT_EQ(_buffer->get_length(), 3); +} +TEST_F(TimestampedRingBufferDynamicTest, RejectsInvalidSize) +{ + EXPECT_FALSE(_buffer->allocate(0)); + EXPECT_FALSE(_buffer->allocate(UINT8_MAX + 1u)); + EXPECT_TRUE(_buffer->valid()); + EXPECT_EQ(_buffer->get_length(), 3); } -TEST_F(TimestampedRingBufferTest, badInitialisation) +TEST_F(TimestampedRingBufferDynamicTest, badInitialisation) { // WHEN: buffer allocation input is bad // THEN: allocation should fail @@ -85,7 +94,7 @@ TEST_F(TimestampedRingBufferTest, badInitialisation) // ASSERT_EQ(false, _buffer->allocate(0)); } -TEST_F(TimestampedRingBufferTest, orderOfSamples) +TEST_F(TimestampedRingBufferDynamicTest, orderOfSamples) { ASSERT_EQ(true, _buffer->allocate(3)); // GIVEN: allocated buffer @@ -103,7 +112,7 @@ TEST_F(TimestampedRingBufferTest, orderOfSamples) EXPECT_EQ(_y.time_us, _buffer->get_newest().time_us); } -TEST_F(TimestampedRingBufferTest, popSample) +TEST_F(TimestampedRingBufferDynamicTest, popSample) { ASSERT_EQ(true, _buffer->allocate(3)); _buffer->push(_x); @@ -115,20 +124,20 @@ TEST_F(TimestampedRingBufferTest, popSample) sample pop = {}; // WHEN: we want to retrieve a sample that is older than any in the buffer // THEN: we should not get any sample - EXPECT_EQ(false, _buffer->pop_first_older_than(0, &pop)); + EXPECT_FALSE(_buffer->pop_first_older_than(0, &pop)); // WHEN: when calling "pop_first_older_than" // THEN: we should get the first sample from the head that is older - EXPECT_EQ(true, _buffer->pop_first_older_than(_x.time_us + 1, &pop)); + EXPECT_TRUE(_buffer->pop_first_older_than(_x.time_us + 1, &pop)); EXPECT_EQ(_x.time_us, pop.time_us); - EXPECT_EQ(true, _buffer->pop_first_older_than(_y.time_us + 10, &pop)); + EXPECT_TRUE(_buffer->pop_first_older_than(_y.time_us + 10, &pop)); EXPECT_EQ(_y.time_us, pop.time_us); - EXPECT_EQ(true, _buffer->pop_first_older_than(_z.time_us + 100, &pop)); + EXPECT_TRUE(_buffer->pop_first_older_than(_z.time_us + 100, &pop)); EXPECT_EQ(_z.time_us, pop.time_us); // TODO: When changing the order of popping sample it does not behave as expected, fix this } -TEST_F(TimestampedRingBufferTest, askingForTooNewSample) +TEST_F(TimestampedRingBufferDynamicTest, askingForTooNewSample) { ASSERT_EQ(true, _buffer->allocate(3)); _buffer->push(_x); @@ -138,11 +147,11 @@ TEST_F(TimestampedRingBufferTest, askingForTooNewSample) sample pop = {}; // WHEN: all buffered samples are older by 0.1s than your query timestamp // THEN: should get no sample returned - EXPECT_EQ(true, _buffer->pop_first_older_than(_z.time_us + 99000, &pop)); - EXPECT_EQ(false, _buffer->pop_first_older_than(_y.time_us + 100000, &pop)); + EXPECT_TRUE(_buffer->pop_first_older_than(_z.time_us + 99000, &pop)); + EXPECT_FALSE(_buffer->pop_first_older_than(_y.time_us + 100000, &pop)); } -TEST_F(TimestampedRingBufferTest, reallocateBuffer) +TEST_F(TimestampedRingBufferDynamicTest, reallocateBuffer) { ASSERT_EQ(true, _buffer->allocate(5)); _buffer->push(_x); @@ -153,8 +162,7 @@ TEST_F(TimestampedRingBufferTest, reallocateBuffer) // GIVEN: allocated and filled buffer // WHEN: do another allocate call - _buffer->allocate(3); + EXPECT_TRUE(_buffer->allocate(3)); // THEN: its length should update EXPECT_EQ(3, _buffer->get_length()); - } diff --git a/src/lib/ringbuffer/TimestampedRingBufferStaticTest.cpp b/src/lib/ringbuffer/TimestampedRingBufferStaticTest.cpp new file mode 100644 index 000000000000..1fa223eab1a5 --- /dev/null +++ b/src/lib/ringbuffer/TimestampedRingBufferStaticTest.cpp @@ -0,0 +1,152 @@ +/**************************************************************************** + * + * Copyright (c) 2019-2023 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +#include +#include +#include "TimestampedRingBuffer.hpp" + +struct Sample { + uint64_t time_us; + float data[3]; +}; + + +class TimestampedRingBufferStaticTest : public ::testing::Test +{ +public: + Sample _x, _y, _z, _w; + TimestampedRingBuffer *_buffer{nullptr}; + + void SetUp() override + { + _buffer = new TimestampedRingBuffer(); + _x.time_us = 1000000; + _x.data[0] = _x.data[1] = _x.data[2] = 1.5f; + + _y.time_us = 2000000; + _y.data[0] = _y.data[1] = _y.data[2] = 3.0f; + + _z.time_us = 3000000; + _z.data[0] = _z.data[1] = _z.data[2] = 4.0f; + + _w.time_us = 4000000; + _w.data[0] = _w.data[1] = _w.data[2] = 5.0f; + } + + void TearDown() override + { + delete _buffer; + } +}; + +TEST_F(TimestampedRingBufferStaticTest, goodInitialisation) +{ + // WHEN: buffer was allocated + // THEN: allocation should have succeed + EXPECT_TRUE(_buffer->valid()); + EXPECT_FALSE(_buffer->allocate(2)); + EXPECT_EQ(_buffer->get_length(), 3); +} + +TEST_F(TimestampedRingBufferStaticTest, NextPrevHelpers) +{ + EXPECT_EQ(_buffer->next(0), 1); + EXPECT_EQ(_buffer->next(1), 2); + EXPECT_EQ(_buffer->next(2), 0); + + EXPECT_EQ(_buffer->prev(0), 2); + EXPECT_EQ(_buffer->prev(1), 0); + EXPECT_EQ(_buffer->prev(2), 1); +} + +TEST_F(TimestampedRingBufferStaticTest, PushWrapAndIndices) +{ + _buffer->reset(); + ASSERT_TRUE(_buffer->empty()); + + _buffer->push(_x); + _buffer->push(_y); + EXPECT_EQ(_buffer->entries(), 2); + _buffer->push(_z); + + EXPECT_EQ(_buffer->entries(), 3); + EXPECT_EQ(_buffer->get_oldest().time_us, _x.time_us); + EXPECT_EQ(_buffer->get_newest().time_us, _z.time_us); + + // Overwrite once (ring wrap-around). + _buffer->push(_w); + + EXPECT_EQ(_buffer->entries(), 3); + EXPECT_EQ(_buffer->get_oldest().time_us, _y.time_us); + EXPECT_EQ(_buffer->get_newest().time_us, _w.time_us); +} + +TEST_F(TimestampedRingBufferStaticTest, PopFirstOlderThanDiscardsOlderHistory) +{ + _buffer->reset(); + _buffer->push(_x); + _buffer->push(_y); + _buffer->push(_z); + + Sample pop = {}; + + // Query timestamp older than any in the buffer: no match. + EXPECT_FALSE(_buffer->pop_first_older_than(0, &pop)); + + // Pop y (also discards x), leaving z as the only remaining Sample. + EXPECT_TRUE(_buffer->pop_first_older_than(_y.time_us + 10, &pop)); + EXPECT_EQ(pop.time_us, _y.time_us); + + EXPECT_EQ(_buffer->entries(), 1); + EXPECT_EQ(_buffer->get_oldest().time_us, _z.time_us); + EXPECT_EQ(_buffer->get_newest().time_us, _z.time_us); + + // Pop z (buffer becomes empty). + EXPECT_TRUE(_buffer->pop_first_older_than(_z.time_us + 1, &pop)); + EXPECT_EQ(pop.time_us, _z.time_us); + EXPECT_TRUE(_buffer->empty()); +} + +TEST_F(TimestampedRingBufferStaticTest, ResetClearsTimestamps) +{ + _buffer->push(_x); + _buffer->push(_y); + EXPECT_FALSE(_buffer->empty()); + + _buffer->reset(); + EXPECT_TRUE(_buffer->empty()); + + for (uint8_t i = 0; i < _buffer->get_length(); i++) { + EXPECT_EQ((*_buffer)[i].time_us, 0u); + } +} diff --git a/src/modules/flight_mode_manager/tasks/Auto/FlightTaskAuto.cpp b/src/modules/flight_mode_manager/tasks/Auto/FlightTaskAuto.cpp index 7fdf29ec0469..61efb14fec56 100644 --- a/src/modules/flight_mode_manager/tasks/Auto/FlightTaskAuto.cpp +++ b/src/modules/flight_mode_manager/tasks/Auto/FlightTaskAuto.cpp @@ -93,6 +93,9 @@ bool FlightTaskAuto::updateInitialize() _sub_home_position.update(); _sub_vehicle_status.update(); _position_setpoint_triplet_sub.update(); +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + _prec_land_status_sub.update(); +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR // require valid reference and valid target ret = ret && _evaluateGlobalReference() && _evaluatePositionSetpointTriplet(); @@ -238,7 +241,12 @@ void FlightTaskAuto::_prepareLandSetpoints() if (_type_previous != WaypointType::land) { // initialize yaw and xy-position - _land_heading = _yaw_setpoint; + _land_heading = PX4_ISFINITE(_yaw_setpoint) ? _yaw_setpoint : _yaw_setpoint_previous; + + if (!PX4_ISFINITE(_land_heading)) { + _land_heading = _yaw; + } + _stick_acceleration_xy.resetPosition(Vector2f(_triplet_current)); _initial_land_position = Vector3f(_triplet_current(0), _triplet_current(1), NAN); } @@ -246,6 +254,18 @@ void FlightTaskAuto::_prepareLandSetpoints() // Update xy-position in case of landing position changes (etc. precision landing) _land_position = Vector3f(_triplet_current(0), _triplet_current(1), NAN); +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + + // Use the raw navigator/precland yaw command here. _yaw_setpoint can stay finite from + // generic land yaw logic even after precland clears current.yaw. + if (_param_pld_yaw_en.get() + && _prec_land_status_sub.get().state == prec_land_status_s::PREC_LAND_STATE_ONGOING) { + const float prec_land_yaw = _position_setpoint_triplet_sub.get().current.yaw; + _land_heading = PX4_ISFINITE(prec_land_yaw) ? prec_land_yaw : _yaw; + } + +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + // User input assisted landing if (_param_mpc_land_rc_help.get() && _sticks.checkAndUpdateStickInputs()) { // Stick full up -1 -> stop, stick full down 1 -> double the speed diff --git a/src/modules/flight_mode_manager/tasks/Auto/FlightTaskAuto.hpp b/src/modules/flight_mode_manager/tasks/Auto/FlightTaskAuto.hpp index 6541c862d0ec..eb21ca3a051c 100644 --- a/src/modules/flight_mode_manager/tasks/Auto/FlightTaskAuto.hpp +++ b/src/modules/flight_mode_manager/tasks/Auto/FlightTaskAuto.hpp @@ -44,6 +44,9 @@ #include #include #include +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR +#include +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR #include #include #include @@ -120,7 +123,10 @@ class FlightTaskAuto : public FlightTask WaypointType _type{WaypointType::idle}; /**< Type of current target triplet. */ uORB::SubscriptionData _position_setpoint_triplet_sub{ORB_ID(position_setpoint_triplet)}; - uORB::SubscriptionData _sub_home_position{ORB_ID(home_position)}; +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + uORB::SubscriptionData _prec_land_status_sub {ORB_ID(prec_land_status)}; +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + uORB::SubscriptionData _sub_home_position {ORB_ID(home_position)}; uORB::SubscriptionData _sub_vehicle_status{ORB_ID(vehicle_status)}; float _target_acceptance_radius{0.0f}; /**< Acceptances radius of the target */ @@ -141,6 +147,9 @@ class FlightTaskAuto : public FlightTask bool _want_takeoff{false}; DEFINE_PARAMETERS_CUSTOM_PARENT(FlightTask, +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + (ParamInt) _param_pld_yaw_en, +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR (ParamFloat) _param_mpc_xy_cruise, (ParamFloat) _param_nav_mc_alt_rad, //vertical acceptance radius at which waypoints are updated diff --git a/src/modules/local_position_estimator/BlockLocalPositionEstimator.hpp b/src/modules/local_position_estimator/BlockLocalPositionEstimator.hpp index 8baf8b292c98..e6c59c2ae5ef 100644 --- a/src/modules/local_position_estimator/BlockLocalPositionEstimator.hpp +++ b/src/modules/local_position_estimator/BlockLocalPositionEstimator.hpp @@ -300,12 +300,6 @@ class BlockLocalPositionEstimator : public ModuleBase, public ModuleParams, MapProjection _global_local_proj_ref{}; float _global_local_alt0{NAN}; - // target mode paramters from landing_target_estimator module - enum TargetMode { - Target_Moving = 0, - Target_Stationary = 1 - }; - // flow gyro filter BlockHighPass _flow_gyro_x_high_pass; BlockHighPass _flow_gyro_y_high_pass; @@ -458,7 +452,6 @@ class BlockLocalPositionEstimator : public ModuleBase, public ModuleParams, (ParamFloat) _param_lpe_t_max_grade, (ParamFloat) _param_lpe_lt_cov, - (ParamInt) _param_ltest_mode, // init origin (ParamInt) _param_lpe_fake_origin, diff --git a/src/modules/local_position_estimator/sensors/landing_target.cpp b/src/modules/local_position_estimator/sensors/landing_target.cpp index 6472fc198fab..315286288935 100644 --- a/src/modules/local_position_estimator/sensors/landing_target.cpp +++ b/src/modules/local_position_estimator/sensors/landing_target.cpp @@ -8,7 +8,7 @@ static const uint64_t TARGET_TIMEOUT = 2000000; // [us] void BlockLocalPositionEstimator::landingTargetInit() { - if (_param_ltest_mode.get() == Target_Moving) { + if (!_sub_landing_target_pose.get().is_static) { // target is in moving mode, do not initialize return; } @@ -24,7 +24,7 @@ void BlockLocalPositionEstimator::landingTargetInit() int BlockLocalPositionEstimator::landingTargetMeasure(Vector &y) { - if (_param_ltest_mode.get() == Target_Stationary) { + if (_sub_landing_target_pose.get().is_static) { if (_sub_landing_target_pose.get().rel_vel_valid) { y(0) = _sub_landing_target_pose.get().vx_rel; y(1) = _sub_landing_target_pose.get().vy_rel; @@ -43,7 +43,7 @@ int BlockLocalPositionEstimator::landingTargetMeasure(Vector void BlockLocalPositionEstimator::landingTargetCorrect() { - if (_param_ltest_mode.get() == Target_Moving) { + if (!_sub_landing_target_pose.get().is_static) { // nothing to do in this mode return; } diff --git a/src/modules/logger/logged_topics.cpp b/src/modules/logger/logged_topics.cpp index e3044c1ee71b..68739a1856d1 100644 --- a/src/modules/logger/logged_topics.cpp +++ b/src/modules/logger/logged_topics.cpp @@ -195,6 +195,23 @@ void LoggedTopics::add_default_topics() add_optional_topic_multi("estimator_status_flags", 10); add_optional_topic_multi("yaw_estimator_status", 1000); + // Vision target estimator topics +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + add_topic("vision_target_est_input", 50); + add_topic("vision_target_est_position", 100); + add_topic("vision_target_est_orientation", 100); + add_optional_topic("vte_bias_init_status", 10); // High rate because rarely published and only for a short period of time + add_optional_topic("vte_aid_gps_pos_target", 100); + add_optional_topic("vte_aid_gps_pos_mission", 100); + add_optional_topic("vte_aid_gps_vel_uav", 100); + add_optional_topic("vte_aid_gps_vel_target", 100); + add_optional_topic("vte_aid_fiducial_marker", 100); + add_optional_topic("vte_aid_ev_yaw", 100); + add_optional_topic("fiducial_marker_pos_report", 100); + add_optional_topic("fiducial_marker_yaw_report", 100); + add_optional_topic("target_gnss", 100); +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + // log all raw sensors at minimal rate (at least 1 Hz) add_topic_multi("battery_status", 200, 3); add_topic_multi("differential_pressure", 1000, 2); diff --git a/src/modules/mavlink/mavlink_receiver.cpp b/src/modules/mavlink/mavlink_receiver.cpp index ce59b940ad45..4ec4df9dd32c 100644 --- a/src/modules/mavlink/mavlink_receiver.cpp +++ b/src/modules/mavlink/mavlink_receiver.cpp @@ -116,6 +116,9 @@ MavlinkReceiver::MavlinkReceiver(Mavlink &parent) : _parameters_manager(parent), _mavlink_timesync(parent) { +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + _param_vte_en = param_find("VTE_EN"); +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR } void @@ -315,6 +318,23 @@ MavlinkReceiver::handle_message(mavlink_message_t *msg) break; #endif // !CONSTRAINED_FLASH +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR +#if defined(MAVLINK_MSG_ID_TARGET_RELATIVE) + + case MAVLINK_MSG_ID_TARGET_RELATIVE: + handle_message_target_relative(msg); + break; +#endif // MAVLINK_MSG_ID_TARGET_RELATIVE + +#if defined(MAVLINK_MSG_ID_TARGET_ABSOLUTE) + + case MAVLINK_MSG_ID_TARGET_ABSOLUTE: + handle_message_target_absolute(msg); + break; +#endif // MAVLINK_MSG_ID_TARGET_ABSOLUTE + +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + case MAVLINK_MSG_ID_GIMBAL_MANAGER_SET_ATTITUDE: handle_message_gimbal_manager_set_attitude(msg); break; @@ -2963,6 +2983,241 @@ MavlinkReceiver::handle_message_debug_float_array(mavlink_message_t *msg) } #endif // !CONSTRAINED_FLASH +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + +#if defined(MAVLINK_MSG_ID_TARGET_ABSOLUTE) +void +MavlinkReceiver::handle_message_target_absolute(mavlink_message_t *msg) +{ + mavlink_target_absolute_t target_absolute; + mavlink_msg_target_absolute_decode(msg, &target_absolute); + + target_gnss_s target_GNSS_report{}; + + target_GNSS_report.timestamp = hrt_absolute_time(); + target_GNSS_report.timestamp_sample = _mavlink_timesync.sync_stamp(target_absolute.timestamp); + + bool updated = false; + + // Position: bit 0 + if (!(target_absolute.sensor_capabilities & static_cast(TargetAbsoluteSensorCapability::kPosition))) { + target_GNSS_report.abs_pos_updated = false; + + } else { + target_GNSS_report.abs_pos_updated = true; + target_GNSS_report.latitude_deg = target_absolute.lat * 1e-7; + target_GNSS_report.longitude_deg = target_absolute.lon * 1e-7; + target_GNSS_report.altitude_msl_m = target_absolute.alt; + target_GNSS_report.eph = target_absolute.position_std[0]; + target_GNSS_report.epv = target_absolute.position_std[1]; + updated = true; + } + + // Velocity: bit 1 + if (!(target_absolute.sensor_capabilities & static_cast(TargetAbsoluteSensorCapability::kVelocity))) { + target_GNSS_report.vel_ned_updated = false; + + } else { + const matrix::Quatf q_target(target_absolute.q_target); + const bool target_attitude_available = (fabsf(q_target(0)) + fabsf(q_target(1)) + fabsf(q_target(2)) + + fabsf(q_target(3))) > FLT_EPSILON; + + if (target_attitude_available) { + const matrix::Vector3f vel_target_body(target_absolute.vel[0], target_absolute.vel[1], target_absolute.vel[2]); + const matrix::Vector3f vel_target_ned = q_target.rotateVector(vel_target_body); + + target_GNSS_report.vel_ned_updated = true; + target_GNSS_report.vel_n_m_s = vel_target_ned(0); + target_GNSS_report.vel_e_m_s = vel_target_ned(1); + target_GNSS_report.vel_d_m_s = vel_target_ned(2); + target_GNSS_report.s_acc_m_s = fmaxf(fabsf(target_absolute.vel_std[0]), + fmaxf(fabsf(target_absolute.vel_std[1]), + fabsf(target_absolute.vel_std[2]))); + updated = true; + + } else { + target_GNSS_report.vel_ned_updated = false; + } + } + + if (updated) { _target_gnss_pub.publish(target_GNSS_report); } +} +#endif // MAVLINK_MSG_ID_TARGET_ABSOLUTE + +#if defined(MAVLINK_MSG_ID_TARGET_RELATIVE) +void +MavlinkReceiver::handle_message_target_relative(mavlink_message_t *msg) +{ + mavlink_target_relative_t target_relative; + mavlink_msg_target_relative_decode(msg, &target_relative); + + vehicle_attitude_s vehicle_attitude; + vehicle_local_position_s vehicle_local_position; + + // Unsupported sensor frame + if (target_relative.frame != TARGET_OBS_FRAME_LOCAL_OFFSET_NED && target_relative.frame != TARGET_OBS_FRAME_LOCAL_NED && + target_relative.frame != TARGET_OBS_FRAME_BODY_FRD && target_relative.frame != TARGET_OBS_FRAME_OTHER) { + mavlink_log_critical(&_mavlink_log_pub, "target relative: coordinate frame %" PRIu8 " unsupported.\t", + target_relative.frame); + events::send(events::ID("mavlink_rcv_target_rel_unsup_coord_frame"), events::Log::Error, + "Target relative: unsupported coordinate frame {1}", target_relative.frame); + + return; + } + + // Unsupported measurement type + if (target_relative.type != LANDING_TARGET_TYPE_VISION_FIDUCIAL) { + mavlink_log_critical(&_mavlink_log_pub, "target relative: type %" PRIu8 " unsupported.\t", + target_relative.type); + events::send(events::ID("mavlink_rcv_target_rel_unsup_type"), events::Log::Error, + "Target relative: unsupported type {1}", target_relative.type); + return; + } + + matrix::Quatf q_sensor(target_relative.q_sensor); + bool attitude_available = (fabsf(q_sensor(0)) + fabsf(q_sensor(1)) + fabsf(q_sensor(2)) + + fabsf(q_sensor(3))) > FLT_EPSILON; + + // Check if attitude can be infered based on the frame + if (!attitude_available) { + if (target_relative.frame == TARGET_OBS_FRAME_LOCAL_OFFSET_NED || target_relative.frame == TARGET_OBS_FRAME_LOCAL_NED) { + // Observation already in NED, set quaternion to identity (1,0,0,0) + q_sensor.setIdentity(); + attitude_available = true; + + } else if (target_relative.frame == TARGET_OBS_FRAME_BODY_FRD && _vehicle_attitude_sub.copy(&vehicle_attitude)) { + // Set the quaternion to the current attitude + const matrix::Quaternionf quat_att(&vehicle_attitude.q[0]); + q_sensor = quat_att; + attitude_available = true; + + } else if (target_relative.frame == TARGET_OBS_FRAME_OTHER) { + // Target sensor frame: OTHER and no quaternion is provided + mavlink_log_critical(&_mavlink_log_pub, + "target relative: coordinate frame %" PRIu8 " unsupported when the q_sensor is not filled.\t", + target_relative.frame); + events::send(events::ID("mavlink_rcv_target_rel_q_sensor_not_filled"), events::Log::Error, + "Target relative: unsupported coordinate frame {1} when the q_sensor is not filled", target_relative.frame); + } + } + + // Target cannot be sent withtout valid attitude + if (!attitude_available) { + return; + } + + // Forward target to the vision target estimator (VTE) or precland based VTE_EN + int32_t vte_enabled = 0; + + if (_param_vte_en == PARAM_INVALID) { + if (hrt_elapsed_time(&_vte_en_invalid_warn_last) > 20_s) { + PX4_WARN(" Could not find VTE_EN, target_relative msg ignored."); + _vte_en_invalid_warn_last = hrt_absolute_time(); + } + + return; + + } else { + param_get(_param_vte_en, &vte_enabled); + } + + if (!vte_enabled) { + + // Send the target directly to the precision landing algorithm in local NED frame + landing_target_pose_s landing_target_pose{}; + landing_target_pose.timestamp = _mavlink_timesync.sync_stamp(target_relative.timestamp); + + // Measurement already in local NED + if (target_relative.frame == TARGET_OBS_FRAME_LOCAL_NED) { + + landing_target_pose.x_abs = target_relative.x; + landing_target_pose.y_abs = target_relative.y; + landing_target_pose.z_abs = target_relative.z; + landing_target_pose.abs_pos_valid = true; + + _landing_target_pose_pub.publish(landing_target_pose); + + } else if (_vehicle_local_position_sub.copy(&vehicle_local_position) + && vehicle_local_position.xy_valid && vehicle_local_position.z_valid) { + + // If the local position is available, convert from sensor frame to local NED + matrix::Vector3f target_relative_frame(target_relative.x, target_relative.y, target_relative.z); + + if (target_relative.frame == TARGET_OBS_FRAME_BODY_FRD || target_relative.frame == TARGET_OBS_FRAME_OTHER) { + // Rotate measurement into vehicle-carried NED + target_relative_frame = q_sensor.rotateVector(target_relative_frame); + } + + // Convert from vehicle-carried NED to local NED + landing_target_pose.x_abs = target_relative_frame(0) + vehicle_local_position.x; + landing_target_pose.y_abs = target_relative_frame(1) + vehicle_local_position.y; + landing_target_pose.z_abs = target_relative_frame(2) + vehicle_local_position.z; + landing_target_pose.abs_pos_valid = true; + + _landing_target_pose_pub.publish(landing_target_pose); + } + + } else { + + // Only supported type: LANDING_TARGET_TYPE_VISION_FIDUCIAL + + // Position report + fiducial_marker_pos_report_s fiducial_marker_pos_report{}; + + const hrt_abstime now = hrt_absolute_time(); + const hrt_abstime timestamp_sample = _mavlink_timesync.sync_stamp(target_relative.timestamp); + + fiducial_marker_pos_report.timestamp = now; + fiducial_marker_pos_report.timestamp_sample = timestamp_sample; + matrix::Vector3f target_relative_frame(target_relative.x, target_relative.y, target_relative.z); + + // TARGET_OBS_FRAME_LOCAL_NED is absolute in local frame: convert to vehicle-relative. + if (target_relative.frame == TARGET_OBS_FRAME_LOCAL_NED) { + if (!_vehicle_local_position_sub.copy(&vehicle_local_position) + || !vehicle_local_position.xy_valid || !vehicle_local_position.z_valid) { + return; + } + + target_relative_frame(0) -= vehicle_local_position.x; + target_relative_frame(1) -= vehicle_local_position.y; + target_relative_frame(2) -= vehicle_local_position.z; + } + + fiducial_marker_pos_report.rel_pos[0] = target_relative_frame(0); + fiducial_marker_pos_report.rel_pos[1] = target_relative_frame(1); + fiducial_marker_pos_report.rel_pos[2] = target_relative_frame(2); + + fiducial_marker_pos_report.cov_rel_pos[0] = target_relative.pos_std[0] * target_relative.pos_std[0]; + fiducial_marker_pos_report.cov_rel_pos[1] = target_relative.pos_std[1] * target_relative.pos_std[1]; + fiducial_marker_pos_report.cov_rel_pos[2] = target_relative.pos_std[2] * target_relative.pos_std[2]; + + q_sensor.copyTo(fiducial_marker_pos_report.q); + _fiducial_marker_pos_report_pub.publish(fiducial_marker_pos_report); + + const matrix::Quatf q_target(target_relative.q_target); + const bool target_attitude_available = (fabsf(q_target(0)) + fabsf(q_target(1)) + fabsf(q_target(2)) + + fabsf(q_target(3))) > FLT_EPSILON; + + if (target_attitude_available) { + // Yaw report + fiducial_marker_yaw_report_s fiducial_marker_yaw_report{}; + fiducial_marker_yaw_report.timestamp = now; + fiducial_marker_yaw_report.timestamp_sample = timestamp_sample; + + // Transform quaternion from the target's frame to the TARGET_OBS_FRAME to the yaw relative to NED + const matrix::Quatf q_in_ned = q_sensor * q_target; + const float target_yaw_ned = matrix::Eulerf(q_in_ned).psi(); + + fiducial_marker_yaw_report.yaw_ned = target_yaw_ned; + fiducial_marker_yaw_report.yaw_var_ned = target_relative.yaw_std * target_relative.yaw_std; + _fiducial_marker_yaw_report_pub.publish(fiducial_marker_yaw_report); + } + } + +} +#endif // MAVLINK_MSG_ID_TARGET_RELATIVE +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + void MavlinkReceiver::handle_message_onboard_computer_status(mavlink_message_t *msg) { diff --git a/src/modules/mavlink/mavlink_receiver.h b/src/modules/mavlink/mavlink_receiver.h index e2adf224dbc1..2362d8369498 100644 --- a/src/modules/mavlink/mavlink_receiver.h +++ b/src/modules/mavlink/mavlink_receiver.h @@ -125,8 +125,24 @@ # include #endif // !CONSTRAINED_FLASH +# if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR +# include +# include +# include +# endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + using namespace time_literals; +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR +enum class TargetAbsoluteSensorCapability : uint8_t { + kPosition = (1 << 0), + kVelocity = (1 << 1), + kAcceleration = (1 << 2), + kAttitude = (1 << 3), + kRates = (1 << 4), +}; +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + class Mavlink; class MavlinkReceiver : public ModuleParams @@ -228,6 +244,12 @@ class MavlinkReceiver : public ModuleParams void handle_message_debug_vect(mavlink_message_t *msg); void handle_message_named_value_float(mavlink_message_t *msg); #endif // !CONSTRAINED_FLASH + +# if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + void handle_message_target_relative(mavlink_message_t *msg); + void handle_message_target_absolute(mavlink_message_t *msg); +# endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + void handle_message_request_event(mavlink_message_t *msg); void CheckHeartbeats(const hrt_abstime &t, bool force = false); @@ -357,6 +379,14 @@ class MavlinkReceiver : public ModuleParams uORB::Publication _debug_vect_pub{ORB_ID(debug_vect)}; #endif // !CONSTRAINED_FLASH +# if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + uORB::Publication _fiducial_marker_pos_report_pub {ORB_ID(fiducial_marker_pos_report)}; + uORB::Publication _fiducial_marker_yaw_report_pub{ORB_ID(fiducial_marker_yaw_report)}; + uORB::Publication _target_gnss_pub{ORB_ID(target_gnss)}; + param_t _param_vte_en{PARAM_INVALID}; + hrt_abstime _vte_en_invalid_warn_last{0}; +# endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + // ORB publications (multi) uORB::PublicationMulti _distance_sensor_pub{ORB_ID(distance_sensor)}; uORB::PublicationMulti _aux_global_position_pub{ORB_ID(aux_global_position)}; diff --git a/src/modules/navigator/precland.cpp b/src/modules/navigator/precland.cpp index a111bf75c932..45133dc21b6b 100644 --- a/src/modules/navigator/precland.cpp +++ b/src/modules/navigator/precland.cpp @@ -78,6 +78,10 @@ PrecLand::on_activation() _search_cnt = 0; _last_slewrate_time = 0; +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + reset_target_yaw_state(); +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + vehicle_local_position_s *vehicle_local_position = _navigator->get_local_position(); if (!_map_ref.isInitialized()) { @@ -122,8 +126,35 @@ PrecLand::on_active() _target_pose_valid = false; } +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + + // get orientation measurement + if (_param_pld_yaw_en.get()) { + vision_target_est_orientation_s target_orientation; + + if (_target_orientation_sub.update(&target_orientation)) { + if (target_orientation.orientation_valid) { + _target_yaw_valid = true; + _last_target_yaw_update = target_orientation.timestamp; + _target_yaw = target_orientation.yaw; + + } else { + reset_target_yaw_state(); + } + } + + if (_target_yaw_valid && ((hrt_elapsed_time(&_last_target_yaw_update) / 1e6f) > _param_pld_btout.get())) { + reset_target_yaw_state(); + } + + } else { + reset_target_yaw_state(); + } + +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + // stop if we are landed - if (_navigator->get_land_detected()->landed) { + if (_navigator->get_land_detected()->landed && _state != PrecLandState::Done) { switch_to_state_done(); } @@ -161,11 +192,21 @@ PrecLand::on_active() break; } +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + publish_prec_land_status(_state != PrecLandState::Done); +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR } void PrecLand::on_inactivation() { + clear_current_yaw_setpoint(); + _navigator->set_position_setpoint_triplet_updated(); + +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + reset_target_yaw_state(); + publish_prec_land_status(false); +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR _is_activated = false; } @@ -268,6 +309,10 @@ PrecLand::run_state_horizontal_approach() pos_sp_triplet->current.alt = _approach_alt; pos_sp_triplet->current.type = position_setpoint_s::SETPOINT_TYPE_POSITION; +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + update_current_yaw_setpoint(); +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + _navigator->set_position_setpoint_triplet_updated(); } @@ -298,6 +343,9 @@ PrecLand::run_state_descend_above_target() _map_ref.reproject(_target_pose.x_abs, _target_pose.y_abs, pos_sp_triplet->current.lat, pos_sp_triplet->current.lon); pos_sp_triplet->current.type = position_setpoint_s::SETPOINT_TYPE_LAND; +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + update_current_yaw_setpoint(); +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR _navigator->set_position_setpoint_triplet_updated(); } @@ -352,6 +400,7 @@ PrecLand::switch_to_state_start() if (check_state_conditions(PrecLandState::Start)) { position_setpoint_triplet_s *pos_sp_triplet = _navigator->get_position_setpoint_triplet(); pos_sp_triplet->current.type = position_setpoint_s::SETPOINT_TYPE_POSITION; + clear_current_yaw_setpoint(); _navigator->set_position_setpoint_triplet_updated(); _search_cnt++; @@ -400,6 +449,8 @@ PrecLand::switch_to_state_final_approach() { if (check_state_conditions(PrecLandState::FinalApproach)) { print_state_switch_message("final approach"); + clear_current_yaw_setpoint(); + _navigator->set_position_setpoint_triplet_updated(); _state = PrecLandState::FinalApproach; _state_start_time = hrt_absolute_time(); return true; @@ -415,6 +466,7 @@ PrecLand::switch_to_state_search() position_setpoint_triplet_s *pos_sp_triplet = _navigator->get_position_setpoint_triplet(); pos_sp_triplet->current.alt = _navigator->get_home_position()->alt + _param_pld_srch_alt.get(); pos_sp_triplet->current.type = position_setpoint_s::SETPOINT_TYPE_POSITION; + clear_current_yaw_setpoint(); _navigator->set_position_setpoint_triplet_updated(); _target_acquired_time = 0; @@ -433,6 +485,7 @@ PrecLand::switch_to_state_fallback() pos_sp_triplet->current.lon = _navigator->get_global_position()->lon; pos_sp_triplet->current.alt = _navigator->get_global_position()->alt; pos_sp_triplet->current.type = position_setpoint_s::SETPOINT_TYPE_LAND; + clear_current_yaw_setpoint(); _navigator->set_position_setpoint_triplet_updated(); _state = PrecLandState::Fallback; @@ -443,6 +496,8 @@ PrecLand::switch_to_state_fallback() bool PrecLand::switch_to_state_done() { + clear_current_yaw_setpoint(); + _navigator->set_position_setpoint_triplet_updated(); _state = PrecLandState::Done; _state_start_time = hrt_absolute_time(); return true; @@ -575,3 +630,41 @@ void PrecLand::slewrate(float &sp_x, float &sp_y) sp_x = sp_curr(0); sp_y = sp_curr(1); } + +void PrecLand::clear_current_yaw_setpoint() +{ + position_setpoint_triplet_s *pos_sp_triplet = _navigator->get_position_setpoint_triplet(); + pos_sp_triplet->current.yaw = NAN; +} + +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR +void PrecLand::update_current_yaw_setpoint() +{ + position_setpoint_triplet_s *pos_sp_triplet = _navigator->get_position_setpoint_triplet(); + pos_sp_triplet->current.yaw = (_param_pld_yaw_en.get() && _target_yaw_valid) ? _target_yaw : NAN; +} + +void PrecLand::reset_target_yaw_state() +{ + _target_yaw = 0.f; + _target_yaw_valid = false; + _last_target_yaw_update = 0; +} + +void PrecLand::publish_prec_land_status(const bool prec_land_ongoing) +{ + prec_land_status_s prec_land_status{}; + prec_land_status.timestamp = hrt_absolute_time(); + + if (prec_land_ongoing) { + prec_land_status.state = prec_land_status_s::PREC_LAND_STATE_ONGOING; + + } else { + prec_land_status.state = prec_land_status_s::PREC_LAND_STATE_STOPPED; + } + + prec_land_status.nav_state = (int)_state; + _prec_land_status_pub.publish(prec_land_status); +} + +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR diff --git a/src/modules/navigator/precland.h b/src/modules/navigator/precland.h index df07ff7bd5f2..6200ca3a0b33 100644 --- a/src/modules/navigator/precland.h +++ b/src/modules/navigator/precland.h @@ -46,6 +46,11 @@ #include #include +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR +#include +#include +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + #include "navigator_mode.h" #include "mission_block.h" @@ -106,10 +111,26 @@ class PrecLand : public MissionBlock, public ModuleParams // check if a given state could be changed into. Return true if possible to transition to state, false otherwise bool check_state_conditions(PrecLandState state); void slewrate(float &sp_x, float &sp_y); + void clear_current_yaw_setpoint(); + +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + void update_current_yaw_setpoint(); + void reset_target_yaw_state(); +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR landing_target_pose_s _target_pose{}; /**< precision landing target position */ uORB::Subscription _target_pose_sub{ORB_ID(landing_target_pose)}; + +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + uORB::Subscription _target_orientation_sub {ORB_ID(vision_target_est_orientation)}; + float _target_yaw{0.f}; + bool _target_yaw_valid{false}; + hrt_abstime _last_target_yaw_update{0}; + uORB::Publication _prec_land_status_pub {ORB_ID(prec_land_status)}; + void publish_prec_land_status(const bool prec_land_ongoing); +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + bool _target_pose_valid{false}; /**< whether we have received a landing target position message */ bool _target_pose_updated{false}; /**< wether the landing target position message is updated */ @@ -133,6 +154,9 @@ class PrecLand : public MissionBlock, public ModuleParams bool _is_activated {false}; /**< indicates if precland is activated */ DEFINE_PARAMETERS( +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + (ParamInt) _param_pld_yaw_en, +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR (ParamFloat) _param_pld_btout, (ParamFloat) _param_pld_hacc_rad, (ParamFloat) _param_pld_fappr_alt, diff --git a/src/modules/navigator/precland_params.yaml b/src/modules/navigator/precland_params.yaml index 979123c4dc37..562af60bf26e 100644 --- a/src/modules/navigator/precland_params.yaml +++ b/src/modules/navigator/precland_params.yaml @@ -70,3 +70,11 @@ parameters: default: 3 min: 0 max: 100 + PLD_YAW_EN: + description: + short: Set to true to control yaw while landing. + long: Control the orientation when landing. The orientation comes from the topic vision_target_est_orientation. + type: boolean + default: 0 + min: 0 + max: 1 \ No newline at end of file diff --git a/src/modules/simulation/simulator_mavlink/SimulatorMavlink.cpp b/src/modules/simulation/simulator_mavlink/SimulatorMavlink.cpp index c579a9ba4b3c..60663df8813e 100644 --- a/src/modules/simulation/simulator_mavlink/SimulatorMavlink.cpp +++ b/src/modules/simulation/simulator_mavlink/SimulatorMavlink.cpp @@ -97,6 +97,12 @@ SimulatorMavlink::SimulatorMavlink() : } _esc_status_pub.advertise(); +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + _fiducial_marker_pos_report_pub.advertise(); + _fiducial_marker_yaw_report_pub.advertise(); + _target_gnss_pub.advertise(); + _param_vte_en = param_find("VTE_EN"); +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR } void SimulatorMavlink::parameters_update(bool force) @@ -382,6 +388,22 @@ void SimulatorMavlink::handle_message(const mavlink_message_t *msg) handle_message_landing_target(msg); break; +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR +#if defined(MAVLINK_MSG_ID_TARGET_RELATIVE) + + case MAVLINK_MSG_ID_TARGET_RELATIVE: + handle_message_target_relative(msg); + break; +#endif // MAVLINK_MSG_ID_TARGET_RELATIVE + +#if defined(MAVLINK_MSG_ID_TARGET_ABSOLUTE) + + case MAVLINK_MSG_ID_TARGET_ABSOLUTE: + handle_message_target_absolute(msg); + break; +#endif // MAVLINK_MSG_ID_TARGET_ABSOLUTE +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + case MAVLINK_MSG_ID_HIL_STATE_QUATERNION: handle_message_hil_state_quaternion(msg); break; @@ -659,6 +681,217 @@ void SimulatorMavlink::handle_message_landing_target(const mavlink_message_t *ms } } + +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + +#if defined(MAVLINK_MSG_ID_TARGET_ABSOLUTE) +void +SimulatorMavlink::handle_message_target_absolute(const mavlink_message_t *msg) +{ + mavlink_target_absolute_t target_absolute; + mavlink_msg_target_absolute_decode(msg, &target_absolute); + + target_gnss_s target_GNSS_report{}; + target_GNSS_report.timestamp = hrt_absolute_time(); + target_GNSS_report.timestamp_sample = target_GNSS_report.timestamp; + + bool updated = false; + + // Position: bit 0 + if (!(target_absolute.sensor_capabilities & static_cast(TargetAbsoluteSensorCapability::kPosition))) { + target_GNSS_report.abs_pos_updated = false; + + } else { + target_GNSS_report.abs_pos_updated = true; + target_GNSS_report.latitude_deg = target_absolute.lat * 1e-7; + target_GNSS_report.longitude_deg = target_absolute.lon * 1e-7; + target_GNSS_report.altitude_msl_m = target_absolute.alt; + target_GNSS_report.eph = target_absolute.position_std[0]; + target_GNSS_report.epv = target_absolute.position_std[1]; + updated = true; + } + + // Velocity: bit 1 + if (!(target_absolute.sensor_capabilities & static_cast(TargetAbsoluteSensorCapability::kVelocity))) { + target_GNSS_report.vel_ned_updated = false; + + } else { + const matrix::Quatf q_target(target_absolute.q_target); + const bool target_attitude_available = (fabsf(q_target(0)) + fabsf(q_target(1)) + fabsf(q_target(2)) + + fabsf(q_target(3))) > FLT_EPSILON; + + if (target_attitude_available) { + const matrix::Vector3f vel_target_body(target_absolute.vel[0], target_absolute.vel[1], target_absolute.vel[2]); + const matrix::Vector3f vel_target_ned = q_target.rotateVector(vel_target_body); + + target_GNSS_report.vel_ned_updated = true; + target_GNSS_report.vel_n_m_s = vel_target_ned(0); + target_GNSS_report.vel_e_m_s = vel_target_ned(1); + target_GNSS_report.vel_d_m_s = vel_target_ned(2); + target_GNSS_report.s_acc_m_s = fmaxf(fabsf(target_absolute.vel_std[0]), + fmaxf(fabsf(target_absolute.vel_std[1]), + fabsf(target_absolute.vel_std[2]))); + updated = true; + + } else { + target_GNSS_report.vel_ned_updated = false; + } + } + + if (updated) { _target_gnss_pub.publish(target_GNSS_report); } +} +#endif // MAVLINK_MSG_ID_TARGET_ABSOLUTE + + +#if defined(MAVLINK_MSG_ID_TARGET_RELATIVE) +void SimulatorMavlink::handle_message_target_relative(const mavlink_message_t *msg) +{ + mavlink_target_relative_t target_relative; + mavlink_msg_target_relative_decode(msg, &target_relative); + + vehicle_attitude_s vehicle_attitude; + vehicle_local_position_s vehicle_local_position; + + // Unsupported sensor frame + if (target_relative.frame != TARGET_OBS_FRAME_LOCAL_OFFSET_NED && target_relative.frame != TARGET_OBS_FRAME_LOCAL_NED && + target_relative.frame != TARGET_OBS_FRAME_BODY_FRD && target_relative.frame != TARGET_OBS_FRAME_OTHER) { + PX4_WARN("target relative: coordinate frame %d unsupported", (int)target_relative.frame); + return; + } + + // Unsupported measurement type + if (target_relative.type != LANDING_TARGET_TYPE_VISION_FIDUCIAL) { + PX4_WARN("target relative: coordinate frame %d type", (int)target_relative.type); + return; + } + + matrix::Quatf q_sensor(target_relative.q_sensor); + bool attitude_available = (fabsf(q_sensor(0)) + fabsf(q_sensor(1)) + fabsf(q_sensor(2)) + + fabsf(q_sensor(3))) > FLT_EPSILON; + + // Check if attitude can be infered based on the frame + if (!attitude_available) { + if (target_relative.frame == TARGET_OBS_FRAME_LOCAL_OFFSET_NED || target_relative.frame == TARGET_OBS_FRAME_LOCAL_NED) { + // Observation already in NED, set quaternion to identity (1,0,0,0) + q_sensor.setIdentity(); + attitude_available = true; + + } else if (target_relative.frame == TARGET_OBS_FRAME_BODY_FRD && _vehicle_attitude_sub.copy(&vehicle_attitude)) { + // Set the quaternion to the current attitude + const matrix::Quaternionf quat_att(&vehicle_attitude.q[0]); + q_sensor = quat_att; + attitude_available = true; + + } else if (target_relative.frame == TARGET_OBS_FRAME_OTHER) { + // Target sensor frame: OTHER and no quaternion is provided + PX4_WARN("target relative: coordinate frame %d unsupported when the q_sensor is not filled", + (int)target_relative.frame); + } + } + + // Target cannot be sent withtout valid attitude + if (!attitude_available) { + return; + } + + // Forward target to the vision target estimator (VTE) or precland based VTE_EN + int32_t vte_enabled = 0; + + if (_param_vte_en == PARAM_INVALID) { + if (hrt_elapsed_time(&_vte_en_invalid_warn_last) > 20_s) { + PX4_WARN(" Could not find VTE_EN, target_relative msg ignored."); + _vte_en_invalid_warn_last = hrt_absolute_time(); + } + + return; + + } else { + param_get(_param_vte_en, &vte_enabled); + } + + if (!vte_enabled) { + + // Send the target directly to the precision landing algorithm in local NED frame + landing_target_pose_s landing_target_pose{}; + landing_target_pose.timestamp = target_relative.timestamp; + + // Measurement already in local NED + if (target_relative.frame == TARGET_OBS_FRAME_LOCAL_NED) { + + landing_target_pose.x_abs = target_relative.x; + landing_target_pose.y_abs = target_relative.y; + landing_target_pose.z_abs = target_relative.z; + landing_target_pose.abs_pos_valid = true; + + _landing_target_pose_pub.publish(landing_target_pose); + + } else if (_vehicle_local_position_sub.copy(&vehicle_local_position) + && vehicle_local_position.xy_valid && vehicle_local_position.z_valid) { + + // If the local position is available, convert from sensor frame to local NED + matrix::Vector3f target_relative_frame(target_relative.x, target_relative.y, target_relative.z); + + if (target_relative.frame == TARGET_OBS_FRAME_BODY_FRD || target_relative.frame == TARGET_OBS_FRAME_OTHER) { + // Rotate measurement into vehicle-carried NED + target_relative_frame = q_sensor.rotateVector(target_relative_frame); + } + + // Convert from vehicle-carried NED to local NED + landing_target_pose.x_abs = target_relative_frame(0) + vehicle_local_position.x; + landing_target_pose.y_abs = target_relative_frame(1) + vehicle_local_position.y; + landing_target_pose.z_abs = target_relative_frame(2) + vehicle_local_position.z; + landing_target_pose.abs_pos_valid = true; + + _landing_target_pose_pub.publish(landing_target_pose); + } + + } else { + + // Only supported type: LANDING_TARGET_TYPE_VISION_FIDUCIAL + + // Position report + fiducial_marker_pos_report_s fiducial_marker_pos_report{}; + + const hrt_abstime now = hrt_absolute_time(); + const hrt_abstime timestamp_sample = target_relative.timestamp; + + fiducial_marker_pos_report.timestamp = now; + fiducial_marker_pos_report.timestamp_sample = timestamp_sample; + fiducial_marker_pos_report.rel_pos[0] = target_relative.x; + fiducial_marker_pos_report.rel_pos[1] = target_relative.y; + fiducial_marker_pos_report.rel_pos[2] = target_relative.z; + + fiducial_marker_pos_report.cov_rel_pos[0] = target_relative.pos_std[0] * target_relative.pos_std[0]; + fiducial_marker_pos_report.cov_rel_pos[1] = target_relative.pos_std[1] * target_relative.pos_std[1]; + fiducial_marker_pos_report.cov_rel_pos[2] = target_relative.pos_std[2] * target_relative.pos_std[2]; + + q_sensor.copyTo(fiducial_marker_pos_report.q); + _fiducial_marker_pos_report_pub.publish(fiducial_marker_pos_report); + + const matrix::Quatf q_target(target_relative.q_target); + const bool target_attitude_available = (fabsf(q_target(0)) + fabsf(q_target(1)) + fabsf(q_target(2)) + + fabsf(q_target(3))) > FLT_EPSILON; + + if (target_attitude_available) { + // Yaw report + fiducial_marker_yaw_report_s fiducial_marker_yaw_report{}; + fiducial_marker_yaw_report.timestamp = now; + fiducial_marker_yaw_report.timestamp_sample = timestamp_sample; + + // Transform quaternion from the target's frame to the TARGET_OBS_FRAME to the yaw relative to NED + const matrix::Quatf q_in_ned = q_sensor * q_target; + const float target_yaw_ned = matrix::Eulerf(q_in_ned).psi(); + + fiducial_marker_yaw_report.yaw_ned = target_yaw_ned; + fiducial_marker_yaw_report.yaw_var_ned = target_relative.yaw_std * target_relative.yaw_std; + _fiducial_marker_yaw_report_pub.publish(fiducial_marker_yaw_report); + } + } + +} +#endif // MAVLINK_MSG_ID_TARGET_RELATIVE +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + void SimulatorMavlink::handle_message_odometry(const mavlink_message_t *msg) { mavlink_odometry_t odom_in; diff --git a/src/modules/simulation/simulator_mavlink/SimulatorMavlink.hpp b/src/modules/simulation/simulator_mavlink/SimulatorMavlink.hpp index 5c1e9481e8ae..605b68ffbb20 100644 --- a/src/modules/simulation/simulator_mavlink/SimulatorMavlink.hpp +++ b/src/modules/simulation/simulator_mavlink/SimulatorMavlink.hpp @@ -63,6 +63,12 @@ #include #include #include +#include +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR +#include +#include +#include +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR #include #include #include @@ -113,6 +119,16 @@ static inline SensorSource operator &(A lhs, B rhs) ); } +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR +enum class TargetAbsoluteSensorCapability : uint8_t { + kPosition = (1 << 0), + kVelocity = (1 << 1), + kAcceleration = (1 << 2), + kAttitude = (1 << 3), + kRates = (1 << 4), +}; +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR + class SimulatorMavlink : public ModuleParams { public: @@ -226,6 +242,8 @@ class SimulatorMavlink : public ModuleParams void handle_message_hil_sensor(const mavlink_message_t *msg); void handle_message_hil_state_quaternion(const mavlink_message_t *msg); void handle_message_landing_target(const mavlink_message_t *msg); + void handle_message_target_relative(const mavlink_message_t *msg); + void handle_message_target_absolute(const mavlink_message_t *msg); void handle_message_odometry(const mavlink_message_t *msg); void handle_message_optical_flow(const mavlink_message_t *msg); void handle_message_rc_channels(const mavlink_message_t *msg); @@ -251,6 +269,14 @@ class SimulatorMavlink : public ModuleParams uORB::Publication _gpos_ground_truth_pub{ORB_ID(vehicle_global_position_groundtruth)}; uORB::Publication _lpos_ground_truth_pub{ORB_ID(vehicle_local_position_groundtruth)}; uORB::Publication _input_rc_pub{ORB_ID(input_rc)}; + uORB::Publication _landing_target_pose_pub{ORB_ID(landing_target_pose)}; +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR + uORB::Publication _fiducial_marker_pos_report_pub {ORB_ID(fiducial_marker_pos_report)}; + uORB::Publication _fiducial_marker_yaw_report_pub{ORB_ID(fiducial_marker_yaw_report)}; + uORB::Publication _target_gnss_pub{ORB_ID(target_gnss)}; + param_t _param_vte_en{PARAM_INVALID}; + hrt_abstime _vte_en_invalid_warn_last{0}; +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR //rpm uORB::Publication _rpm_pub{ORB_ID(rpm)}; @@ -268,6 +294,8 @@ class SimulatorMavlink : public ModuleParams uORB::Subscription _vehicle_status_sub{ORB_ID(vehicle_status)}; uORB::Subscription _vehicle_command_sub{ORB_ID(vehicle_command)}; uORB::Subscription _battery_status_sub{ORB_ID(battery_status)}; + uORB::Subscription _vehicle_attitude_sub{ORB_ID(vehicle_attitude)}; + uORB::Subscription _vehicle_local_position_sub{ORB_ID(vehicle_local_position)}; // hil map_ref data MapProjection _global_local_proj_ref{}; diff --git a/src/modules/vision_target_estimator/CMakeLists.txt b/src/modules/vision_target_estimator/CMakeLists.txt new file mode 100644 index 000000000000..3c1f946cde67 --- /dev/null +++ b/src/modules/vision_target_estimator/CMakeLists.txt @@ -0,0 +1,290 @@ +############################################################################ +# +# Copyright (c) 2025 PX4 Development Team. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# 3. Neither the name PX4 nor the names of its contributors may be +# used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +# OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +############################################################################ + +# +# Vision Target Estimator (VTEST) Symforce code generation +# +option(VTEST_SYMFORCE_GEN "vtest generate symforce output" OFF) +option(VTEST_UPDATE_COMMITTED_DERIVATION "Rewrite committed default generated files" OFF) + +# Check if symforce is available +execute_process( + COMMAND ${PYTHON_EXECUTABLE} -m symforce.symbolic + RESULT_VARIABLE PYTHON_SYMFORCE_EXIT_CODE + OUTPUT_QUIET + ERROR_QUIET +) + +# Enable symforce generation if a non-default configuration is used +if(CONFIG_VTEST_MOVING) + set(VTEST_SYMFORCE_GEN ON) +endif() + +# Set up paths and default variables +set(VTEST_DERIVATION_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/Position/vtest_derivation) +set(VTEST_PREGENERATED_STATIC_DIR ${VTEST_DERIVATION_SRC_DIR}/generated) +set(VTEST_PREGENERATED_MOVING_DIR ${VTEST_DERIVATION_SRC_DIR}/generated_moving) + +set(VTEST_PREGENERATED_STATIC_FILES + ${VTEST_PREGENERATED_STATIC_DIR}/state.h + ${VTEST_PREGENERATED_STATIC_DIR}/predictCov.h + ${VTEST_PREGENERATED_STATIC_DIR}/computeInnovCov.h + ${VTEST_PREGENERATED_STATIC_DIR}/predictState.h + ${VTEST_PREGENERATED_STATIC_DIR}/getTransitionMatrix.h + ${VTEST_PREGENERATED_STATIC_DIR}/applyCorrection.h +) + +set(VTEST_PREGENERATED_MOVING_FILES + ${VTEST_PREGENERATED_MOVING_DIR}/state.h + ${VTEST_PREGENERATED_MOVING_DIR}/predictCov.h + ${VTEST_PREGENERATED_MOVING_DIR}/computeInnovCov.h + ${VTEST_PREGENERATED_MOVING_DIR}/predictState.h + ${VTEST_PREGENERATED_MOVING_DIR}/getTransitionMatrix.h + ${VTEST_PREGENERATED_MOVING_DIR}/applyCorrection.h +) + +set(VTEST_DERIVATION_DST_DIR ${CMAKE_CURRENT_BINARY_DIR}/vtest_derivation) +file(MAKE_DIRECTORY ${VTEST_DERIVATION_DST_DIR}) + +set(VTEST_GENERATED_FILE_LIST_DST + ${VTEST_DERIVATION_DST_DIR}/generated/state.h + ${VTEST_DERIVATION_DST_DIR}/generated/predictCov.h + ${VTEST_DERIVATION_DST_DIR}/generated/computeInnovCov.h + ${VTEST_DERIVATION_DST_DIR}/generated/predictState.h + ${VTEST_DERIVATION_DST_DIR}/generated/getTransitionMatrix.h + ${VTEST_DERIVATION_DST_DIR}/generated/applyCorrection.h +) + +# Use one file as the dependency anchor. The custom_command OUTPUT will ensure all are generated. +set(VTEST_GENERATED_FILES ${VTEST_DERIVATION_DST_DIR}/generated/state.h) +set(VTEST_GENERATED_DERIVATION_INCLUDE_PATH ${CMAKE_CURRENT_BINARY_DIR}) + +set(VTEST_ORIENTATION_DERIVATION_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/Orientation/vtest_derivation) +set(VTEST_ORIENTATION_PREGENERATED_DIR ${VTEST_ORIENTATION_DERIVATION_SRC_DIR}/generated) +set(VTEST_ORIENTATION_PREGENERATED_FILES + ${VTEST_ORIENTATION_PREGENERATED_DIR}/predictState.h + ${VTEST_ORIENTATION_PREGENERATED_DIR}/getTransitionMatrix.h + ${VTEST_ORIENTATION_PREGENERATED_DIR}/predictCov.h +) +set(VTEST_ORIENTATION_DERIVATION_DST_DIR ${CMAKE_CURRENT_BINARY_DIR}/vte_orientation_derivation) +file(MAKE_DIRECTORY ${VTEST_ORIENTATION_DERIVATION_DST_DIR}) +set(VTEST_ORIENTATION_GENERATED_FILE_LIST_DST + ${VTEST_ORIENTATION_DERIVATION_DST_DIR}/generated/predictState.h + ${VTEST_ORIENTATION_DERIVATION_DST_DIR}/generated/getTransitionMatrix.h + ${VTEST_ORIENTATION_DERIVATION_DST_DIR}/generated/predictCov.h +) +set(VTEST_ORIENTATION_GENERATED_FILES ${VTEST_ORIENTATION_DERIVATION_DST_DIR}/generated/predictCov.h) + +set(VTEST_GENERATED_TARGET "") + +if(VTEST_SYMFORCE_GEN AND (${PYTHON_SYMFORCE_EXIT_CODE} EQUAL 0)) + + set(VTEST_SYMFORCE_TARGET_DEPS) + + # Regenerate committed defaults when requested. + if(VTEST_UPDATE_COMMITTED_DERIVATION) + add_custom_command( + OUTPUT + ${VTEST_PREGENERATED_STATIC_FILES} + COMMAND + ${PYTHON_EXECUTABLE} ${VTEST_DERIVATION_SRC_DIR}/derivation.py + DEPENDS + ${VTEST_DERIVATION_SRC_DIR}/derivation.py + ${VTEST_DERIVATION_SRC_DIR}/utils/derivation_utils.py + + WORKING_DIRECTORY ${VTEST_DERIVATION_SRC_DIR} + COMMENT "VTEST Symforce code generation (default, committed)" + USES_TERMINAL + ) + list(APPEND VTEST_SYMFORCE_TARGET_DEPS ${VTEST_PREGENERATED_STATIC_FILES}) + + if(CONFIG_VTEST_MOVING) + set(VTEST_DERIVATION_MOVING_TMP_DIR ${CMAKE_CURRENT_BINARY_DIR}/vtest_derivation_committed_moving) + file(MAKE_DIRECTORY ${VTEST_DERIVATION_MOVING_TMP_DIR}) + + add_custom_command( + OUTPUT + ${VTEST_PREGENERATED_MOVING_FILES} + COMMAND + ${CMAKE_COMMAND} -E remove_directory ${VTEST_DERIVATION_MOVING_TMP_DIR}/generated + COMMAND + ${PYTHON_EXECUTABLE} ${VTEST_DERIVATION_SRC_DIR}/derivation.py --moving + COMMAND + ${CMAKE_COMMAND} -E remove_directory ${VTEST_PREGENERATED_MOVING_DIR} + COMMAND + ${CMAKE_COMMAND} -E make_directory ${VTEST_PREGENERATED_MOVING_DIR} + COMMAND + ${CMAKE_COMMAND} -E copy_directory ${VTEST_DERIVATION_MOVING_TMP_DIR}/generated ${VTEST_PREGENERATED_MOVING_DIR} + DEPENDS + ${VTEST_DERIVATION_SRC_DIR}/derivation.py + ${VTEST_DERIVATION_SRC_DIR}/utils/derivation_utils.py + WORKING_DIRECTORY ${VTEST_DERIVATION_MOVING_TMP_DIR} + COMMENT "VTEST Symforce code generation (moving, committed)" + USES_TERMINAL + ) + list(APPEND VTEST_SYMFORCE_TARGET_DEPS ${VTEST_PREGENERATED_MOVING_FILES}) + endif() + + add_custom_command( + OUTPUT + ${VTEST_ORIENTATION_PREGENERATED_FILES} + COMMAND + ${PYTHON_EXECUTABLE} ${VTEST_ORIENTATION_DERIVATION_SRC_DIR}/derivation.py + DEPENDS + ${VTEST_ORIENTATION_DERIVATION_SRC_DIR}/derivation.py + WORKING_DIRECTORY ${VTEST_ORIENTATION_DERIVATION_SRC_DIR} + COMMENT "VTEST orientation Symforce code generation (committed)" + USES_TERMINAL + ) + list(APPEND VTEST_SYMFORCE_TARGET_DEPS ${VTEST_ORIENTATION_PREGENERATED_FILES}) + endif() + + set(SYMFORCE_ARGS) + if(CONFIG_VTEST_MOVING) + message(STATUS "vtest: symforce --moving") + list(APPEND SYMFORCE_ARGS --moving) + endif() + + add_custom_command( + OUTPUT + ${VTEST_GENERATED_FILE_LIST_DST} + COMMAND + ${PYTHON_EXECUTABLE} ${VTEST_DERIVATION_SRC_DIR}/derivation.py ${SYMFORCE_ARGS} + DEPENDS + ${VTEST_DERIVATION_SRC_DIR}/derivation.py + ${VTEST_DERIVATION_SRC_DIR}/utils/derivation_utils.py + + WORKING_DIRECTORY ${VTEST_DERIVATION_DST_DIR} + COMMENT "VTEST Symforce code generation" + USES_TERMINAL + ) + list(APPEND VTEST_SYMFORCE_TARGET_DEPS ${VTEST_GENERATED_FILE_LIST_DST}) + + add_custom_command( + OUTPUT + ${VTEST_ORIENTATION_GENERATED_FILE_LIST_DST} + COMMAND + ${PYTHON_EXECUTABLE} ${VTEST_ORIENTATION_DERIVATION_SRC_DIR}/derivation.py + DEPENDS + ${VTEST_ORIENTATION_DERIVATION_SRC_DIR}/derivation.py + WORKING_DIRECTORY ${VTEST_ORIENTATION_DERIVATION_DST_DIR} + COMMENT "VTEST orientation Symforce code generation" + USES_TERMINAL + ) + list(APPEND VTEST_SYMFORCE_TARGET_DEPS ${VTEST_ORIENTATION_GENERATED_FILE_LIST_DST}) + + add_custom_target(vtest_symforce_generate + DEPENDS ${VTEST_SYMFORCE_TARGET_DEPS} + ) + set(VTEST_GENERATED_TARGET vtest_symforce_generate) + +else() + if(CONFIG_VTEST_MOVING) + set(VTEST_PREGENERATED_SOURCE_DIR ${VTEST_PREGENERATED_MOVING_DIR}) + set(VTEST_PREGENERATED_SOURCE_FILES ${VTEST_PREGENERATED_MOVING_FILES}) + set(VTEST_PREGENERATED_CONFIG moving) + else() + set(VTEST_PREGENERATED_SOURCE_DIR ${VTEST_PREGENERATED_STATIC_DIR}) + set(VTEST_PREGENERATED_SOURCE_FILES ${VTEST_PREGENERATED_STATIC_FILES}) + set(VTEST_PREGENERATED_CONFIG static) + endif() + + add_custom_command( + OUTPUT + ${VTEST_GENERATED_FILE_LIST_DST} + COMMAND + ${CMAKE_COMMAND} -E remove_directory ${VTEST_DERIVATION_DST_DIR}/generated + COMMAND + ${CMAKE_COMMAND} -E make_directory ${VTEST_DERIVATION_DST_DIR}/generated + COMMAND + ${CMAKE_COMMAND} -E copy_directory ${VTEST_PREGENERATED_SOURCE_DIR} ${VTEST_DERIVATION_DST_DIR}/generated + DEPENDS + ${VTEST_PREGENERATED_SOURCE_FILES} + COMMENT "VTEST using pre-generated symforce outputs (${VTEST_PREGENERATED_CONFIG})" + ) + + add_custom_command( + OUTPUT + ${VTEST_ORIENTATION_GENERATED_FILE_LIST_DST} + COMMAND + ${CMAKE_COMMAND} -E remove_directory ${VTEST_ORIENTATION_DERIVATION_DST_DIR}/generated + COMMAND + ${CMAKE_COMMAND} -E make_directory ${VTEST_ORIENTATION_DERIVATION_DST_DIR}/generated + COMMAND + ${CMAKE_COMMAND} -E copy_directory ${VTEST_ORIENTATION_PREGENERATED_DIR} + ${VTEST_ORIENTATION_DERIVATION_DST_DIR}/generated + DEPENDS + ${VTEST_ORIENTATION_PREGENERATED_FILES} + COMMENT "VTEST orientation using pre-generated symforce outputs" + ) + + add_custom_target(vtest_derivation_prebuilt + DEPENDS ${VTEST_GENERATED_FILE_LIST_DST} ${VTEST_ORIENTATION_GENERATED_FILE_LIST_DST} + ) + set(VTEST_GENERATED_TARGET vtest_derivation_prebuilt) +endif() + +if(VTEST_GENERATED_TARGET) + add_dependencies(prebuild_targets ${VTEST_GENERATED_TARGET}) +endif() + +if(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) + px4_add_module( + MODULE modules__vision_target_estimator + MAIN vision_target_estimator + STACK_MAX + 5000 + SRCS + VisionTargetEst.cpp + Position/VTEPosition.cpp + Position/KF_position.cpp + Orientation/VTEOrientation.cpp + Orientation/KF_orientation.cpp + + ${VTEST_GENERATED_FILES} + ${VTEST_ORIENTATION_GENERATED_FILES} + MODULE_CONFIG + vision_target_estimator_params.yaml + DEPENDS + mathlib + px4_work_queue + ) + + target_include_directories(modules__vision_target_estimator BEFORE PRIVATE + ${VTEST_GENERATED_DERIVATION_INCLUDE_PATH} + ) +endif() + +if(BUILD_TESTING) + add_subdirectory(test) +endif() diff --git a/src/modules/vision_target_estimator/Kconfig b/src/modules/vision_target_estimator/Kconfig new file mode 100644 index 000000000000..510e2b5c83d8 --- /dev/null +++ b/src/modules/vision_target_estimator/Kconfig @@ -0,0 +1,24 @@ +menuconfig MODULES_VISION_TARGET_ESTIMATOR + bool "vision_target_estimator" + default n + depends on !BOARD_CONSTRAINED_MEMORY + depends on !BOARD_CONSTRAINED_FLASH + ---help--- + Enable support for vision_target_estimator + +menuconfig USER_VISION_TARGET_ESTIMATOR + bool "vision_target_estimator running as userspace module" + default y + depends on BOARD_PROTECTED && MODULES_VISION_TARGET_ESTIMATOR + ---help--- + Put vision_target_estimator in userspace memory + +menuconfig VTEST_MOVING + depends on MODULES_VISION_TARGET_ESTIMATOR + bool "vtest with moving target" + default n + depends on !BOARD_CONSTRAINED_MEMORY + depends on !BOARD_CONSTRAINED_FLASH + ---help--- + Enable support for estimation of moving targets. + WARNING: Experimental beta version diff --git a/src/modules/vision_target_estimator/Orientation/KF_orientation.cpp b/src/modules/vision_target_estimator/Orientation/KF_orientation.cpp new file mode 100644 index 000000000000..54739ecff491 --- /dev/null +++ b/src/modules/vision_target_estimator/Orientation/KF_orientation.cpp @@ -0,0 +1,145 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file KF_orientation.cpp + * @brief Filter to estimate the orientation of static and moving targets. State: [yaw, yaw_rate] + * + * @author Jonas Perolini + * + */ + +#include "KF_orientation.h" + +#include +#include + +#include +#include +#include +#include + +namespace vision_target_estimator +{ + +using VectorState = KF_orientation::VectorState; +using SquareMatrixState = KF_orientation::SquareMatrixState; + +void KF_orientation::predict(float dt) +{ + if (!PX4_ISFINITE(dt) || (dt < 0.f)) { + return; + } + + VectorState state_next; + SquareMatrixState cov_next; + + predictState(dt, EmptyInput{}, _state, _state_covariance, state_next, cov_next); + + _state = state_next; + _state_covariance = cov_next; +} + +SquareMatrixState KF_orientation::getTransitionMatrix(float dt) const +{ + return sym::Gettransitionmatrix(dt); +} + +void KF_orientation::predictState(float dt, const EmptyInput &input, + const VectorState &x_in, + const SquareMatrixState &P_in, + VectorState &x_out, + SquareMatrixState &P_out) +{ + (void)input; + + x_out = sym::Predictstate(dt, x_in); + x_out(KF_orientation::kYaw) = matrix::wrap_pi(x_out(KF_orientation::kYaw)); + + // Keep invalid values from injecting covariance and use generated covariance propagation. + const float yaw_acc_var = (PX4_ISFINITE(_yaw_acc_var) && (_yaw_acc_var > 0.f)) ? _yaw_acc_var : 0.f; + P_out = sym::Predictcov(dt, yaw_acc_var, P_in); +} + +void KF_orientation::computeInnovation(const VectorState &state, + const SquareMatrixState &cov, + const ScalarMeas &meas, + float &innov, float &innov_var) const +{ + + innov = meas.val - (meas.H.transpose() * state)(0, 0); + innov_var = (meas.H.transpose() * cov * meas.H)(0, 0) + meas.unc; + + // Wrap innovations for yaw measurements + const bool is_yaw_meas = (fabsf(meas.H(KF_orientation::kYaw)) > 0.f) && (fabsf(meas.H(KF_orientation::kYawRate)) < FLT_EPSILON); + + if (is_yaw_meas) { + innov = matrix::wrap_pi(innov); + } +} + +void KF_orientation::pushHistory(const uint64_t time_us) +{ + _oosm.push(time_us, _state, _state_covariance, EmptyInput{}); +} + +void KF_orientation::resetHistory() +{ + _oosm.reset(); +} + +FusionResult KF_orientation::fuseScalarAtTime(const ScalarMeas &meas, uint64_t now_us, float nis_threshold) +{ + return _oosm.fuse(*this, meas, now_us, nis_threshold, _state, _state_covariance, EmptyInput{}); +} + +void KF_orientation::applyCorrection(VectorState &state, + SquareMatrixState &cov, + const VectorState &K, + float innov, float S) +{ + state = state + K * innov; + state(KF_orientation::kYaw) = matrix::wrap_pi(state(KF_orientation::kYaw)); + + for (size_t row = 0; row < KF_orientation::kSize; row++) { + for (size_t col = 0; col < KF_orientation::kSize; col++) { + cov(row, col) -= K(row) * K(col) * S; + } + } + + for (size_t i = 0; i < KF_orientation::kSize; i++) { + cov(i, i) = fmaxf(cov(i, i), kMinVar); + } +} + +} // namespace vision_target_estimator diff --git a/src/modules/vision_target_estimator/Orientation/KF_orientation.h b/src/modules/vision_target_estimator/Orientation/KF_orientation.h new file mode 100644 index 000000000000..80dec47fb0f5 --- /dev/null +++ b/src/modules/vision_target_estimator/Orientation/KF_orientation.h @@ -0,0 +1,133 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file KF_orientation.h + * @brief Filter to estimate the orientation of static and moving targets. State: [yaw, yaw_rate] + * + * @author Jonas Perolini + * + */ + +#include + +#include + +#pragma once + +#include "../VTEOosm.h" +#include "../common.h" + +namespace vision_target_estimator +{ + +class KF_orientation +{ +public: + enum State : uint8_t { + kYaw = 0, + kYawRate, + kSize + }; + using VectorState = matrix::Vector; + using SquareMatrixState = matrix::SquareMatrix; + + friend class OOSMManager; + + KF_orientation() = default; + ~KF_orientation() = default; + static constexpr float kMinVar = 1e-9f; + + struct ScalarMeas { + uint64_t time_us; + float val; + float unc; + VectorState H; + }; + + void predict(float dt); + + // Primary Fusion Interface (history-consistent OOSM) + // Projected correction OOSM approximation: + // - Most accurate with bounded delays and when fusions are processed in timestamp order (oldest -> newest). + // - Does not replay intermediate measurement updates (unlike EKF2's delayed fusion horizon). + // Returns FusionResult describing the result for logging. + FusionResult fuseScalarAtTime(const ScalarMeas &meas, uint64_t now_us, float nis_threshold); + + // History Management + void pushHistory(const uint64_t time_us); + void resetHistory(); + + // State + void setState(const VectorState &state) { _state = state; } + void setStateCovarianceDiag(const VectorState &var) + { + const SquareMatrixState var_mat = diag(var); + _state_covariance = var_mat; + }; + + const VectorState &getState() const { return _state; } + const SquareMatrixState &getStateCovariance() const { return _state_covariance; } + VectorState getStateCovarianceDiag() const { return _state_covariance.diag(); } + + void setYawAccVar(float var) { _yaw_acc_var = var; } + +private: + SquareMatrixState getTransitionMatrix(float dt) const; + + void predictState(float dt, const EmptyInput &input, + const VectorState &x_in, + const SquareMatrixState &P_in, + VectorState &x_out, + SquareMatrixState &P_out); + + void computeInnovation(const VectorState &state, + const SquareMatrixState &cov, const ScalarMeas &meas, + float &innov, float &innov_var) const; + + void applyCorrection(VectorState &state, + SquareMatrixState &cov, + const VectorState &K, + float innov, float S); + + VectorState _state{}; + SquareMatrixState _state_covariance{}; + + float _yaw_acc_var{0.f}; + + // OOSM history buffer: + // 0.5 s window @ 50 Hz predict rate = 25 samples. + // Note that the 0.5 s window is enforced with kOosmMaxTimeUs = 500_ms. + OOSMManager _oosm; +}; +} // namespace vision_target_estimator diff --git a/src/modules/vision_target_estimator/Orientation/VTEOrientation.cpp b/src/modules/vision_target_estimator/Orientation/VTEOrientation.cpp new file mode 100644 index 000000000000..c5df6bd51745 --- /dev/null +++ b/src/modules/vision_target_estimator/Orientation/VTEOrientation.cpp @@ -0,0 +1,314 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file VTEOrientation.cpp + * @brief Estimate the orientation of a target by processing and fusing sensor + * data in a Kalman Filter. + * + * @author Jonas Perolini + * + */ + +#include + +#include "VTEOrientation.h" + + +namespace vision_target_estimator +{ + +using namespace matrix; +using OrientationStateVec = KF_orientation::VectorState; + +VTEOrientation::VTEOrientation() : ModuleParams(nullptr) +{ + _vte_aid_ev_yaw_pub.advertise(); + _target_orientation_pub.advertise(); + + updateParams(); +} + +VTEOrientation::~VTEOrientation() = default; + +bool VTEOrientation::init() +{ + _target_est_yaw.resetHistory(); + return true; +} + +void VTEOrientation::resetFilter() +{ + _estimator_initialized = false; + _target_est_yaw.resetHistory(); +} + +void VTEOrientation::update() +{ + if (_parameter_update_sub.updated()) { + updateParams(); + } + + checkMeasurementInputs(); + + // Predict the target yaw state using the configured kinematic model. + if (_estimator_initialized) { + const hrt_abstime now_us = hrt_absolute_time(); + + if (now_us < _last_predict) { + resetFilter(); + PX4_WARN("VTE orientation invalid _last_predict time"); + return; + } + + const uint64_t dt_us = now_us - _last_predict; + + // Guard against long gaps; this estimator is expected to run near 50 Hz. + static constexpr uint64_t kMaxValidDeltaTimeUs = 100_ms; + + if (dt_us > kMaxValidDeltaTimeUs) { + resetFilter(); + PX4_WARN("VTE orientation stale, resetting filter"); + return; + } + + predictionStep(dt_us * kMicrosecondsToSeconds); + _last_predict = now_us; + } + + // Update with the latest observations and publish any resulting innovations. + if (performUpdateStep()) { + _last_update = _last_predict; + } + + if (_estimator_initialized) { + // Store the post-fusion posterior snapshot at the current timestamp for OOSM fusion. + _target_est_yaw.pushHistory(_last_predict); + publishTarget(); + } +} + +bool VTEOrientation::initEstimator(const ObsValidMaskU &fusion_mask, + const TargetObs observations[kObsTypeCount]) +{ + // Until a sensor measures the yaw rate, the estimator starts with zero yaw-rate. + OrientationStateVec state_init{}; + + // Yaw init + if (fusion_mask.flags.fuse_vision) { + state_init(KF_orientation::kYaw) = observations[obsIndex(ObsType::kFiducialMarker)].meas; + } + + OrientationStateVec state_var_init; + state_var_init.setAll(fmaxf(_param_vte_yaw_unc_in.get(), kMinVariance)); + + _target_est_yaw.setState(state_init); + _target_est_yaw.setStateCovarianceDiag(state_var_init); + _target_est_yaw.resetHistory(); + + PX4_DEBUG("Orientation filter init yaw: %.2f [rad] yaw_rate: %.2f [rad/s]", + (double)state_init(KF_orientation::kYaw), + (double)state_init(KF_orientation::kYawRate)); + + PX4_DEBUG("VTE Orientation Estimator properly initialized."); + _estimator_initialized = true; + _last_update = hrt_absolute_time(); + _last_predict = _last_update; + + return true; +} + +void VTEOrientation::predictionStep(float dt) +{ + _target_est_yaw.setYawAccVar(fmaxf(_param_vte_yaw_acc_unc.get(), kMinVariance)); + _target_est_yaw.predict(dt); +} + +bool VTEOrientation::performUpdateStep() +{ + ObsValidMaskU fusion_mask{}; + resetObservations(); + // Gather the latest observations from each enabled data source. + processObservations(fusion_mask, _obs_buffer); + + if (fusion_mask.value == 0) { + return false; + } + + if (!_estimator_initialized && !initEstimator(fusion_mask, _obs_buffer)) { + return false; + } + + return fuseActiveMeasurements(fusion_mask, _obs_buffer); +} + +void VTEOrientation::resetObservations() +{ + for (auto &obs : _obs_buffer) { + obs = {}; + } +} + +void VTEOrientation::processObservations(ObsValidMaskU &fusion_mask, + TargetObs observations[kObsTypeCount]) +{ + fusion_mask.flags.fuse_vision = + _vte_aid_mask.flags.use_vision_pos && + processObsVision(observations[obsIndex(ObsType::kFiducialMarker)]); +} + +bool VTEOrientation::isVisionDataValid(const fiducial_marker_yaw_report_s &fiducial_marker_yaw) const +{ + if (!isMeasRecent(fiducial_marker_yaw.timestamp_sample)) { + PX4_DEBUG("Vision yaw is outdated!"); // TODO: throttle, similar to _vision_pos_warn_last + return false; + } + + if (!PX4_ISFINITE(fiducial_marker_yaw.yaw_ned)) { + PX4_DEBUG("Vision yaw is corrupt!"); // TODO: throttle, similar to _vision_pos_warn_last + return false; + } + + if (!PX4_ISFINITE(fiducial_marker_yaw.yaw_var_ned)) { + PX4_DEBUG("Vision yaw var is corrupt!"); // TODO: throttle, similar to _vision_pos_warn_last + return false; + } + + return true; +} + +bool VTEOrientation::processObsVision(TargetObs &obs) +{ + fiducial_marker_yaw_report_s fiducial_marker_yaw; + + if (!_fiducial_marker_yaw_report_sub.update(&fiducial_marker_yaw) || + !isVisionDataValid(fiducial_marker_yaw)) { + return false; + } + + const float min_ev_angle_var = sq(fmaxf(_param_vte_ev_angle_noise.get(), kMinObservationNoise)); + const float yaw_unc = fmaxf(fiducial_marker_yaw.yaw_var_ned, min_ev_angle_var); + + obs.timestamp = fiducial_marker_yaw.timestamp_sample; + obs.updated = true; + obs.meas = wrap_pi(fiducial_marker_yaw.yaw_ned); + obs.meas_unc = yaw_unc; + obs.meas_h_theta(KF_orientation::kYaw) = 1.f; + obs.type = ObsType::kFiducialMarker; + + return true; +} + +bool VTEOrientation::fuseActiveMeasurements(ObsValidMaskU &fusion_mask, + const TargetObs observations[kObsTypeCount]) +{ + bool meas_fused = false; + + // Fuse vision position + if (fusion_mask.flags.fuse_vision) { + meas_fused |= fuseMeas(observations[obsIndex(ObsType::kFiducialMarker)]); + } + + return meas_fused; +} + +bool VTEOrientation::fuseMeas(const TargetObs &target_obs) +{ + vte_aid_source1d_s target_innov = {}; + const float nis_threshold = fmaxf(_param_vte_yaw_nis_thre.get(), kMinNisThreshold); + + target_innov.timestamp_sample = target_obs.timestamp; + target_innov.timestamp = hrt_absolute_time(); + target_innov.time_last_predict = _last_predict; + target_innov.time_since_meas_ms = static_cast(signedTimeDiffUs( + _last_predict, target_obs.timestamp)) * kMicrosecondsToMilliseconds; + + if (!target_obs.updated) { + target_innov.fusion_status = static_cast(FusionStatus::kIdle); + _vte_aid_ev_yaw_pub.publish(target_innov); + return false; + } + + const KF_orientation::ScalarMeas meas_input{ + target_obs.timestamp, target_obs.meas, target_obs.meas_unc, + target_obs.meas_h_theta}; + + target_innov.observation = meas_input.val; + target_innov.observation_variance = meas_input.unc; + + const FusionResult result = _target_est_yaw.fuseScalarAtTime(meas_input, _last_predict, nis_threshold); + + target_innov.innovation = result.innov; + target_innov.innovation_variance = result.innov_var; + target_innov.test_ratio = result.test_ratio; + target_innov.fusion_status = static_cast(result.status); + target_innov.history_steps = result.history_steps; + + _vte_aid_ev_yaw_pub.publish(target_innov); + + return (result.status == FusionStatus::kFusedCurrent || result.status == FusionStatus::kFusedOosm); +} + +void VTEOrientation::publishTarget() +{ + vision_target_est_orientation_s &vision_target_orientation = _orientation_msg; + vision_target_orientation = {}; + + const OrientationStateVec &state = _target_est_yaw.getState(); + const OrientationStateVec state_var = _target_est_yaw.getStateCovarianceDiag(); + + vision_target_orientation.timestamp = _last_predict; + vision_target_orientation.orientation_valid = !hasTimedOut(_last_update, _target_valid_timeout_us); + + vision_target_orientation.yaw = state(KF_orientation::kYaw); + vision_target_orientation.cov_yaw = state_var(KF_orientation::kYaw); + + vision_target_orientation.yaw_rate = state(KF_orientation::kYawRate); + vision_target_orientation.cov_yaw_rate = state_var(KF_orientation::kYawRate); + + _target_orientation_pub.publish(vision_target_orientation); +} + +void VTEOrientation::checkMeasurementInputs() +{ +} + +void VTEOrientation::updateParams() +{ + parameter_update_s pupdate; + _parameter_update_sub.copy(&pupdate); + + ModuleParams::updateParams(); +} + +} // namespace vision_target_estimator diff --git a/src/modules/vision_target_estimator/Orientation/VTEOrientation.h b/src/modules/vision_target_estimator/Orientation/VTEOrientation.h new file mode 100644 index 000000000000..d1ed18aab3a9 --- /dev/null +++ b/src/modules/vision_target_estimator/Orientation/VTEOrientation.h @@ -0,0 +1,205 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file VTEOrientation.h + * @brief Estimate the orientation of a target by processing and fusing sensor + * data in a Kalman Filter. + * + * @author Jonas Perolini + * + */ + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../common.h" +#include "KF_orientation.h" + +namespace vision_target_estimator +{ + +using namespace time_literals; + +class VTEOrientation : public ModuleParams +{ +public: + VTEOrientation(); + virtual ~VTEOrientation(); + + /* + * Get new measurements and update the state estimate + */ + void update(); + + bool init(); + + void resetFilter(); + + void setVteTimeout(uint32_t tout) { _vte_timeout_us = tout; } + void setTargetValidTimeout(uint32_t tout) + { + _target_valid_timeout_us = tout; + } + void setMeasRecentTimeout(uint32_t tout) + { + _meas_recent_timeout_us = tout; + } + void setMeasUpdatedTimeout(uint32_t tout) + { + _meas_updated_timeout_us = tout; + } + void setVteAidMask(uint16_t mask_value) + { + _vte_aid_mask.value = mask_value; + } + + bool timedOut() const + { + return _estimator_initialized && hasTimedOut(_last_update, _vte_timeout_us); + } + bool fusionEnabled() const + { + return _vte_aid_mask.flags.use_vision_pos; + } // only vision yaw measurements are handled + +protected: + /* + * update parameters. + */ + void updateParams() override; + + uORB::Publication _target_orientation_pub{ORB_ID(vision_target_est_orientation)}; + + // publish innovations target_estimator_gps_pos + uORB::Publication _vte_aid_ev_yaw_pub{ORB_ID(vte_aid_ev_yaw)}; + + uORB::SubscriptionInterval _parameter_update_sub{ORB_ID(parameter_update), 1_s}; + +private: + enum class ObsType : uint8_t { kFiducialMarker, kTypeCount }; + + static constexpr size_t kObsTypeCount = static_cast(ObsType::kTypeCount); + static constexpr size_t obsIndex(ObsType type) + { + return static_cast(type); + } + + union ObsValidMaskU { + struct { + uint8_t fuse_vision : 1; ///< bit0: external vision data ready to be fused + uint8_t reserved : 7; ///< bit1..7: reserved for future use + } flags; + + uint8_t value{0}; + }; + + static_assert(sizeof(ObsValidMaskU) == 1, "Unexpected masking size"); + + struct TargetObs { + ObsType type; + hrt_abstime timestamp = 0; + bool updated = false; + float meas{}; + float meas_unc{}; + KF_orientation::VectorState meas_h_theta{}; + }; + + bool initEstimator(const ObsValidMaskU &fusion_mask, const TargetObs observations[kObsTypeCount]); + bool performUpdateStep(); + void predictionStep(float dt); + + inline bool isMeasRecent(hrt_abstime ts) const + { + return !hasTimedOut(ts, _meas_recent_timeout_us); + } + + inline bool isMeasUpdated(hrt_abstime ts) const + { + return !hasTimedOut(ts, _meas_updated_timeout_us); + } + + void processObservations(ObsValidMaskU &fusion_mask, + TargetObs observations[kObsTypeCount]); + bool fuseActiveMeasurements(ObsValidMaskU &fusion_mask, const TargetObs observations[kObsTypeCount]); + + /* Vision data */ + bool isVisionDataValid(const fiducial_marker_yaw_report_s &fiducial_marker_yaw) const; + bool processObsVision(TargetObs &obs); + + bool fuseMeas(const TargetObs &target_pos_obs); + void publishTarget(); + void resetObservations(); + + uORB::Subscription _fiducial_marker_yaw_report_sub{ORB_ID(fiducial_marker_yaw_report)}; + + bool _estimator_initialized{false}; + + KF_orientation _target_est_yaw{}; + TargetObs _obs_buffer[kObsTypeCount] {}; + vision_target_est_orientation_s _orientation_msg{}; + + hrt_abstime _last_predict{0}; // timestamp of last filter prediction + hrt_abstime _last_update{0}; // timestamp of last filter update (used to check timeout) + + void checkMeasurementInputs(); + + /* parameters from vision_target_estimator_params.yaml */ + uint32_t _vte_timeout_us{3_s}; + uint32_t _target_valid_timeout_us{2_s}; + uint32_t _meas_recent_timeout_us{1_s}; + uint32_t _meas_updated_timeout_us{100_ms}; + SensorFusionMaskU _vte_aid_mask{}; + + DEFINE_PARAMETERS( + (ParamFloat) _param_vte_yaw_unc_in, + (ParamFloat) _param_vte_yaw_acc_unc, + (ParamInt) _param_vte_yaw_en, + (ParamFloat) _param_vte_ev_angle_noise, + (ParamFloat) _param_vte_yaw_nis_thre + ) +}; +} // namespace vision_target_estimator diff --git a/src/modules/vision_target_estimator/Orientation/vtest_derivation/derivation.py b/src/modules/vision_target_estimator/Orientation/vtest_derivation/derivation.py new file mode 100644 index 000000000000..897847093c3c --- /dev/null +++ b/src/modules/vision_target_estimator/Orientation/vtest_derivation/derivation.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" + Copyright (c) 2022-2026 PX4 Development Team + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + 3. Neither the name PX4 nor the names of its contributors may be + used to endorse or promote products derived from this software + without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + +File: derivation.py +Description: + Orientation state and covariance prediction derivation. +""" + +import re + +import symforce +symforce.set_epsilon_to_symbol() + +import symforce.symbolic as sf +from symforce.values import Values +import sympy as sp + + +def generate_px4_function(function_name, output_names): + from symforce.codegen import Codegen, CppConfig + import os + + return_key = output_names[0] if output_names is not None and len(output_names) == 1 else None + + codegen = Codegen.function( + function_name, + output_names=output_names, + return_key=return_key, + config=CppConfig(zero_initialization_sparsity_threshold=1), + ) + + metadata = codegen.generate_function( + output_dir="generated", + skip_directory_nesting=True, + ) + + for generated_file in metadata.generated_files: + print(f" |- {os.path.relpath(generated_file, metadata.output_dir)}") + + generated_path = os.path.abspath(metadata.generated_files[0]) + + with open(generated_path) as generated_file: + content = generated_file.read() + + content = content.replace("std::max", "math::max") + content = content.replace("std::min", "math::min") + content = content.replace("Eigen", "matrix") + content = content.replace("matrix/Dense", "matrix/math.hpp") + + # Avoid reserved naming pattern: underscore + uppercase identifier. + content = re.sub(r'_([A-Z])', lambda match: '_' + match.group(1).lower(), content) + + with open(generated_path, "w") as generated_file: + generated_file.write(content) + + +class MState(sf.Matrix): + SHAPE = (2, 2) + + +class VState(sf.Matrix): + SHAPE = (2, 1) + + +State = Values( + yaw=sf.V1(), + yaw_rate=sf.V1(), +) + + +def symbolic_state(state_structure): + symbolic = Values() + for key in state_structure.keys_recursive(): + var_type = type(state_structure[key]) + if hasattr(var_type, "symbolic"): + symbolic[key] = var_type.symbolic(key) + return symbolic + + +def matrix_to_state(state_vector: VState) -> Values: + state_values = Values() + for idx, key in enumerate(State.keys_recursive()): + state_values[key] = state_vector[idx] + return state_values + + +def state_to_matrix(state_values: Values) -> VState: + return VState(state_values.to_storage()) + + +def predict_state_with_input(dt: sf.Scalar, state_values: Values, yaw_acc: sf.Scalar) -> Values: + state_pred = state_values.copy() + state_pred["yaw"] = state_values["yaw"] + dt * state_values["yaw_rate"] + dt**2 * yaw_acc / 2 + state_pred["yaw_rate"] = state_values["yaw_rate"] + dt * yaw_acc + return state_pred + + +def get_Phi_and_G(dt: sf.Scalar): + state = symbolic_state(State) + yaw_acc = sf.Symbol("yaw_acc") + state_pred_matrix = state_to_matrix(predict_state_with_input(dt, state, yaw_acc)) + + # Compute Phi = ∂f/∂x + Phi = state_pred_matrix.jacobian([state]) + # Compute G = ∂f/∂w + G = state_pred_matrix.jacobian(yaw_acc) + return Phi, G + + +def predictState(dt: sf.Scalar, state: VState) -> VState: + state_values = matrix_to_state(state) + return state_to_matrix(predict_state_with_input(dt, state_values, sf.S(0))) + + +def getTransitionMatrix(dt: sf.Scalar) -> MState: + Phi, _ = get_Phi_and_G(dt) + return Phi + + +def derive_discrete_process_noise(dt: sf.Scalar, yaw_acc_var: sf.Scalar) -> MState: + tau = sf.Symbol("tau") + _, G_tau = get_Phi_and_G(tau) + + # Continuous-time white yaw acceleration enters through d/dtau of the discrete G(tau). + G_continuous = G_tau.jacobian(tau) + + Q = sf.Matrix.zeros(State.storage_dim(), State.storage_dim()) + + for row in range(State.storage_dim()): + for col in range(State.storage_dim()): + integrand = G_continuous[row, 0] * G_continuous[col, 0] * yaw_acc_var + Q[row, col] = sp.integrate(integrand, (tau, 0, dt)) + + return Q + + +def predictCov(dt: sf.Scalar, yaw_acc_var: sf.Scalar, covariance: MState) -> MState: + Phi, _ = get_Phi_and_G(dt) + Q = derive_discrete_process_noise(dt, yaw_acc_var) + return Phi * covariance * Phi.T + Q + + +print("Derive VTEST orientation equations...") +generate_px4_function(predictState, output_names=["predict_state"]) +generate_px4_function(getTransitionMatrix, output_names=["transition_matrix"]) +generate_px4_function(predictCov, output_names=["cov_updated"]) diff --git a/src/modules/vision_target_estimator/Orientation/vtest_derivation/generated/getTransitionMatrix.h b/src/modules/vision_target_estimator/Orientation/vtest_derivation/generated/getTransitionMatrix.h new file mode 100644 index 000000000000..a9c52066de5f --- /dev/null +++ b/src/modules/vision_target_estimator/Orientation/vtest_derivation/generated/getTransitionMatrix.h @@ -0,0 +1,47 @@ +// ----------------------------------------------------------------------------- +// This file was autogenerated by symforce from template: +// function/FUNCTION.h.jinja +// Do NOT modify by hand. +// ----------------------------------------------------------------------------- + +#pragma once + +#include + +namespace sym +{ + +/** + * This function was autogenerated from a symbolic function. Do not modify by hand. + * + * Symbolic function: getTransitionMatrix + * + * Args: + * dt: Scalar + * + * Outputs: + * transition_matrix: Matrix22 + */ +template +matrix::Matrix Gettransitionmatrix(const Scalar dt) +{ + // Total ops: 0 + + // Input arrays + + // Intermediate terms (0) + + // Output terms (1) + matrix::Matrix _transition_matrix; + + _transition_matrix.setZero(); + + _transition_matrix(0, 0) = 1; + _transition_matrix(0, 1) = dt; + _transition_matrix(1, 1) = 1; + + return _transition_matrix; +} // NOLINT(readability/fn_size) + +// NOLINTNEXTLINE(readability/fn_size) +} // namespace sym diff --git a/src/modules/vision_target_estimator/Orientation/vtest_derivation/generated/predictCov.h b/src/modules/vision_target_estimator/Orientation/vtest_derivation/generated/predictCov.h new file mode 100644 index 000000000000..d0dc011759fd --- /dev/null +++ b/src/modules/vision_target_estimator/Orientation/vtest_derivation/generated/predictCov.h @@ -0,0 +1,56 @@ +// ----------------------------------------------------------------------------- +// This file was autogenerated by symforce from template: +// function/FUNCTION.h.jinja +// Do NOT modify by hand. +// ----------------------------------------------------------------------------- + +#pragma once + +#include + +namespace sym +{ + +/** + * This function was autogenerated from a symbolic function. Do not modify by hand. + * + * Symbolic function: predictCov + * + * Args: + * dt: Scalar + * yaw_acc_var: Scalar + * covariance: Matrix22 + * + * Outputs: + * cov_updated: Matrix22 + */ +template +matrix::Matrix Predictcov(const Scalar dt, const Scalar yaw_acc_var, + const matrix::Matrix &covariance) +{ + // Total ops: 18 + + // Input arrays + + // Intermediate terms (3) + const Scalar _tmp0 = covariance(1, 1) * dt; + const Scalar _tmp1 = _tmp0 + covariance(0, 1); + const Scalar _tmp2 = (Scalar(1) / Scalar(2)) * std::pow(dt, Scalar(2)) * yaw_acc_var; + + // Output terms (1) + matrix::Matrix _cov_updated; + + _cov_updated(0, 0) = + _tmp1 * dt + covariance(0, 0) + covariance(1, 0) * dt + (Scalar(1) / Scalar(3)) * [&]() { + const Scalar base = dt; + return base * base * base; + }() * yaw_acc_var; + _cov_updated(1, 0) = _tmp0 + _tmp2 + covariance(1, 0); + _cov_updated(0, 1) = _tmp1 + _tmp2; + _cov_updated(1, 1) = covariance(1, 1) + dt * yaw_acc_var; + + return _cov_updated; +} // NOLINT(readability/fn_size) + +// NOLINTNEXTLINE(readability/fn_size) +} // namespace sym diff --git a/src/modules/vision_target_estimator/Orientation/vtest_derivation/generated/predictState.h b/src/modules/vision_target_estimator/Orientation/vtest_derivation/generated/predictState.h new file mode 100644 index 000000000000..6be511e4157d --- /dev/null +++ b/src/modules/vision_target_estimator/Orientation/vtest_derivation/generated/predictState.h @@ -0,0 +1,46 @@ +// ----------------------------------------------------------------------------- +// This file was autogenerated by symforce from template: +// function/FUNCTION.h.jinja +// Do NOT modify by hand. +// ----------------------------------------------------------------------------- + +#pragma once + +#include + +namespace sym +{ + +/** + * This function was autogenerated from a symbolic function. Do not modify by hand. + * + * Symbolic function: predictState + * + * Args: + * dt: Scalar + * state: Matrix21 + * + * Outputs: + * predict_state: Matrix21 + */ +template +matrix::Matrix Predictstate(const Scalar dt, + const matrix::Matrix &state) +{ + // Total ops: 2 + + // Input arrays + + // Intermediate terms (0) + + // Output terms (1) + matrix::Matrix _predict_state; + + _predict_state(0, 0) = dt * state(1, 0) + state(0, 0); + _predict_state(1, 0) = state(1, 0); + + return _predict_state; +} // NOLINT(readability/fn_size) + +// NOLINTNEXTLINE(readability/fn_size) +} // namespace sym diff --git a/src/modules/vision_target_estimator/Position/KF_position.cpp b/src/modules/vision_target_estimator/Position/KF_position.cpp new file mode 100644 index 000000000000..957d0685bcea --- /dev/null +++ b/src/modules/vision_target_estimator/Position/KF_position.cpp @@ -0,0 +1,135 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file KF_position.cpp + * @brief Filter to estimate the pose of moving targets. State: [pos_rel, vel_uav, bias, acc_target, vel_target] + * + * @author Jonas Perolini + * + */ + +#include "KF_position.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace vision_target_estimator +{ + +using VectorState = KF_position::VectorState; +using SquareMatrixState = KF_position::SquareMatrixState; + +void KF_position::predict(float dt, float acc_uav) +{ + + if (!PX4_ISFINITE(dt) || (dt < 0.f) || !PX4_ISFINITE(acc_uav)) { + return; + } + + _last_acc = acc_uav; + + VectorState state_next; + SquareMatrixState cov_next; + + predictState(dt, acc_uav, _state, _state_covariance, state_next, cov_next); + + _state = state_next; + _state_covariance = cov_next; +} + +SquareMatrixState KF_position::getTransitionMatrix(float dt) const +{ + return sym::Gettransitionmatrix(dt); +} + +void KF_position::predictState(float dt, float acc, + const VectorState &x_in, + const SquareMatrixState &P_in, + VectorState &x_out, + SquareMatrixState &P_out) +{ + x_out = sym::Predictstate(dt, x_in, acc); + P_out = sym::Predictcov(dt, _input_var, _bias_var, _acc_var, P_in); +} + +void KF_position::computeInnovation(const VectorState &state, + const SquareMatrixState &cov, + const ScalarMeas &meas, + float &innov, float &innov_var) const +{ + innov = meas.val - (meas.H.transpose() * state)(0, 0); + innov_var = sym::Computeinnovcov(meas.unc, cov, meas.H.transpose()); +} + +void KF_position::pushHistory(const uint64_t time_us) +{ + _oosm.push(time_us, _state, _state_covariance, _last_acc); +} + +void KF_position::resetHistory() +{ + _oosm.reset(); +} + +FusionResult KF_position::fuseScalarAtTime(const ScalarMeas &meas, uint64_t now_us, float nis_threshold) +{ + return _oosm.fuse(*this, meas, now_us, nis_threshold, _state, _state_covariance, _last_acc); +} + +void KF_position::applyCorrection(VectorState &state, + SquareMatrixState &cov, + const VectorState &K, + float innov, float S) +{ + VectorState state_new; + SquareMatrixState cov_new; + + sym::Applycorrection(state, cov, K, innov, S, &state_new, &cov_new); + + state = state_new; + cov = cov_new; + + // Clamp diagonal assuming a small epsilon for stability: + for (size_t i = 0; i < kStateSize; i++) { + cov(i, i) = fmaxf(cov(i, i), kMinVar); + } +} + +} // namespace vision_target_estimator diff --git a/src/modules/vision_target_estimator/Position/KF_position.h b/src/modules/vision_target_estimator/Position/KF_position.h new file mode 100644 index 000000000000..951eaeaac74c --- /dev/null +++ b/src/modules/vision_target_estimator/Position/KF_position.h @@ -0,0 +1,137 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file KF_position.h + * @brief Per-axis Kalman Filter for VTEST position states. + * + * @author Jonas Perolini + * + */ + +#pragma once + +#include +#include + +#include +#include + +#include "../VTEOosm.h" +#include "../common.h" + +namespace vision_target_estimator +{ + +class KF_position +{ +public: + static constexpr size_t kStateSize = vtest::State::size; + using VectorState = matrix::Vector; + using SquareMatrixState = matrix::SquareMatrix; + + friend class OOSMManager; + + KF_position() = default; + ~KF_position() = default; + static constexpr float kMinVar = 1e-9f; + + struct ScalarMeas { + uint64_t time_us; + float val; + float unc; + VectorState H; + }; + + void predict(float dt, float acc_uav); + + // Primary Fusion Interface (history-consistent OOSM) + // Projected correction OOSM approximation: + // - Most accurate with bounded delays and when fusions are processed in timestamp order (oldest -> newest). + // - Does not replay intermediate measurement updates (unlike EKF2's delayed fusion horizon). + // Returns FusionResult describing the result for logging. + FusionResult fuseScalarAtTime(const ScalarMeas &meas, uint64_t now_us, float nis_threshold); + + // History Management + void pushHistory(const uint64_t time_us); + void resetHistory(); + + // Setters / Getters + void setState(const VectorState &state) { _state = state; } + void setStateCovarianceDiag(const VectorState &var) + { + const SquareMatrixState var_mat = diag(var); + _state_covariance = var_mat; + }; + + const VectorState &getState() const { return _state; } + const SquareMatrixState &getStateCovariance() const { return _state_covariance; } + VectorState getStateCovarianceDiag() const { return _state_covariance.diag(); } + + void setInputVar(float var) { _input_var = var; } + void setBiasVar(float var) { _bias_var = var; } + void setTargetAccVar(float var) { _acc_var = var; } + +private: + SquareMatrixState getTransitionMatrix(float dt) const; + + void predictState(float dt, float acc, + const VectorState &x_in, + const SquareMatrixState &P_in, + VectorState &x_out, + SquareMatrixState &P_out); + + void computeInnovation(const VectorState &state, + const SquareMatrixState &cov, const ScalarMeas &meas, + float &innov, float &innov_var) const; + + void applyCorrection(VectorState &state, + SquareMatrixState &cov, + const VectorState &K, + float innov, float S); + + VectorState _state{}; + SquareMatrixState _state_covariance{}; + + float _bias_var{0.f}; // target/UAV GNSS bias variance + float _acc_var{0.f}; // target acceleration variance + float _input_var{0.f}; // UAV acceleration variance + + float _last_acc{0.f}; // last UAV acceleration input (also used as OOSM fallback input) + + // OOSM history buffer: + // 0.5 s window @ 50 Hz predict rate = 25 samples. + // Note that the 0.5 s window is enforced with kOosmMaxTimeUs = 500_ms. + OOSMManager _oosm; +}; +} // namespace vision_target_estimator diff --git a/src/modules/vision_target_estimator/Position/VTEPosition.cpp b/src/modules/vision_target_estimator/Position/VTEPosition.cpp new file mode 100644 index 000000000000..4f9c83727492 --- /dev/null +++ b/src/modules/vision_target_estimator/Position/VTEPosition.cpp @@ -0,0 +1,1676 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file VTEPosition.cpp + * @brief Estimate the state of a target by processing and fusing sensor data in a Kalman Filter. + * + * @author Jonas Perolini + * + */ + +#include + +#include +#include + +#include + +#include "VTEPosition.h" + +namespace vision_target_estimator +{ + +using namespace matrix; + +VTEPosition::VTEPosition() : + ModuleParams(nullptr) +{ + _target_pose_pub.advertise(); + _target_estimator_state_pub.advertise(); + _vte_aid_gps_pos_target_pub.advertise(); + _vte_aid_gps_pos_mission_pub.advertise(); + _vte_aid_gps_vel_uav_pub.advertise(); + _vte_aid_gps_vel_target_pub.advertise(); + _vte_aid_fiducial_marker_pub.advertise(); + _vte_bias_init_status_pub.advertise(); + + updateParams(); +} + +VTEPosition::~VTEPosition() +{ + perf_free(_vte_predict_perf); + perf_free(_vte_update_perf); + perf_free(_vte_fusion_perf); +} + +bool VTEPosition::shouldEmitWarning(hrt_abstime &last_warn) +{ + const hrt_abstime now = hrt_absolute_time(); + + if ((last_warn == 0) || (now < last_warn) || ((now - last_warn) > kWarnThrottleIntervalUs)) { + last_warn = now; + return true; + } + + return false; +} + +bool VTEPosition::init() +{ + // Check valid vtest_derivation/generated/state.h + if (vtest::Axis::size != kExpectedAxisCount) { + PX4_ERR("VTE: Invalid axis size: %d, expected %d. Generate vtest_derivation/derivation.py", + static_cast(vtest::Axis::size), kExpectedAxisCount); + return false; + } + +#if defined(CONFIG_VTEST_MOVING) + PX4_INFO("VTE for moving target init"); + PX4_WARN("VTE for moving targets has not been thoroughly tested"); + + if (vtest::State::size != kExpectedMovingPositionStateCount) { + PX4_ERR("VTE: Invalid state size: %d, expected %d. Generate vtest_derivation/derivation.py", + static_cast(vtest::State::size), kExpectedMovingPositionStateCount); + return false; + } + + _param_mpc_z_v_auto_dn = param_find("MPC_Z_V_AUTO_DN"); +#else + PX4_INFO("VTE for static target init"); + + if (vtest::State::size != kExpectedStaticPositionStateCount) { + PX4_ERR("VTE: Invalid state size: %d, expected %d. Generate vtest_derivation/derivation.py", + static_cast(vtest::State::size), kExpectedStaticPositionStateCount); + return false; + } + +#endif // CONFIG_VTEST_MOVING + + for (int axis = 0; axis < vtest::Axis::size; ++axis) { + _target_est_pos[axis].resetHistory(); + } + + return true; +} + +void VTEPosition::resetFilter() +{ + _estimator_initialized = false; + _last_relative_meas_fused_time = 0; + _bias.set = false; + _pre_bias_reference = PreBiasReference::kUnknown; + resetBiasAveraging(); + _mission_land_position.valid = false; + + for (int axis = 0; axis < vtest::Axis::size; ++axis) { + _target_est_pos[axis].resetHistory(); + } +} + +void VTEPosition::update(const Vector3f &acc_ned) +{ + if (_parameter_update_sub.updated()) { + updateParams(); + } + + checkMeasurementInputs(); + + // Predict the target state using a constant relative-acceleration model. + if (_estimator_initialized) { + const hrt_abstime now_us = hrt_absolute_time(); + + if (now_us < _last_predict) { + resetFilter(); + PX4_WARN("VTE position invalid _last_predict time"); + return; + } + + const uint64_t dt_us = now_us - _last_predict; + + // Guard against large dt values for which the constant acceleration assumption does not hold. + static constexpr uint64_t kMaxPredictionDeltaTimeUs = 100_ms; // Filter runs at 50 Hz, expect dt = 20 ms. + + if (dt_us > kMaxPredictionDeltaTimeUs) { + resetFilter(); + PX4_WARN("VTE position stale, resetting filter"); + return; + } + + const float dt = dt_us * kMicrosecondsToSeconds; + perf_begin(_vte_predict_perf); + predictionStep(acc_ned, dt); + perf_end(_vte_predict_perf); + + _last_predict = now_us; + } + + // Update with the newest observations, fuse if the filter is initialised, + // and publish any resulting innovations. + if (performUpdateStep(acc_ned)) { + _last_update = _last_predict; + } + + if (_estimator_initialized) { + // Store the post-fusion posterior snapshot at the current timestamp for OOSM fusion. + for (int axis = 0; axis < vtest::Axis::size; ++axis) { + _target_est_pos[axis].pushHistory(_last_predict); + } + + publishTarget(); + } +} + +bool VTEPosition::initEstimator(const AxisEstimatorStates &state_init) +{ + // Get initial variance from params + const float state_pos_var = _param_vte_pos_unc_in.get(); + const float state_vel_var = _param_vte_vel_unc_in.get(); + const float state_bias_var = _param_vte_bias_unc_in.get(); + + EstimatorState state_var_init{}; + state_var_init(vtest::State::pos_rel) = state_pos_var; + state_var_init(vtest::State::vel_uav) = state_vel_var; + state_var_init(vtest::State::bias) = state_bias_var; + +#if defined(CONFIG_VTEST_MOVING) + const float state_acc_var = _param_vte_acc_unc_in.get(); + const float state_target_vel_var = _param_vte_vel_unc_in.get(); + state_var_init(vtest::State::acc_target) = state_acc_var; + state_var_init(vtest::State::vel_target) = state_target_vel_var; + +#endif // CONFIG_VTEST_MOVING + + for (int i = 0; i < vtest::Axis::size; i++) { + _target_est_pos[i].setState(state_init[i]); + _target_est_pos[i].setStateCovarianceDiag(state_var_init); + } + + // Debug INFO + PX4_DEBUG("Rel pos init %.2f %.2f %.2f", (double)state_init[vtest::Axis::x](vtest::State::pos_rel), + (double)state_init[vtest::Axis::y](vtest::State::pos_rel), + (double)state_init[vtest::Axis::z](vtest::State::pos_rel)); + PX4_DEBUG("Vel uav init %.2f %.2f %.2f", (double)state_init[vtest::Axis::x](vtest::State::vel_uav), + (double)state_init[vtest::Axis::y](vtest::State::vel_uav), + (double)state_init[vtest::Axis::z](vtest::State::vel_uav)); + PX4_DEBUG("GNSS bias init %.2f %.2f %.2f", (double)state_init[vtest::Axis::x](vtest::State::bias), + (double)state_init[vtest::Axis::y](vtest::State::bias), + (double)state_init[vtest::Axis::z](vtest::State::bias)); + +#if defined(CONFIG_VTEST_MOVING) + PX4_DEBUG("Target acc init %.2f %.2f %.2f", (double)state_init[vtest::Axis::x](vtest::State::acc_target), + (double)state_init[vtest::Axis::y](vtest::State::acc_target), + (double)state_init[vtest::Axis::z](vtest::State::acc_target)); + PX4_DEBUG("Target vel init %.2f %.2f %.2f", (double)state_init[vtest::Axis::x](vtest::State::vel_target), + (double)state_init[vtest::Axis::y](vtest::State::vel_target), + (double)state_init[vtest::Axis::z](vtest::State::vel_target)); +#endif // CONFIG_VTEST_MOVING + + return true; +} + +void VTEPosition::predictionStep(const Vector3f &vehicle_acc_ned, const float dt) +{ + const float bias_var = fmaxf(_param_vte_bias_unc.get(), kMinVariance); + const float uav_acc_var = fmaxf(_param_vte_acc_d_unc.get(), kMinVariance); + + //Decoupled dynamics, we neglect the off diag elements. + for (int i = 0; i < vtest::Axis::size; i++) { +#if defined(CONFIG_VTEST_MOVING) + const float target_acc_var = fmaxf(_param_vte_acc_t_unc.get(), kMinVariance); + _target_est_pos[i].setTargetAccVar(target_acc_var); +#endif // CONFIG_VTEST_MOVING + + // We assume an isotropic uncertainty (cov = sigma^2 * I) + // The rotated input covariance from body to NED R*(sigma^2 * I)*R^T = sigma^2*I because R*R^T = I. + _target_est_pos[i].setBiasVar(bias_var); + _target_est_pos[i].setInputVar(uav_acc_var); + + _target_est_pos[i].predict(dt, vehicle_acc_ned(i)); + } +} + +bool VTEPosition::performUpdateStep(const Vector3f &vehicle_acc_ned) +{ + // Gather observations and tag the fusion mask bits for each new valid source. + resetObservations(); + ObsValidMaskU fusion_mask{}; + processObservations(fusion_mask, _obs_buffer); + + if (fusion_mask.value == 0) { + return false; + } + + if (!_estimator_initialized) { + if (!hasNewPositionSensorData(fusion_mask)) { + return false; + } + + if (!initializeEstimator(fusion_mask, _obs_buffer)) { + return false; + } + } + + if (!_bias.set) { + updateBiasIfObservable(fusion_mask, _obs_buffer); + } + + return fuseActiveMeasurements(vehicle_acc_ned, fusion_mask, _obs_buffer); +} + +void VTEPosition::resetObservations() +{ + for (auto &obs : _obs_buffer) { + obs = {}; + } +} + +void VTEPosition::processObservations(ObsValidMaskU &fusion_mask, + TargetObs observations[kObsTypeCount]) +{ + if (_vte_aid_mask.flags.use_vision_pos) { + fusion_mask.flags.fuse_vision = processObsVision(observations[obsIndex(ObsType::kFiducialMarker)]); + } + + if (updateUavGpsData()) { + if (_vte_aid_mask.flags.use_mission_pos && _mission_land_position.valid) { + fusion_mask.flags.fuse_mission_pos = processObsGNSSPosMission(observations[obsIndex(ObsType::kMissionGpsPos)]); + } + + if (_vte_aid_mask.flags.use_uav_gps_vel && _uav_gps_vel.valid) { + fusion_mask.flags.fuse_uav_gps_vel = processObsGNSSVelUav(observations[obsIndex(ObsType::kUavGpsVel)]); + } + } + + target_gnss_s target_gnss{}; + + if (_target_gnss_sub.update(&target_gnss)) { + const bool target_gps_position_valid = isTargetGpsPositionValid(target_gnss); + + if (_vte_aid_mask.flags.use_target_gps_pos && _uav_gps_position.valid + && target_gps_position_valid && target_gnss.abs_pos_updated) { + fusion_mask.flags.fuse_target_gps_pos = processObsGNSSPosTarget(target_gnss, + observations[obsIndex(ObsType::kTargetGpsPos)]); + } + +#if defined(CONFIG_VTEST_MOVING) + + if (isTargetGpsVelocityValid(target_gnss)) { + + updateTargetGpsVelocity(target_gnss); + + if (_vte_aid_mask.flags.use_target_gps_vel && _target_gps_vel.valid) { + fusion_mask.flags.fuse_target_gps_vel = processObsGNSSVelTarget(target_gnss, + observations[obsIndex(ObsType::kTargetGpsVel)]); + } + } + +#endif // CONFIG_VTEST_MOVING + + } +} + +bool VTEPosition::isVisionDataValid(const fiducial_marker_pos_report_s &fiducial_marker_pose) +{ + if (!isMeasRecent(fiducial_marker_pose.timestamp_sample)) { + if (shouldEmitWarning(_vision_pos_warn_last)) { + PX4_WARN("VTE: Vision data too old!"); + } + + return false; + } + + const bool finite_measurement = PX4_ISFINITE(fiducial_marker_pose.rel_pos[0]) + && PX4_ISFINITE(fiducial_marker_pose.rel_pos[1]) + && PX4_ISFINITE(fiducial_marker_pose.rel_pos[2]); + + if (!finite_measurement) { + if (shouldEmitWarning(_vision_pos_warn_last)) { + PX4_WARN("VTE: Vision meas is corrupt!"); + } + + return false; + } + + const bool finite_cov = PX4_ISFINITE(fiducial_marker_pose.cov_rel_pos[0]) + && PX4_ISFINITE(fiducial_marker_pose.cov_rel_pos[1]) + && PX4_ISFINITE(fiducial_marker_pose.cov_rel_pos[2]); + + if (!finite_cov) { + if (shouldEmitWarning(_vision_pos_warn_last)) { + PX4_WARN("VTE: Vision cov is corrupt!"); + } + + return false; + } + + // Rotate vision observation into vc-NED using the reported attitude quaternion + const bool finite_q = PX4_ISFINITE(fiducial_marker_pose.q[0]) + && PX4_ISFINITE(fiducial_marker_pose.q[1]) + && PX4_ISFINITE(fiducial_marker_pose.q[2]) + && PX4_ISFINITE(fiducial_marker_pose.q[3]); + + if (!finite_q) { + if (shouldEmitWarning(_vision_pos_warn_last)) { + PX4_WARN("VTE: Vision attitude quaternion is corrupt!"); + } + + return false; + } + + return true; +} + +bool VTEPosition::isUavGpsPositionValid() +{ + if (!isMeasRecent(_uav_gps_position.timestamp)) { + return false; + } + + if (!isLatLonAltValid(_uav_gps_position.lat_deg, _uav_gps_position.lon_deg, + _uav_gps_position.alt_m, "UAV GPS ", &_uav_gps_pos_warn_last)) { + return false; + } + + if (_gps_pos_is_offset && !_gps_pos_offset_ned.valid) { + PX4_DEBUG("Uav Gps position invalid offset!"); // Debug because this message can spam + return false; + } + + return true; +} + +bool VTEPosition::isUavGpsVelocityValid() +{ + if (!PX4_ISFINITE(_uav_gps_vel.xyz(vtest::Axis::x)) || + !PX4_ISFINITE(_uav_gps_vel.xyz(vtest::Axis::y)) || + !PX4_ISFINITE(_uav_gps_vel.xyz(vtest::Axis::z))) { + if (shouldEmitWarning(_uav_gps_vel_warn_last)) { + PX4_WARN("UAV GPS velocity not finite! vx: %.1f, vy: %.1f, vz: %.1f", + (double)_uav_gps_vel.xyz(vtest::Axis::x), (double)_uav_gps_vel.xyz(vtest::Axis::y), + (double)_uav_gps_vel.xyz(vtest::Axis::z)); + } + + return false; + } + + if (!isMeasRecent(_uav_gps_vel.timestamp)) { + return false; + } + + if (_gps_pos_is_offset && !_velocity_offset_ned.valid) { + PX4_DEBUG("Uav Gps velocity invalid offset!"); + return false; + } + + return true; +} + +bool VTEPosition::updateUavGpsData() +{ + sensor_gps_s vehicle_gps_position; + const bool vehicle_gps_position_updated = _vehicle_gps_position_sub.update(&vehicle_gps_position); + + if (vehicle_gps_position_updated) { + // Position + _uav_gps_position.lat_deg = vehicle_gps_position.latitude_deg; + _uav_gps_position.lon_deg = vehicle_gps_position.longitude_deg; + _uav_gps_position.alt_m = (float)vehicle_gps_position.altitude_msl_m; + _uav_gps_position.timestamp = vehicle_gps_position.timestamp_sample; + _uav_gps_position.eph = vehicle_gps_position.eph; + _uav_gps_position.epv = vehicle_gps_position.epv; + _uav_gps_position.valid = isUavGpsPositionValid(); + + // Velocity + _uav_gps_vel.timestamp = vehicle_gps_position.timestamp_sample; + _uav_gps_vel.xyz(vtest::Axis::x) = vehicle_gps_position.vel_n_m_s; + _uav_gps_vel.xyz(vtest::Axis::y) = vehicle_gps_position.vel_e_m_s; + _uav_gps_vel.xyz(vtest::Axis::z) = vehicle_gps_position.vel_d_m_s; + _uav_gps_vel.uncertainty = vehicle_gps_position.s_variance_m_s; + _uav_gps_vel.valid = vehicle_gps_position.vel_ned_valid && isUavGpsVelocityValid(); + + } else { + // Check if stored data is still valid + _uav_gps_position.valid = _uav_gps_position.valid && isMeasRecent(_uav_gps_position.timestamp); + _uav_gps_vel.valid = _uav_gps_vel.valid && isMeasRecent(_uav_gps_vel.timestamp); + } + + return vehicle_gps_position_updated; +} + +#if defined(CONFIG_VTEST_MOVING) +void VTEPosition::updateTargetGpsVelocity(const target_gnss_s &target_gnss) +{ + _target_gps_vel.timestamp = target_gnss.timestamp_sample; + _target_gps_vel.valid = isMeasRecent(target_gnss.timestamp_sample); + + _target_gps_vel.xyz(vtest::Axis::x) = target_gnss.vel_n_m_s; + _target_gps_vel.xyz(vtest::Axis::y) = target_gnss.vel_e_m_s; + _target_gps_vel.xyz(vtest::Axis::z) = target_gnss.vel_d_m_s; +} +#endif // CONFIG_VTEST_MOVING + +bool VTEPosition::isTargetGpsPositionValid(const target_gnss_s &target_gnss) +{ + if (!isMeasRecent(target_gnss.timestamp_sample)) { + return false; + } + + if (!isLatLonAltValid(target_gnss.latitude_deg, target_gnss.longitude_deg, + target_gnss.altitude_msl_m, "Target GPS ", &_target_gps_pos_warn_last)) { + return false; + } + + return true; +} + +bool VTEPosition::isTargetGpsVelocityValid(const target_gnss_s &target_gnss) +{ + if (!isMeasRecent(target_gnss.timestamp_sample)) { + return false; + } + + if (!target_gnss.vel_ned_updated || + !PX4_ISFINITE(target_gnss.vel_n_m_s) || + !PX4_ISFINITE(target_gnss.vel_e_m_s) || + !PX4_ISFINITE(target_gnss.vel_d_m_s)) { + if (shouldEmitWarning(_target_gps_vel_warn_last)) { + PX4_WARN("Target GPS velocity is corrupt!"); + } + + return false; + } + + return true; +} + +bool VTEPosition::initializeEstimator(const ObsValidMaskU &fusion_mask, + const TargetObs observations[kObsTypeCount]) +{ + if (!hasNewPositionSensorData(fusion_mask)) { + return false; + } + + // Check for initial velocity estimate + const bool has_initial_velocity_estimate = (_local_velocity.valid && isMeasRecent(_local_velocity.timestamp)) || + (_uav_gps_vel.valid && isMeasRecent(_uav_gps_vel.timestamp)); + + if (!has_initial_velocity_estimate) { + if (shouldEmitWarning(_init_vel_warn_last)) { + PX4_WARN("No UAV velocity estimate. Estimator cannot be started."); + } + + return false; + } + + // Initialize state variables + Vector3f initial_position{}; + Vector3f initial_uav_velocity{}; + Vector3f initial_bias{}; +#if defined(CONFIG_VTEST_MOVING) + Vector3f initial_target_accel {}; // Assume null target absolute acceleration + Vector3f initial_target_velocity{}; +#endif // CONFIG_VTEST_MOVING + + // If both GNSS and vision are available at startup, only keep the GNSS-first + // initialization path when bias averaging is explicitly enabled. Otherwise, + // trust the current vision position and activate the bias immediately. + const bool can_average_initial_bias = shouldSetBias(fusion_mask) && isBiasAveragingEnabled(); + const PreBiasReference initial_reference = selectInitialPosition(fusion_mask, observations, initial_position, + !can_average_initial_bias); + const bool average_initial_bias = shouldAverageInitialBias(fusion_mask, initial_reference); + + // If the current GNSS/vision pair is already trusted, initialize both position and bias from that pair. + if (shouldSetBias(fusion_mask) && !average_initial_bias) { + PX4_DEBUG("VTE Position setting GNSS bias."); + const hrt_abstime sample_time = observations[obsIndex(ObsType::kFiducialMarker)].timestamp; + Vector3f gnss_sample{}; + + if (selectBiasGnssSample(sample_time, gnss_sample)) { + initial_bias = gnss_sample - initial_position; + } + + _bias.set = true; + } + +#if defined(CONFIG_VTEST_MOVING) + + if (_target_gps_vel.valid && (isMeasRecent(_target_gps_vel.timestamp))) { + initial_target_velocity = _target_gps_vel.xyz; + } + +#endif // CONFIG_VTEST_MOVING + + // Define initial UAV velocity + if (_uav_gps_vel.valid && isMeasRecent(_uav_gps_vel.timestamp)) { + initial_uav_velocity = _uav_gps_vel.xyz; + + } else if (_local_velocity.valid && isMeasRecent(_local_velocity.timestamp)) { + initial_uav_velocity = _local_velocity.xyz; + } + + // Initialize estimator state + AxisEstimatorStates state_init{}; + + for (int axis = 0; axis < vtest::Axis::size; ++axis) { + state_init[axis](vtest::State::pos_rel) = initial_position(axis); + state_init[axis](vtest::State::vel_uav) = initial_uav_velocity(axis); + state_init[axis](vtest::State::bias) = initial_bias(axis); + +#if defined(CONFIG_VTEST_MOVING) + state_init[axis](vtest::State::acc_target) = initial_target_accel(axis); + state_init[axis](vtest::State::vel_target) = initial_target_velocity(axis); +#endif // CONFIG_VTEST_MOVING + } + + if (!initEstimator(state_init)) { + resetFilter(); + return false; + } + + PX4_DEBUG("VTE Position Estimator properly initialized."); + _estimator_initialized = true; + _pre_bias_reference = _bias.set ? PreBiasReference::kUnknown : initial_reference; + _last_update = hrt_absolute_time(); + _last_predict = _last_update; + + for (int axis = 0; axis < vtest::Axis::size; ++axis) { + _target_est_pos[axis].resetHistory(); + } + + return true; +} + +VTEPosition::PreBiasReference VTEPosition::selectInitialPosition(const ObsValidMaskU &fusion_mask, + const TargetObs observations[kObsTypeCount], matrix::Vector3f &initial_position, + const bool prefer_vision) +{ + // Select the best direct position source before the GNSS bias is trusted. + if (prefer_vision && fusion_mask.flags.fuse_vision) { + initial_position = observations[obsIndex(ObsType::kFiducialMarker)].meas_xyz; + return PreBiasReference::kVision; + } + + if (fusion_mask.flags.fuse_target_gps_pos) { + initial_position = observations[obsIndex(ObsType::kTargetGpsPos)].meas_xyz; + return PreBiasReference::kGnss; + } + + if (fusion_mask.flags.fuse_mission_pos) { + initial_position = observations[obsIndex(ObsType::kMissionGpsPos)].meas_xyz; + return PreBiasReference::kGnss; + } + + if (!prefer_vision && fusion_mask.flags.fuse_vision) { + initial_position = observations[obsIndex(ObsType::kFiducialMarker)].meas_xyz; + return PreBiasReference::kVision; + } + + return PreBiasReference::kUnknown; +} + +bool VTEPosition::getOffsetUavVel(const hrt_abstime sample_time, Vector3f &uav_velocity_ned) const +{ + if (!_uav_gps_vel.valid || !isTimeDifferenceWithin(_uav_gps_vel.timestamp, sample_time, _meas_updated_timeout_us)) { + return false; + } + + uav_velocity_ned = _uav_gps_vel.xyz; + + if (_gps_pos_is_offset) { + if (!_velocity_offset_ned.valid + || !isTimeDifferenceWithin(_velocity_offset_ned.timestamp, _uav_gps_vel.timestamp, _meas_updated_timeout_us)) { + return false; + } + + uav_velocity_ned -= _velocity_offset_ned.xyz; + } + + return true; +} + +bool VTEPosition::selectBiasGnssSample(const hrt_abstime sample_time, Vector3f &gnss_sample) +{ + if (!_pos_rel_gnss.valid || !isMeasRecent(_pos_rel_gnss.timestamp)) { + return false; + } + + gnss_sample = _pos_rel_gnss.xyz; + + const int64_t dt_us = signedTimeDiffUs(sample_time, _pos_rel_gnss.timestamp); + + if (dt_us != 0) { + const float dt = static_cast(dt_us) * kMicrosecondsToSeconds; + Vector3f uav_velocity_ned{}; + bool has_velocity_for_propagation = false; + + if (_local_velocity.valid + && isTimeDifferenceWithin(_local_velocity.timestamp, sample_time, _meas_updated_timeout_us)) { + uav_velocity_ned = _local_velocity.xyz; + has_velocity_for_propagation = true; + + } else if (getOffsetUavVel(sample_time, uav_velocity_ned)) { + has_velocity_for_propagation = true; + } + + if (has_velocity_for_propagation) { + gnss_sample -= uav_velocity_ned * dt; + } + } + + return true; +} + +void VTEPosition::updateBiasIfObservable(const ObsValidMaskU &fusion_mask, + const TargetObs observations[kObsTypeCount]) +{ + if (!fusion_mask.flags.fuse_vision) { + return; + } + + const TargetObs &vision_obs = observations[obsIndex(ObsType::kFiducialMarker)]; + const hrt_abstime sample_time = vision_obs.timestamp; + + if (sample_time == 0) { + return; + } + + Vector3f initial_position{}; + selectInitialPosition(fusion_mask, observations, initial_position); + + Vector3f gnss_sample{}; + + if (!selectBiasGnssSample(sample_time, gnss_sample)) { + if (_bias.averaging_active) { + PX4_INFO("GNSS unavailable, ending bias averaging and switching to vision."); + activateBiasEstimate(initial_position, _bias.initial_lpf.getState()); + } + + return; + } + + const Vector3f initial_bias = gnss_sample - initial_position; + + if (!_bias.averaging_active && shouldAverageInitialBias(fusion_mask)) { + PX4_INFO("GNSS/vision bias observable, averaging initial GNSS bias."); + startBiasAveraging(initial_bias, sample_time); + return; + } + + if (_bias.averaging_active) { + if (!updateBiasAveraging(initial_bias, sample_time)) { + return; + } + + PX4_INFO("GNSS bias averaging complete, setting position and bias."); + const Vector3f filtered_bias = _bias.initial_lpf.getState(); + activateBiasEstimate(gnss_sample - filtered_bias, filtered_bias); + return; + } + + PX4_INFO("GNSS/vision bias observable, setting position and bias."); + activateBiasEstimate(initial_position, initial_bias); +} + +void VTEPosition::startBiasAveraging(const Vector3f &bias_sample, const hrt_abstime sample_time) +{ + _bias.initial_lpf.reset(bias_sample); + _bias.averaging_active = true; + _bias.stable_delta_count = 0; + _bias.averaging_start_time = sample_time; + _bias.last_sample_time = sample_time; + + publishBiasInitStatus(bias_sample, bias_sample, 0.f, sample_time); +} + +bool VTEPosition::updateBiasAveraging(const Vector3f &bias_sample, const hrt_abstime sample_time) +{ + static constexpr float kInitialBiasLpfTimeConstantS{0.3f}; + static constexpr float kInitialBiasLpfMinTimeFactor{2.f}; + static constexpr uint8_t kRequiredStableBiasDeltas{5}; + + if (!_bias.averaging_active) { + return false; + } + + const Vector3f filtered_bias_before_update = _bias.initial_lpf.getState(); + const float bias_delta = (bias_sample - filtered_bias_before_update).norm(); + + if (sample_time > _bias.last_sample_time) { + const float dt = static_cast(sample_time - _bias.last_sample_time) * kMicrosecondsToSeconds; + _bias.initial_lpf.setParameters(dt, kInitialBiasLpfTimeConstantS); + _bias.initial_lpf.update(bias_sample); + + } else { + _bias.initial_lpf.update(bias_sample); + } + + _bias.last_sample_time = sample_time; + + const Vector3f filtered_bias_logged = _bias.initial_lpf.getState(); + publishBiasInitStatus(bias_sample, filtered_bias_logged, bias_delta, sample_time); + + if (bias_delta <= getBiasAveragingThreshold()) { + if (_bias.stable_delta_count < UINT8_MAX) { + ++_bias.stable_delta_count; + } + + } else { + _bias.stable_delta_count = 0; + } + + const hrt_abstime min_averaging_time_us = static_cast( + kInitialBiasLpfMinTimeFactor * kInitialBiasLpfTimeConstantS * 1e6f); + const bool min_time_elapsed = (sample_time >= _bias.averaging_start_time) + && ((sample_time - _bias.averaging_start_time) >= min_averaging_time_us); + const bool stable = (_bias.stable_delta_count >= kRequiredStableBiasDeltas) && min_time_elapsed; + const bool timed_out = (sample_time >= _bias.averaging_start_time) + && ((sample_time - _bias.averaging_start_time) >= getBiasAveragingTimeoutUs()); + + return stable || timed_out; +} + +void VTEPosition::resetBiasAveraging() +{ + _bias.averaging_active = false; + _bias.stable_delta_count = 0; + _bias.averaging_start_time = 0; + _bias.last_sample_time = 0; + _bias.initial_lpf.reset(Vector3f{}); +} + +void VTEPosition::activateBiasEstimate(const Vector3f &initial_position, const Vector3f &initial_bias) +{ + const float pos_var = _param_vte_pos_unc_in.get(); + const float bias_var = _param_vte_bias_unc_in.get(); + + for (int axis = 0; axis < vtest::Axis::size; ++axis) { + KF_position &filter = _target_est_pos[axis]; + EstimatorState state = filter.getState(); + state(vtest::State::bias) = initial_bias(axis); + state(vtest::State::pos_rel) = initial_position(axis); + filter.setState(state); + + EstimatorState state_var = filter.getStateCovarianceDiag(); + state_var(vtest::State::bias) = bias_var; + state_var(vtest::State::pos_rel) = pos_var; + filter.setStateCovarianceDiag(state_var); + + // Bias initialization is a state reset: restart OOSM history to avoid mixing pre/post-reset states. + filter.resetHistory(); + } + + PX4_DEBUG("Rel pos init %.2f %.2f %.2f", (double)initial_position(vtest::Axis::x), + (double)initial_position(vtest::Axis::y), (double)initial_position(vtest::Axis::z)); + + PX4_DEBUG("GNSS bias init %.2f %.2f %.2f", (double)initial_bias(vtest::Axis::x), + (double)initial_bias(vtest::Axis::y), (double)initial_bias(vtest::Axis::z)); + + _bias.set = true; + _pre_bias_reference = PreBiasReference::kUnknown; + resetBiasAveraging(); +} + +void VTEPosition::publishBiasInitStatus(const Vector3f &raw_bias, const Vector3f &filtered_bias, + const float raw_bias_delta_norm, const hrt_abstime sample_time) +{ + vte_bias_init_status_s status{}; + status.timestamp = sample_time; + status.raw_bias[0] = raw_bias(vtest::Axis::x); + status.raw_bias[1] = raw_bias(vtest::Axis::y); + status.raw_bias[2] = raw_bias(vtest::Axis::z); + status.filtered_bias[0] = filtered_bias(vtest::Axis::x); + status.filtered_bias[1] = filtered_bias(vtest::Axis::y); + status.filtered_bias[2] = filtered_bias(vtest::Axis::z); + status.delta_norm = raw_bias_delta_norm; + _vte_bias_init_status_pub.publish(status); +} + +bool VTEPosition::fuseActiveMeasurements(const matrix::Vector3f &vehicle_acc_ned, ObsValidMaskU &fusion_mask, + const TargetObs observations[kObsTypeCount]) +{ + struct FusionJob { + hrt_abstime timestamp{}; + ObsType type{ObsType::kTypeCount}; + const TargetObs *obs{nullptr}; + }; + + FusionJob jobs[kObsTypeCount] {}; + size_t job_count = 0; + + auto add_job = [&](ObsType type) { + const TargetObs &obs = observations[obsIndex(type)]; + + if ((job_count >= kObsTypeCount) || (obs.timestamp == 0)) { + return; + } + + jobs[job_count++] = FusionJob{obs.timestamp, type, &obs}; + }; + + bool position_fused = false; + + if (fusion_mask.flags.fuse_target_gps_pos && !shouldBlockGnssFusionUntilBiasReady()) { + add_job(ObsType::kTargetGpsPos); + } + + if (fusion_mask.flags.fuse_mission_pos && !shouldBlockGnssFusionUntilBiasReady()) { + add_job(ObsType::kMissionGpsPos); + } + + if (fusion_mask.flags.fuse_vision && !shouldBlockVisionFusionUntilBiasReady()) { + add_job(ObsType::kFiducialMarker); + } + + if (fusion_mask.flags.fuse_uav_gps_vel) { + add_job(ObsType::kUavGpsVel); + } + +#if defined(CONFIG_VTEST_MOVING) + + if (fusion_mask.flags.fuse_target_gps_vel) { + add_job(ObsType::kTargetGpsVel); + } + +#endif // CONFIG_VTEST_MOVING + + // Fuse strictly in timestamp order (oldest -> newest) to keep the OOSM approximation consistent. + for (size_t i = 1; i < job_count; ++i) { + FusionJob key = jobs[i]; + size_t j = i; + + while ((j > 0) && (jobs[j - 1].timestamp > key.timestamp)) { + jobs[j] = jobs[j - 1]; + --j; + } + + jobs[j] = key; + } + + for (size_t i = 0; i < job_count; ++i) { + const FusionJob &job = jobs[i]; + + const bool fused = (job.obs != nullptr) && fuseMeas(vehicle_acc_ned, *job.obs); + + switch (job.type) { + case ObsType::kTargetGpsPos: + case ObsType::kMissionGpsPos: + position_fused |= fused; + + if (!_bias.set && fused) { + _pre_bias_reference = PreBiasReference::kGnss; + } + + break; + + case ObsType::kFiducialMarker: + position_fused |= fused; + + if (!_bias.set && fused) { + _pre_bias_reference = PreBiasReference::kVision; + } + + if (fused) { + _last_relative_meas_fused_time = hrt_absolute_time(); + } + + break; + + case ObsType::kUavGpsVel: + case ObsType::kTargetGpsVel: + case ObsType::kTypeCount: + break; + } + } + + return position_fused; +} + +/*Vision observation: [rx, ry, rz]*/ +bool VTEPosition::processObsVision(TargetObs &obs) +{ + fiducial_marker_pos_report_s report; + + if (!_fiducial_marker_pos_report_sub.update(&report) || !isVisionDataValid(report)) { + return false; + } + + const matrix::Quaternionf quat_att(report.q); + const Vector3f vision_rel(report.rel_pos); + const Vector3f vision_ned = quat_att.rotateVector(vision_rel); + const float min_ev_pos_var = sq(fmaxf(_param_vte_ev_pos_noise.get(), kMinObservationNoise)); + + // Rotate covariance matrix to vc-NED + const SquareMatrix cov_mat = diag(Vector3f( + fmaxf(report.cov_rel_pos[0], min_ev_pos_var), + fmaxf(report.cov_rel_pos[1], min_ev_pos_var), + fmaxf(report.cov_rel_pos[2], min_ev_pos_var))); + const matrix::Dcmf r_att = matrix::Dcm(quat_att); + const SquareMatrix cov_rotated = r_att * cov_mat * r_att.transpose(); + + // Relative position + if (!PX4_ISFINITE(vision_ned(0)) || !PX4_ISFINITE(vision_ned(1)) || !PX4_ISFINITE(vision_ned(2))) { + if (shouldEmitWarning(_vision_pos_warn_last)) { + PX4_WARN("VTE: Vision position NED is corrupt!"); + } + + return false; + } + + obs.timestamp = report.timestamp_sample; + + obs.type = ObsType::kFiducialMarker; + obs.updated(vtest::Axis::x) = true; + obs.updated(vtest::Axis::y) = true; + obs.updated(vtest::Axis::z) = true; + + // Assume noise correlation negligible: + obs.meas_h_xyz(vtest::Axis::x, vtest::State::pos_rel) = 1; + obs.meas_h_xyz(vtest::Axis::y, vtest::State::pos_rel) = 1; + obs.meas_h_xyz(vtest::Axis::z, vtest::State::pos_rel) = 1; + + obs.meas_xyz = vision_ned; + + // Assume off diag elements ~ 0 + obs.meas_unc_xyz(vtest::Axis::x) = cov_rotated(vtest::Axis::x, vtest::Axis::x); + obs.meas_unc_xyz(vtest::Axis::y) = cov_rotated(vtest::Axis::y, vtest::Axis::y); + obs.meas_unc_xyz(vtest::Axis::z) = cov_rotated(vtest::Axis::z, vtest::Axis::z); + + return true; +} + +/*Drone GNSS velocity observation: [vel_uav_x, vel_uav_y ,vel_uav_z]*/ +bool VTEPosition::processObsGNSSVelUav(TargetObs &obs) const +{ + Vector3f vel_uav_ned{}; + + if (!getOffsetUavVel(_uav_gps_vel.timestamp, vel_uav_ned)) { + return false; + } + + const float min_gps_vel_var = getMinGpsVelVar(); + + obs.meas_xyz = vel_uav_ned; + + obs.meas_h_xyz(vtest::Axis::x, vtest::State::vel_uav) = 1; + obs.meas_h_xyz(vtest::Axis::y, vtest::State::vel_uav) = 1; + obs.meas_h_xyz(vtest::Axis::z, vtest::State::vel_uav) = 1; + + const float unc = fmaxf(sq(_uav_gps_vel.uncertainty), min_gps_vel_var); + obs.meas_unc_xyz(vtest::Axis::x) = unc; + obs.meas_unc_xyz(vtest::Axis::y) = unc; + obs.meas_unc_xyz(vtest::Axis::z) = unc; + + obs.timestamp = _uav_gps_vel.timestamp; + + obs.type = ObsType::kUavGpsVel; + + obs.updated(vtest::Axis::x) = true; + obs.updated(vtest::Axis::y) = true; + obs.updated(vtest::Axis::z) = true; + + return true; +} + +#if defined(CONFIG_VTEST_MOVING) + +/*Target GNSS velocity observation: [r_dotx, r_doty, r_dotz]*/ +bool VTEPosition::processObsGNSSVelTarget(const target_gnss_s &target_gnss, TargetObs &obs) const +{ + // If the target is moving, the relative velocity is expressed as the drone velocity minus the target velocity. + obs.meas_xyz(vtest::Axis::x) = target_gnss.vel_n_m_s; + obs.meas_xyz(vtest::Axis::y) = target_gnss.vel_e_m_s; + obs.meas_xyz(vtest::Axis::z) = target_gnss.vel_d_m_s; + + const float unc = fmaxf(sq(target_gnss.s_acc_m_s), getMinGpsVelVar()); + + obs.meas_unc_xyz(vtest::Axis::x) = unc; + obs.meas_unc_xyz(vtest::Axis::y) = unc; + obs.meas_unc_xyz(vtest::Axis::z) = unc; + + obs.meas_h_xyz(vtest::Axis::x, vtest::State::vel_target) = 1; + obs.meas_h_xyz(vtest::Axis::y, vtest::State::vel_target) = 1; + obs.meas_h_xyz(vtest::Axis::z, vtest::State::vel_target) = 1; + + obs.timestamp = target_gnss.timestamp_sample; + + obs.type = ObsType::kTargetGpsVel; + + obs.updated(vtest::Axis::x) = true; + obs.updated(vtest::Axis::y) = true; + obs.updated(vtest::Axis::z) = true; + + return true; +} + +#endif // CONFIG_VTEST_MOVING + +/*Target GNSS mission observation: [rx + bx, ry + by, rz + bz]*/ +bool VTEPosition::processObsGNSSPosMission(TargetObs &obs) +{ + // Obtain GPS relative measurements in NED as target_global - uav_gps_global followed by global2local transformation + Vector3f gps_relative_pos; + get_vector_to_next_waypoint(_uav_gps_position.lat_deg, _uav_gps_position.lon_deg, + _mission_land_position.lat_deg, _mission_land_position.lon_deg, + &gps_relative_pos(0), &gps_relative_pos(1)); + + // Down direction (if the drone is above the target, the relative position is positive) + gps_relative_pos(2) = _uav_gps_position.alt_m - _mission_land_position.alt_m; + + // Offset gps relative position to the center of mass: + if (_gps_pos_is_offset) { + + if (!_gps_pos_offset_ned.valid + || !isTimeDifferenceWithin(_gps_pos_offset_ned.timestamp, _uav_gps_position.timestamp, _meas_updated_timeout_us)) { + return false; + } + + gps_relative_pos += _gps_pos_offset_ned.xyz; + } + + const float min_gps_pos_var = getMinGpsPosVar(); + const float gps_unc_horizontal = fmaxf(sq(_uav_gps_position.eph), min_gps_pos_var); + const float gps_unc_vertical = fmaxf(sq(_uav_gps_position.epv), min_gps_pos_var); + + // GPS already in NED, no rotation required. + // Obs: [pos_rel + bias] + obs.meas_h_xyz(vtest::Axis::x, vtest::State::pos_rel) = 1; + obs.meas_h_xyz(vtest::Axis::y, vtest::State::pos_rel) = 1; + obs.meas_h_xyz(vtest::Axis::z, vtest::State::pos_rel) = 1; + + obs.timestamp = _uav_gps_position.timestamp; + + obs.meas_xyz = gps_relative_pos; + + obs.meas_unc_xyz(vtest::Axis::x) = gps_unc_horizontal; + obs.meas_unc_xyz(vtest::Axis::y) = gps_unc_horizontal; + obs.meas_unc_xyz(vtest::Axis::z) = gps_unc_vertical; + + if (_bias.set) { + obs.meas_h_xyz(vtest::Axis::x, vtest::State::bias) = 1; + obs.meas_h_xyz(vtest::Axis::y, vtest::State::bias) = 1; + obs.meas_h_xyz(vtest::Axis::z, vtest::State::bias) = 1; + } + + obs.type = ObsType::kMissionGpsPos; + + obs.updated(vtest::Axis::x) = true; + obs.updated(vtest::Axis::y) = true; + obs.updated(vtest::Axis::z) = true; + + // Keep track of the latest GNSS-relative position used for bias initialization. + // Mission and target GPS position fusion are mutually exclusive, so the mission + // path can refresh this cache unconditionally. + _pos_rel_gnss.timestamp = obs.timestamp; + _pos_rel_gnss.valid = (PX4_ISFINITE(gps_relative_pos(0)) && PX4_ISFINITE(gps_relative_pos(1)) + && PX4_ISFINITE(gps_relative_pos(2))); + _pos_rel_gnss.xyz = gps_relative_pos; + + return true; +} + +/*Target GNSS observation: [rx + bx, ry + by, rz + bz]*/ +bool VTEPosition::processObsGNSSPosTarget(const target_gnss_s &target_gnss, TargetObs &obs) +{ + const int64_t time_diff_us = signedTimeDiffUs(target_gnss.timestamp_sample, _uav_gps_position.timestamp); + const float dt_sync_us = fabsf(static_cast(time_diff_us)); + + if (dt_sync_us > _meas_recent_timeout_us) { + PX4_DEBUG("Time diff between UAV GNSS and target GNSS too high: %.2f [ms] > timeout: %.2f [ms]", + static_cast(dt_sync_us * kMicrosecondsToMilliseconds), + static_cast(_meas_recent_timeout_us * kMicrosecondsToMilliseconds)); + return false; + } + + double uav_lat_deg = _uav_gps_position.lat_deg; + double uav_lon_deg = _uav_gps_position.lon_deg; + float uav_alt_m = _uav_gps_position.alt_m; + + const float dt_sync_s = static_cast(time_diff_us) * kMicrosecondsToSeconds; + const float dt_sync_s_abs = fabsf(dt_sync_s); + bool uav_position_interpolated = false; + + // Compensate UAV motion during the latency interval + static constexpr float kGnssSyncInterpolationMinTimeS = 0.1f; + + Vector3f uav_vel_ned{}; + + if ((dt_sync_s_abs > kGnssSyncInterpolationMinTimeS) + && getOffsetUavVel(_uav_gps_position.timestamp, uav_vel_ned)) { + + const float delta_n = uav_vel_ned(vtest::Axis::x) * dt_sync_s; + const float delta_e = uav_vel_ned(vtest::Axis::y) * dt_sync_s; + + double lat_res = uav_lat_deg; + double lon_res = uav_lon_deg; + add_vector_to_global_position(uav_lat_deg, uav_lon_deg, delta_n, delta_e, &lat_res, &lon_res); + uav_lat_deg = lat_res; + uav_lon_deg = lon_res; + uav_alt_m = _uav_gps_position.alt_m - uav_vel_ned(vtest::Axis::z) * dt_sync_s; + uav_position_interpolated = true; + } + + // Obtain GPS relative measurements in NED as target_global - uav_gps_global followed by global2local transformation + Vector3f gps_relative_pos; + get_vector_to_next_waypoint(uav_lat_deg, uav_lon_deg, + target_gnss.latitude_deg, target_gnss.longitude_deg, + &gps_relative_pos(0), &gps_relative_pos(1)); + + // Down direction (if the drone is above the target, the relative position is positive) + gps_relative_pos(2) = uav_alt_m - target_gnss.altitude_msl_m; + + // Offset gps relative position to the center of mass: + if (_gps_pos_is_offset) { + if (!_gps_pos_offset_ned.valid + || !isTimeDifferenceWithin(_gps_pos_offset_ned.timestamp, _uav_gps_position.timestamp, _meas_updated_timeout_us)) { + + return false; + } + + gps_relative_pos += _gps_pos_offset_ned.xyz; + } + + // Var(aX - bY) = a^2 Var(X) + b^2Var(Y) - 2ab Cov(X,Y) + const float propagation_unc = uav_position_interpolated + ? sq(_uav_gps_vel.uncertainty * dt_sync_s_abs) : 0.f; + const float min_gps_pos_var = getMinGpsPosVar(); + const float gps_unc_horizontal = fmaxf(sq(_uav_gps_position.eph), min_gps_pos_var) + + fmaxf(sq(target_gnss.eph), min_gps_pos_var) + + propagation_unc; + const float gps_unc_vertical = fmaxf(sq(_uav_gps_position.epv), min_gps_pos_var) + + fmaxf(sq(target_gnss.epv), min_gps_pos_var) + + propagation_unc; + + // GPS already in NED, no rotation required. + // Obs: [pos_rel + bias] + obs.meas_h_xyz(vtest::Axis::x, vtest::State::pos_rel) = 1; + obs.meas_h_xyz(vtest::Axis::y, vtest::State::pos_rel) = 1; + obs.meas_h_xyz(vtest::Axis::z, vtest::State::pos_rel) = 1; + + if (_bias.set) { + obs.meas_h_xyz(vtest::Axis::x, vtest::State::bias) = 1; + obs.meas_h_xyz(vtest::Axis::y, vtest::State::bias) = 1; + obs.meas_h_xyz(vtest::Axis::z, vtest::State::bias) = 1; + } + + obs.timestamp = target_gnss.timestamp_sample; + + obs.meas_xyz = gps_relative_pos; + + obs.meas_unc_xyz(vtest::Axis::x) = gps_unc_horizontal; + obs.meas_unc_xyz(vtest::Axis::y) = gps_unc_horizontal; + obs.meas_unc_xyz(vtest::Axis::z) = gps_unc_vertical; + + obs.type = ObsType::kTargetGpsPos; + + obs.updated(vtest::Axis::x) = true; + obs.updated(vtest::Axis::y) = true; + obs.updated(vtest::Axis::z) = true; + + // Keep track of the gps relative position + _pos_rel_gnss.timestamp = obs.timestamp; + _pos_rel_gnss.valid = (PX4_ISFINITE(gps_relative_pos(0)) && PX4_ISFINITE(gps_relative_pos(1)) + && PX4_ISFINITE(gps_relative_pos(2))); + _pos_rel_gnss.xyz = gps_relative_pos; + + return true; +} + +bool VTEPosition::fuseMeas(const Vector3f &vehicle_acc_ned, const TargetObs &target_obs) +{ + perf_begin(_vte_update_perf); + const float nis_threshold = fmaxf(_param_vte_pos_nis_thre.get(), kMinNisThreshold); + + vte_aid_source3d_s target_innov = {}; + target_innov.timestamp_sample = target_obs.timestamp; + target_innov.timestamp = hrt_absolute_time(); + target_innov.time_last_predict = _last_predict; + target_innov.time_since_meas_ms = static_cast(signedTimeDiffUs(_last_predict, target_obs.timestamp)) + * kMicrosecondsToMilliseconds; + + bool any_axis_fused = false; + uint8_t history_steps = 0; + + for (int j = 0; j < vtest::Axis::size; j++) { + + if (!target_obs.updated(j)) { + target_innov.fusion_status[j] = static_cast(FusionStatus::kIdle); + continue; // nothing to do for this axis + } + + const KF_position::ScalarMeas meas_input{target_obs.timestamp, target_obs.meas_xyz(j), + target_obs.meas_unc_xyz(j), target_obs.meas_h_xyz.row(j)}; + + target_innov.observation[j] = meas_input.val; + target_innov.observation_variance[j] = meas_input.unc; + + perf_begin(_vte_fusion_perf); + const FusionResult result = _target_est_pos[j].fuseScalarAtTime(meas_input, _last_predict, nis_threshold); + perf_end(_vte_fusion_perf); + + target_innov.innovation[j] = result.innov; + target_innov.innovation_variance[j] = result.innov_var; + target_innov.test_ratio[j] = result.test_ratio; + target_innov.fusion_status[j] = static_cast(result.status); + any_axis_fused |= (result.status == FusionStatus::kFusedCurrent || + result.status == FusionStatus::kFusedOosm); + + if (result.status == FusionStatus::kFusedOosm) { + history_steps = math::max(result.history_steps, history_steps); + } + } + + target_innov.history_steps = history_steps; + + perf_end(_vte_update_perf); + publishInnov(target_innov, target_obs.type); + + return any_axis_fused; +} + +void VTEPosition::publishInnov(const vte_aid_source3d_s &target_innov, const ObsType type) +{ + // Publish innovations + switch (type) { + case ObsType::kTargetGpsPos: + _vte_aid_gps_pos_target_pub.publish(target_innov); + break; + + case ObsType::kMissionGpsPos: + _vte_aid_gps_pos_mission_pub.publish(target_innov); + break; + + case ObsType::kUavGpsVel: + _vte_aid_gps_vel_uav_pub.publish(target_innov); + break; + + case ObsType::kTargetGpsVel: + _vte_aid_gps_vel_target_pub.publish(target_innov); + break; + + case ObsType::kFiducialMarker: + _vte_aid_fiducial_marker_pub.publish(target_innov); + break; + + case ObsType::kTypeCount: + break; + } +} + +void VTEPosition::publishTarget() +{ + _vte_state = {}; + _target_pose = {}; + + _target_pose.timestamp = _last_predict; + _vte_state.timestamp = _last_predict; + + _target_pose.rel_pos_valid = !hasTimedOut(_last_update, _target_valid_timeout_us); + +#if defined(CONFIG_VTEST_MOVING) + _target_pose.is_static = false; +#else + _target_pose.is_static = true; +#endif // CONFIG_VTEST_MOVING + + // Get state + const EstimatorState &state_x = _target_est_pos[vtest::Axis::x].getState(); + const EstimatorState &state_y = _target_est_pos[vtest::Axis::y].getState(); + const EstimatorState &state_z = _target_est_pos[vtest::Axis::z].getState(); + + const EstimatorState state_var_x = _target_est_pos[vtest::Axis::x].getStateCovarianceDiag(); + const EstimatorState state_var_y = _target_est_pos[vtest::Axis::y].getStateCovarianceDiag(); + const EstimatorState state_var_z = _target_est_pos[vtest::Axis::z].getStateCovarianceDiag(); + + // Fill target relative pose + _target_pose.x_rel = state_x(vtest::State::pos_rel); + _target_pose.y_rel = state_y(vtest::State::pos_rel); + _target_pose.z_rel = state_z(vtest::State::pos_rel); + + _target_pose.cov_x_rel = state_var_x(vtest::State::pos_rel); + _target_pose.cov_y_rel = state_var_y(vtest::State::pos_rel); + _target_pose.cov_z_rel = state_var_z(vtest::State::pos_rel); + + // Fill target relative velocity + _target_pose.vx_rel = -state_x(vtest::State::vel_uav); + _target_pose.vy_rel = -state_y(vtest::State::vel_uav); + _target_pose.vz_rel = -state_z(vtest::State::vel_uav); + + _target_pose.cov_vx_rel = state_var_x(vtest::State::vel_uav); + _target_pose.cov_vy_rel = state_var_y(vtest::State::vel_uav); + _target_pose.cov_vz_rel = state_var_z(vtest::State::vel_uav); + +#if defined(CONFIG_VTEST_MOVING) + const EstimatorStateCovariance &state_cov_x = _target_est_pos[vtest::Axis::x].getStateCovariance(); + const EstimatorStateCovariance &state_cov_y = _target_est_pos[vtest::Axis::y].getStateCovariance(); + const EstimatorStateCovariance &state_cov_z = _target_est_pos[vtest::Axis::z].getStateCovariance(); + + // If target is moving, relative velocity = vel_target - vel_uav + _target_pose.vx_rel += state_x(vtest::State::vel_target); + _target_pose.vy_rel += state_y(vtest::State::vel_target); + _target_pose.vz_rel += state_z(vtest::State::vel_target); + + // Var(vel_target - vel_uav) = Var(vel_target) + Var(vel_uav) - 2 Cov(vel_uav, vel_target) + _target_pose.cov_vx_rel += state_var_x(vtest::State::vel_target) + - 2.f * state_cov_x(vtest::State::vel_uav, vtest::State::vel_target); + _target_pose.cov_vy_rel += state_var_y(vtest::State::vel_target) + - 2.f * state_cov_y(vtest::State::vel_uav, vtest::State::vel_target); + _target_pose.cov_vz_rel += state_var_z(vtest::State::vel_target) + - 2.f * state_cov_z(vtest::State::vel_uav, vtest::State::vel_target); + +#endif // CONFIG_VTEST_MOVING + + // Fill vision target estimator state + _vte_state.rel_pos[0] = _target_pose.x_rel; + _vte_state.rel_pos[1] = _target_pose.y_rel; + _vte_state.rel_pos[2] = _target_pose.z_rel; + + _vte_state.cov_rel_pos[0] = _target_pose.cov_x_rel; + _vte_state.cov_rel_pos[1] = _target_pose.cov_y_rel; + _vte_state.cov_rel_pos[2] = _target_pose.cov_z_rel; + + // Fill uav velocity + _vte_state.vel_uav[0] = state_x(vtest::State::vel_uav); + _vte_state.vel_uav[1] = state_y(vtest::State::vel_uav); + _vte_state.vel_uav[2] = state_z(vtest::State::vel_uav); + + _vte_state.cov_vel_uav[0] = state_var_x(vtest::State::vel_uav); + _vte_state.cov_vel_uav[1] = state_var_y(vtest::State::vel_uav); + _vte_state.cov_vel_uav[2] = state_var_z(vtest::State::vel_uav); + + _vte_state.bias[0] = state_x(vtest::State::bias); + _vte_state.bias[1] = state_y(vtest::State::bias); + _vte_state.bias[2] = state_z(vtest::State::bias); + + _vte_state.cov_bias[0] = state_var_x(vtest::State::bias); + _vte_state.cov_bias[1] = state_var_y(vtest::State::bias); + _vte_state.cov_bias[2] = state_var_z(vtest::State::bias); + +#if defined(CONFIG_VTEST_MOVING) + + _vte_state.vel_target[0] = state_x(vtest::State::vel_target); + _vte_state.vel_target[1] = state_y(vtest::State::vel_target); + _vte_state.vel_target[2] = state_z(vtest::State::vel_target); + + _vte_state.cov_vel_target[0] = state_var_x(vtest::State::vel_target); + _vte_state.cov_vel_target[1] = state_var_y(vtest::State::vel_target); + _vte_state.cov_vel_target[2] = state_var_z(vtest::State::vel_target); + + _vte_state.acc_target[0] = state_x(vtest::State::acc_target); + _vte_state.acc_target[1] = state_y(vtest::State::acc_target); + _vte_state.acc_target[2] = state_z(vtest::State::acc_target); + + _vte_state.cov_acc_target[0] = state_var_x(vtest::State::acc_target); + _vte_state.cov_acc_target[1] = state_var_y(vtest::State::acc_target); + _vte_state.cov_acc_target[2] = state_var_z(vtest::State::acc_target); + +#endif // CONFIG_VTEST_MOVING + + // If the target is static, valid and vision obs was fused recently, use the relative to aid the EKF2 state estimation. + // Check performed in EKF2 to use target vel: if (landing_target_pose.is_static && landing_target_pose.rel_vel_valid) + const hrt_abstime now = hrt_absolute_time(); + _target_pose.rel_vel_valid = _target_pose.is_static && _param_vte_ekf_aid.get() && _target_pose.rel_pos_valid && + (_last_relative_meas_fused_time != 0) && (now >= _last_relative_meas_fused_time) && + (now - _last_relative_meas_fused_time) < _meas_recent_timeout_us; + _vte_state.rel_pos_valid = _target_pose.rel_pos_valid; + _vte_state.rel_vel_valid = _target_pose.rel_vel_valid; + + // Prec land does not check _target_pose.abs_pos_valid. Only send the target if abs pose valid. + if (_local_position.valid && _target_pose.rel_pos_valid) { + _target_pose.x_abs = _target_pose.x_rel + _local_position.xyz(0); + _target_pose.y_abs = _target_pose.y_rel + _local_position.xyz(1); + _target_pose.z_abs = _target_pose.z_rel + _local_position.xyz(2); + _target_pose.abs_pos_valid = true; + +#if defined(CONFIG_VTEST_MOVING) + + // If the target is moving, move towards its expected location + float mpc_z_v_auto_dn = _mpc_z_v_auto_dn; + + static constexpr float kMinAbsMpcZVAutoDnMps = 1e-3f; + + if (fabsf(mpc_z_v_auto_dn) < kMinAbsMpcZVAutoDnMps) { + mpc_z_v_auto_dn = (mpc_z_v_auto_dn >= 0.f) ? kMinAbsMpcZVAutoDnMps : -kMinAbsMpcZVAutoDnMps; + } + + float intersection_time_s = fabsf(_target_pose.z_rel / mpc_z_v_auto_dn); + intersection_time_s = math::constrain(intersection_time_s, _param_vte_moving_t_min.get(), + _param_vte_moving_t_max.get()); + + // Anticipate where the target will be + _target_pose.x_abs += _vte_state.vel_target[0] * intersection_time_s; + _target_pose.y_abs += _vte_state.vel_target[1] * intersection_time_s; + _target_pose.z_abs += _vte_state.vel_target[2] * intersection_time_s; + +#endif // CONFIG_VTEST_MOVING + + _target_pose_pub.publish(_target_pose); + + } + + _target_estimator_state_pub.publish(_vte_state); + + // TODO: decide what to do with Bias lim + float bias_lim = _param_vte_bias_lim.get(); + + if ((fabsf(_vte_state.bias[0]) > bias_lim) + || (fabsf(_vte_state.bias[1]) > bias_lim) + || (fabsf(_vte_state.bias[2]) > bias_lim)) { + + PX4_DEBUG("Bias exceeds limit: %.2f bias x: %.2f bias y: %.2f bias z: %.2f", (double)bias_lim, + (double)_vte_state.bias[0], (double)_vte_state.bias[1], (double)_vte_state.bias[2]); + + // resetFilter(); + } + +} + +void VTEPosition::checkMeasurementInputs() +{ + // Make sure local position and local velocity are up to date. + if (_local_position.valid) { + _local_position.valid = isMeasUpdated(_local_position.timestamp); + } + + if (_local_velocity.valid) { + _local_velocity.valid = isMeasUpdated(_local_velocity.timestamp); + } + +#if defined(CONFIG_VTEST_MOVING) + + if (_target_gps_vel.valid) { + _target_gps_vel.valid = isMeasUpdated(_target_gps_vel.timestamp); + } + +#endif // CONFIG_VTEST_MOVING + + if (_pos_rel_gnss.valid) { + _pos_rel_gnss.valid = isMeasUpdated(_pos_rel_gnss.timestamp); + } + + if (_velocity_offset_ned.valid) { + _velocity_offset_ned.valid = isMeasUpdated(_velocity_offset_ned.timestamp); + } + + if (_gps_pos_offset_ned.valid) { + _gps_pos_offset_ned.valid = isMeasUpdated(_gps_pos_offset_ned.timestamp); + } + + if (_uav_gps_vel.valid) { + _uav_gps_vel.valid = isMeasUpdated(_uav_gps_vel.timestamp); + } +} + +void VTEPosition::setGpsPosOffset(const matrix::Vector3f &xyz, const bool gps_is_offset) +{ + _gps_pos_is_offset = gps_is_offset; + _gps_pos_offset_ned.xyz = xyz; + _gps_pos_offset_ned.valid = PX4_ISFINITE(xyz(0)) + && PX4_ISFINITE(xyz(1)) + && PX4_ISFINITE(xyz(2)); + _gps_pos_offset_ned.timestamp = hrt_absolute_time(); +} + +void VTEPosition::setVelOffset(const matrix::Vector3f &xyz) +{ + _velocity_offset_ned.xyz = xyz; + _velocity_offset_ned.valid = PX4_ISFINITE(xyz(0)) + && PX4_ISFINITE(xyz(1)) + && PX4_ISFINITE(xyz(2)); + _velocity_offset_ned.timestamp = hrt_absolute_time(); +} + +void VTEPosition::setLocalVelocity(const matrix::Vector3f &vel_xyz, const bool vel_valid, hrt_abstime timestamp) +{ + _local_velocity.xyz = vel_xyz; + _local_velocity.valid = vel_valid && isMeasUpdated(timestamp); + _local_velocity.timestamp = timestamp; +} + +void VTEPosition::setLocalPosition(const matrix::Vector3f &xyz, const bool pos_valid, hrt_abstime timestamp) +{ + _local_position.xyz = xyz; + _local_position.valid = pos_valid && isMeasUpdated(timestamp); + _local_position.timestamp = timestamp; +} + +void VTEPosition::setMissionPosition(const double lat_deg, const double lon_deg, const float alt_m) +{ + if (_vte_aid_mask.flags.use_mission_pos) { + _mission_land_position.lat_deg = lat_deg; + _mission_land_position.lon_deg = lon_deg; + _mission_land_position.alt_m = alt_m; + _mission_land_position.valid = isLatLonAltValid(_mission_land_position.lat_deg, _mission_land_position.lon_deg, + _mission_land_position.alt_m, "Mission position ", &_mission_pos_warn_last); + + if (_mission_land_position.valid) { + PX4_INFO("Mission position lat %.8f, lon %.8f [deg], alt %.1f [m]", lat_deg, + lon_deg, (double)(alt_m)); + + } else { + if (shouldEmitWarning(_mission_pos_status_warn_last)) { + PX4_WARN("Mission position not used because not valid"); + } + } + + } else { + _mission_land_position.valid = false; + } +} + +void VTEPosition::updateParams() +{ + parameter_update_s pupdate; + _parameter_update_sub.copy(&pupdate); + + ModuleParams::updateParams(); + +#if defined(CONFIG_VTEST_MOVING) + + if (_param_mpc_z_v_auto_dn != PARAM_INVALID) { + float new_mpc_z_v_auto_dn = _mpc_z_v_auto_dn; + + if (param_get(_param_mpc_z_v_auto_dn, &new_mpc_z_v_auto_dn) == PX4_OK) { + _mpc_z_v_auto_dn = new_mpc_z_v_auto_dn; + } + } + +#endif // CONFIG_VTEST_MOVING +} + +float VTEPosition::getMinGpsVelVar() const +{ + return sq(fmaxf(_param_vte_gps_vel_noise.get(), kMinObservationNoise)); +} + +float VTEPosition::getMinGpsPosVar() const +{ + return sq(fmaxf(_param_vte_gps_pos_noise.get(), kMinObservationNoise)); +} + +float VTEPosition::getBiasAveragingThreshold() const +{ + return fmaxf(_param_vte_bias_avg_thr.get(), 0.f); +} + +uint32_t VTEPosition::getBiasAveragingTimeoutUs() const +{ + return static_cast(fmaxf(_param_vte_bias_avg_tout.get(), 0.f) * 1e6f); +} + +bool VTEPosition::isLatLonAltValid(double lat_deg, double lon_deg, float alt_m, const char *who, + hrt_abstime *warn_last) +{ + // all finite + if (!PX4_ISFINITE(lat_deg) || !PX4_ISFINITE(lon_deg) || !PX4_ISFINITE(alt_m)) { + if (who && (!warn_last || shouldEmitWarning(*warn_last))) { + PX4_WARN("%s position not finite! lat: %.8f, lon: %.8f, alt: %.1f", + who, lat_deg, lon_deg, (double)alt_m); + } + + return false; + } + + // latitude/longitude within symmetric geographic ranges + if ((fabs(lat_deg) > kLatAbsMaxDeg) || (fabs(lon_deg) > kLonAbsMaxDeg)) { + if (who && (!warn_last || shouldEmitWarning(*warn_last))) { + PX4_WARN("%s lat/lon out of range! lat: %.8f, lon: %.8f", who, lat_deg, lon_deg); + } + + return false; + } + + // (0,0) sentinel is considered invalid + if ((fabs(lat_deg) < DBL_EPSILON) && (fabs(lon_deg) < DBL_EPSILON)) { + if (who && (!warn_last || shouldEmitWarning(*warn_last))) { + PX4_WARN("%s position near (0,0)!", who); + } + + return false; + } + + // altitude window + if ((alt_m < kAltMinM) || (alt_m > kAltMaxM)) { + if (who && (!warn_last || shouldEmitWarning(*warn_last))) { + PX4_WARN("%s altitude out of range! alt: %.1f [m] (limits: %.1f;%.1f)", + who, (double)alt_m, (double)kAltMinM, (double)kAltMaxM); + } + + return false; + } + + return true; +} + +} // namespace vision_target_estimator diff --git a/src/modules/vision_target_estimator/Position/VTEPosition.h b/src/modules/vision_target_estimator/Position/VTEPosition.h new file mode 100644 index 000000000000..25eee2243492 --- /dev/null +++ b/src/modules/vision_target_estimator/Position/VTEPosition.h @@ -0,0 +1,419 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file VTEPosition.h + * @brief Estimate the state of a target by processing and fusing sensor data in + * a Kalman Filter. + * + * @author Jonas Perolini + * + */ + +#pragma once + +#include "../common.h" +#include "KF_position.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace vision_target_estimator +{ + +class VTEPosition : public ModuleParams +{ +public: + VTEPosition(); + virtual ~VTEPosition(); + + /* + * Get new measurements and update the state estimate + */ + void update(const matrix::Vector3f &acc_ned); + + bool init(); + + void resetFilter(); + + void setMissionPosition(double lat_deg, double lon_deg, float alt_m); + void setLocalVelocity(const matrix::Vector3f &vel_xyz, bool valid, hrt_abstime timestamp); + void setLocalPosition(const matrix::Vector3f &xyz, bool valid, hrt_abstime timestamp); + void setGpsPosOffset(const matrix::Vector3f &xyz, bool gps_is_offset); + void setVelOffset(const matrix::Vector3f &xyz); + void setVteTimeout(uint32_t tout) { _vte_timeout_us = tout; } + void setTargetValidTimeout(uint32_t tout) + { + _target_valid_timeout_us = tout; + } + void setMeasRecentTimeout(uint32_t tout) + { + _meas_recent_timeout_us = tout; + } + void setMeasUpdatedTimeout(uint32_t tout) + { + _meas_updated_timeout_us = tout; + } + void setVteAidMask(uint16_t mask_value) + { + _vte_aid_mask.value = mask_value; + } + + bool timedOut() const + { + return _estimator_initialized && hasTimedOut(_last_update, _vte_timeout_us); + } + // TODO: Could be more strict and require a relative position meas (vision, GNSS) + bool fusionEnabled() const { return _vte_aid_mask.value != 0; } + +protected: + static constexpr double kLatAbsMaxDeg = 90.0; + static constexpr double kLonAbsMaxDeg = 180.0; + static constexpr float kAltMinM = -350.f; + static constexpr float kAltMaxM = 10000.f; + + /* + * update parameters. + */ + void updateParams() override; + + uORB::Publication _target_pose_pub{ORB_ID(landing_target_pose)}; + uORB::Publication _target_estimator_state_pub{ORB_ID(vision_target_est_position)}; + + // publish innovations target_estimator_gps_pos + uORB::Publication _vte_aid_gps_pos_target_pub{ORB_ID(vte_aid_gps_pos_target)}; + uORB::Publication _vte_aid_gps_pos_mission_pub{ORB_ID(vte_aid_gps_pos_mission)}; + uORB::Publication _vte_aid_gps_vel_target_pub{ORB_ID(vte_aid_gps_vel_target)}; + uORB::Publication _vte_aid_gps_vel_uav_pub{ORB_ID(vte_aid_gps_vel_uav)}; + uORB::Publication _vte_aid_fiducial_marker_pub{ORB_ID(vte_aid_fiducial_marker)}; + uORB::Publication _vte_bias_init_status_pub{ORB_ID(vte_bias_init_status)}; + + uORB::SubscriptionInterval _parameter_update_sub{ORB_ID(parameter_update), 1_s}; + +private: + // Observation types used by the estimator. Keep ordering stable for array indexing. + enum class ObsType : uint8_t { + kTargetGpsPos, + kMissionGpsPos, + kUavGpsVel, + kTargetGpsVel, + kFiducialMarker, + kTypeCount + }; + + enum class PreBiasReference : uint8_t { + kUnknown, + kVision, + kGnss + }; + + static constexpr size_t kObsTypeCount = static_cast(ObsType::kTypeCount); + static constexpr size_t obsIndex(ObsType type) + { + return static_cast(type); + } + + struct TargetObs { + + ObsType type; + hrt_abstime timestamp = 0; + + matrix::Vector updated{}; // Indicates if observations were updated. + matrix::Vector3f meas_xyz{}; // Measurements (meas_x, meas_y, meas_z) + matrix::Vector3f meas_unc_xyz{}; // Measurements' uncertainties + matrix::Matrix + meas_h_xyz{}; // Observation matrix where the rows correspond to the + // x,y,z observations and the columns to the state + }; + + union ObsValidMaskU { + struct { + uint8_t fuse_target_gps_pos : 1; ///< bit0: target GPS position ready to be fused + uint8_t fuse_uav_gps_vel : 1; ///< bit1: UAV GPS velocity ready to be fused + uint8_t fuse_vision : 1; ///< bit2: external vision-relative position ready to be fused + uint8_t fuse_mission_pos : 1; ///< bit3: mission position ready to be fused + uint8_t fuse_target_gps_vel : 1; ///< bit4: target GPS velocity ready to be fused + uint8_t reserved : 3; ///< bits5..7: reserved for future use + } flags; + + uint8_t value{0}; + }; + + static_assert(sizeof(ObsValidMaskU) == 1, "Unexpected masking size"); + + using EstimatorState = KF_position::VectorState; + using EstimatorStateCovariance = KF_position::SquareMatrixState; + using AxisEstimatorStates = px4::Array; + + bool initEstimator(const AxisEstimatorStates &state_init); + bool performUpdateStep(const matrix::Vector3f &vehicle_acc_ned); + void predictionStep(const matrix::Vector3f &acc, float dt); + float getMinGpsVelVar() const; + float getMinGpsPosVar() const; + float getBiasAveragingThreshold() const; + uint32_t getBiasAveragingTimeoutUs() const; + + inline bool isMeasRecent(hrt_abstime ts) const + { + return !hasTimedOut(ts, _meas_recent_timeout_us); + } + + inline bool isMeasUpdated(hrt_abstime ts) const + { + return !hasTimedOut(ts, _meas_updated_timeout_us); + } + + inline bool hasNewPositionSensorData(const ObsValidMaskU &fusion_mask) const + { + return fusion_mask.flags.fuse_mission_pos || + fusion_mask.flags.fuse_target_gps_pos || + fusion_mask.flags.fuse_vision; + } + + inline bool isTimeDifferenceWithin(const hrt_abstime a, const hrt_abstime b, const uint32_t timeout_us) const + { + if ((a == 0) || (b == 0)) { + return false; + } + + const hrt_abstime diff = (a > b) ? (a - b) : (b - a); + return diff <= timeout_us; + } + + // Only estimate the GNSS bias if we have a GNSS estimation and a secondary source of position + inline bool shouldSetBias(const ObsValidMaskU &fusion_mask) const + { + return _pos_rel_gnss.valid && isMeasRecent(_pos_rel_gnss.timestamp) && fusion_mask.flags.fuse_vision; + }; + + inline bool isBiasAveragingEnabled() const + { + return getBiasAveragingThreshold() > 0.f && (getBiasAveragingTimeoutUs() > 0); + } + + inline bool shouldAverageInitialBias(const ObsValidMaskU &fusion_mask, + const PreBiasReference reference) const + { + return shouldSetBias(fusion_mask) && (reference == PreBiasReference::kGnss) + && isBiasAveragingEnabled(); + } + + inline bool shouldAverageInitialBias(const ObsValidMaskU &fusion_mask) const + { + return shouldAverageInitialBias(fusion_mask, _pre_bias_reference); + } + + inline bool shouldBlockVisionFusionUntilBiasReady() const + { + // Vision must wait until the GNSS bias is ready when the state is still + // referenced to GNSS. + return !_bias.set && (_pre_bias_reference == PreBiasReference::kGnss); + } + + inline bool shouldBlockGnssFusionUntilBiasReady() const + { + // GNSS position must wait until the GNSS bias is ready when the state is + // still referenced to vision. + return !_bias.set && (_pre_bias_reference == PreBiasReference::kVision); + } + + bool initializeEstimator(const ObsValidMaskU &fusion_mask, + const TargetObs observations[kObsTypeCount]); + void updateBiasIfObservable(const ObsValidMaskU &fusion_mask, + const TargetObs observations[kObsTypeCount]); + bool selectBiasGnssSample(hrt_abstime sample_time, matrix::Vector3f &gnss_sample); + PreBiasReference selectInitialPosition(const ObsValidMaskU &fusion_mask, + const TargetObs observations[kObsTypeCount], matrix::Vector3f &initial_position, + bool prefer_vision = true); + bool fuseActiveMeasurements(const matrix::Vector3f &vehicle_acc_ned, ObsValidMaskU &fusion_mask, + const TargetObs observations[kObsTypeCount]); + void processObservations(ObsValidMaskU &fusion_mask, + TargetObs observations[kObsTypeCount]); + bool getOffsetUavVel(hrt_abstime sample_time, matrix::Vector3f &uav_velocity_ned) const; + void startBiasAveraging(const matrix::Vector3f &bias_sample, hrt_abstime sample_time); + bool updateBiasAveraging(const matrix::Vector3f &bias_sample, hrt_abstime sample_time); + void resetBiasAveraging(); + void activateBiasEstimate(const matrix::Vector3f &initial_position, const matrix::Vector3f &initial_bias); + void publishBiasInitStatus(const matrix::Vector3f &raw_bias, const matrix::Vector3f &filtered_bias, + float raw_bias_delta_norm, hrt_abstime sample_time); + + bool isLatLonAltValid(double lat_deg, double lon_deg, float alt_m, const char *who = nullptr, + hrt_abstime *warn_last = nullptr); + + /* Vision data */ + bool isVisionDataValid(const fiducial_marker_pos_report_s &fiducial_marker_pose); + bool processObsVision(TargetObs &obs); + + /* UAV GPS data */ + bool updateUavGpsData(); + bool isUavGpsPositionValid(); + bool isUavGpsVelocityValid(); + bool processObsGNSSPosMission(TargetObs &obs); + bool processObsGNSSVelUav(TargetObs &obs) const; + + /* Target GPS data */ + bool isTargetGpsPositionValid(const target_gnss_s &target_gnss); + bool isTargetGpsVelocityValid(const target_gnss_s &target_gnss); + bool processObsGNSSPosTarget(const target_gnss_s &target_gnss, TargetObs &obs); +#if defined(CONFIG_VTEST_MOVING) + bool processObsGNSSVelTarget(const target_gnss_s &target_gnss, TargetObs &obs) const; + void updateTargetGpsVelocity(const target_gnss_s &target_gnss); +#endif // CONFIG_VTEST_MOVING + + bool fuseMeas(const matrix::Vector3f &vehicle_acc_ned, const TargetObs &target_pos_obs); + void publishTarget(); + void publishInnov(const vte_aid_source3d_s &target_innov, const ObsType type); + void resetObservations(); + bool shouldEmitWarning(hrt_abstime &last_warn); + + uORB::Subscription _vehicle_gps_position_sub{ORB_ID(vehicle_gps_position)}; + uORB::Subscription _fiducial_marker_pos_report_sub{ORB_ID(fiducial_marker_pos_report)}; + uORB::Subscription _target_gnss_sub{ORB_ID(target_gnss)}; + + perf_counter_t _vte_predict_perf{perf_alloc(PC_ELAPSED, MODULE_NAME": VTE prediction")}; + perf_counter_t _vte_update_perf{perf_alloc(PC_ELAPSED, MODULE_NAME": VTE update")}; + perf_counter_t _vte_fusion_perf{perf_alloc(PC_ELAPSED, MODULE_NAME": VTE fusion")}; + + TargetObs _obs_buffer[kObsTypeCount] {}; + landing_target_pose_s _target_pose{}; + vision_target_est_position_s _vte_state{}; + + struct GlobalPose { + hrt_abstime timestamp = 0; + bool valid = false; + double lat_deg = 0.0; // Latitude in degrees + double lon_deg = 0.0; // Longitude in degrees + float alt_m = 0.f; // Altitude in meters AMSL + float eph = 0.f; + float epv = 0.f; + }; + + GlobalPose _mission_land_position{}; + GlobalPose _uav_gps_position{}; + + struct VelStamped { + hrt_abstime timestamp = 0; + bool valid = false; + matrix::Vector3f xyz{}; + float uncertainty = 0.f; + }; + + VelStamped _uav_gps_vel{}; + + Vector3fStamped _local_position{}; + Vector3fStamped _local_velocity{}; + Vector3fStamped _pos_rel_gnss{}; + Vector3fStamped _velocity_offset_ned{}; + Vector3fStamped _gps_pos_offset_ned{}; + +#if defined(CONFIG_VTEST_MOVING) + Vector3fStamped _target_gps_vel {}; + float _mpc_z_v_auto_dn{0.f}; + param_t _param_mpc_z_v_auto_dn{PARAM_INVALID}; +#endif // CONFIG_VTEST_MOVING + + struct BiasState { + bool set{false}; + bool averaging_active{false}; + uint8_t stable_delta_count{0}; + hrt_abstime averaging_start_time{0}; + hrt_abstime last_sample_time{0}; + AlphaFilter initial_lpf{}; + }; + + bool _gps_pos_is_offset{false}; + BiasState _bias{}; + PreBiasReference _pre_bias_reference{PreBiasReference::kUnknown}; + + uint64_t _last_relative_meas_fused_time{0}; + bool _estimator_initialized{false}; + + px4::Array _target_est_pos{}; + + hrt_abstime _last_predict{0}; // timestamp of last filter prediction + hrt_abstime _last_update{0}; // timestamp of last filter update (used to check timeout) + hrt_abstime _uav_gps_vel_warn_last{0}; + hrt_abstime _target_gps_vel_warn_last{0}; + hrt_abstime _vision_pos_warn_last{0}; + hrt_abstime _uav_gps_pos_warn_last{0}; + hrt_abstime _target_gps_pos_warn_last{0}; + hrt_abstime _mission_pos_warn_last{0}; + hrt_abstime _mission_pos_status_warn_last{0}; + hrt_abstime _init_vel_warn_last{0}; + + /* parameters from vision_target_estimator_params.yaml */ + void checkMeasurementInputs(); + + uint32_t _vte_timeout_us{3_s}; + uint32_t _target_valid_timeout_us{2_s}; + uint32_t _meas_recent_timeout_us{1_s}; + uint32_t _meas_updated_timeout_us{100_ms}; + SensorFusionMaskU _vte_aid_mask{}; + + DEFINE_PARAMETERS( + (ParamFloat) _param_vte_acc_d_unc, + (ParamFloat) _param_vte_acc_t_unc, + (ParamFloat) _param_vte_bias_lim, + (ParamFloat) _param_vte_bias_unc, + (ParamFloat) _param_vte_bias_avg_thr, + (ParamFloat) _param_vte_bias_avg_tout, + (ParamFloat) _param_vte_pos_unc_in, + (ParamFloat) _param_vte_vel_unc_in, + (ParamFloat) _param_vte_bias_unc_in, + (ParamFloat) _param_vte_acc_unc_in, + (ParamFloat) _param_vte_gps_vel_noise, + (ParamFloat) _param_vte_gps_pos_noise, + (ParamFloat) _param_vte_ev_pos_noise, + (ParamInt) _param_vte_ekf_aid, + (ParamFloat) _param_vte_moving_t_max, + (ParamFloat) _param_vte_moving_t_min, + (ParamFloat) _param_vte_pos_nis_thre + ) +}; +} // namespace vision_target_estimator diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/derivation.py b/src/modules/vision_target_estimator/Position/vtest_derivation/derivation.py new file mode 100755 index 000000000000..8e288680c092 --- /dev/null +++ b/src/modules/vision_target_estimator/Position/vtest_derivation/derivation.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" + Copyright (c) 2022-2026 PX4 Development Team + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + 3. Neither the name PX4 nor the names of its contributors may be + used to endorse or promote products derived from this software + without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + +File: derivation.py +Description: + Derivation of KF +""" + +import argparse + +import symforce +symforce.set_epsilon_to_symbol() + +import symforce.symbolic as sf +from symforce import typing as T +from symforce import ops +from symforce.values import Values + +import sympy as sp +from utils.derivation_utils import * + +# Initialize parser +parser = argparse.ArgumentParser() + +parser.add_argument("--moving", action='store_true', help="generate equations for moving filter") + +# Read arguments from command line +args = parser.parse_args() +moving = args.moving + +# The state vector is organized in an ordered dictionary +State = Values( + pos_rel=sf.V1(), + vel_uav=sf.V1(), + bias=sf.V1(), + acc_target=sf.V1(), + vel_target=sf.V1() +) + +if not moving: + del State["acc_target"] + del State["vel_target"] + +class IdxDof(): + def __init__(self, idx, dof): + self.idx = idx + self.dof = dof + +def BuildTangentStateIndex(): + tangent_state_index = {} + idx = 0 + for key in State.keys_recursive(): + dof = State[key].tangent_dim() + tangent_state_index[key] = IdxDof(idx, dof) + idx += dof + return tangent_state_index + +tangent_idx = BuildTangentStateIndex() + +class VState(sf.Matrix): + SHAPE = (State.storage_dim(), 1) + +class MState(sf.Matrix): + SHAPE = (State.storage_dim(), State.storage_dim()) + +class VMeas(sf.Matrix): + SHAPE = (1, State.storage_dim()) + +def symbolic_state(state_structure): + symbolic_state = Values() + for key in state_structure.keys_recursive(): + var_type = type(state_structure[key]) + if hasattr(var_type, 'symbolic'): + symbolic_state[key] = var_type.symbolic(key) + + return symbolic_state + +def get_Phi_and_G(dt: sf.Scalar, +) -> MState: + + state = symbolic_state(State) + + # Input acceleration (symbolic) + acc = sf.Symbol('acc') + + state_pred = state.copy() + state_pred["pos_rel"] = state["pos_rel"] - dt * state["vel_uav"] - 0.5 * dt**2 * acc + state_pred["vel_uav"] = state["vel_uav"] + dt * acc + state_pred["bias"] = state["bias"] + + if moving: + state_pred["pos_rel"] += dt * state["vel_target"] + 0.5 * dt**2 * state["acc_target"] + state_pred["vel_target"] = state["vel_target"] + dt * state["acc_target"] + state_pred["acc_target"] = state["acc_target"] + + # State vector + state_pred_matrix = VState(state_pred.to_storage()) + + # Compute Phi = ∂f/∂x + Phi = state_pred_matrix.jacobian([state]) + # Compute G = ∂f/∂w + G = state_pred_matrix.jacobian(acc) + + return Phi, G + +def predictState(dt: sf.Scalar, state: VState, acc: sf.Scalar) -> VState: + Phi, G = get_Phi_and_G(dt) + return Phi * state + G * acc + +def getTransitionMatrix(dt: sf.Scalar) -> MState: + Phi, _ = get_Phi_and_G(dt) + return Phi + +def predictCov(dt: sf.Scalar, input_var: sf.Scalar, bias_var: sf.Scalar, acc_var: sf.Scalar, covariance: MState) -> MState: + Phi, G = get_Phi_and_G(dt) + Q = sf.Matrix.zeros(State.storage_dim(), State.storage_dim()) + + # Scale bias random walk by dt + idx_bias = tangent_idx['bias'].idx + Q[idx_bias, idx_bias] = bias_var * dt + + if moving: + # Scale target acceleration random walk by dt + idx_a_target = tangent_idx['acc_target'].idx + Q[idx_a_target, idx_a_target] = acc_var * dt + + return G * input_var * G.T + Q + Phi * covariance * Phi.T + +def computeInnovCov(meas_unc: sf.Scalar, covariance: MState, meas_matrix: VMeas) -> sf.Scalar: + return (meas_matrix * covariance * meas_matrix.T)[0, 0] + meas_unc + +# OOSM correction +def applyCorrection(state: VState, cov: MState, K: VState, innov: sf.Scalar, S: sf.Scalar) -> (VState, MState): + + # Update State + state_new = state + K * innov + + # Update Covariance + cov_update = K * S * K.T + cov_new = cov - cov_update + + # Force Symmetry + cov_new = 0.5 * (cov_new + cov_new.T) + + return state_new, cov_new + +# Generate functions +print(f"Derive VTEST equations for {'moving' if moving else 'static'} targets...") +generate_px4_function(predictState, output_names=["predict_state"]) +generate_px4_function(getTransitionMatrix, output_names=["transition_matrix"]) +generate_px4_function(predictCov, output_names=["cov_updated"]) +generate_px4_function(computeInnovCov, output_names=["innov_cov_updated"]) +generate_px4_function(applyCorrection, output_names=["state_new", "cov_new"]) + +generate_px4_state(State, tangent_idx) diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/generated/applyCorrection.h b/src/modules/vision_target_estimator/Position/vtest_derivation/generated/applyCorrection.h new file mode 100644 index 000000000000..d0bd6dbbf870 --- /dev/null +++ b/src/modules/vision_target_estimator/Position/vtest_derivation/generated/applyCorrection.h @@ -0,0 +1,76 @@ +// ----------------------------------------------------------------------------- +// This file was autogenerated by symforce from template: +// function/FUNCTION.h.jinja +// Do NOT modify by hand. +// ----------------------------------------------------------------------------- + +#pragma once + +#include + +namespace sym +{ + +/** + * This function was autogenerated from a symbolic function. Do not modify by hand. + * + * Symbolic function: applyCorrection + * + * Args: + * state: Matrix31 + * cov: Matrix33 + * K: Matrix31 + * innov: Scalar + * S: Scalar + * + * Outputs: + * state_new: Matrix31 + * cov_new: Matrix33 + */ +template +void Applycorrection(const matrix::Matrix &state, + const matrix::Matrix &cov, const matrix::Matrix &K, + const Scalar innov, const Scalar S, + matrix::Matrix *const state_new = nullptr, + matrix::Matrix *const cov_new = nullptr) +{ + // Total ops: 42 + + // Input arrays + + // Intermediate terms (5) + const Scalar _tmp0 = 2 * S; + const Scalar _tmp1 = + -Scalar(0.5) * K(0, 0) * K(1, 0) * _tmp0 + Scalar(0.5) * cov(0, 1) + Scalar(0.5) * cov(1, 0); + const Scalar _tmp2 = K(2, 0) * _tmp0; + const Scalar _tmp3 = + -Scalar(0.5) * K(0, 0) * _tmp2 + Scalar(0.5) * cov(0, 2) + Scalar(0.5) * cov(2, 0); + const Scalar _tmp4 = + -Scalar(0.5) * K(1, 0) * _tmp2 + Scalar(0.5) * cov(1, 2) + Scalar(0.5) * cov(2, 1); + + // Output terms (2) + if (state_new != nullptr) { + matrix::Matrix &_state_new = (*state_new); + + _state_new(0, 0) = K(0, 0) * innov + state(0, 0); + _state_new(1, 0) = K(1, 0) * innov + state(1, 0); + _state_new(2, 0) = K(2, 0) * innov + state(2, 0); + } + + if (cov_new != nullptr) { + matrix::Matrix &_cov_new = (*cov_new); + + _cov_new(0, 0) = -Scalar(0.5) * std::pow(K(0, 0), Scalar(2)) * _tmp0 + Scalar(1.0) * cov(0, 0); + _cov_new(1, 0) = _tmp1; + _cov_new(2, 0) = _tmp3; + _cov_new(0, 1) = _tmp1; + _cov_new(1, 1) = -Scalar(0.5) * std::pow(K(1, 0), Scalar(2)) * _tmp0 + Scalar(1.0) * cov(1, 1); + _cov_new(2, 1) = _tmp4; + _cov_new(0, 2) = _tmp3; + _cov_new(1, 2) = _tmp4; + _cov_new(2, 2) = -Scalar(0.5) * std::pow(K(2, 0), Scalar(2)) * _tmp0 + Scalar(1.0) * cov(2, 2); + } +} // NOLINT(readability/fn_size) + +// NOLINTNEXTLINE(readability/fn_size) +} // namespace sym diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/generated/computeInnovCov.h b/src/modules/vision_target_estimator/Position/vtest_derivation/generated/computeInnovCov.h new file mode 100644 index 000000000000..fb44b42e5ee9 --- /dev/null +++ b/src/modules/vision_target_estimator/Position/vtest_derivation/generated/computeInnovCov.h @@ -0,0 +1,55 @@ +// ----------------------------------------------------------------------------- +// This file was autogenerated by symforce from template: +// function/FUNCTION.h.jinja +// Do NOT modify by hand. +// ----------------------------------------------------------------------------- + +#pragma once + +#include + +namespace sym +{ + +/** + * This function was autogenerated from a symbolic function. Do not modify by hand. + * + * Symbolic function: computeInnovCov + * + * Args: + * meas_unc: Scalar + * covariance: Matrix33 + * meas_matrix: Matrix13 + * + * Outputs: + * innov_cov_updated: Scalar + */ +template +Scalar Computeinnovcov(const Scalar meas_unc, const matrix::Matrix &covariance, + const matrix::Matrix &meas_matrix) +{ + // Total ops: 21 + + // Input arrays + + // Intermediate terms (0) + + // Output terms (1) + Scalar _innov_cov_updated; + + _innov_cov_updated = meas_matrix(0, 0) * (covariance(0, 0) * meas_matrix(0, 0) + + covariance(1, 0) * meas_matrix(0, 1) + + covariance(2, 0) * meas_matrix(0, 2)) + + meas_matrix(0, 1) * (covariance(0, 1) * meas_matrix(0, 0) + + covariance(1, 1) * meas_matrix(0, 1) + + covariance(2, 1) * meas_matrix(0, 2)) + + meas_matrix(0, 2) * (covariance(0, 2) * meas_matrix(0, 0) + + covariance(1, 2) * meas_matrix(0, 1) + + covariance(2, 2) * meas_matrix(0, 2)) + + meas_unc; + + return _innov_cov_updated; +} // NOLINT(readability/fn_size) + +// NOLINTNEXTLINE(readability/fn_size) +} // namespace sym diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/generated/getTransitionMatrix.h b/src/modules/vision_target_estimator/Position/vtest_derivation/generated/getTransitionMatrix.h new file mode 100644 index 000000000000..99ea68d19f03 --- /dev/null +++ b/src/modules/vision_target_estimator/Position/vtest_derivation/generated/getTransitionMatrix.h @@ -0,0 +1,48 @@ +// ----------------------------------------------------------------------------- +// This file was autogenerated by symforce from template: +// function/FUNCTION.h.jinja +// Do NOT modify by hand. +// ----------------------------------------------------------------------------- + +#pragma once + +#include + +namespace sym +{ + +/** + * This function was autogenerated from a symbolic function. Do not modify by hand. + * + * Symbolic function: getTransitionMatrix + * + * Args: + * dt: Scalar + * + * Outputs: + * transition_matrix: Matrix33 + */ +template +matrix::Matrix Gettransitionmatrix(const Scalar dt) +{ + // Total ops: 1 + + // Input arrays + + // Intermediate terms (0) + + // Output terms (1) + matrix::Matrix _transition_matrix; + + _transition_matrix.setZero(); + + _transition_matrix(0, 0) = 1; + _transition_matrix(0, 1) = -dt; + _transition_matrix(1, 1) = 1; + _transition_matrix(2, 2) = 1; + + return _transition_matrix; +} // NOLINT(readability/fn_size) + +// NOLINTNEXTLINE(readability/fn_size) +} // namespace sym diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/generated/predictCov.h b/src/modules/vision_target_estimator/Position/vtest_derivation/generated/predictCov.h new file mode 100644 index 000000000000..aa2cd04311d1 --- /dev/null +++ b/src/modules/vision_target_estimator/Position/vtest_derivation/generated/predictCov.h @@ -0,0 +1,67 @@ +// ----------------------------------------------------------------------------- +// This file was autogenerated by symforce from template: +// function/FUNCTION.h.jinja +// Do NOT modify by hand. +// ----------------------------------------------------------------------------- + +#pragma once + +#include + +namespace sym +{ + +/** + * This function was autogenerated from a symbolic function. Do not modify by hand. + * + * Symbolic function: predictCov + * + * Args: + * dt: Scalar + * input_var: Scalar + * bias_var: Scalar + * acc_var: Scalar + * covariance: Matrix33 + * + * Outputs: + * cov_updated: Matrix33 + */ +template +matrix::Matrix Predictcov(const Scalar dt, const Scalar input_var, + const Scalar bias_var, const Scalar acc_var, + const matrix::Matrix &covariance) +{ + // Total ops: 27 + + // Unused inputs + (void)acc_var; + + // Input arrays + + // Intermediate terms (3) + const Scalar _tmp0 = -covariance(1, 1) * dt; + const Scalar _tmp1 = _tmp0 + covariance(0, 1); + const Scalar _tmp2 = -Scalar(0.5) * [&]() { + const Scalar base = dt; + return base * base * base; + }() * input_var; + + // Output terms (1) + matrix::Matrix _cov_updated; + + _cov_updated(0, 0) = -_tmp1 * dt + covariance(0, 0) - covariance(1, 0) * dt + + Scalar(0.25) * std::pow(dt, Scalar(4)) * input_var; + _cov_updated(1, 0) = _tmp0 + _tmp2 + covariance(1, 0); + _cov_updated(2, 0) = covariance(2, 0) - covariance(2, 1) * dt; + _cov_updated(0, 1) = _tmp1 + _tmp2; + _cov_updated(1, 1) = covariance(1, 1) + std::pow(dt, Scalar(2)) * input_var; + _cov_updated(2, 1) = covariance(2, 1); + _cov_updated(0, 2) = covariance(0, 2) - covariance(1, 2) * dt; + _cov_updated(1, 2) = covariance(1, 2); + _cov_updated(2, 2) = bias_var * dt + covariance(2, 2); + + return _cov_updated; +} // NOLINT(readability/fn_size) + +// NOLINTNEXTLINE(readability/fn_size) +} // namespace sym diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/generated/predictState.h b/src/modules/vision_target_estimator/Position/vtest_derivation/generated/predictState.h new file mode 100644 index 000000000000..4783b0dc79a5 --- /dev/null +++ b/src/modules/vision_target_estimator/Position/vtest_derivation/generated/predictState.h @@ -0,0 +1,49 @@ +// ----------------------------------------------------------------------------- +// This file was autogenerated by symforce from template: +// function/FUNCTION.h.jinja +// Do NOT modify by hand. +// ----------------------------------------------------------------------------- + +#pragma once + +#include + +namespace sym +{ + +/** + * This function was autogenerated from a symbolic function. Do not modify by hand. + * + * Symbolic function: predictState + * + * Args: + * dt: Scalar + * state: Matrix31 + * acc: Scalar + * + * Outputs: + * predict_state: Matrix31 + */ +template +matrix::Matrix Predictstate(const Scalar dt, const matrix::Matrix &state, + const Scalar acc) +{ + // Total ops: 8 + + // Input arrays + + // Intermediate terms (0) + + // Output terms (1) + matrix::Matrix _predict_state; + + _predict_state(0, 0) = + -Scalar(0.5) * acc * std::pow(dt, Scalar(2)) - dt * state(1, 0) + state(0, 0); + _predict_state(1, 0) = acc * dt + state(1, 0); + _predict_state(2, 0) = state(2, 0); + + return _predict_state; +} // NOLINT(readability/fn_size) + +// NOLINTNEXTLINE(readability/fn_size) +} // namespace sym diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/generated/state.h b/src/modules/vision_target_estimator/Position/vtest_derivation/generated/state.h new file mode 100644 index 000000000000..80ba136184ad --- /dev/null +++ b/src/modules/vision_target_estimator/Position/vtest_derivation/generated/state.h @@ -0,0 +1,49 @@ +// -------------------------------------------------- +// This file was autogenerated, do NOT modify by hand +// -------------------------------------------------- + +#ifndef EKF_STATE_H +#define EKF_STATE_H + +#include + +namespace vtest +{ +struct StateSample { + float pos_rel{}; + float vel_uav{}; + float bias{}; + + matrix::Vector Data() const + { + matrix::Vector state; + state.slice<1, 1>(0, 0) = pos_rel; + state.slice<1, 1>(1, 0) = vel_uav; + state.slice<1, 1>(2, 0) = bias; + return state; + }; + + const matrix::Vector &vector() const + { + return *reinterpret_cast*>(const_cast(reinterpret_cast(&pos_rel))); + }; + +}; +static_assert(sizeof(matrix::Vector) == sizeof(StateSample), "state vector doesn't match StateSample size"); +namespace Axis +{ +static constexpr uint8_t x{0}; +static constexpr uint8_t y{1}; +static constexpr uint8_t z{2}; +static constexpr uint8_t size{3}; +}; + +namespace State +{ +static constexpr uint8_t pos_rel{0}; +static constexpr uint8_t vel_uav{1}; +static constexpr uint8_t bias{2}; +static constexpr uint8_t size{3}; +}; +} +#endif // !EKF_STATE_H diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/applyCorrection.h b/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/applyCorrection.h new file mode 100644 index 000000000000..c3243aac2d07 --- /dev/null +++ b/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/applyCorrection.h @@ -0,0 +1,110 @@ +// ----------------------------------------------------------------------------- +// This file was autogenerated by symforce from template: +// function/FUNCTION.h.jinja +// Do NOT modify by hand. +// ----------------------------------------------------------------------------- + +#pragma once + +#include + +namespace sym +{ + +/** + * This function was autogenerated from a symbolic function. Do not modify by hand. + * + * Symbolic function: applyCorrection + * + * Args: + * state: Matrix51 + * cov: Matrix55 + * K: Matrix51 + * innov: Scalar + * S: Scalar + * + * Outputs: + * state_new: Matrix51 + * cov_new: Matrix55 + */ +template +void Applycorrection(const matrix::Matrix &state, + const matrix::Matrix &cov, const matrix::Matrix &K, + const Scalar innov, const Scalar S, + matrix::Matrix *const state_new = nullptr, + matrix::Matrix *const cov_new = nullptr) +{ + // Total ops: 100 + + // Input arrays + + // Intermediate terms (14) + const Scalar _tmp0 = 2 * S; + const Scalar _tmp1 = K(0, 0) * _tmp0; + const Scalar _tmp2 = + -Scalar(0.5) * K(1, 0) * _tmp1 + Scalar(0.5) * cov(0, 1) + Scalar(0.5) * cov(1, 0); + const Scalar _tmp3 = + -Scalar(0.5) * K(2, 0) * _tmp1 + Scalar(0.5) * cov(0, 2) + Scalar(0.5) * cov(2, 0); + const Scalar _tmp4 = K(3, 0) * _tmp0; + const Scalar _tmp5 = + -Scalar(0.5) * K(0, 0) * _tmp4 + Scalar(0.5) * cov(0, 3) + Scalar(0.5) * cov(3, 0); + const Scalar _tmp6 = K(4, 0) * _tmp0; + const Scalar _tmp7 = + -Scalar(0.5) * K(0, 0) * _tmp6 + Scalar(0.5) * cov(0, 4) + Scalar(0.5) * cov(4, 0); + const Scalar _tmp8 = + -Scalar(0.5) * K(1, 0) * K(2, 0) * _tmp0 + Scalar(0.5) * cov(1, 2) + Scalar(0.5) * cov(2, 1); + const Scalar _tmp9 = + -Scalar(0.5) * K(1, 0) * _tmp4 + Scalar(0.5) * cov(1, 3) + Scalar(0.5) * cov(3, 1); + const Scalar _tmp10 = + -Scalar(0.5) * K(1, 0) * _tmp6 + Scalar(0.5) * cov(1, 4) + Scalar(0.5) * cov(4, 1); + const Scalar _tmp11 = + -Scalar(0.5) * K(2, 0) * _tmp4 + Scalar(0.5) * cov(2, 3) + Scalar(0.5) * cov(3, 2); + const Scalar _tmp12 = + -Scalar(0.5) * K(2, 0) * _tmp6 + Scalar(0.5) * cov(2, 4) + Scalar(0.5) * cov(4, 2); + const Scalar _tmp13 = + -Scalar(0.5) * K(3, 0) * _tmp6 + Scalar(0.5) * cov(3, 4) + Scalar(0.5) * cov(4, 3); + + // Output terms (2) + if (state_new != nullptr) { + matrix::Matrix &_state_new = (*state_new); + + _state_new(0, 0) = K(0, 0) * innov + state(0, 0); + _state_new(1, 0) = K(1, 0) * innov + state(1, 0); + _state_new(2, 0) = K(2, 0) * innov + state(2, 0); + _state_new(3, 0) = K(3, 0) * innov + state(3, 0); + _state_new(4, 0) = K(4, 0) * innov + state(4, 0); + } + + if (cov_new != nullptr) { + matrix::Matrix &_cov_new = (*cov_new); + + _cov_new(0, 0) = -Scalar(0.5) * std::pow(K(0, 0), Scalar(2)) * _tmp0 + Scalar(1.0) * cov(0, 0); + _cov_new(1, 0) = _tmp2; + _cov_new(2, 0) = _tmp3; + _cov_new(3, 0) = _tmp5; + _cov_new(4, 0) = _tmp7; + _cov_new(0, 1) = _tmp2; + _cov_new(1, 1) = -Scalar(0.5) * std::pow(K(1, 0), Scalar(2)) * _tmp0 + Scalar(1.0) * cov(1, 1); + _cov_new(2, 1) = _tmp8; + _cov_new(3, 1) = _tmp9; + _cov_new(4, 1) = _tmp10; + _cov_new(0, 2) = _tmp3; + _cov_new(1, 2) = _tmp8; + _cov_new(2, 2) = -Scalar(0.5) * std::pow(K(2, 0), Scalar(2)) * _tmp0 + Scalar(1.0) * cov(2, 2); + _cov_new(3, 2) = _tmp11; + _cov_new(4, 2) = _tmp12; + _cov_new(0, 3) = _tmp5; + _cov_new(1, 3) = _tmp9; + _cov_new(2, 3) = _tmp11; + _cov_new(3, 3) = -Scalar(0.5) * std::pow(K(3, 0), Scalar(2)) * _tmp0 + Scalar(1.0) * cov(3, 3); + _cov_new(4, 3) = _tmp13; + _cov_new(0, 4) = _tmp7; + _cov_new(1, 4) = _tmp10; + _cov_new(2, 4) = _tmp12; + _cov_new(3, 4) = _tmp13; + _cov_new(4, 4) = -Scalar(0.5) * std::pow(K(4, 0), Scalar(2)) * _tmp0 + Scalar(1.0) * cov(4, 4); + } +} // NOLINT(readability/fn_size) + +// NOLINTNEXTLINE(readability/fn_size) +} // namespace sym diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/computeInnovCov.h b/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/computeInnovCov.h new file mode 100644 index 000000000000..00b30b3c8ebb --- /dev/null +++ b/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/computeInnovCov.h @@ -0,0 +1,67 @@ +// ----------------------------------------------------------------------------- +// This file was autogenerated by symforce from template: +// function/FUNCTION.h.jinja +// Do NOT modify by hand. +// ----------------------------------------------------------------------------- + +#pragma once + +#include + +namespace sym +{ + +/** + * This function was autogenerated from a symbolic function. Do not modify by hand. + * + * Symbolic function: computeInnovCov + * + * Args: + * meas_unc: Scalar + * covariance: Matrix55 + * meas_matrix: Matrix15 + * + * Outputs: + * innov_cov_updated: Scalar + */ +template +Scalar Computeinnovcov(const Scalar meas_unc, const matrix::Matrix &covariance, + const matrix::Matrix &meas_matrix) +{ + // Total ops: 55 + + // Input arrays + + // Intermediate terms (0) + + // Output terms (1) + Scalar _innov_cov_updated; + + _innov_cov_updated = + meas_matrix(0, 0) * + (covariance(0, 0) * meas_matrix(0, 0) + covariance(1, 0) * meas_matrix(0, 1) + + covariance(2, 0) * meas_matrix(0, 2) + covariance(3, 0) * meas_matrix(0, 3) + + covariance(4, 0) * meas_matrix(0, 4)) + + meas_matrix(0, 1) * + (covariance(0, 1) * meas_matrix(0, 0) + covariance(1, 1) * meas_matrix(0, 1) + + covariance(2, 1) * meas_matrix(0, 2) + covariance(3, 1) * meas_matrix(0, 3) + + covariance(4, 1) * meas_matrix(0, 4)) + + meas_matrix(0, 2) * + (covariance(0, 2) * meas_matrix(0, 0) + covariance(1, 2) * meas_matrix(0, 1) + + covariance(2, 2) * meas_matrix(0, 2) + covariance(3, 2) * meas_matrix(0, 3) + + covariance(4, 2) * meas_matrix(0, 4)) + + meas_matrix(0, 3) * + (covariance(0, 3) * meas_matrix(0, 0) + covariance(1, 3) * meas_matrix(0, 1) + + covariance(2, 3) * meas_matrix(0, 2) + covariance(3, 3) * meas_matrix(0, 3) + + covariance(4, 3) * meas_matrix(0, 4)) + + meas_matrix(0, 4) * + (covariance(0, 4) * meas_matrix(0, 0) + covariance(1, 4) * meas_matrix(0, 1) + + covariance(2, 4) * meas_matrix(0, 2) + covariance(3, 4) * meas_matrix(0, 3) + + covariance(4, 4) * meas_matrix(0, 4)) + + meas_unc; + + return _innov_cov_updated; +} // NOLINT(readability/fn_size) + +// NOLINTNEXTLINE(readability/fn_size) +} // namespace sym diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/getTransitionMatrix.h b/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/getTransitionMatrix.h new file mode 100644 index 000000000000..939e7519999d --- /dev/null +++ b/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/getTransitionMatrix.h @@ -0,0 +1,53 @@ +// ----------------------------------------------------------------------------- +// This file was autogenerated by symforce from template: +// function/FUNCTION.h.jinja +// Do NOT modify by hand. +// ----------------------------------------------------------------------------- + +#pragma once + +#include + +namespace sym +{ + +/** + * This function was autogenerated from a symbolic function. Do not modify by hand. + * + * Symbolic function: getTransitionMatrix + * + * Args: + * dt: Scalar + * + * Outputs: + * transition_matrix: Matrix55 + */ +template +matrix::Matrix Gettransitionmatrix(const Scalar dt) +{ + // Total ops: 3 + + // Input arrays + + // Intermediate terms (0) + + // Output terms (1) + matrix::Matrix _transition_matrix; + + _transition_matrix.setZero(); + + _transition_matrix(0, 0) = 1; + _transition_matrix(0, 1) = -dt; + _transition_matrix(1, 1) = 1; + _transition_matrix(2, 2) = 1; + _transition_matrix(0, 3) = Scalar(0.5) * std::pow(dt, Scalar(2)); + _transition_matrix(3, 3) = 1; + _transition_matrix(4, 3) = dt; + _transition_matrix(0, 4) = dt; + _transition_matrix(4, 4) = 1; + + return _transition_matrix; +} // NOLINT(readability/fn_size) + +// NOLINTNEXTLINE(readability/fn_size) +} // namespace sym diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/predictCov.h b/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/predictCov.h new file mode 100644 index 000000000000..4b36dcaa31f0 --- /dev/null +++ b/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/predictCov.h @@ -0,0 +1,97 @@ +// ----------------------------------------------------------------------------- +// This file was autogenerated by symforce from template: +// function/FUNCTION.h.jinja +// Do NOT modify by hand. +// ----------------------------------------------------------------------------- + +#pragma once + +#include + +namespace sym +{ + +/** + * This function was autogenerated from a symbolic function. Do not modify by hand. + * + * Symbolic function: predictCov + * + * Args: + * dt: Scalar + * input_var: Scalar + * bias_var: Scalar + * acc_var: Scalar + * covariance: Matrix55 + * + * Outputs: + * cov_updated: Matrix55 + */ +template +matrix::Matrix Predictcov(const Scalar dt, const Scalar input_var, + const Scalar bias_var, const Scalar acc_var, + const matrix::Matrix &covariance) +{ + // Total ops: 92 + + // Input arrays + + // Intermediate terms (16) + const Scalar _tmp0 = covariance(1, 4) * dt; + const Scalar _tmp1 = std::pow(dt, Scalar(2)); + const Scalar _tmp2 = Scalar(0.5) * _tmp1; + const Scalar _tmp3 = -_tmp0 + _tmp2 * covariance(3, 4) + covariance(0, 4) + covariance(4, 4) * dt; + const Scalar _tmp4 = -covariance(1, 1) * dt; + const Scalar _tmp5 = _tmp2 * covariance(3, 1) + _tmp4 + covariance(0, 1) + covariance(4, 1) * dt; + const Scalar _tmp6 = covariance(1, 3) * dt; + const Scalar _tmp7 = _tmp2 * covariance(3, 3); + const Scalar _tmp8 = -_tmp6 + _tmp7 + covariance(0, 3) + covariance(4, 3) * dt; + const Scalar _tmp9 = -Scalar(0.5) * [&]() { + const Scalar base = dt; + return base * base * base; + }() * input_var; + const Scalar _tmp10 = covariance(3, 4) * dt; + const Scalar _tmp11 = covariance(3, 1) * dt; + const Scalar _tmp12 = covariance(3, 3) * dt; + const Scalar _tmp13 = _tmp12 + covariance(4, 3); + const Scalar _tmp14 = _tmp10 + covariance(4, 4); + const Scalar _tmp15 = _tmp11 + covariance(4, 1); + + // Output terms (1) + matrix::Matrix _cov_updated; + + _cov_updated(0, 0) = _tmp2 * _tmp8 + _tmp2 * covariance(3, 0) + _tmp3 * dt - _tmp5 * dt + + covariance(0, 0) - covariance(1, 0) * dt + covariance(4, 0) * dt + + Scalar(0.25) * std::pow(dt, Scalar(4)) * input_var; + _cov_updated(1, 0) = _tmp0 + _tmp2 * covariance(1, 3) + _tmp4 + _tmp9 + covariance(1, 0); + _cov_updated(2, 0) = + _tmp2 * covariance(2, 3) + covariance(2, 0) - covariance(2, 1) * dt + covariance(2, 4) * dt; + _cov_updated(3, 0) = _tmp10 - _tmp11 + _tmp7 + covariance(3, 0); + _cov_updated(4, 0) = + _tmp13 * _tmp2 + _tmp14 * dt - _tmp15 * dt + covariance(3, 0) * dt + covariance(4, 0); + _cov_updated(0, 1) = _tmp5 + _tmp9; + _cov_updated(1, 1) = _tmp1 * input_var + covariance(1, 1); + _cov_updated(2, 1) = covariance(2, 1); + _cov_updated(3, 1) = covariance(3, 1); + _cov_updated(4, 1) = _tmp15; + _cov_updated(0, 2) = + _tmp2 * covariance(3, 2) + covariance(0, 2) - covariance(1, 2) * dt + covariance(4, 2) * dt; + _cov_updated(1, 2) = covariance(1, 2); + _cov_updated(2, 2) = bias_var * dt + covariance(2, 2); + _cov_updated(3, 2) = covariance(3, 2); + _cov_updated(4, 2) = covariance(3, 2) * dt + covariance(4, 2); + _cov_updated(0, 3) = _tmp8; + _cov_updated(1, 3) = covariance(1, 3); + _cov_updated(2, 3) = covariance(2, 3); + _cov_updated(3, 3) = acc_var * dt + covariance(3, 3); + _cov_updated(4, 3) = _tmp13; + _cov_updated(0, 4) = _tmp3 + _tmp8 * dt; + _cov_updated(1, 4) = _tmp6 + covariance(1, 4); + _cov_updated(2, 4) = covariance(2, 3) * dt + covariance(2, 4); + _cov_updated(3, 4) = _tmp12 + covariance(3, 4); + _cov_updated(4, 4) = _tmp13 * dt + _tmp14; + + return _cov_updated; +} // NOLINT(readability/fn_size) + +// NOLINTNEXTLINE(readability/fn_size) +} // namespace sym diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/predictState.h b/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/predictState.h new file mode 100644 index 000000000000..4783b0dc79a5 --- /dev/null +++ b/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/predictState.h @@ -0,0 +1,49 @@ +// ----------------------------------------------------------------------------- +// This file was autogenerated by symforce from template: +// function/FUNCTION.h.jinja +// Do NOT modify by hand. +// ----------------------------------------------------------------------------- + +#pragma once + +#include + +namespace sym +{ + +/** + * This function was autogenerated from a symbolic function. Do not modify by hand. + * + * Symbolic function: predictState + * + * Args: + * dt: Scalar + * state: Matrix31 + * acc: Scalar + * + * Outputs: + * predict_state: Matrix31 + */ +template +matrix::Matrix Predictstate(const Scalar dt, const matrix::Matrix &state, + const Scalar acc) +{ + // Total ops: 8 + + // Input arrays + + // Intermediate terms (0) + + // Output terms (1) + matrix::Matrix _predict_state; + + _predict_state(0, 0) = + -Scalar(0.5) * acc * std::pow(dt, Scalar(2)) - dt * state(1, 0) + state(0, 0); + _predict_state(1, 0) = acc * dt + state(1, 0); + _predict_state(2, 0) = state(2, 0); + + return _predict_state; +} // NOLINT(readability/fn_size) + +// NOLINTNEXTLINE(readability/fn_size) +} // namespace sym diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/state.h b/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/state.h new file mode 100644 index 000000000000..8c26dbd522ef --- /dev/null +++ b/src/modules/vision_target_estimator/Position/vtest_derivation/generated_moving/state.h @@ -0,0 +1,55 @@ +// -------------------------------------------------- +// This file was autogenerated, do NOT modify by hand +// -------------------------------------------------- + +#ifndef EKF_STATE_H +#define EKF_STATE_H + +#include + +namespace vtest +{ +struct StateSample { + float pos_rel{}; + float vel_uav{}; + float bias{}; + float acc_target{}; + float vel_target{}; + + matrix::Vector Data() const + { + matrix::Vector state; + state.slice<1, 1>(0, 0) = pos_rel; + state.slice<1, 1>(1, 0) = vel_uav; + state.slice<1, 1>(2, 0) = bias; + state.slice<1, 1>(3, 0) = acc_target; + state.slice<1, 1>(4, 0) = vel_target; + return state; + }; + + const matrix::Vector &vector() const + { + return *reinterpret_cast*>(const_cast(reinterpret_cast(&pos_rel))); + }; + +}; +static_assert(sizeof(matrix::Vector) == sizeof(StateSample), "state vector doesn't match StateSample size"); +namespace Axis +{ +static constexpr uint8_t x{0}; +static constexpr uint8_t y{1}; +static constexpr uint8_t z{2}; +static constexpr uint8_t size{3}; +}; + +namespace State +{ +static constexpr uint8_t pos_rel{0}; +static constexpr uint8_t vel_uav{1}; +static constexpr uint8_t bias{2}; +static constexpr uint8_t acc_target{3}; +static constexpr uint8_t vel_target{4}; +static constexpr uint8_t size{5}; +}; +} +#endif // !EKF_STATE_H diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/utils/__init__.py b/src/modules/vision_target_estimator/Position/vtest_derivation/utils/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/modules/vision_target_estimator/Position/vtest_derivation/utils/derivation_utils.py b/src/modules/vision_target_estimator/Position/vtest_derivation/utils/derivation_utils.py new file mode 100644 index 000000000000..41960888a713 --- /dev/null +++ b/src/modules/vision_target_estimator/Position/vtest_derivation/utils/derivation_utils.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" + Copyright (c) 2022-2025 PX4 Development Team + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + 3. Neither the name PX4 nor the names of its contributors may be + used to endorse or promote products derived from this software + without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + +File: derivation_utils.py +Description: + Common functions used for the derivation of most estimators +""" + +import symforce.symbolic as sf + +import re + +def sign_no_zero(x) -> sf.Scalar: + """ + Returns -1 if x is negative, 1 if x is positive, and 1 if x is zero + """ + return 2 * sf.Min(sf.sign(x), 0) + 1 + +def add_epsilon_sign(expr, var, eps): + # Avoids a singularity at 0 while keeping the derivative correct + return expr.subs(var, var + eps * sign_no_zero(var)) + +def generate_px4_function(function_name, output_names): + from symforce.codegen import Codegen, CppConfig + import os + + return_key = output_names[0] if output_names is not None and len(output_names) == 1 else None + + codegen = Codegen.function( + function_name, + output_names=output_names, + return_key=return_key, + config=CppConfig(zero_initialization_sparsity_threshold=1)) + metadata = codegen.generate_function( + output_dir="generated", + skip_directory_nesting=True) + + for f in metadata.generated_files: + print(" |- {}".format(os.path.relpath(f, metadata.output_dir))) + + generated_path = os.path.abspath(metadata.generated_files[0]) + + with open(generated_path) as generated_file: + content = generated_file.read() + + content = content.replace("std::max", "math::max") + content = content.replace("std::min", "math::min") + content = content.replace("Eigen", "matrix") + content = content.replace("matrix/Dense", "matrix/math.hpp") + + # don't allow underscore + uppercase identifier naming (always reserved for any use) + content = re.sub(r'_([A-Z])', lambda x: '_' + x.group(1).lower(), content) + + with open(generated_path, "w") as generated_file: + generated_file.write(content) + +def generate_python_function(function_name, output_names): + from symforce.codegen import Codegen, PythonConfig + codegen = Codegen.function( + function_name, + output_names=output_names, + config=PythonConfig()) + + metadata = codegen.generate_function( + output_dir="generated", + skip_directory_nesting=True) + +def build_state_struct(state, T="float"): + out = "struct StateSample {\n" + + def TypeFromLength(len): + if len == 1: + return f"{T}" + elif len == 2: + return f"matrix::Vector2<{T}>" + elif len == 3: + return f"matrix::Vector3<{T}>" + elif len == 4: + return f"matrix::Quaternion<{T}>" + else: + raise NotImplementedError + + for key, val in state.items(): + out += f"\t{TypeFromLength(val.storage_dim())} {key}{{}};\n" + + state_size = state.storage_dim() + out += f"\n\tmatrix::Vector<{T}, {state_size}> Data() const {{\n" \ + + f"\t\tmatrix::Vector<{T}, {state_size}> state;\n" + + index = state.index() + for key in index: + out += f"\t\tstate.slice<{index[key].storage_dim}, 1>({index[key].offset}, 0) = {key};\n" + + out += "\t\treturn state;\n" + out += "\t};\n" # Data + + # const ref vector access + first_field = next(iter(state)) + + out += f"\n\tconst matrix::Vector<{T}, {state_size}>& vector() const {{\n" \ + + f"\t\treturn *reinterpret_cast*>(const_cast(reinterpret_cast(&{first_field})));\n" \ + + f"\t}};\n\n" + + out += "};\n" # StateSample + + out += f"static_assert(sizeof(matrix::Vector<{T}, {state_size}>) == sizeof(StateSample), \"state vector doesn't match StateSample size\");\n" + + return out + +def build_tangent_state_struct(state, tangent_state_index): + out = "namespace State {\n" + + for key in tangent_state_index.keys(): + out += f"\tstatic constexpr uint8_t {key}{{{tangent_state_index[key].idx}}};\n" + + out += f"\tstatic constexpr uint8_t size{{{state.tangent_dim()}}};\n" + out += "};\n" # namespace State + return out + +def build_axis_struct(): + out = "namespace Axis {\n" + + out += f"\tstatic constexpr uint8_t x{{{0}}};\n" + out += f"\tstatic constexpr uint8_t y{{{1}}};\n" + out += f"\tstatic constexpr uint8_t z{{{2}}};\n" + + out += f"\tstatic constexpr uint8_t size{{{3}}};\n" + out += "};\n" # namespace State + return out + +def generate_px4_state(state, tangent_state_index): + print("Generate EKF tangent state definition") + filename = "state.h" + f = open(f"./generated/{filename}", "w") + header = ["// --------------------------------------------------\n", + "// This file was autogenerated, do NOT modify by hand\n", + "// --------------------------------------------------\n", + "\n#ifndef EKF_STATE_H", + "\n#define EKF_STATE_H\n\n", + "#include \n\n", + "namespace vtest\n{\n"] + f.writelines(header) + + f.write(build_state_struct(state)) + f.write(build_axis_struct()) + f.write("\n") + f.write(build_tangent_state_struct(state, tangent_state_index)) + + f.write("}\n") # namespace vtest + f.write("#endif // !EKF_STATE_H\n") + f.close() + print(f" |- {filename}") diff --git a/src/modules/vision_target_estimator/VTEOosm.h b/src/modules/vision_target_estimator/VTEOosm.h new file mode 100644 index 000000000000..997cbd297c0a --- /dev/null +++ b/src/modules/vision_target_estimator/VTEOosm.h @@ -0,0 +1,333 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file VTEOosm.h + * @brief Generic Out-Of-Sequence Measurement (OOSM) Manager for VTE filters. + * + * Implements a templated Ring Buffer and the "Projected Correction" algorithm + * to fuse delayed measurements without replaying the full filter history. + */ + +#pragma once + +#include + +#include +#include +#include + +#include "common.h" + +namespace vision_target_estimator +{ + +// Tag type for filters that do not store control inputs (e.g. Orientation filter) +struct EmptyInput {}; + +/** + * @class OOSMManager + * @brief Manages history and performs OOSM fusion. + * + * @tparam FilterT The filter class (Must implement the Interface defined below). + * @tparam StateDim Dimension of the state vector. + * @tparam InputT Type of control input stored (e.g. float for acc, or EmptyInput). + * @tparam HistorySize Size of the static ring buffer. + */ +template +class OOSMManager +{ +public: + using StateVec = matrix::Vector; + using StateCov = matrix::SquareMatrix; + + struct Sample { + uint64_t time_us{0}; + StateVec state{}; + StateCov cov{}; + InputT input{}; // Input used to reach this state from the previous timestamp + }; + + static_assert(HistorySize > 0, "HistorySize must be > 0"); + static_assert(HistorySize <= 255, "HistorySize must fit in uint8_t"); + static constexpr uint8_t kHistorySize = static_cast(HistorySize); + static constexpr uint64_t kOosmMinTimeUs = vision_target_estimator::kOosmMinTimeUs; + static constexpr uint64_t kOosmMaxTimeUs = vision_target_estimator::kOosmMaxTimeUs; + + OOSMManager() = default; + + void reset() + { + if (_history.valid()) { + _history.reset(); + } + } + + /** + * @brief Add a post-update state snapshot to history. + * @param input The control input that was used to propagate *to* this state (from prev). + */ + void push(uint64_t time_us, const StateVec &state, const StateCov &cov, const InputT &input) + { + if (!_history.valid()) { + return; + } + + Sample sample{}; + sample.time_us = time_us; + sample.state = state; + sample.cov = cov; + sample.input = input; + + _history.push(sample); + } + + /** + * @brief Fuse a measurement that might be delayed (OOSM). + * + * @param filter Reference to the filter (provides physics/math methods). + * @param meas The scalar measurement. + * @param now_us Current system time. + * @param nis_threshold NIS threshold for rejection. + * @param curr_state [In/Out] Current filter state (will be corrected). + * @param curr_cov [In/Out] Current filter covariance. + * @param curr_input The input currently being used for the *next* prediction (fallback). + */ + template + FusionResult fuse(FilterT &filter, const ScalarMeasT &meas, uint64_t now_us, + float nis_threshold, StateVec &curr_state, + StateCov &curr_cov, const InputT &curr_input) + { + FusionResult res{}; + + const uint64_t time_diff = (now_us >= meas.time_us) + ? (now_us - meas.time_us) + : (meas.time_us - now_us); + + // No OOSM, current Measurement (or very recent) + if (time_diff < kOosmMinTimeUs) { + StateVec K; + + if (processMeasurement(filter, curr_state, curr_cov, meas, nis_threshold, res, K)) { + filter.applyCorrection(curr_state, curr_cov, K, res.innov, res.innov_var); + res.status = FusionStatus::kFusedCurrent; + } + + return res; + } + + // OOSM checks + if (!_history.valid() || (_history.entries() == 0)) { + res.status = FusionStatus::kRejectEmpty; + return res; + } + + const uint8_t newest_idx = _history.get_newest_index(); + const uint64_t newest_time_us = _history.get_newest().time_us; + const uint64_t oldest_time_us = _history.get_oldest().time_us; + + // Check buffer consistency (reset if the "live" time jumped backwards or too far forward) + if (now_us < newest_time_us || (now_us - newest_time_us) > kOosmMaxTimeUs) { + reset(); + res.status = FusionStatus::kRejectStale; + return res; + } + + // Reject fusion if the measurement time is in the future beyond a small tolerance. + if (meas.time_us > now_us + kOosmMinTimeUs) { + res.status = FusionStatus::kRejectTooNew; + return res; + } + + // Reject fusion if the measurement is too old. + if (time_diff > kOosmMaxTimeUs || meas.time_us < oldest_time_us) { + res.status = FusionStatus::kRejectTooOld; + return res; + } + + // Find the floor sample (sample immediately before or at meas time) + bool floor_found = false; + uint8_t floor_idx = newest_idx; + uint8_t curr_idx = newest_idx; + + const int history_count = _history.entries(); + + for (int i = 0; i < history_count; i++) { + if (_history[curr_idx].time_us <= meas.time_us) { + floor_idx = curr_idx; + floor_found = true; + break; + } + + curr_idx = _history.prev(curr_idx); + } + + if (!floor_found) { + res.status = FusionStatus::kRejectTooOld; + return res; + } + + // Predict from floor to exact measurement time + const Sample &sample_floor = _history[floor_idx]; + const uint64_t t_floor_us = sample_floor.time_us; + + // Determine input for the interval [t_floor, t_meas]. + // The history stores the input used to reach a sample. + // So the input *during* the interval starting at `floor` is stored in `floor+1`. + // If floor is the newest, we use the current live input. + InputT input_interval = curr_input; + + if (floor_idx != newest_idx) { + const uint8_t next_idx = _history.next(floor_idx); + input_interval = _history[next_idx].input; + } + + const float dt_meas = (meas.time_us - t_floor_us) * kMicrosecondsToSeconds; + + // Temp variables for prediction at t_meas + StateVec x_meas = sample_floor.state; + StateCov P_meas = sample_floor.cov; + + if (dt_meas > 0.f) { + // Filter must implement: predictState(dt, input, x_in, P_in, x_out, P_out) + filter.predictState(dt_meas, input_interval, sample_floor.state, sample_floor.cov, x_meas, P_meas); + } + + // Compute Gain K at t_meas + StateVec K_meas; + + if (!processMeasurement(filter, x_meas, P_meas, meas, nis_threshold, res, K_meas)) { + return res; // REJECT_NIS or REJECT_COV + } + + // Apply & Project Correction + // If t_meas matches t_floor exactly, correct the floor sample directly + if (meas.time_us == t_floor_us) { + filter.applyCorrection(_history[floor_idx].state, _history[floor_idx].cov, K_meas, res.innov, res.innov_var); + } + + StateCov Phi_step; + StateCov Phi_cumulative; + Phi_cumulative.setIdentity(); + + uint64_t prev_time_us = meas.time_us; + const uint8_t end_idx = _history.next(newest_idx); + uint8_t idx = _history.next(floor_idx); + uint8_t steps = 0; + + // Iterate from meas time up to newest sample + while (idx != end_idx) { + Sample &sample = _history[idx]; + + // Skip if this sample is older than measurement (shouldn't happen given finding logic, but safety) + if (sample.time_us <= meas.time_us) { + idx = _history.next(idx); + continue; + } + + const float dt = (sample.time_us - prev_time_us) * kMicrosecondsToSeconds; + + // Get transition matrix for the error state dynamics + Phi_step = filter.getTransitionMatrix(dt); + Phi_cumulative = Phi_step * Phi_cumulative; + + // Project K: K_k = Phi * K_{k-1} + const StateVec K_proj = Phi_cumulative * K_meas; + + // Apply correction to history + filter.applyCorrection(sample.state, sample.cov, K_proj, res.innov, res.innov_var); + + prev_time_us = sample.time_us; + idx = _history.next(idx); + steps++; + } + + // Update Live State + if (now_us > prev_time_us) { + const float dt_now = (now_us - prev_time_us) * kMicrosecondsToSeconds; + Phi_step = filter.getTransitionMatrix(dt_now); + Phi_cumulative = Phi_step * Phi_cumulative; + } + + const StateVec K_final = Phi_cumulative * K_meas; + filter.applyCorrection(curr_state, curr_cov, K_final, res.innov, res.innov_var); + + res.status = FusionStatus::kFusedOosm; + res.history_steps = steps; + + return res; + } + +private: + template + bool processMeasurement(FilterT &filter, const StateVec &state, + const StateCov &cov, const ScalarMeasT &meas, + float nis_threshold, FusionResult &out_res, + StateVec &out_K) + { + float innov = 0.f; + float innov_var = 0.f; + + filter.computeInnovation(state, cov, meas, innov, innov_var); + + out_res.innov = innov; + out_res.innov_var = innov_var; + + if (!PX4_ISFINITE(innov_var) || (innov_var < kMinInnovationVariance)) { + out_res.status = FusionStatus::kRejectCov; + out_res.test_ratio = -1.f; + return false; + } + + const float beta = (innov * innov) / innov_var; + + if (nis_threshold > 0.f) { + out_res.test_ratio = beta / nis_threshold; + + if (beta > nis_threshold) { + out_res.status = FusionStatus::kRejectNis; + return false; + } + + } else { + out_res.test_ratio = -1.f; + } + + out_K = (cov * meas.H) / innov_var; + return true; + } + + TimestampedRingBuffer _history; +}; + +} // namespace vision_target_estimator diff --git a/src/modules/vision_target_estimator/VisionTargetEst.cpp b/src/modules/vision_target_estimator/VisionTargetEst.cpp new file mode 100644 index 000000000000..d20226915f93 --- /dev/null +++ b/src/modules/vision_target_estimator/VisionTargetEst.cpp @@ -0,0 +1,819 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file VisionTargetEst.cpp + * @brief Handles the position and orientation estimators. + * + * @author Jonas Perolini + * + */ + +#include + +#include + +#include "VisionTargetEst.h" + +namespace vision_target_estimator +{ + +ModuleBase::Descriptor VisionTargetEst::desc{task_spawn, custom_command, print_usage}; + +VisionTargetEst::VisionTargetEst() : + ModuleParams(nullptr), + ScheduledWorkItem(MODULE_NAME, px4::wq_configurations::vte) +{ + _vision_target_est_input_pub.advertise(); +} + +VisionTargetEst::~VisionTargetEst() +{ + perf_free(_cycle_perf_pos); + perf_free(_cycle_perf_yaw); + perf_free(_cycle_perf); +} + +int VisionTargetEst::task_spawn(int argc, char *argv[]) +{ +#if !defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) || !CONFIG_MODULES_VISION_TARGET_ESTIMATOR + PX4_ERR("Vision Target Estimator cannot run without CONFIG_MODULES_VISION_TARGET_ESTIMATOR (missing topics)."); + return PX4_ERROR; +#endif // !CONFIG_MODULES_VISION_TARGET_ESTIMATOR + +#if defined(CONFIG_MODULES_LANDING_TARGET_ESTIMATOR) && CONFIG_MODULES_LANDING_TARGET_ESTIMATOR + PX4_ERR("Vision Target Estimator cannot run with CONFIG_MODULES_LANDING_TARGET_ESTIMATOR enabled."); + return PX4_ERROR; +#endif // CONFIG_MODULES_LANDING_TARGET_ESTIMATOR + + VisionTargetEst *instance = new VisionTargetEst(); + + if (instance) { + desc.object.store(instance); + desc.task_id = task_id_is_work_queue; + + if (instance->init()) { + return PX4_OK; + } + + } else { + PX4_ERR("alloc failed"); + } + + delete instance; + desc.object.store(nullptr); + desc.task_id = -1; + + return PX4_ERROR; +} + +void VisionTargetEst::Run() +{ + if (should_exit()) { + handleExit(); + return; + } + + updateTaskTopics(); + + if (_parameter_update_sub.updated()) { + updateParams(); + } + + // If a new task is available, stop the estimators if they were already running + if (isNewTaskAvailable()) { + stopAllEstimators(); + } + + // No task running: switch to a low-rate polling mode instead of waking up on every attitude update + if (noActiveTask()) { + static constexpr uint32_t kIdleUpdateIntervalUs = 200_ms; + + if (_vehicle_attitude_sub.registered()) { + _vehicle_attitude_sub.unregisterCallback(); + ScheduleOnInterval(kIdleUpdateIntervalUs); + } + + return; + } + + // Task is active: ensure we're callback-driven and not running a periodic timer + if (!_vehicle_attitude_sub.registered()) { + if (_vehicle_attitude_sub.registerCallback()) { + ScheduleClear(); + + } else { + const uint32_t estimator_update_period_us = static_cast(math::min(kPosUpdatePeriodUs, + kYawUpdatePeriodUs)); + ScheduleOnInterval(estimator_update_period_us); + } + } + + // Task is running, check if an estimator must be started or re-started + startEstIfNeeded(); + + const bool task_completed = isCurrentTaskComplete(); + + if (task_completed) { + stopAllEstimators(); + _current_task = VisionTargetEstTaskMaskU{}; + return; + } + + if (noEstRunning()) { + return; + } + + if (allEstStoppedDueToTimeout()) { + return; + } + + // Early return checks passed, start filter computations. + updateEstimators(); +} + +void VisionTargetEst::handleExit() +{ + _vehicle_attitude_sub.unregisterCallback(); + exit_and_cleanup(desc); +} + +bool VisionTargetEst::init() +{ + if (!_param_vte_pos_en.get() && !_param_vte_yaw_en.get()) { + PX4_WARN("VTE not enabled, update VTE_POS_EN or VTE_YAW_EN"); + return false; + } + + if (!_vehicle_attitude_sub.registerCallback()) { + PX4_ERR("VTE vehicle_attitude callback registration failed!"); + return false; + } + + _orientation_estimator_running = false; + _position_estimator_running = false; + + _vte_position_enabled = false; + _vte_orientation_enabled = false; + + // Params with reboot required + if (_param_vte_pos_en.get()) { + PX4_DEBUG("VTE position estimator enabled."); + + if (_vte_position.init()) { + _vte_position_enabled = true; + + } else { + PX4_ERR("VTE position estimator initialisation failed"); + } + } + + if (_param_vte_yaw_en.get()) { + PX4_DEBUG("VTE yaw estimator enabled."); + + if (_vte_orientation.init()) { + _vte_orientation_enabled = true; + + } else { + PX4_ERR("VTE yaw estimator initialisation failed"); + } + } + + // Params that impact the estimators + updateParams(); + + return _vte_position_enabled || _vte_orientation_enabled; +} + +void VisionTargetEst::updateParams() +{ + parameter_update_s pupdate; + _parameter_update_sub.copy(&pupdate); + + ModuleParams::updateParams(); + + const uint8_t new_vte_task_mask = static_cast(_param_vte_task_mask.get()); + + if (new_vte_task_mask != _vte_task_mask.value) { + + _vte_task_mask.value = new_vte_task_mask; + + if (_vte_task_mask.value < 1) { + PX4_WARN("VTE no task, update VTE_TASK_MASK"); + + } else { + PX4_DEBUG("VTE VTE_TASK_MASK config: "); + + if (_vte_task_mask.flags.for_prec_land) { PX4_DEBUG(" Precision landing"); } + + if (_vte_task_mask.flags.debug) { PX4_WARN(" DEBUG, always active"); } + } + } + + const uint16_t requested_aid_mask = adjustAidMask(_param_vte_aid_mask.get()); + + if (requested_aid_mask != _vte_aid_mask.value) { + _vte_aid_mask.value = requested_aid_mask; + + if (_vte_position_enabled) { + _vte_position.setVteAidMask(requested_aid_mask); + } + + if (_vte_orientation_enabled) { + _vte_orientation.setVteAidMask(requested_aid_mask); + } + + printAidMask(); + } + + const uint32_t vte_timeout_us = static_cast(_param_vte_btout.get() * 1_s); + _vte_position.setVteTimeout(vte_timeout_us); + _vte_orientation.setVteTimeout(vte_timeout_us); + + const uint32_t target_valid_timeout_us = static_cast(_param_vte_tgt_tout.get() * 1_s); + _vte_position.setTargetValidTimeout(target_valid_timeout_us); + _vte_orientation.setTargetValidTimeout(target_valid_timeout_us); + + const uint32_t meas_recent_timeout_us = static_cast(_param_vte_mrec_tout.get() * 1_s); + _vte_position.setMeasRecentTimeout(meas_recent_timeout_us); + _vte_orientation.setMeasRecentTimeout(meas_recent_timeout_us); + + const uint32_t meas_updated_timeout_us = static_cast(_param_vte_mupd_tout.get() * 1_s); + _vte_position.setMeasUpdatedTimeout(meas_updated_timeout_us); + _vte_orientation.setMeasUpdatedTimeout(meas_updated_timeout_us); + + if (_vte_position_enabled && _position_estimator_running + && !_vte_position.fusionEnabled()) { + PX4_DEBUG("VTE position estimator stopped, no fusion source selected."); + stopPosEst(); + } + + if (_vte_orientation_enabled && _orientation_estimator_running + && !_vte_orientation.fusionEnabled()) { + PX4_DEBUG("VTE yaw estimator stopped, no fusion source selected."); + stopYawEst(); + } +} + +uint16_t VisionTargetEst::adjustAidMask(const int input_vte_aid_mask) +{ + SensorFusionMaskU new_aid_mask{}; + new_aid_mask.value = input_vte_aid_mask; + +#if defined(CONFIG_VTEST_MOVING) + + if (new_aid_mask.flags.use_mission_pos) { + PX4_WARN("VTE for moving target. Disabling mission land position data fusion."); + new_aid_mask.flags.use_mission_pos = false; + } + +#endif // CONFIG_VTEST_MOVING + + if (new_aid_mask.flags.use_target_gps_pos && new_aid_mask.flags.use_mission_pos) { + PX4_WARN("VTE both target GPS position and mission land position data fusion cannot be enabled simultaneously."); + PX4_WARN("Disabling mission land position fusion."); + new_aid_mask.flags.use_mission_pos = false; + } + + return new_aid_mask.value; +} + +void VisionTargetEst::printAidMask() +{ + PX4_DEBUG("VTE VTE_AID_MASK config: "); + + if (_vte_aid_mask.flags.use_vision_pos) {PX4_DEBUG(" vision relative position fusion enabled");} + + if (_vte_aid_mask.flags.use_target_gps_pos) {PX4_DEBUG(" target GPS position fusion enabled");} + + if (_vte_aid_mask.flags.use_target_gps_vel) {PX4_DEBUG(" target GPS velocity fusion enabled");} + + if (_vte_aid_mask.flags.use_mission_pos) {PX4_DEBUG(" mission land position fusion enabled");} + + if (_vte_aid_mask.flags.use_uav_gps_vel) {PX4_DEBUG(" UAV GPS velocity fusion enabled");} + + if (_vte_aid_mask.value == 0) {PX4_WARN(" no data fusion. Modify VTE_AID_MASK");} +} + +void VisionTargetEst::resetAccDownsample() +{ + _vehicle_acc_ned_sum.setAll(0); + _acc_sample_count = 0; + _last_acc_reset = hrt_absolute_time(); +} + +bool VisionTargetEst::startYawEst() +{ + if (!_vte_orientation_enabled) { + return false; + } + + if (!hasTimedOut(_vte_orientation_stop_time, kEstRestartTimeUs)) { + return false; + } + + PX4_INFO("Starting Orientation Vision Target Estimator."); + _vte_orientation.resetFilter(); + return true; +} + +bool VisionTargetEst::startPosEst() +{ + if (!_vte_position_enabled) { + return false; + } + + if (!hasTimedOut(_vte_position_stop_time, kEstRestartTimeUs)) { + return false; + } + + resetAccDownsample(); + + PX4_INFO("Starting Position Vision Target Estimator."); + _vte_position.resetFilter(); + + if (_current_task.flags.for_prec_land) { + if (const position_setpoint_s *land_setpoint = findLandSetpoint()) { + _vte_position.setMissionPosition(land_setpoint->lat, land_setpoint->lon, land_setpoint->alt); + + } else { + PX4_WARN("VTE for precision landing, land position cannot be used."); + _vte_position.setMissionPosition(0.0, 0.0, NAN); + } + } + + return true; +} + +const position_setpoint_s *VisionTargetEst::findLandSetpoint() +{ + (void)_pos_sp_triplet_sub.update(&_pos_sp_triplet_buffer); + + if (_pos_sp_triplet_buffer.next.type == position_setpoint_s::SETPOINT_TYPE_LAND) { + PX4_DEBUG("VTE for precision landing, next sp is land."); + return &_pos_sp_triplet_buffer.next; + } + + if (_pos_sp_triplet_buffer.current.type == position_setpoint_s::SETPOINT_TYPE_LAND) { + PX4_DEBUG("VTE for precision landing, current sp is land."); + return &_pos_sp_triplet_buffer.current; + } + + return nullptr; +} + +bool VisionTargetEst::isNewTaskAvailable() +{ + if (_vte_task_mask.flags.for_prec_land && _is_in_prec_land) { + + // Precision land task already running + if (_current_task.flags.for_prec_land) { + return false; + } + + PX4_INFO("VTE, precision landing task requested."); + VisionTargetEstTaskMaskU new_task{}; + new_task.flags.for_prec_land = 1; + _current_task = new_task; + return true; + + } else if (_vte_task_mask.flags.debug) { + + // DEBUG task already running + if (_current_task.flags.debug) { + return false; + } + + PX4_WARN("VTE, DEBUG task requested."); + VisionTargetEstTaskMaskU new_task{}; + new_task.flags.debug = 1; + _current_task = new_task; + return true; + } + + return false; +} + +bool VisionTargetEst::isCurrentTaskComplete() +{ + // Prec-land + if (_current_task.flags.for_prec_land) { + + if (!_vte_task_mask.flags.for_prec_land) { + PX4_DEBUG("VTE_TASK_MASK updated, precision landing task completed."); + return true; + } + + vehicle_land_detected_s vehicle_land_detected; + + // Stop computations once the drone has landed + if (_vehicle_land_detected_sub.update(&vehicle_land_detected) && vehicle_land_detected.landed) { + PX4_DEBUG("Land detected, precision landing task completed."); + _is_in_prec_land = false; + return true; + } + + // Stop computations once precision landing is over + if (!_is_in_prec_land) { + PX4_DEBUG("Precision landing task completed."); + return true; + } + + } else if (_current_task.flags.debug) { + + if (!_vte_task_mask.flags.debug) { + PX4_DEBUG("VTE_TASK_MASK updated, DEBUG task completed."); + return true; + } + } + + // The structure allows adding additional tasks here, e.g. precision delivery, follow me, precision takeoff. + + return false; +} + +void VisionTargetEst::updateTaskTopics() +{ + // The structure allows adding additional task status here, e.g. precision delivery, follow me, precision takeoff. + + if (_vte_task_mask.flags.for_prec_land) { + prec_land_status_s prec_land_status; + + if (_prec_land_status_sub.update(&prec_land_status)) { + _is_in_prec_land = prec_land_status.state == prec_land_status_s::PREC_LAND_STATE_ONGOING; + } + } +} + +void VisionTargetEst::stopAllEstimators() +{ + if (_vte_position_enabled && _position_estimator_running) { + stopPosEst(); + } + + if (_vte_orientation_enabled && _orientation_estimator_running) { + stopYawEst(); + } +} + +void VisionTargetEst::stopPosEst() +{ + PX4_INFO("Stopping Position Vision Target Estimator."); + + if (_vte_position_enabled) { + _vte_position.resetFilter(); + } + + _position_estimator_running = false; + _vte_position_stop_time = hrt_absolute_time(); +} + +void VisionTargetEst::stopYawEst() +{ + PX4_INFO("Stopping Orientation Vision Target Estimator."); + + if (_vte_orientation_enabled) { + _vte_orientation.resetFilter(); + } + + _orientation_estimator_running = false; + _vte_orientation_stop_time = hrt_absolute_time(); +} + +void VisionTargetEst::startEstIfNeeded() +{ + // Only start the estimator if fusion is enabled, otherwise it will timeout + if (!_position_estimator_running && _vte_position_enabled && _vte_position.fusionEnabled()) { + _position_estimator_running = startPosEst(); + } + + if (!_orientation_estimator_running && _vte_orientation_enabled && _vte_orientation.fusionEnabled()) { + _orientation_estimator_running = startYawEst(); + } +} + +bool VisionTargetEst::allEstStoppedDueToTimeout() +{ + bool timed_out = false; + + if (_position_estimator_running && _vte_position.timedOut()) { + stopPosEst(); + PX4_WARN("Estimator TIMEOUT, position VTE stopped."); + timed_out = true; + } + + if (_orientation_estimator_running && _vte_orientation.timedOut()) { + stopYawEst(); + PX4_WARN("Estimator TIMEOUT, orientation VTE stopped."); + timed_out = true; + } + + return timed_out && !_position_estimator_running && !_orientation_estimator_running; +} + +void VisionTargetEst::updateEstimators() +{ + perf_begin(_cycle_perf); + + updateGpsAntennaOffset(); + + LocalPose local_pose; + const bool local_pose_updated = pollLocalPose(local_pose); + + matrix::Vector3f vel_offset_body{}; + const bool vel_offset_updated = computeGpsVelocityOffset(vel_offset_body); + + matrix::Vector3f vehicle_acc_ned{}; + matrix::Quaternionf q_att{}; + matrix::Vector3f gps_pos_offset_ned{}; + matrix::Vector3f vel_offset_ned = vel_offset_body; + bool acc_valid = false; + + // Minimal requirements: attitude (always) and acceleration for position estimator + if (!pollEstimatorInput(vehicle_acc_ned, q_att, gps_pos_offset_ned, vel_offset_ned, vel_offset_updated, acc_valid)) { + perf_end(_cycle_perf); + return; + } + + _last_att = q_att; + + if (_vte_position_enabled && _position_estimator_running) { + if (acc_valid) { + updatePosEst(local_pose, local_pose_updated, vehicle_acc_ned, + gps_pos_offset_ned, vel_offset_ned, vel_offset_updated); + + } else { + if ((_vehicle_acc_body.timestamp != 0) && (hrt_elapsed_time(&_acc_sample_warn_last) > kWarnThrottleIntervalUs)) { + PX4_WARN("VTE acc sample stale (%.1f ms)", + static_cast((hrt_absolute_time() - _vehicle_acc_body.timestamp) * kMicrosecondsToMilliseconds)); + _acc_sample_warn_last = hrt_absolute_time(); + } + + resetAccDownsample(); + } + } + + if (_vte_orientation_enabled && _orientation_estimator_running) { + updateYawEst(); + } + + perf_end(_cycle_perf); +} + +void VisionTargetEst::updatePosEst(const LocalPose &local_pose, const bool local_pose_updated, + const matrix::Vector3f &vehicle_acc_ned, + const matrix::Vector3f &gps_pos_offset_ned, + const matrix::Vector3f &vel_offset_ned, + const bool vel_offset_updated) +{ + /* If the acceleration has been averaged for too long, reset the accumulator */ + if (hasTimedOut(_last_acc_reset, kMinAccDownsampleTimeoutUs)) { + PX4_DEBUG("Forced acc downsample reset"); + resetAccDownsample(); + } + + _vehicle_acc_ned_sum += vehicle_acc_ned; + _acc_sample_count++; + + if (!updateWhenIntervalElapsed(_last_update_pos, kPosUpdatePeriodUs)) { + return; + } + + perf_begin(_cycle_perf_pos); + + if (local_pose_updated) { + _vte_position.setLocalVelocity(local_pose.vel_xyz, local_pose.vel_valid, local_pose.timestamp); + _vte_position.setLocalPosition(local_pose.xyz, local_pose.pos_valid, local_pose.timestamp); + } + + _vte_position.setGpsPosOffset(gps_pos_offset_ned, _gps_pos_is_offset); + + if (vel_offset_updated) { + _vte_position.setVelOffset(vel_offset_ned); + } + + const matrix::Vector3f vehicle_acc_ned_sampled = _vehicle_acc_ned_sum / _acc_sample_count; + + _vte_position.update(vehicle_acc_ned_sampled); + publishVteInput(vehicle_acc_ned_sampled, _last_att); + + resetAccDownsample(); + + perf_end(_cycle_perf_pos); +} + +void VisionTargetEst::updateYawEst() +{ + if (!updateWhenIntervalElapsed(_last_update_yaw, kYawUpdatePeriodUs)) { + return; + } + + perf_begin(_cycle_perf_yaw); + _vte_orientation.update(); + perf_end(_cycle_perf_yaw); +} + +void VisionTargetEst::publishVteInput(const matrix::Vector3f &vehicle_acc_ned_sampled, + const matrix::Quaternionf &q_att_sampled) +{ + vision_target_est_input_s vte_input_report{}; + vte_input_report.timestamp = hrt_absolute_time(); + vte_input_report.timestamp_sample = _vehicle_acc_body.timestamp; + + for (int i = 0; i < kVector3ComponentCount; ++i) { + vte_input_report.acc_xyz[i] = vehicle_acc_ned_sampled(i); + } + + for (int i = 0; i < kQuaternionComponentCount; ++i) { + vte_input_report.q_att[i] = q_att_sampled(i); + } + + _vision_target_est_input_pub.publish(vte_input_report); +} + +bool VisionTargetEst::computeGpsVelocityOffset(matrix::Vector3f &vel_offset_body) +{ + if (!_gps_pos_is_offset) { + return false; + } + + vehicle_angular_velocity_s vehicle_angular_velocity; + + if (!_vehicle_angular_velocity_sub.update(&vehicle_angular_velocity)) { + return false; + } + + // If the GPS antenna is not at the center of mass, when the drone rotates around the center of mass, the GPS will record a velocity. + const matrix::Vector3f ang_vel = matrix::Vector3f(vehicle_angular_velocity.xyz); + vel_offset_body = ang_vel % _gps_pos_offset_xyz; // Get extra velocity from drone's rotation + + return true; +} + +bool VisionTargetEst::updateGpsAntennaOffset() +{ + sensor_gps_s vehicle_gps_position{}; + + if (!_vehicle_gps_position_sub.update(&vehicle_gps_position)) { + return false; + } + + _gps_pos_offset_xyz = matrix::Vector3f(vehicle_gps_position.antenna_offset_x, + vehicle_gps_position.antenna_offset_y, + vehicle_gps_position.antenna_offset_z); + + static constexpr float kMinGpsOffsetM = 0.01f; // Consider GNSS not offset below 1cm + _gps_pos_is_offset = (fabsf(_gps_pos_offset_xyz(0)) > kMinGpsOffsetM) + || (fabsf(_gps_pos_offset_xyz(1)) > kMinGpsOffsetM) + || (fabsf(_gps_pos_offset_xyz(2)) > kMinGpsOffsetM); + + return true; +} + +bool VisionTargetEst::pollLocalPose(LocalPose &local_pose) +{ + vehicle_local_position_s vehicle_local_position; + + if (!_vehicle_local_position_sub.update(&vehicle_local_position)) { + return false; + } + + local_pose.xyz(0) = vehicle_local_position.x; + local_pose.xyz(1) = vehicle_local_position.y; + local_pose.xyz(2) = vehicle_local_position.z; + local_pose.pos_valid = vehicle_local_position.xy_valid && vehicle_local_position.z_valid; + + local_pose.vel_xyz(0) = vehicle_local_position.vx; + local_pose.vel_xyz(1) = vehicle_local_position.vy; + local_pose.vel_xyz(2) = vehicle_local_position.vz; + local_pose.vel_valid = vehicle_local_position.v_xy_valid && vehicle_local_position.v_z_valid; + + local_pose.timestamp = vehicle_local_position.timestamp; + + return true; +} + +bool VisionTargetEst::pollEstimatorInput(matrix::Vector3f &vehicle_acc_ned, matrix::Quaternionf &quat_att, + matrix::Vector3f &gps_pos_offset_ned, matrix::Vector3f &vel_offset_ned, + const bool vel_offset_updated, bool &acc_valid) +{ + vehicle_attitude_s vehicle_attitude{}; + vehicle_acceleration_s vehicle_acceleration{}; + + const bool vehicle_attitude_updated = _vehicle_attitude_sub.update(&vehicle_attitude); + const bool vehicle_acceleration_updated = _vehicle_acceleration_sub.update(&vehicle_acceleration); + + if (!vehicle_attitude_updated) { + return false; + } + + if (vehicle_acceleration_updated) { + _vehicle_acc_body.xyz = matrix::Vector3f(vehicle_acceleration.xyz); + _vehicle_acc_body.timestamp = vehicle_acceleration.timestamp; + } + + _vehicle_acc_body.valid = (_vehicle_acc_body.timestamp != 0) + && !hasTimedOut(_vehicle_acc_body.timestamp, kAccUpdatedTimeoutUs); + acc_valid = _vehicle_acc_body.valid; + + /* Transform FRD body acc to NED */ + const matrix::Quaternionf q_att(&vehicle_attitude.q[0]); // (w,x,y,z) + quat_att = q_att; + + if (acc_valid) { + /* Compensate for gravity. */ + const matrix::Vector3f gravity_ned(0.f, 0.f, kGravityMps2); + vehicle_acc_ned = quat_att.rotateVector(_vehicle_acc_body.xyz) + gravity_ned; + + } else { + vehicle_acc_ned.setAll(0.f); + } + + /* Rotate position and velocity offset into ned frame */ + if (_gps_pos_is_offset) { + gps_pos_offset_ned = quat_att.rotateVector(_gps_pos_offset_xyz); + + if (vel_offset_updated) { + vel_offset_ned = quat_att.rotateVector(vel_offset_ned); + + } + + } else { + gps_pos_offset_ned.setAll(0.f); + vel_offset_ned.setAll(0.f); + } + + return true; +} + +bool VisionTargetEst::updateWhenIntervalElapsed(hrt_abstime &last_time, const hrt_abstime interval) const +{ + if (hrt_elapsed_time(&last_time) > interval) { + last_time = hrt_absolute_time(); + return true; + } + + return false; +} + +int VisionTargetEst::print_status() +{ + PX4_INFO("VTE running"); + return 0; +} +int VisionTargetEst::print_usage(const char *reason) +{ + if (reason != nullptr) { + PX4_ERR("%s\n", reason); + } + + PRINT_MODULE_DESCRIPTION( + R"DESCR_STR( +### Description +Module to estimate the position and orientation of a target using relative sensors. + +The module runs periodically on the px4::wq_configurations::vte queue. +)DESCR_STR"); + + PRINT_MODULE_USAGE_NAME("vision_target_estimator", "system"); + PRINT_MODULE_USAGE_COMMAND_DESCR("start", "Start the background task"); + PRINT_MODULE_USAGE_DEFAULT_COMMANDS(); + return 0; +} + +extern "C" __EXPORT int vision_target_estimator_main(int argc, char *argv[]) +{ + return ModuleBase::main(VisionTargetEst::desc, argc, argv); +} + +} // namespace vision_target_estimator diff --git a/src/modules/vision_target_estimator/VisionTargetEst.h b/src/modules/vision_target_estimator/VisionTargetEst.h new file mode 100644 index 000000000000..8abe3f659fe6 --- /dev/null +++ b/src/modules/vision_target_estimator/VisionTargetEst.h @@ -0,0 +1,241 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file VisionTargetEst.h + * @brief Handles the position and orientation estimators. + * + * @author Jonas Perolini + * + */ + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "Orientation/VTEOrientation.h" +#include "Position/VTEPosition.h" +#include "common.h" + +class VisionTargetEstTest; +class VisionTargetEstTestable; + +namespace vision_target_estimator +{ + +class VisionTargetEst : public ModuleBase, public ModuleParams, public px4::ScheduledWorkItem +{ +public: + static Descriptor desc; + + VisionTargetEst(); + virtual ~VisionTargetEst(); + + /** @see ModuleBase */ + static int custom_command(int argc, char *argv[]) + { + return print_usage("unknown command"); + } + + /** @see ModuleBase */ + static int print_usage(const char *reason = nullptr); + + /** @see ModuleBase::print_status() */ + int print_status() override; + + bool init(); + + static int task_spawn(int argc, char *argv[]); + + friend class ::VisionTargetEstTest; + friend class ::VisionTargetEstTestable; + +protected: + static constexpr uint32_t kEstRestartTimeUs = 3_s; // Wait at least 3 second before re-starting the filter + static constexpr uint64_t kPosUpdatePeriodUs{kEstimatorUpdatePeriodUs}; // 50 Hz + static constexpr uint64_t kYawUpdatePeriodUs{kEstimatorUpdatePeriodUs}; // 50 Hz + static constexpr uint32_t kMinAccDownsampleTimeoutUs = static_cast(2 * kPosUpdatePeriodUs); // 40 ms -> 25 Hz + static constexpr uint32_t kAccUpdatedTimeoutUs = static_cast(kEstimatorUpdatePeriodUs); + static constexpr uint8_t kVector3ComponentCount{3}; + static constexpr uint8_t kQuaternionComponentCount{4}; + + struct LocalPose { + bool pos_valid = false; + matrix::Vector3f xyz{}; + + bool vel_valid = false; + matrix::Vector3f vel_xyz{}; + + hrt_abstime timestamp{0}; + }; + + void Run() override; + void updateParams() override; + void handleExit(); + void stopAllEstimators(); + void startEstIfNeeded(); + bool allEstStoppedDueToTimeout(); + void updateEstimators(); + void updatePosEst(const LocalPose &local_pose, const bool local_pose_updated, + const matrix::Vector3f &vehicle_acc_ned, + const matrix::Vector3f &gps_pos_offset_ned, + const matrix::Vector3f &vel_offset_ned, + const bool vel_offset_updated); + void updateYawEst(); + void publishVteInput(const matrix::Vector3f &vehicle_acc_ned_sampled, const matrix::Quaternionf &q_att); + + inline bool noActiveTask() const { return _current_task.value == 0; } + inline bool noEstRunning() const + { + return (!_vte_orientation_enabled || !_orientation_estimator_running) && + (!_vte_position_enabled || !_position_estimator_running); + }; + + void updateTaskTopics(); + bool isNewTaskAvailable(); + bool isCurrentTaskComplete(); + bool startPosEst(); + void stopPosEst(); + bool startYawEst(); + void stopYawEst(); + bool pollEstimatorInput(matrix::Vector3f &acc_ned, matrix::Quaternionf &q_att, + matrix::Vector3f &gps_pos_offset, + matrix::Vector3f &vel_offset_ned, + bool vel_offset_updated, bool &acc_valid); + + perf_counter_t _cycle_perf_pos{perf_alloc(PC_ELAPSED, MODULE_NAME": VTE cycle pos")}; + perf_counter_t _cycle_perf_yaw{perf_alloc(PC_ELAPSED, MODULE_NAME": VTE cycle yaw")}; + perf_counter_t _cycle_perf{perf_alloc(PC_ELAPSED, MODULE_NAME": VTE cycle ")}; + + Vector3fStamped _vehicle_acc_body{}; + matrix::Quaternionf _last_att{1.f, 0.f, 0.f, 0.f}; + hrt_abstime _acc_sample_warn_last{0}; + + uORB::SubscriptionInterval _parameter_update_sub{ORB_ID(parameter_update), 1_s}; + + uORB::SubscriptionCallbackWorkItem _vehicle_attitude_sub{this, ORB_ID(vehicle_attitude)}; + + uORB::Subscription _pos_sp_triplet_sub{ORB_ID(position_setpoint_triplet)}; + uORB::Subscription _vehicle_gps_position_sub{ORB_ID(vehicle_gps_position)}; + uORB::Subscription _vehicle_land_detected_sub{ORB_ID(vehicle_land_detected)}; + uORB::Subscription _vehicle_local_position_sub{ORB_ID(vehicle_local_position)}; + uORB::Subscription _vehicle_acceleration_sub{ORB_ID(vehicle_acceleration)}; + uORB::Subscription _vehicle_angular_velocity_sub{ORB_ID(vehicle_angular_velocity)}; + uORB::Subscription _prec_land_status_sub{ORB_ID(prec_land_status)}; + + uORB::Publication _vision_target_est_input_pub{ORB_ID(vision_target_est_input)}; + + union VisionTargetEstTaskMaskU { + struct { + uint8_t for_prec_land : 1; ///< bit0: precision landing task active + uint8_t debug : 1; ///< bit1: debug task active + uint8_t reserved : 6; ///< bits2..7: reserved for future use + } flags; + + uint8_t value{0}; + }; + + static_assert(sizeof(VisionTargetEstTaskMaskU) == 1, "Unexpected task mask size"); + + VisionTargetEstTaskMaskU _current_task{}; + VisionTargetEstTaskMaskU _vte_task_mask{}; + + bool _position_estimator_running{false}; + bool _orientation_estimator_running{false}; + bool _is_in_prec_land{false}; // Start target estimator during precision landing + uint64_t _vte_position_stop_time{0}; + uint64_t _vte_orientation_stop_time{0}; + + SensorFusionMaskU _vte_aid_mask{}; + uint16_t adjustAidMask(const int input_mask); + void printAidMask(); + + bool _vte_orientation_enabled{false}; + VTEOrientation _vte_orientation{}; + hrt_abstime _last_update_yaw{0}; + + VTEPosition _vte_position{}; + bool _vte_position_enabled{false}; + hrt_abstime _last_update_pos{0}; + + matrix::Vector3f _gps_pos_offset_xyz{}; + bool _gps_pos_is_offset{false}; + + bool updateGpsAntennaOffset(); + bool computeGpsVelocityOffset(matrix::Vector3f &vel_offset_body); + bool pollLocalPose(LocalPose &local_pose); + const position_setpoint_s *findLandSetpoint(); + + bool updateWhenIntervalElapsed(hrt_abstime &last_time, const hrt_abstime interval) const; + + /* Down sample acceleration data */ + matrix::Vector3f _vehicle_acc_ned_sum{}; + uint32_t _acc_sample_count{0}; + hrt_abstime _last_acc_reset{0}; + void resetAccDownsample(); + position_setpoint_triplet_s _pos_sp_triplet_buffer{}; + +private: + DEFINE_PARAMETERS( + (ParamInt) _param_vte_yaw_en, + (ParamInt) _param_vte_pos_en, + (ParamInt) _param_vte_task_mask, + (ParamFloat) _param_vte_btout, + (ParamFloat) _param_vte_tgt_tout, + (ParamFloat) _param_vte_mrec_tout, + (ParamFloat) _param_vte_mupd_tout, + (ParamInt) _param_vte_aid_mask + ) +}; +} // namespace vision_target_estimator diff --git a/src/modules/vision_target_estimator/common.h b/src/modules/vision_target_estimator/common.h new file mode 100644 index 000000000000..70eb4cd4b1a0 --- /dev/null +++ b/src/modules/vision_target_estimator/common.h @@ -0,0 +1,132 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file common.h + * @brief Definitions common to the position and orientation filters. + * + * @author Jonas Perolini + * + */ + +#pragma once + +#include + +#include +#include +#include + +namespace vision_target_estimator +{ + +using namespace time_literals; + +static constexpr float kMicrosecondsToSeconds = 1e-6f; +static constexpr float kMicrosecondsToMilliseconds = 1e-3f; +static constexpr float kMinVariance = 1e-6f; +static constexpr float kMinObservationNoise = 1e-2f; +static constexpr float kMinNisThreshold = 0.1f; +static constexpr float kDefaultNisThreshold = 3.84f; +static constexpr float kGravityMps2 = 9.80665f; +static constexpr float kMinInnovationVariance = kMinVariance; + +constexpr hrt_abstime kWarnThrottleIntervalUs = 20_s; +constexpr hrt_abstime kEstimatorUpdatePeriodUs = 20_ms; +constexpr hrt_abstime kOosmMinTimeUs = kEstimatorUpdatePeriodUs; +constexpr hrt_abstime kOosmMaxTimeUs = 500_ms; +constexpr int kOosmHistorySize = static_cast(kOosmMaxTimeUs / kEstimatorUpdatePeriodUs); +constexpr int kExpectedAxisCount = 3; +constexpr int kExpectedStaticPositionStateCount = 3; +constexpr int kExpectedMovingPositionStateCount = 5; + +enum class FusionStatus : uint8_t { + kIdle = 0, + kFusedCurrent, + kFusedOosm, + kRejectNis, + kRejectCov, + kRejectTooOld, + kRejectTooNew, + kRejectStale, + kRejectEmpty +}; + +struct FusionResult { + FusionStatus status{FusionStatus::kIdle}; + float innov{0.f}; + float innov_var{0.f}; + float test_ratio{-1.f}; // Normalized NIS (beta / threshold) + uint8_t history_steps{0}; +}; + +static inline bool hasTimedOut(const hrt_abstime ts, const uint32_t timeout_us) +{ + const hrt_abstime now = hrt_absolute_time(); + + // Future timestamps are considered invalid -> timed out + if (ts > now) { + return true; + } + + return (now - ts) >= static_cast(timeout_us); +} + +constexpr inline int64_t signedTimeDiffUs(const hrt_abstime newer, const hrt_abstime older) +{ + return static_cast(newer) - static_cast(older); +} + +static constexpr float sq(float var) { return var * var; } + +union SensorFusionMaskU { + struct { + uint16_t use_target_gps_pos : 1; ///< bit 0 + uint16_t use_uav_gps_vel : 1; ///< bit 1 + uint16_t use_vision_pos : 1; ///< bit 2 + uint16_t use_mission_pos : 1; ///< bit 3 + uint16_t use_target_gps_vel : 1; ///< bit 4 + uint16_t reserved : 11; ///< bits 5..15 (future use) + } flags; + + uint16_t value; +}; +static_assert(sizeof(SensorFusionMaskU) == 2, "Unexpected packing"); + +struct Vector3fStamped { + hrt_abstime timestamp = 0; + bool valid = false; + matrix::Vector3f xyz{}; +}; + +} // namespace vision_target_estimator diff --git a/src/modules/vision_target_estimator/test/CMakeLists.txt b/src/modules/vision_target_estimator/test/CMakeLists.txt new file mode 100644 index 000000000000..1d3cd2654831 --- /dev/null +++ b/src/modules/vision_target_estimator/test/CMakeLists.txt @@ -0,0 +1,75 @@ +############################################################################ +# +# Copyright (c) 2025 PX4 Development Team. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# 3. Neither the name PX4 nor the names of its contributors may be +# used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +# OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +############################################################################ + +px4_add_unit_gtest( + SRC TEST_VTEOosm.cpp + INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../Position +) + +px4_add_unit_gtest( + SRC TEST_VTE_KF_position.cpp + INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../Position + LINKLIBS modules__vision_target_estimator +) + +px4_add_unit_gtest( + SRC TEST_VTE_KF_orientation.cpp + INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../Position + LINKLIBS modules__vision_target_estimator +) + +px4_add_functional_gtest( + SRC TEST_VTEPosition.cpp + INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../Position + LINKLIBS modules__vision_target_estimator + geo +) + +px4_add_functional_gtest( + SRC TEST_VTEOrientation.cpp + INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../Position + LINKLIBS modules__vision_target_estimator +) + +px4_add_functional_gtest( + SRC TEST_VTE_VisionTargetEst.cpp + INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../Position + LINKLIBS modules__vision_target_estimator + geo +) diff --git a/src/modules/vision_target_estimator/test/TEST_VTEOosm.cpp b/src/modules/vision_target_estimator/test/TEST_VTEOosm.cpp new file mode 100644 index 000000000000..64f98d4cc207 --- /dev/null +++ b/src/modules/vision_target_estimator/test/TEST_VTEOosm.cpp @@ -0,0 +1,752 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file TEST_VTEOosm.cpp + * @brief Unit test VTEOosm.h + * + * @author Jonas Perolini + * + */ + +#include + +#include +#include + +#include "VTEOosm.h" +#include "common.h" + +namespace vte = vision_target_estimator; + +namespace +{ +using namespace time_literals; + +static constexpr int kStateDim = 1; +static constexpr float kTolerance = 1e-4f; +static constexpr int kStateDim2 = 2; +using StateVec2 = matrix::Vector; +using StateCov2 = matrix::SquareMatrix; + +struct ScalarMeas { + uint64_t time_us{0}; + float val{0.f}; + float unc{0.f}; + matrix::Vector H{}; +}; + +struct ScalarMeas2 { + uint64_t time_us{0}; + float val{0.f}; + float unc{0.f}; + StateVec2 H{}; +}; + +class TestFilter +{ +public: + float last_predict_input{NAN}; + matrix::Vector last_H{}; + std::vector applied_k; + std::vector applied_state_before; + std::vector applied_state_after; + + void resetTracking() + { + last_predict_input = NAN; + last_H.setZero(); + applied_k.clear(); + applied_state_before.clear(); + applied_state_after.clear(); + } + + // Description: + // Implements a simple linear dynamic model for testing: x_k+1 = x_k + input * dt. + // This corresponds to a Transition Matrix (Phi) of 1.0 and a Control Matrix (B) of dt. + // We add a small Process Noise (Q) to P_out to simulate uncertainty growth + void predictState(float dt, float input, const matrix::Vector &x_in, + const matrix::SquareMatrix &P_in, + matrix::Vector &x_out, + matrix::SquareMatrix &P_out) + { + last_predict_input = input; + + // Linear model: x_{k+1} = F * x_k + B * u + x_out = x_in; + x_out(0) += input * dt; + + // P_{k+1} = F * P_k * F^T + Q + // We use F=1 (Identity), so P_new = P_old + Q + // Adding process noise (Q = 0.1 * dt) ensures covariance grows over time + P_out = P_in; + P_out(0, 0) += 0.1f * dt; + } + + matrix::SquareMatrix getTransitionMatrix(float dt) const + { + // Consistent with predictState + matrix::SquareMatrix phi{}; + phi.setIdentity(); + return phi; + } + + void computeInnovation(const matrix::Vector &state, + const matrix::SquareMatrix &cov, + const ScalarMeas &meas, float &innov, float &innov_var) + { + last_H = meas.H; + innov = meas.val - (meas.H.transpose() * state)(0, 0); + innov_var = (meas.H.transpose() * cov * meas.H)(0, 0) + meas.unc; + } + + void applyCorrection(matrix::Vector &state, + matrix::SquareMatrix &cov, + const matrix::Vector &K, float innov, float innov_var) + { + (void)innov_var; + applied_k.push_back(K(0)); + applied_state_before.push_back(state(0)); + state(0) += K(0) * innov; + applied_state_after.push_back(state(0)); + cov(0, 0) = (1.f - K(0) * last_H(0)) * cov(0, 0); + } +}; + +class TestFilter2d +{ +public: + float last_predict_input{NAN}; + StateVec2 last_H{}; + std::vector applied_k; + std::vector applied_state_before; + std::vector applied_state_after; + + void resetTracking() + { + last_predict_input = NAN; + applied_k.clear(); + applied_state_before.clear(); + applied_state_after.clear(); + } + + void predictState(float dt, float input, const StateVec2 &x_in, + const StateCov2 &P_in, + StateVec2 &x_out, StateCov2 &P_out) + { + last_predict_input = input; + + const StateCov2 phi = getTransitionMatrix(dt); + + x_out = phi * x_in; + P_out = phi * P_in * phi.transpose(); + } + + StateCov2 getTransitionMatrix(float dt) const + { + StateCov2 phi{}; + phi.setIdentity(); + phi(0, 1) = dt; + return phi; + } + + void computeInnovation(const StateVec2 &state, const StateCov2 &cov, + const ScalarMeas2 &meas, float &innov, float &innov_var) + { + last_H = meas.H; + innov = meas.val - (meas.H.transpose() * state)(0, 0); + innov_var = (meas.H.transpose() * cov * meas.H)(0, 0) + meas.unc; + } + + void applyCorrection(StateVec2 &state, StateCov2 &cov, + const StateVec2 &K, float innov, float innov_var) + { + applied_k.push_back(K); + applied_state_before.push_back(state); + state = state + K * innov; + applied_state_after.push_back(state); + + matrix::Matrix K_mat = K; + + cov = cov - (K_mat * K.transpose()) * innov_var; + } +}; + +class VTEOosmTest : public ::testing::Test +{ +protected: + using Manager = vte::OOSMManager; + + Manager oosm{}; + TestFilter filter{}; + float state{0.f}; + float cov{0.f}; + float curr_input{0.f}; + + void SetUp() override + { + state = 0.f; + cov = 4.f; + curr_input = 0.f; + filter.resetTracking(); + } + + static matrix::Vector makeVec(float value) + { + matrix::Vector v{}; + v(0) = value; + return v; + } + + static matrix::SquareMatrix makeCov(float variance) + { + matrix::SquareMatrix P{}; + P.setZero(); + P(0, 0) = variance; + return P; + } + + static ScalarMeas makeMeas(uint64_t time_us, float val, float unc, float h_val = 1.f) + { + ScalarMeas meas{}; + meas.time_us = time_us; + meas.val = val; + meas.unc = unc; + meas.H(0) = h_val; + return meas; + } + + void pushSample(uint64_t time_us, float state_val, float cov_val, float input) + { + oosm.push(time_us, makeVec(state_val), makeCov(cov_val), input); + } + + vte::FusionResult fuseMeas(const ScalarMeas &meas, uint64_t now_us, float nis_threshold) + { + auto state_vec = makeVec(state); + auto cov_mat = makeCov(cov); + + const vte::FusionResult res = oosm.fuse(filter, meas, now_us, nis_threshold, state_vec, cov_mat, curr_input); + + state = state_vec(0); + cov = cov_mat(0, 0); + return res; + } +}; + +class VTEOosmTest2d : public ::testing::Test +{ +protected: + using Manager = vte::OOSMManager; + + Manager oosm{}; + TestFilter2d filter{}; + StateVec2 state{}; + StateCov2 cov{}; + float curr_input{0.f}; + + void SetUp() override + { + state = makeVec(0.f, 1.f); + cov = makeCov(4.f, 1.f, 0.5f); + curr_input = 0.f; + filter.resetTracking(); + } + + static StateVec2 makeVec(float pos, float vel) + { + StateVec2 v{}; + v(0) = pos; + v(1) = vel; + return v; + } + + static StateCov2 makeCov(float p00, float p11, float p01) + { + StateCov2 P{}; + P.setZero(); + P(0, 0) = p00; + P(1, 1) = p11; + P(0, 1) = p01; + P(1, 0) = p01; + return P; + } + + static ScalarMeas2 makeMeas(uint64_t time_us, float val, float unc, float h_pos = 1.f, float h_vel = 0.f) + { + ScalarMeas2 meas{}; + meas.time_us = time_us; + meas.val = val; + meas.unc = unc; + meas.H = makeVec(h_pos, h_vel); + return meas; + } + + void pushSample(uint64_t time_us, const StateVec2 &state_in, + const StateCov2 &cov_in, float input) + { + oosm.push(time_us, state_in, cov_in, input); + } + + vte::FusionResult fuseMeas(const ScalarMeas2 &meas, uint64_t now_us, float nis_threshold) + { + StateVec2 state_vec = state; + StateCov2 cov_mat = cov; + + const vte::FusionResult res = oosm.fuse(filter, meas, now_us, nis_threshold, state_vec, cov_mat, curr_input); + + state = state_vec; + cov = cov_mat; + return res; + } + +}; + +TEST_F(VTEOosmTest, FusesCurrentMeasurement) +{ + // WHY: The OOSM manager must use the fast path for fresh measurements. + // WHAT: Ensure a current measurement updates state/covariance and returns FUSED_CURRENT. + const uint64_t now_us = 1_s; + const uint64_t current_offset = Manager::kOosmMinTimeUs - 1_ms; + const ScalarMeas meas = makeMeas(now_us - current_offset, 2.f, 1.f); + + const vte::FusionResult res = fuseMeas(meas, now_us, 10.f); + + EXPECT_EQ(res.status, vte::FusionStatus::kFusedCurrent); + EXPECT_NEAR(state, 1.6f, kTolerance); + EXPECT_NEAR(cov, 0.8f, kTolerance); + EXPECT_EQ(filter.applied_k.size(), 1u); +} + +TEST_F(VTEOosmTest, RejectsInvalidInnovationVariance) +{ + // WHY: Near-zero innovation variance should never result in a correction. + // WHAT: Verify REJECT_COV and that the live state stays unchanged. + const uint64_t now_us = 2_s; + state = 1.f; + cov = 0.f; + const uint64_t current_offset = Manager::kOosmMinTimeUs - 1_ms; + const ScalarMeas meas = makeMeas(now_us - current_offset, 1.f, 0.f); + + const vte::FusionResult res = fuseMeas(meas, now_us, 10.f); + + EXPECT_EQ(res.status, vte::FusionStatus::kRejectCov); + EXPECT_EQ(filter.applied_k.size(), 0u); + EXPECT_NEAR(state, 1.f, kTolerance); + EXPECT_NEAR(cov, 0.f, kTolerance); +} + +TEST_F(VTEOosmTest, RejectsNisForCurrentMeasurement) +{ + // WHY: NIS gating is the primary protection against outlier measurements. + // WHAT: Ensure large innovation triggers REJECT_NIS without changing state. + const uint64_t now_us = 3_s; + state = 0.f; + cov = 0.1f; + const uint64_t current_offset = Manager::kOosmMinTimeUs - 1_ms; + const ScalarMeas meas = makeMeas(now_us - current_offset, 10.f, 0.1f); + + const vte::FusionResult res = fuseMeas(meas, now_us, 1.f); + + EXPECT_EQ(res.status, vte::FusionStatus::kRejectNis); + EXPECT_EQ(filter.applied_k.size(), 0u); + EXPECT_NEAR(state, 0.f, kTolerance); + EXPECT_NEAR(cov, 0.1f, kTolerance); +} + +TEST_F(VTEOosmTest, RejectsOosmHistoryGuards) +{ + // WHY: OOSM fusion must protect against empty or inconsistent history. + // WHAT: Validate REJECT_EMPTY and REJECT_STALE behavior with history reset. + const uint64_t meas_time_us = 900_ms; + const ScalarMeas meas = makeMeas(meas_time_us, 0.f, 1.f); + + const vte::FusionResult empty_res = fuseMeas(meas, 1_s, 10.f); + EXPECT_EQ(empty_res.status, vte::FusionStatus::kRejectEmpty); + + pushSample(980_ms, 0.5f, 4.f, 1.f); + + const vte::FusionResult stale_res = fuseMeas(meas, 800_ms, 10.f); + EXPECT_EQ(stale_res.status, vte::FusionStatus::kRejectStale); + + const vte::FusionResult post_reset_res = fuseMeas(meas, 1_s, 10.f); + EXPECT_EQ(post_reset_res.status, vte::FusionStatus::kRejectEmpty); +} + +TEST_F(VTEOosmTest, RejectsOosmOutOfRangeTimestamps) +{ + // WHY: OOSM fusion must reject timestamps outside the valid window. + // WHAT: Check REJECT_TOO_NEW and REJECT_TOO_OLD for valid history. + pushSample(100_ms, 0.f, 4.f, 0.f); + pushSample(200_ms, 1.f, 4.f, 0.f); + pushSample(250_ms, 2.f, 4.f, 0.f); + + const uint64_t now_us = 300_ms; + const uint64_t too_new_time = now_us + Manager::kOosmMinTimeUs + 1_us; + const ScalarMeas meas_too_new = makeMeas(too_new_time, 0.f, 1.f); + const vte::FusionResult too_new_res = fuseMeas(meas_too_new, now_us, 10.f); + EXPECT_EQ(too_new_res.status, vte::FusionStatus::kRejectTooNew); + + const uint64_t oldest_time = 100_ms; + const uint64_t too_old_time = oldest_time - 10_ms; + const ScalarMeas meas_too_old = makeMeas(too_old_time, 0.f, 1.f); + const vte::FusionResult too_old_res = fuseMeas(meas_too_old, now_us, 10.f); + EXPECT_EQ(too_old_res.status, vte::FusionStatus::kRejectTooOld); +} + +TEST_F(VTEOosmTest, UsesCurrentInputWhenFloorIsNewest) +{ + // WHY: When the floor sample is the newest, OOSM should use current input. + // WHAT: Provide a measurement between newest sample and now and verify curr_input was used. + const uint64_t t0 = 1_s; + pushSample(t0, 0.f, 4.f, 1.f); + + curr_input = 5.f; + + const ScalarMeas meas = makeMeas(t0 + 30_ms, 1.f, 1.f); + const vte::FusionResult res = fuseMeas(meas, t0 + 60_ms, 10.f); + + EXPECT_EQ(res.status, vte::FusionStatus::kFusedOosm); + EXPECT_FLOAT_EQ(filter.last_predict_input, curr_input); +} + +TEST_F(VTEOosmTest, RejectsStaleWhenNowExceedsHistoryWindow) +{ + // WHY: Large time gaps between history and now should reset the buffer. + // WHAT: Force now to exceed the max OOSM window and verify REJECT_STALE with reset. + const uint64_t t0 = 1_s; + pushSample(t0, 0.f, 4.f, 0.f); + + const ScalarMeas meas = makeMeas(t0 + 50_ms, 1.f, 1.f); + const uint64_t stale_now = t0 + Manager::kOosmMaxTimeUs + 1_ms; + const vte::FusionResult stale_res = fuseMeas(meas, stale_now, 10.f); + EXPECT_EQ(stale_res.status, vte::FusionStatus::kRejectStale); + + const vte::FusionResult empty_res = fuseMeas(meas, stale_now, 10.f); + EXPECT_EQ(empty_res.status, vte::FusionStatus::kRejectEmpty); +} + +TEST_F(VTEOosmTest, FusesOosmBetweenSamples_Simplified) +{ + // WHY: Ensure OOSM (delayed fusion) yields a result equivalent to + // fusing the measurement sequentially at the correct time. + // WHAT: Run a filter in which the measurement comes on time and compare it with + // a filter with the same measurement but delayed + + static constexpr uint64_t t0 = 100_ms; + static constexpr uint64_t t_meas = 150_ms; + static constexpr uint64_t t1 = 200_ms; + static constexpr uint64_t t2 = 300_ms; + + static constexpr float kInput = 2.f; + static constexpr float kDtStep = 0.1f; // 100ms steps for history + + // Initial Conditions + const auto x0 = makeVec(0.f); + const auto P0 = makeCov(1.f); + const ScalarMeas meas = makeMeas(t_meas, 10.f, 1.f); + + // Build history: t0 -> t1 -> t2 + // We simulate the filter running normally without the measurement first. + matrix::Vector x1, x2; + matrix::SquareMatrix P1, P2; + + // t0 -> t1 + filter.predictState(kDtStep, kInput, x0, P0, x1, P1); + // t1 -> t2 + filter.predictState(kDtStep, kInput, x1, P1, x2, P2); + + // Setup OOSM Manager + oosm.reset(); + filter.resetTracking(); // Reset the helper's internal tracking logs + + oosm.push(t0, x0, P0, kInput); + oosm.push(t1, x1, P1, kInput); + oosm.push(t2, x2, P2, kInput); + + // Now is t2 + state = x2(0); + cov = P2(0, 0); + + // The "OOSM" Path (Real World Scenario), Fuse Delayed Measurement + const vte::FusionResult oosm_res = fuseMeas(meas, t2, 100.f); + + EXPECT_EQ(oosm_res.status, vte::FusionStatus::kFusedOosm); + EXPECT_EQ(oosm_res.history_steps, 2); // Should update t1 and t2 (t0 is floor, not updated) + + const float val_oosm_state = state; + const float val_oosm_cov = cov; + + // We run a separate filter instance in perfect chronological order. + TestFilter truth_filter; + matrix::Vector x_truth = x0; + matrix::SquareMatrix P_truth = P0; + + // Predict t0 -> t_meas (100ms -> 150ms = 0.05s) + float dt_1 = (t_meas - t0) * 1e-6f; + truth_filter.predictState(dt_1, kInput, x_truth, P_truth, x_truth, P_truth); + + // Fuse Measurement exactly at t_meas + float innov, innov_var; + truth_filter.computeInnovation(x_truth, P_truth, meas, innov, innov_var); + + // Calculate K and Apply (Manually calling apply to mimic update step) + // K = PH' / S + matrix::Vector K = (P_truth * meas.H) / innov_var; + truth_filter.applyCorrection(x_truth, P_truth, K, innov, innov_var); + + // Predict t_meas -> t2 (150ms -> 300ms = 0.15s) + float dt_2 = (t2 - t_meas) * 1e-6f; + truth_filter.predictState(dt_2, kInput, x_truth, P_truth, x_truth, P_truth); + + // State: For linear systems, OOSM state projection is exact. + EXPECT_NEAR(val_oosm_state, x_truth(0), kTolerance) << "State mismatch: OOSM vs Truth"; + + // Covariance: Will differ slightly. + // OOSM uses an approximation for covariance propagation (Projected P). + // Truth uses exact sequential P propagation. + // We increase tolerance to ~1-2% of the value to account for this approximation. + EXPECT_NEAR(val_oosm_cov, P_truth(0, 0), 0.01f) << "Covariance mismatch exceeds approximation tolerance"; +} + +TEST_F(VTEOosmTest2d, FusesOosm2d_TruthComparison) +{ + // WHY: Verify that 2D OOSM (with matrix projection) matches sequential fusion. + // WHAT: Run a filter in which the measurement comes on time and compare it with + // a filter with the same measurement but delayed + + static constexpr uint64_t t0 = 100_ms; + static constexpr uint64_t t_meas = 150_ms; + static constexpr uint64_t t1 = 200_ms; + static constexpr uint64_t t2 = 300_ms; + + static constexpr float kInput = 0.5f; // Non-zero input to test B*u dynamics + static constexpr float kDtStep = 0.1f; + + // Initial Conditions + const StateVec2 x0 = makeVec(0.f, 5.f); + const StateCov2 P0 = makeCov(1.f, 1.f, 0.f); + + // Measurement: Position only (H=[1,0]), Value=0.2 + const ScalarMeas2 meas = makeMeas(t_meas, 0.2f, 1.f, 1.f, 0.f); + + matrix::Vector x1, x2; + matrix::SquareMatrix P1, P2; + + // Predict t0 -> t1 -> t2 + filter.predictState(kDtStep, kInput, x0, P0, x1, P1); + filter.predictState(kDtStep, kInput, x1, P1, x2, P2); + + oosm.reset(); + filter.resetTracking(); + oosm.push(t0, x0, P0, kInput); + oosm.push(t1, x1, P1, kInput); + oosm.push(t2, x2, P2, kInput); + + // Current State is t2 + state = x2; + cov = P2; + curr_input = kInput; + + // Fuse delayed measurement (OOSM) + const vte::FusionResult oosm_res = fuseMeas(meas, t2, 100.f); + EXPECT_EQ(oosm_res.status, vte::FusionStatus::kFusedOosm); + + const StateVec2 oosm_state = state; + const StateCov2 oosm_cov = cov; + + // We run a separate filter instance in perfect chronological order. + TestFilter2d truth_filter; + StateVec2 x_truth = x0; + StateCov2 P_truth = P0; + + // Predict t0 -> t_meas + float dt_1 = (t_meas - t0) * 1e-6f; + truth_filter.predictState(dt_1, kInput, x_truth, P_truth, x_truth, P_truth); + + // Fuse at t_meas + float innov, innov_var; + truth_filter.computeInnovation(x_truth, P_truth, meas, innov, innov_var); + StateVec2 K = P_truth * meas.H / innov_var; // Simple K calculation for this scope + + // Manual correction + matrix::Matrix K_mat = K; + x_truth = x_truth + K * innov; + P_truth = P_truth - (K_mat * K.transpose()) * innov_var; + + // Predict t_meas -> t2 + float dt_2 = (t2 - t_meas) * 1e-6f; + truth_filter.predictState(dt_2, kInput, x_truth, P_truth, x_truth, P_truth); + + // State must match closely (checking both Pos and Vel) + EXPECT_NEAR(oosm_state(0), x_truth(0), kTolerance) << "Position Mismatch"; + EXPECT_NEAR(oosm_state(1), x_truth(1), kTolerance) << "Velocity Mismatch"; + + // Covariance approx tolerance (~1-2%) + EXPECT_NEAR(oosm_cov(0, 0), P_truth(0, 0), 0.05f) << "Cov(0,0) Mismatch"; + EXPECT_NEAR(oosm_cov(1, 1), P_truth(1, 1), 0.05f) << "Cov(1,1) Mismatch"; +} + +TEST_F(VTEOosmTest, HandlesRingBufferWrapAroundAndBoundaries) +{ + // WHY: The OOSM manager relies on a ring buffer. Indices must wrap correctly. + // WHAT: Push enough samples to wrap the buffer (Size=8), then perform OOSM + // on a sample that is technically "older" in memory index but "newer" in time + // than the wrapped limit, ensuring the iterator logic holds. + + // Standard Wrap-Around (Wide Timestamps) + // Fill buffer (0 to 7) + for (int i = 0; i < 8; ++i) { + pushSample(100_ms * (i + 1), (float)i, 1.f, 0.f); + } + + // Buffer: [100 ms, 200 ms, ..., 800 ms] + // Push 9th sample (wraps to index 0, overwrites 100 ms) + pushSample(900_ms, 9.f, 1.f, 0.f); + + // State: + // Newest: 900 ms (Index 0) + // Oldest: 200 ms (Index 1) + // Now: 950 ms + // Max OOSM Lag: kOosmMaxTimeUs -> Cutoff: (Now - kOosmMaxTimeUs) + const uint64_t now_us = 950_ms; + const uint64_t max_oosm_lag = Manager::kOosmMaxTimeUs; + const uint64_t cutoff_time = now_us - max_oosm_lag; + + // Verify Valid Fusion in the middle + // This forces the search to traverse from Index 0 backwards to Index 4 + // And the correction loop to traverse Index 4 -> 5 -> 6 -> 7 -> 0 + const uint64_t mid_offset = 100_ms; + const ScalarMeas meas_mid = makeMeas(cutoff_time + mid_offset, 5.5f, 1.f); + EXPECT_EQ(fuseMeas(meas_mid, now_us, 10.f).status, + vte::FusionStatus::kFusedOosm) << "Failed mid-buffer fusion"; + + // Verify Time Window Boundaries (Max OOSM Timeout) + + // Case A: Just inside the window (cutoff time) -> Should Fuse (floor is 400 ms) + // Note: now - cutoff_time = kOosmMaxTimeUs. Code uses `if (diff > max)`, so equal is OK. + const ScalarMeas meas_edge_valid = makeMeas(cutoff_time, 4.5f, 1.f); + EXPECT_EQ(fuseMeas(meas_edge_valid, now_us, 10.f).status, + vte::FusionStatus::kFusedOosm) << "Failed valid edge case (cutoff time)"; + + // Case B: Just outside the window (cutoff - 10 us) -> Reject Too Old + const uint64_t edge_invalid_time = cutoff_time - 10_us; + const ScalarMeas meas_edge_invalid = makeMeas(edge_invalid_time, 4.5f, 1.f); + EXPECT_EQ(fuseMeas(meas_edge_invalid, now_us, 10.f).status, + vte::FusionStatus::kRejectTooOld) << "Failed invalid edge case (cutoff - 10 us)"; + + // Tight Timestamp Verification + // We reset and use tiny time steps to prove that data is rejected + // because it fell off the ring buffer, NOT because of the max OOSM timeout. + oosm.reset(); + filter.resetTracking(); + state = 0.f; + cov = 4.f; + + // We need time_diff > kOosmMinTimeUs to force OOSM logic. + // We use 1ms steps. + // Buffer: [10ms, 11ms, ..., 17ms] (Size 8) + const uint64_t base_t = 10_ms; + const uint64_t step_t = 1_ms; + + for (int i = 0; i < 8; ++i) { + pushSample(base_t + step_t *i, (float)i, 1.f, 0.f); + } + + // Buffer: [10 ms, 11 ms, ..., 17 ms] + + // Push 9th sample (wraps to index 0, overwrites 10 ms) + // Timestamp: 18 ms + pushSample(base_t + step_t * 8, 9.f, 1.f, 0.f); + + // Buffer State: + // Newest: 18 ms + // Oldest: 11 ms (Index 1) + + // Set 'now' such that (now - meas) > kOosmMinTimeUs to avoid FUSED_CURRENT. + const uint64_t extra_margin = 10_ms; + const uint64_t tight_now = base_t + Manager::kOosmMinTimeUs + extra_margin; + + // Case C: Try to fuse time=base_t + 500 us. + // Time diff = tight_now - meas_overwritten_time (> kOosmMinTimeUs). + // History Check: 10.5 ms < Oldest (11 ms). + // MUST return REJECT_TOO_OLD (buffer overwritten). + const uint64_t meas_overwritten_time = base_t + 500_us; + const ScalarMeas meas_overwritten = makeMeas(meas_overwritten_time, 0.f, 1.f); + vte::FusionResult res_over = fuseMeas(meas_overwritten, tight_now, 10.f); + + EXPECT_EQ(res_over.status, vte::FusionStatus::kRejectTooOld) + << "Expected REJECT_TOO_OLD for overwritten sample. Got: " << (int)res_over.status; + + // Case D: Verify boundary of new oldest (11 ms) + const uint64_t oldest_time = base_t + step_t; + const ScalarMeas meas_oldest_valid = makeMeas(oldest_time, 0.f, 1.f); + vte::FusionResult res_valid = fuseMeas(meas_oldest_valid, tight_now, 10.f); + + EXPECT_EQ(res_valid.status, vte::FusionStatus::kFusedOosm) + << "Expected FUSED_OOSM for oldest valid sample. Got: " << (int)res_valid.status; +} + +TEST_F(VTEOosmTest, FusesOosmExactTimeMatch) +{ + // WHY: OOSM logic has a specific optimization when t_meas == t_history. + // WHAT: Verify fusion works when measurement time aligns exactly with a sample. + pushSample(100_ms, 0.f, 4.f, 0.f); + pushSample(200_ms, 2.f, 4.f, 0.f); + + // Measurement matches the sample at 100 ms exactly + const ScalarMeas meas = makeMeas(100_ms, 0.5f, 1.f); + const vte::FusionResult res = fuseMeas(meas, 250_ms, 10.f); + + EXPECT_EQ(res.status, vte::FusionStatus::kFusedOosm); + // Should update 100 ms (floor) and project to 200 ms, then Live. + EXPECT_GT(filter.applied_k.size(), 0u); +} + +TEST_F(VTEOosmTest, RejectsNisDuringOosmPrediction) +{ + // WHY: A delayed measurement might look valid now, but was an outlier at the time it occurred. + // WHAT: Provide a measurement that contradicts the history state at t_meas. + pushSample(100_ms, 0.f, 0.1f, 0.f); + pushSample(200_ms, 0.f, 0.1f, 0.f); + + // State at 150 ms is ~0.0. Meas is 10.0. Variance small. Should fail NIS. + const ScalarMeas meas = makeMeas(150_ms, 10.f, 0.1f); + const vte::FusionResult res = fuseMeas(meas, 250_ms, 1.f); + + EXPECT_EQ(res.status, vte::FusionStatus::kRejectNis); + // Ensure no history corrections were applied + EXPECT_EQ(filter.applied_k.size(), 0u); +} + +} // namespace diff --git a/src/modules/vision_target_estimator/test/TEST_VTEOrientation.cpp b/src/modules/vision_target_estimator/test/TEST_VTEOrientation.cpp new file mode 100644 index 000000000000..1de13b56d018 --- /dev/null +++ b/src/modules/vision_target_estimator/test/TEST_VTEOrientation.cpp @@ -0,0 +1,403 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file TEST_VTEOrientation.cpp + * @brief Unit test VTEOrientation.cpp + * + * @author Jonas Perolini + * + */ + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Orientation/VTEOrientation.h" +#include "VTETestHelper.hpp" + +namespace vte = vision_target_estimator; + +namespace +{ +using namespace time_literals; + +static constexpr float kTolerance = 1e-4f; +static constexpr float kPi = 3.14159265358979323846f; +static constexpr float kTwoPi = 2.f * kPi; +static constexpr hrt_abstime kStepUs = 1_ms; +static constexpr float kDefaultEvYawNoise = 0.5f; +static constexpr float kDefaultEvYawVar = kDefaultEvYawNoise * kDefaultEvYawNoise; + +class VTEOrientationTestable : public vte::VTEOrientation +{ +public: + void updateParamsPublic() { updateParams(); } +}; + +} // namespace + +class VTEOrientationTest : public ::testing::Test +{ +protected: + static void SetUpTestSuite() { uORB::Manager::initialize(); } + + void SetUp() override + { + param_control_autosave(false); + + setParamFloat("VTE_YAW_UNC_IN", 0.2f); + setParamFloat("VTE_EVA_NOISE", kDefaultEvYawNoise); + setParamFloat("VTE_YAW_NIS_THRE", 3.84f); + + _vte = std::make_unique(); + ASSERT_TRUE(_vte->init()); + _vte->updateParamsPublic(); + + _vision_pub = std::make_unique>(ORB_ID(fiducial_marker_yaw_report)); + _aid_sub = std::make_unique>(ORB_ID(vte_aid_ev_yaw)); + _state_sub = std::make_unique>(ORB_ID(vision_target_est_orientation)); + + vte_test::flushSubscription(_aid_sub); + vte_test::flushSubscription(_state_sub); + } + + void TearDown() override + { + _param_guard.restore(); + param_control_autosave(true); + _state_sub.reset(); + _aid_sub.reset(); + _vision_pub.reset(); + + _vte.reset(); + // Keep the uORB manager alive; parameters retain uORB handles across tests. + } + + void setParamFloat(const char *name, float value) + { + ASSERT_TRUE(_param_guard.setFloat(name, value)); + } + + void publishVisionYaw(float yaw, float yaw_var, hrt_abstime timestamp) + { + ASSERT_TRUE(vte_test::publishVisionYaw(*_vision_pub, yaw, yaw_var, timestamp)); + } + + void enableVisionFusion() + { + vte::SensorFusionMaskU mask{}; + mask.flags.use_vision_pos = 1; + _vte->setVteAidMask(mask.value); + } + + std::unique_ptr _vte; + std::unique_ptr> _vision_pub; + std::unique_ptr> _aid_sub; + std::unique_ptr> _state_sub; + + vte_test::ParamGuard _param_guard{}; +}; + +TEST_F(VTEOrientationTest, InitFromVisionYawPublishesOrientation) +{ + // WHY: The estimator must initialize from the first valid yaw measurement. + // WHAT: Publish a valid yaw and verify state and innovation messages. + enableVisionFusion(); + + const float yaw_meas = 0.5f; + publishVisionYaw(yaw_meas, 0.01f, vte_test::advanceMicroseconds(kStepUs)); + + _vte->update(); + + ASSERT_TRUE(_state_sub->update()); + const auto state = _state_sub->get(); + EXPECT_NEAR(state.yaw, yaw_meas, kTolerance); + EXPECT_NEAR(state.yaw_rate, 0.f, kTolerance); + const float meas_var = fmaxf(0.01f, kDefaultEvYawVar); + const float expected_cov = (0.2f * meas_var) / (0.2f + meas_var); + EXPECT_NEAR(state.cov_yaw, expected_cov, kTolerance); + + ASSERT_TRUE(_aid_sub->update()); + const auto aid = _aid_sub->get(); + EXPECT_NEAR(aid.observation_variance, kDefaultEvYawVar, kTolerance); + EXPECT_EQ(aid.fusion_status, static_cast(vte::FusionStatus::kFusedCurrent)); +} + +TEST_F(VTEOrientationTest, DoesNotFuseWhenVisionDisabled) +{ + // WHY: Fusion should be disabled when the aid mask is empty. + // WHAT: Publish a valid yaw without enabling vision fusion and expect no + // output. + publishVisionYaw(0.2f, 0.01f, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(); + + EXPECT_FALSE(_aid_sub->update()); + EXPECT_FALSE(_state_sub->update()); +} + +TEST_F(VTEOrientationTest, RejectsInvalidYaw) +{ + // WHY: Corrupt yaw measurements should not initialize or fuse. + // WHAT: Publish NaN yaw and ensure no outputs are produced. + enableVisionFusion(); + + publishVisionYaw(NAN, 0.01f, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(); + + EXPECT_FALSE(_aid_sub->update()); + EXPECT_FALSE(_state_sub->update()); +} + +TEST_F(VTEOrientationTest, RejectsInvalidYawVariance) +{ + // WHY: Vision yaw variance must be finite for fusion. + // WHAT: Publish NaN variance and ensure no outputs are produced. + enableVisionFusion(); + + publishVisionYaw(0.1f, NAN, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(); + + EXPECT_FALSE(_aid_sub->update()); + EXPECT_FALSE(_state_sub->update()); +} + +TEST_F(VTEOrientationTest, RejectsStaleMeasurement) +{ + // WHY: Stale yaw data can destabilize the filter. + // WHAT: Set a tiny timeout and verify stale data is ignored. + enableVisionFusion(); + + const hrt_abstime meas_timeout = 1_ms; + _vte->setMeasRecentTimeout(meas_timeout); + + const hrt_abstime stale_time = vte_test::nowUs() - (meas_timeout + 1_ms); + publishVisionYaw(0.1f, 0.01f, stale_time); + _vte->update(); + + EXPECT_FALSE(_aid_sub->update()); + EXPECT_FALSE(_state_sub->update()); +} + +TEST_F(VTEOrientationTest, YawNoiseFloor) +{ + // WHY: Measurement variance must never drop below the configured floor. + // WHAT: Provide a tiny variance and verify the floor is applied. + enableVisionFusion(); + + static constexpr float kEvNoise = 0.2f; + setParamFloat("VTE_EVA_NOISE", kEvNoise); + _vte->updateParamsPublic(); + + publishVisionYaw(0.3f, 0.0f, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(); + + ASSERT_TRUE(_aid_sub->update()); + const auto aid = _aid_sub->get(); + EXPECT_NEAR(aid.observation_variance, kEvNoise * kEvNoise, kTolerance); +} + +TEST_F(VTEOrientationTest, RejectsOutlierNis) +{ + // WHY: NIS gating protects against yaw outliers. + // WHAT: Initialize at yaw 0, then fuse a far measurement and expect + // rejection. + enableVisionFusion(); + setParamFloat("VTE_YAW_NIS_THRE", 0.2f); + _vte->updateParamsPublic(); + + publishVisionYaw(0.f, 0.01f, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(); + + ASSERT_TRUE(_state_sub->update()); + vte_test::flushSubscription(_aid_sub); + + publishVisionYaw(kPi, 0.01f, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(); + + ASSERT_TRUE(_aid_sub->update()); + const auto aid = _aid_sub->get(); + EXPECT_EQ(aid.fusion_status, + static_cast(vte::FusionStatus::kRejectNis)); + + ASSERT_TRUE(_state_sub->update()); + const auto state = _state_sub->get(); + EXPECT_NEAR(state.yaw, 0.f, 0.1f); +} + +TEST_F(VTEOrientationTest, WrapsAcrossZeroAndTwoPi) +{ + // WHY: Wrap handling should not average 0 and 2pi to pi. + // WHAT: Toggle measurements near 0 and 2pi and verify yaw stays near zero. + enableVisionFusion(); + + const float yaw_near_zero = 0.01f; + const float yaw_near_two_pi = kTwoPi - 0.013f; + + publishVisionYaw(yaw_near_zero, 0.01f, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(); + + publishVisionYaw(yaw_near_two_pi, 0.01f, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(); + + ASSERT_TRUE(_state_sub->update()); + const auto state = _state_sub->get(); + const float expected = matrix::wrap_pi(yaw_near_two_pi); + + EXPECT_NEAR(state.yaw, expected, 0.1f); + EXPECT_LT(fabsf(state.yaw), 0.2f); +} + +TEST_F(VTEOrientationTest, DoesNotResetOnYawUncChangeWhileRunning) +{ + // WHY: Initial yaw uncertainty is only meant to seed a new estimator instance. + // WHAT: Change VTE_YAW_UNC_IN while the filter is running and verify the active estimator is not reset. + enableVisionFusion(); + + static constexpr float kEvNoise = 0.2f; + static constexpr float kEvVar = kEvNoise * kEvNoise; + setParamFloat("VTE_EVA_NOISE", kEvNoise); + _vte->updateParamsPublic(); + + publishVisionYaw(0.2f, kEvVar, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(); + ASSERT_TRUE(_state_sub->update()); + const auto state_before = _state_sub->get(); + + setParamFloat("VTE_YAW_UNC_IN", 2.0f); + _vte->updateParamsPublic(); + + publishVisionYaw(0.2f, kEvVar, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(); + + ASSERT_TRUE(_state_sub->update()); + const auto state = _state_sub->get(); + EXPECT_NEAR(state.yaw, 0.2f, 0.1f); + EXPECT_LT(state.cov_yaw, state_before.cov_yaw); +} + +TEST_F(VTEOrientationTest, FusesOosmYawMeasurement) +{ + // WHY: Delayed yaw measurements should follow the OOSM fusion path. + // WHAT: Populate history, then fuse a delayed measurement and expect FUSED_OOSM. + enableVisionFusion(); + setParamFloat("VTE_YAW_NIS_THRE", 100.f); + _vte->updateParamsPublic(); + + hrt_abstime first_predict_time = 0; + + for (int i = 0; i < 3; ++i) { + const hrt_abstime sample_time = vte_test::advanceMicroseconds(kStepUs); + + publishVisionYaw(0.1f, 0.01f, sample_time); + _vte->update(); + + if (_aid_sub->update() && (first_predict_time == 0)) { + first_predict_time = _aid_sub->get().time_last_predict; + } + + (void)_state_sub->update(); + } + + ASSERT_GT(first_predict_time, 0u); + + vte_test::advanceMicroseconds(30_ms); + const hrt_abstime oosm_time = first_predict_time; + publishVisionYaw(0.12f, 0.01f, oosm_time); + _vte->update(); + + ASSERT_TRUE(_aid_sub->update()); + const auto aid = _aid_sub->get(); + EXPECT_EQ(aid.fusion_status, static_cast(vte::FusionStatus::kFusedOosm)); + EXPECT_GT(aid.history_steps, 0u); +} + +TEST_F(VTEOrientationTest, ResetsFilterOnStalePredictionGap) +{ + // WHY: A long scheduling gap should reset the filter instead of predicting across stale time. + // WHAT: Initialize once, wait past the allowed prediction gap, then verify a fresh measurement re-initializes cleanly. + enableVisionFusion(); + + publishVisionYaw(0.1f, 0.01f, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(); + + ASSERT_TRUE(_state_sub->update()); + EXPECT_NEAR(_state_sub->get().yaw, 0.1f, kTolerance); + + vte_test::flushSubscription(_aid_sub); + vte_test::flushSubscription(_state_sub); + + vte_test::advanceMicroseconds(120_ms); + _vte->update(); + + EXPECT_FALSE(_aid_sub->update()); + EXPECT_FALSE(_state_sub->update()); + + publishVisionYaw(-0.25f, 0.01f, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(); + + ASSERT_TRUE(_aid_sub->update()); + EXPECT_EQ(_aid_sub->get().fusion_status, + static_cast(vte::FusionStatus::kFusedCurrent)); + + ASSERT_TRUE(_state_sub->update()); + const auto state = _state_sub->get(); + EXPECT_NEAR(state.yaw, -0.25f, kTolerance); + EXPECT_NEAR(state.yaw_rate, 0.f, kTolerance); +} + +TEST_F(VTEOrientationTest, TargetValidityTimeout) +{ + // WHY: Orientation validity must reflect the configured timeout. + // WHAT: Set timeout to zero and confirm orientation_valid is false. + enableVisionFusion(); + _vte->setTargetValidTimeout(0); + + publishVisionYaw(0.1f, 0.01f, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(); + + ASSERT_TRUE(_state_sub->update()); + const auto state = _state_sub->get(); + EXPECT_FALSE(state.orientation_valid); +} diff --git a/src/modules/vision_target_estimator/test/TEST_VTEPosition.cpp b/src/modules/vision_target_estimator/test/TEST_VTEPosition.cpp new file mode 100644 index 000000000000..09b69f8d823f --- /dev/null +++ b/src/modules/vision_target_estimator/test/TEST_VTEPosition.cpp @@ -0,0 +1,1594 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file TEST_VTEPosition.cpp + * @brief Unit test VTEPosition.cpp + * + * @author Jonas Perolini + * + */ + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Position/VTEPosition.h" +#include "VTETestHelper.hpp" + +namespace vte = vision_target_estimator; + +namespace +{ +using namespace time_literals; + +static constexpr float kTolerance = 1e-3f; +static constexpr hrt_abstime kStepUs = 1_ms; +static constexpr float kDefaultEvPosNoise = 0.2f; +static constexpr float kDefaultGpsPosNoise = 0.1f; +static constexpr float kDefaultGpsVelNoise = 0.2f; +static constexpr float kDefaultBiasUnc = 0.5f; +static constexpr float kDefaultEvPosVar = kDefaultEvPosNoise * kDefaultEvPosNoise; +static constexpr float kDefaultGpsPosVar = kDefaultGpsPosNoise * kDefaultGpsPosNoise; +static constexpr float kDefaultGpsVelVar = kDefaultGpsVelNoise * kDefaultGpsVelNoise; +static constexpr double kUavLat = 47.3977419; +static constexpr double kUavLon = 8.5455938; +static constexpr float kUavAltM = 100.f; +static constexpr float kTargetAltM = 90.f; +static const matrix::Vector3f kSmallVisionCov{0.01f, 0.01f, 0.01f}; +static const matrix::Vector3f kZeroVec{0.f, 0.f, 0.f}; + +enum class FusionMaskOption { + kVisionPos, + kMissionPos, + kTargetGpsPos, + kUavGpsVel, + kTargetGpsVel +}; + +class VTEPositionTestable : public vte::VTEPosition +{ +public: + using vte::VTEPosition::kAltMaxM; + using vte::VTEPosition::kLatAbsMaxDeg; + using vte::VTEPosition::kLonAbsMaxDeg; + void updateParamsPublic() { updateParams(); } +}; + +} // namespace + +class VTEPositionTest : public ::testing::Test +{ +protected: + static void SetUpTestSuite() { uORB::Manager::initialize(); } + + void SetUp() override + { + param_control_autosave(false); + + setParamFloat("VTE_POS_UNC_IN", 1.f); + setParamFloat("VTE_VEL_UNC_IN", 1.f); + setParamFloat("VTE_BIA_UNC_IN", kDefaultBiasUnc); + setParamFloat("VTE_EVP_NOISE", kDefaultEvPosNoise); + setParamFloat("VTE_GPS_P_NOISE", kDefaultGpsPosNoise); + setParamFloat("VTE_GPS_V_NOISE", kDefaultGpsVelNoise); + setParamFloat("VTE_POS_NIS_THRE", 3.84f); + setParamFloat("VTE_BIA_AVG_THR", 0.3f); + setParamFloat("VTE_BIA_AVG_TOUT", 1.0f); + + _vte = std::make_unique(); + ASSERT_TRUE(_vte->init()); + _vte->updateParamsPublic(); + + _vision_pub = std::make_unique>(ORB_ID(fiducial_marker_pos_report)); + _uav_gps_pub = std::make_unique>(ORB_ID(vehicle_gps_position)); + _target_gps_pub = std::make_unique>(ORB_ID(target_gnss)); + + _aid_fiducial_sub = std::make_unique>(ORB_ID(vte_aid_fiducial_marker)); + _aid_gps_mission_sub = std::make_unique>(ORB_ID(vte_aid_gps_pos_mission)); + _aid_gps_target_sub = std::make_unique>(ORB_ID(vte_aid_gps_pos_target)); + _aid_gps_vel_uav_sub = std::make_unique>(ORB_ID(vte_aid_gps_vel_uav)); + _aid_gps_vel_target_sub = std::make_unique>(ORB_ID(vte_aid_gps_vel_target)); + _bias_init_status_sub = std::make_unique>(ORB_ID(vte_bias_init_status)); + _vte_state_sub = std::make_unique>(ORB_ID(vision_target_est_position)); + _target_pose_sub = std::make_unique>(ORB_ID(landing_target_pose)); + + vte_test::flushSubscription(_aid_fiducial_sub); + vte_test::flushSubscription(_aid_gps_mission_sub); + vte_test::flushSubscription(_aid_gps_target_sub); + vte_test::flushSubscription(_aid_gps_vel_uav_sub); + vte_test::flushSubscription(_aid_gps_vel_target_sub); + vte_test::flushSubscription(_bias_init_status_sub); + vte_test::flushSubscription(_vte_state_sub); + vte_test::flushSubscription(_target_pose_sub); + } + + void TearDown() override + { + _param_guard.restore(); + param_control_autosave(true); + _target_pose_sub.reset(); + _vte_state_sub.reset(); + _aid_gps_vel_target_sub.reset(); + _aid_gps_vel_uav_sub.reset(); + _aid_gps_target_sub.reset(); + _aid_gps_mission_sub.reset(); + _aid_fiducial_sub.reset(); + _bias_init_status_sub.reset(); + _target_gps_pub.reset(); + _uav_gps_pub.reset(); + _vision_pub.reset(); + + _vte.reset(); + // Keep the uORB manager alive; parameters retain uORB handles across tests. + } + + void setParamFloat(const char *name, float value) + { + ASSERT_TRUE(_param_guard.setFloat(name, value)); + } + + void enableMask(std::initializer_list sources) + { + vte::SensorFusionMaskU mask{}; + + for (const FusionMaskOption source : sources) { + switch (source) { + case FusionMaskOption::kVisionPos: + mask.flags.use_vision_pos = 1; + break; + + case FusionMaskOption::kMissionPos: + mask.flags.use_mission_pos = 1; + break; + + case FusionMaskOption::kTargetGpsPos: + mask.flags.use_target_gps_pos = 1; + break; + + case FusionMaskOption::kUavGpsVel: + mask.flags.use_uav_gps_vel = 1; + break; + + case FusionMaskOption::kTargetGpsVel: + mask.flags.use_target_gps_vel = 1; + break; + } + } + + _vte->setVteAidMask(mask.value); + } + + void publishVisionPos(const matrix::Vector3f &rel_pos, const matrix::Quaternionf &q, + const matrix::Vector3f &cov, hrt_abstime timestamp) + { + ASSERT_TRUE(vte_test::publishVisionPos(*_vision_pub, rel_pos, q, cov, timestamp)); + } + + void publishUavGps(double lat, double lon, float alt, float eph, float epv, + const matrix::Vector3f &vel_ned, float vel_var, bool vel_valid, hrt_abstime timestamp) + { + ASSERT_TRUE(vte_test::publishUavGps(*_uav_gps_pub, lat, lon, alt, eph, epv, vel_ned, vel_var, vel_valid, timestamp)); + } + + void publishTargetGnss(double lat, double lon, float alt, float eph, float epv, + hrt_abstime timestamp, bool abs_pos_updated, bool vel_ned_updated = false, + const matrix::Vector3f &vel_ned = matrix::Vector3f{}, float vel_acc = NAN) + { + ASSERT_TRUE(vte_test::publishTargetGnss(*_target_gps_pub, lat, lon, alt, eph, epv, timestamp, + abs_pos_updated, vel_ned_updated, vel_ned, vel_acc)); + } + + void setLocalVelocity(const matrix::Vector3f &vel_ned, hrt_abstime timestamp) + { + _vte->setLocalVelocity(vel_ned, true, timestamp); + } + + void setLocalPosition(const matrix::Vector3f &pos_ned, hrt_abstime timestamp) + { + _vte->setLocalPosition(pos_ned, true, timestamp); + } + + std::unique_ptr _vte; + std::unique_ptr> _vision_pub; + std::unique_ptr> _uav_gps_pub; + std::unique_ptr> _target_gps_pub; + + std::unique_ptr> _aid_fiducial_sub; + std::unique_ptr> _aid_gps_mission_sub; + std::unique_ptr> _aid_gps_target_sub; + std::unique_ptr> _aid_gps_vel_uav_sub; + std::unique_ptr> _aid_gps_vel_target_sub; + std::unique_ptr> _bias_init_status_sub; + std::unique_ptr> _vte_state_sub; + std::unique_ptr> _target_pose_sub; + + vte_test::ParamGuard _param_guard{}; +}; + +TEST_F(VTEPositionTest, DoesNotFuseWhenVisionDisabled) +{ + // WHY: Fusion must stay off when the vision bit is not enabled in the aid mask. + // WHAT: Publish a valid vision sample with an empty aid mask and expect no estimator output. + publishVisionPos(matrix::Vector3f(1.f, -2.f, 3.f), vte_test::identityQuat(), + kSmallVisionCov, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(matrix::Vector3f{}); + + EXPECT_FALSE(_aid_fiducial_sub->update()); + EXPECT_FALSE(_vte_state_sub->update()); +} + +TEST_F(VTEPositionTest, InitRequiresVelocityEstimate) +{ + // WHY: Estimator must not initialize without a velocity estimate. + // WHAT: Provide a position measurement only and expect no output. + enableMask({FusionMaskOption::kVisionPos}); + + const matrix::Vector3f rel_pos(1.f, 2.f, -3.f); + const hrt_abstime vision_time = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos, vte_test::identityQuat(), kZeroVec, vision_time); + + _vte->update(matrix::Vector3f{}); + + EXPECT_FALSE(_vte_state_sub->update()); + EXPECT_FALSE(_aid_fiducial_sub->update()); +} + +TEST_F(VTEPositionTest, InitWithVisionAndLocalVelocity) +{ + // WHY: Vision + velocity should be sufficient to start the filter. + // WHAT: Verify initial state matches the vision position and local velocity. + enableMask({FusionMaskOption::kVisionPos}); + + const matrix::Vector3f rel_pos(4.f, -2.f, 1.f); + const matrix::Vector3f vel_ned(1.f, 2.f, 3.f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + const hrt_abstime vision_time = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos, vte_test::identityQuat(), kZeroVec, vision_time); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto state = _vte_state_sub->get(); + + EXPECT_NEAR(state.rel_pos[0], rel_pos(0), kTolerance); + EXPECT_NEAR(state.rel_pos[1], rel_pos(1), kTolerance); + EXPECT_NEAR(state.rel_pos[2], rel_pos(2), kTolerance); + EXPECT_TRUE(state.rel_pos_valid); + EXPECT_TRUE(state.rel_vel_valid); + EXPECT_NEAR(state.vel_uav[0], vel_ned(0), kTolerance); + EXPECT_NEAR(state.vel_uav[1], vel_ned(1), kTolerance); + EXPECT_NEAR(state.vel_uav[2], vel_ned(2), kTolerance); + + ASSERT_TRUE(_aid_fiducial_sub->update()); + const auto aid = _aid_fiducial_sub->get(); + EXPECT_NEAR(aid.observation_variance[0], kDefaultEvPosVar, kTolerance); + EXPECT_NEAR(aid.observation_variance[1], kDefaultEvPosVar, kTolerance); + EXPECT_NEAR(aid.observation_variance[2], kDefaultEvPosVar, kTolerance); +} + +TEST_F(VTEPositionTest, InitialPositionUsesVisionFirstWhenBiasAveragingDisabled) +{ + // WHY: Bias averaging can be disabled when the first relative source is already trusted. + // WHAT: Disable bias averaging, provide vision and GNSS together, and check vision + // initialization remains the active path. + enableMask({FusionMaskOption::kVisionPos, FusionMaskOption::kTargetGpsPos}); + + const matrix::Vector3f vel_ned(0.1f, 0.2f, 0.3f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + setParamFloat("VTE_POS_NIS_THRE", 100.f); + setParamFloat("VTE_BIA_AVG_TOUT", 0.f); + _vte->updateParamsPublic(); + + static constexpr hrt_abstime kBaseAgeUs = 10_ms; + static constexpr hrt_abstime kGpsLagUs = 4_ms; + const hrt_abstime base_time = vte_test::nowUs() - kBaseAgeUs; + const hrt_abstime vision_time = base_time; + const hrt_abstime gps_time = base_time + kGpsLagUs; + + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.5f, 0.5f, vel_ned, 0.1f, true, gps_time); + publishTargetGnss(kUavLat, kUavLon, kTargetAltM, 0.5f, 0.5f, gps_time, true); + + const matrix::Vector3f rel_pos(5.f, 6.f, 7.f); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, vision_time); + + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto state = _vte_state_sub->get(); + ASSERT_TRUE(_aid_fiducial_sub->update()); + const auto aid = _aid_fiducial_sub->get(); + EXPECT_NEAR(aid.innovation[0], 0.f, 0.5f); + EXPECT_NEAR(aid.innovation[1], 0.f, 0.5f); + EXPECT_NEAR(aid.innovation[2], 0.f, 0.5f); + EXPECT_NEAR(state.bias[0], -rel_pos(0), 0.1f); + EXPECT_NEAR(state.bias[1], -rel_pos(1), 0.1f); + EXPECT_NEAR(state.bias[2], (kUavAltM - kTargetAltM) - rel_pos(2), 0.1f); + EXPECT_NEAR(state.rel_pos[0] + state.bias[0], 0.f, 0.5f); + EXPECT_NEAR(state.rel_pos[1] + state.bias[1], 0.f, 0.5f); + EXPECT_NEAR(state.rel_pos[2] + state.bias[2], kUavAltM - kTargetAltM, 0.5f); + + ASSERT_TRUE(_aid_gps_target_sub->update()); + const auto aid_gps = _aid_gps_target_sub->get(); + EXPECT_EQ(aid_gps.fusion_status[0], static_cast(vte::FusionStatus::kFusedCurrent)); +} + +TEST_F(VTEPositionTest, RejectsOutlierNis) +{ + // WHY: NIS gating should reject large innovations. + // WHAT: Initialize with vision, then feed an outlier and expect REJECT_NIS. + vision_target_est_position_s state_before{}; + + enableMask({FusionMaskOption::kVisionPos}); + setLocalVelocity(matrix::Vector3f(0.1f, 0.1f, 0.1f), vte_test::advanceMicroseconds(kStepUs)); + + publishVisionPos(matrix::Vector3f(0.f, 0.f, 0.f), vte_test::identityQuat(), + kSmallVisionCov, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + state_before = _vte_state_sub->get(); + vte_test::flushSubscription(_aid_fiducial_sub); + + setParamFloat("VTE_POS_NIS_THRE", 0.11f); + _vte->updateParamsPublic(); + + publishVisionPos(matrix::Vector3f(100.f, -100.f, 50.f), vte_test::identityQuat(), + kSmallVisionCov, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_fiducial_sub->update()); + const auto aid = _aid_fiducial_sub->get(); + EXPECT_EQ(aid.fusion_status[0], static_cast(vte::FusionStatus::kRejectNis)); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto state_after = _vte_state_sub->get(); + EXPECT_NEAR(state_after.rel_pos[0], state_before.rel_pos[0], 0.1f); + EXPECT_NEAR(state_after.rel_pos[1], state_before.rel_pos[1], 0.1f); + EXPECT_NEAR(state_after.rel_pos[2], state_before.rel_pos[2], 0.1f); +} + +TEST_F(VTEPositionTest, RejectsInvalidTargetGnssData) +{ + // WHY: Invalid GNSS data should be rejected before fusion. + // WHAT: Feed out-of-range, sentinel, and altitude-invalid measurements and + // expect no aid update. + enableMask({FusionMaskOption::kTargetGpsPos}); + + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(matrix::Vector3f(0.1f, 0.1f, 0.1f), vel_time); + + const matrix::Vector3f vel_ned(0.1f, 0.1f, 0.1f); + + auto run_case = [&](double lat, double lon, float alt) { + const hrt_abstime uav_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, uav_time); + const hrt_abstime target_time = vte_test::advanceMicroseconds(kStepUs); + publishTargetGnss(lat, lon, alt, 0.2f, 0.2f, target_time, true); + _vte->update(matrix::Vector3f{}); + EXPECT_FALSE(_aid_gps_target_sub->update()); + vte_test::flushSubscription(_aid_gps_target_sub); + }; + + const double invalid_lat = VTEPositionTestable::kLatAbsMaxDeg + 1.0; + const double invalid_lon = VTEPositionTestable::kLonAbsMaxDeg + 1.0; + const double sentinel_lat = 0.0; + const double sentinel_lon = 0.0; + const float invalid_alt = VTEPositionTestable::kAltMaxM + 1.f; + + run_case(invalid_lat, kUavLon, kTargetAltM); + run_case(kUavLat, invalid_lon, kTargetAltM); + run_case(sentinel_lat, sentinel_lon, kTargetAltM); + run_case(kUavLat, kUavLon, invalid_alt); +} + +TEST_F(VTEPositionTest, FusesMeasurementsInTimestampOrder) +{ + // WHY: Fusions must occur in timestamp order to keep OOSM consistent. + // WHAT: Apply non-zero vision then GNSS updates and verify ordering in the innovations. + enableMask({FusionMaskOption::kVisionPos, FusionMaskOption::kMissionPos}); + + static constexpr float kPosNoise = 1.f; + static constexpr float kPosVar = kPosNoise * kPosNoise; + setParamFloat("VTE_EVP_NOISE", kPosNoise); + setParamFloat("VTE_GPS_P_NOISE", kPosNoise); + setParamFloat("VTE_BIA_UNC_IN", 0.f); + setParamFloat("VTE_BIA_AVG_TOUT", 0.f); + setParamFloat("VTE_POS_NIS_THRE", 100.f); + _vte->updateParamsPublic(); + + const matrix::Vector3f vel_ned(0.f, 0.f, 0.f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + static constexpr double kMetersPerDegLat = 1.11111e5; + static constexpr double kTargetNorthMeters = 20.0; + + const hrt_abstime init_time = vte_test::advanceMicroseconds(kStepUs); + _vte->setMissionPosition(kUavLat, kUavLon, kUavAltM); + publishUavGps(kUavLat, kUavLon, kUavAltM, kPosNoise, kPosNoise, vel_ned, 0.1f, true, init_time); + publishVisionPos(matrix::Vector3f{}, vte_test::identityQuat(), + matrix::Vector3f(kPosVar, kPosVar, kPosVar), init_time); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + vte_test::flushSubscription(_aid_fiducial_sub); + vte_test::flushSubscription(_aid_gps_mission_sub); + vte_test::flushSubscription(_vte_state_sub); + + static constexpr hrt_abstime kBaseAgeUs = 10_ms; + static constexpr hrt_abstime kGpsLagUs = 5_ms; + const hrt_abstime base_time = vte_test::nowUs() - kBaseAgeUs; + const hrt_abstime vision_time = base_time; + const hrt_abstime gps_time = base_time + kGpsLagUs; + + const double mission_lat = kUavLat + (kTargetNorthMeters / kMetersPerDegLat); + _vte->setMissionPosition(mission_lat, kUavLon, kUavAltM); + + publishUavGps(kUavLat, kUavLon, kUavAltM, kPosNoise, kPosNoise, vel_ned, 0.1f, true, gps_time); + + const matrix::Vector3f rel_pos(10.f, 0.f, 0.f); + publishVisionPos(rel_pos, vte_test::identityQuat(), + matrix::Vector3f(kPosVar, kPosVar, kPosVar), vision_time); + + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_fiducial_sub->update()); + ASSERT_TRUE(_aid_gps_mission_sub->update()); + + const auto aid_vision = _aid_fiducial_sub->get(); + const auto aid_gps = _aid_gps_mission_sub->get(); + + EXPECT_GT(fabsf(aid_vision.innovation[0]), 1.f); + EXPECT_NEAR(aid_vision.observation[0], rel_pos(0), 0.1f); + EXPECT_NEAR(aid_gps.observation[0], kTargetNorthMeters, 0.6f); + + const float pred_state_vision = aid_vision.observation[0] - aid_vision.innovation[0]; + const float p_vision = aid_vision.innovation_variance[0] - aid_vision.observation_variance[0]; + const float k_vision = p_vision / aid_vision.innovation_variance[0]; + const float state_after_vision = pred_state_vision + k_vision * aid_vision.innovation[0]; + + const float pred_state_gps = aid_gps.observation[0] - aid_gps.innovation[0]; + EXPECT_NEAR(pred_state_gps, state_after_vision, 0.5f); +} + +TEST_F(VTEPositionTest, BiasAveragingTimeoutZeroActivatesBiasImmediately) +{ + // WHY: A zero averaging timeout should immediately activate the bias. + // WHAT: Initialize from GNSS, then provide the first vision sample with averaging disabled and verify that + // the same update sets the bias and fuses vision. + enableMask({FusionMaskOption::kVisionPos, FusionMaskOption::kMissionPos}); + + setParamFloat("VTE_BIA_AVG_TOUT", 0.f); + _vte->updateParamsPublic(); + + const matrix::Vector3f vel_ned(0.5f, -0.5f, 0.1f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + const hrt_abstime init_gps_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, init_gps_time); + _vte->setMissionPosition(kUavLat, kUavLon, kTargetAltM); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + vte_test::flushSubscription(_aid_fiducial_sub); + vte_test::flushSubscription(_aid_gps_mission_sub); + vte_test::flushSubscription(_bias_init_status_sub); + vte_test::flushSubscription(_vte_state_sub); + + const matrix::Vector3f rel_pos(1.f, 2.f, 3.f); + const hrt_abstime gps_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, gps_time); + const hrt_abstime vision_time = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, vision_time); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_fiducial_sub->update()); + ASSERT_TRUE(_vte_state_sub->update()); + EXPECT_FALSE(_bias_init_status_sub->update()); + + const auto state = _vte_state_sub->get(); + + EXPECT_LT(state.bias[0], -0.5f); + EXPECT_LT(state.bias[1], -1.0f); + EXPECT_GT(state.bias[2], 5.0f); +} + +TEST_F(VTEPositionTest, BiasAveragingDelaysVisionFusionUntilStable) +{ + // WHY: The first vision sample after GNSS can be noisy and should not set the GNSS bias immediately. + // WHAT: Initialize from GNSS, provide one vision sample to start averaging, + // then require both consecutive agreement and a minimum averaging time before activating the bias. + enableMask({FusionMaskOption::kVisionPos, FusionMaskOption::kMissionPos}); + + setParamFloat("VTE_BIA_AVG_THR", 0.05f); + setParamFloat("VTE_BIA_AVG_TOUT", 2.0f); + _vte->updateParamsPublic(); + + const matrix::Vector3f vel_ned(0.5f, -0.5f, 0.1f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + const hrt_abstime init_gps_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, init_gps_time); + _vte->setMissionPosition(kUavLat, kUavLon, kTargetAltM); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + vte_test::flushSubscription(_aid_fiducial_sub); + vte_test::flushSubscription(_aid_gps_mission_sub); + vte_test::flushSubscription(_vte_state_sub); + + const matrix::Vector3f rel_pos(1.f, 2.f, 3.f); + const hrt_abstime gps_time_1 = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, gps_time_1); + const hrt_abstime vision_time_1 = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, vision_time_1); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + ASSERT_TRUE(_bias_init_status_sub->update()); + const auto initial_bias_status = _bias_init_status_sub->get(); + const auto pending_state = _vte_state_sub->get(); + EXPECT_FALSE(_aid_fiducial_sub->update()); + const float altitude_bias = (kUavAltM - kTargetAltM) - rel_pos(2); + const matrix::Vector3f expected_bias(-rel_pos(0), -rel_pos(1), altitude_bias); + EXPECT_NEAR(initial_bias_status.raw_bias[0], expected_bias(0), kTolerance); + EXPECT_NEAR(initial_bias_status.raw_bias[1], expected_bias(1), kTolerance); + EXPECT_NEAR(initial_bias_status.raw_bias[2], expected_bias(2), kTolerance); + EXPECT_NEAR(initial_bias_status.filtered_bias[0], expected_bias(0), kTolerance); + EXPECT_NEAR(initial_bias_status.filtered_bias[1], expected_bias(1), kTolerance); + EXPECT_NEAR(initial_bias_status.filtered_bias[2], expected_bias(2), kTolerance); + EXPECT_NEAR(initial_bias_status.delta_norm, 0.f, kTolerance); + EXPECT_NEAR(pending_state.bias[0], 0.f, kTolerance); + EXPECT_NEAR(pending_state.bias[1], 0.f, kTolerance); + EXPECT_NEAR(pending_state.bias[2], 0.f, kTolerance); + EXPECT_NEAR(pending_state.rel_pos[0], 0.f, 0.1f); + EXPECT_NEAR(pending_state.rel_pos[1], 0.f, 0.1f); + EXPECT_NEAR(pending_state.rel_pos[2], kUavAltM - kTargetAltM, 0.1f); + + auto advanceEstimator = [&]() { + constexpr hrt_abstime kKeepAliveStepUs = 50_ms; + + for (int i = 0; i < 4; ++i) { + const hrt_abstime now = vte_test::advanceMicroseconds(kKeepAliveStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, now); + _vte->update(matrix::Vector3f{}); + vte_test::flushSubscription(_vte_state_sub); + vte_test::flushSubscription(_aid_gps_mission_sub); + } + }; + + advanceEstimator(); + const hrt_abstime vision_time_2 = vte_test::nowUs(); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, vision_time_2); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto still_pending_state = _vte_state_sub->get(); + EXPECT_FALSE(_aid_fiducial_sub->update()); + EXPECT_NEAR(still_pending_state.bias[0], 0.f, kTolerance); + EXPECT_NEAR(still_pending_state.bias[1], 0.f, kTolerance); + EXPECT_NEAR(still_pending_state.bias[2], 0.f, kTolerance); + vte_test::flushSubscription(_vte_state_sub); + + advanceEstimator(); + const hrt_abstime vision_time_3 = vte_test::nowUs(); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, vision_time_3); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto min_time_not_reached_state = _vte_state_sub->get(); + EXPECT_FALSE(_aid_fiducial_sub->update()); + EXPECT_NEAR(min_time_not_reached_state.bias[0], 0.f, kTolerance); + EXPECT_NEAR(min_time_not_reached_state.bias[1], 0.f, kTolerance); + EXPECT_NEAR(min_time_not_reached_state.bias[2], 0.f, kTolerance); + vte_test::flushSubscription(_vte_state_sub); + + advanceEstimator(); + const hrt_abstime vision_time_4 = vte_test::nowUs(); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, vision_time_4); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto still_waiting_for_consecutive_state = _vte_state_sub->get(); + EXPECT_FALSE(_aid_fiducial_sub->update()); + EXPECT_NEAR(still_waiting_for_consecutive_state.bias[0], 0.f, kTolerance); + EXPECT_NEAR(still_waiting_for_consecutive_state.bias[1], 0.f, kTolerance); + EXPECT_NEAR(still_waiting_for_consecutive_state.bias[2], 0.f, kTolerance); + vte_test::flushSubscription(_vte_state_sub); + + advanceEstimator(); + const hrt_abstime vision_time_5 = vte_test::nowUs(); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, vision_time_5); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto still_waiting_for_last_delta_state = _vte_state_sub->get(); + EXPECT_FALSE(_aid_fiducial_sub->update()); + EXPECT_NEAR(still_waiting_for_last_delta_state.bias[0], 0.f, kTolerance); + EXPECT_NEAR(still_waiting_for_last_delta_state.bias[1], 0.f, kTolerance); + EXPECT_NEAR(still_waiting_for_last_delta_state.bias[2], 0.f, kTolerance); + vte_test::flushSubscription(_vte_state_sub); + + advanceEstimator(); + const hrt_abstime vision_time_6 = vte_test::nowUs(); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, vision_time_6); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_fiducial_sub->update()); + ASSERT_TRUE(_vte_state_sub->update()); + const auto state = _vte_state_sub->get(); + EXPECT_NEAR(state.bias[0], expected_bias(0), 0.1f); + EXPECT_NEAR(state.bias[1], expected_bias(1), 0.1f); + EXPECT_NEAR(state.bias[2], expected_bias(2), 0.1f); + EXPECT_NEAR(state.cov_bias[0], kDefaultBiasUnc, kTolerance); + EXPECT_NEAR(state.cov_bias[1], kDefaultBiasUnc, kTolerance); + EXPECT_NEAR(state.cov_bias[2], kDefaultBiasUnc, kTolerance); +} + +TEST_F(VTEPositionTest, BiasAveragingUsesRawConsecutiveDeltaForStability) +{ + // WHY: Stability should reflect agreement between consecutive raw bias samples, not just closeness to the LPF output. + // WHAT: Feed alternating vision samples that remain close to the LPF but disagree with each other and + // verify averaging stays active until the required consecutive raw deltas are below threshold. + enableMask({FusionMaskOption::kVisionPos, FusionMaskOption::kMissionPos}); + + setParamFloat("VTE_BIA_AVG_THR", 0.05f); + setParamFloat("VTE_BIA_AVG_TOUT", 1.0f); + _vte->updateParamsPublic(); + + const matrix::Vector3f vel_ned(0.5f, -0.5f, 0.1f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + const hrt_abstime init_gps_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, init_gps_time); + _vte->setMissionPosition(kUavLat, kUavLon, kTargetAltM); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + vte_test::flushSubscription(_aid_fiducial_sub); + vte_test::flushSubscription(_aid_gps_mission_sub); + vte_test::flushSubscription(_vte_state_sub); + + const matrix::Vector3f rel_pos_first(1.00f, 2.f, 3.f); + const matrix::Vector3f rel_pos_second(1.04f, 2.f, 3.f); + const matrix::Vector3f rel_pos_third(0.96f, 2.f, 3.f); + + const hrt_abstime gps_time_1 = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, gps_time_1); + const hrt_abstime vision_time_1 = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos_first, vte_test::identityQuat(), kSmallVisionCov, vision_time_1); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + EXPECT_FALSE(_aid_fiducial_sub->update()); + vte_test::flushSubscription(_bias_init_status_sub); + vte_test::flushSubscription(_vte_state_sub); + + auto advanceEstimator = [&](hrt_abstime duration_us) { + constexpr hrt_abstime kKeepAliveStepUs = 50_ms; + hrt_abstime elapsed = 0; + + while (elapsed < duration_us) { + const hrt_abstime remaining = duration_us - elapsed; + const hrt_abstime step = (remaining < kKeepAliveStepUs) ? remaining : kKeepAliveStepUs; + const hrt_abstime now = vte_test::advanceMicroseconds(step); + setLocalVelocity(vel_ned, now); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, now); + _vte->update(matrix::Vector3f{}); + vte_test::flushSubscription(_vte_state_sub); + vte_test::flushSubscription(_aid_gps_mission_sub); + elapsed += step; + } + }; + + advanceEstimator(300_ms); + const hrt_abstime vision_time_2 = vte_test::nowUs(); + publishVisionPos(rel_pos_second, vte_test::identityQuat(), kSmallVisionCov, vision_time_2); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + EXPECT_FALSE(_aid_fiducial_sub->update()); + vte_test::flushSubscription(_bias_init_status_sub); + vte_test::flushSubscription(_vte_state_sub); + + advanceEstimator(310_ms); + const hrt_abstime vision_time_3 = vte_test::nowUs(); + publishVisionPos(rel_pos_third, vte_test::identityQuat(), kSmallVisionCov, vision_time_3); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto still_averaging_state = _vte_state_sub->get(); + EXPECT_FALSE(_aid_fiducial_sub->update()); + EXPECT_NEAR(still_averaging_state.bias[0], 0.f, kTolerance); + EXPECT_NEAR(still_averaging_state.bias[1], 0.f, kTolerance); + EXPECT_NEAR(still_averaging_state.bias[2], 0.f, kTolerance); + vte_test::flushSubscription(_bias_init_status_sub); + vte_test::flushSubscription(_vte_state_sub); + + advanceEstimator(50_ms); + const hrt_abstime vision_time_4 = vte_test::nowUs(); + publishVisionPos(rel_pos_third, vte_test::identityQuat(), kSmallVisionCov, vision_time_4); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + EXPECT_FALSE(_aid_fiducial_sub->update()); + vte_test::flushSubscription(_bias_init_status_sub); + vte_test::flushSubscription(_vte_state_sub); + + advanceEstimator(50_ms); + const hrt_abstime vision_time_5 = vte_test::nowUs(); + publishVisionPos(rel_pos_third, vte_test::identityQuat(), kSmallVisionCov, vision_time_5); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + EXPECT_FALSE(_aid_fiducial_sub->update()); + vte_test::flushSubscription(_bias_init_status_sub); + vte_test::flushSubscription(_vte_state_sub); + + advanceEstimator(50_ms); + const hrt_abstime vision_time_6 = vte_test::nowUs(); + publishVisionPos(rel_pos_third, vte_test::identityQuat(), kSmallVisionCov, vision_time_6); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + EXPECT_FALSE(_aid_fiducial_sub->update()); + vte_test::flushSubscription(_bias_init_status_sub); + vte_test::flushSubscription(_vte_state_sub); + + advanceEstimator(50_ms); + const hrt_abstime vision_time_7 = vte_test::nowUs(); + publishVisionPos(rel_pos_third, vte_test::identityQuat(), kSmallVisionCov, vision_time_7); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + EXPECT_FALSE(_aid_fiducial_sub->update()); + vte_test::flushSubscription(_bias_init_status_sub); + vte_test::flushSubscription(_vte_state_sub); + + advanceEstimator(50_ms); + const hrt_abstime vision_time_8 = vte_test::nowUs(); + publishVisionPos(rel_pos_third, vte_test::identityQuat(), kSmallVisionCov, vision_time_8); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_fiducial_sub->update()); + ASSERT_TRUE(_vte_state_sub->update()); + const auto state = _vte_state_sub->get(); + const float altitude_bias = (kUavAltM - kTargetAltM) - rel_pos_third(2); + const matrix::Vector3f expected_bias(-rel_pos_third(0), -rel_pos_third(1), altitude_bias); + EXPECT_NEAR(state.bias[0], expected_bias(0), 0.1f); + EXPECT_NEAR(state.bias[1], expected_bias(1), 0.1f); + EXPECT_NEAR(state.bias[2], expected_bias(2), 0.1f); +} + +TEST_F(VTEPositionTest, BiasAveragingPropagatesValidGnssUsingVelocity) +{ + // WHY: When the vision timestamp is newer than a still-valid GNSS-relative sample, the bias logic should + // propagate that GNSS measurement with the UAV velocity estimate before comparing it to vision. + // WHAT: Keep the second vision sample inside the GNSS validity window, force a timeout exit, and verify + // the resulting bias matches the velocity-compensated GNSS/vision difference. + enableMask({FusionMaskOption::kVisionPos, FusionMaskOption::kMissionPos}); + + setParamFloat("VTE_BIA_AVG_THR", 0.05f); + setParamFloat("VTE_BIA_AVG_TOUT", 0.06f); + _vte->updateParamsPublic(); + + const matrix::Vector3f vel_ned(5.0f, -3.0f, 1.5f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + const hrt_abstime init_gps_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, init_gps_time); + _vte->setMissionPosition(kUavLat, kUavLon, kTargetAltM); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + vte_test::flushSubscription(_aid_fiducial_sub); + vte_test::flushSubscription(_aid_gps_mission_sub); + vte_test::flushSubscription(_vte_state_sub); + + const matrix::Vector3f initial_gnss_rel(0.f, 0.f, kUavAltM - kTargetAltM); + const matrix::Vector3f expected_bias(0.6f, -0.4f, 1.2f); + + const hrt_abstime vision_time_1 = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(initial_gnss_rel - expected_bias, vte_test::identityQuat(), kSmallVisionCov, vision_time_1); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + EXPECT_FALSE(_aid_fiducial_sub->update()); + vte_test::flushSubscription(_bias_init_status_sub); + vte_test::flushSubscription(_vte_state_sub); + + const hrt_abstime vision_time_2 = vte_test::advanceMicroseconds(60_ms); + setLocalVelocity(vel_ned, vision_time_2); + const matrix::Vector3f propagated_gnss = initial_gnss_rel - vel_ned * 0.06f; + publishVisionPos(propagated_gnss - expected_bias, vte_test::identityQuat(), kSmallVisionCov, vision_time_2); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_bias_init_status_sub->update()); + const auto propagated_bias_status = _bias_init_status_sub->get(); + ASSERT_TRUE(_aid_fiducial_sub->update()); + ASSERT_TRUE(_vte_state_sub->update()); + const auto state = _vte_state_sub->get(); + EXPECT_NEAR(propagated_bias_status.raw_bias[0], expected_bias(0), 0.05f); + EXPECT_NEAR(propagated_bias_status.raw_bias[1], expected_bias(1), 0.05f); + EXPECT_NEAR(propagated_bias_status.raw_bias[2], expected_bias(2), 0.05f); + EXPECT_LT(propagated_bias_status.delta_norm, 0.05f); + EXPECT_NEAR(state.bias[0], expected_bias(0), 0.05f); + EXPECT_NEAR(state.bias[1], expected_bias(1), 0.05f); + EXPECT_NEAR(state.bias[2], expected_bias(2), 0.05f); +} + +TEST_F(VTEPositionTest, BiasInitializationBackPropagatesNewerGnssSampleToOlderVision) +{ + // WHY: The vision sample can be older than the latest GNSS sample. + // WHAT: Publish a newer mission/GNSS relative position from a moved UAV and verify the bias setup + // back-propagates that GNSS sample before initializing the bias, even if the state then advances + // to the newer GNSS observation in the same update. + enableMask({FusionMaskOption::kVisionPos, FusionMaskOption::kMissionPos}); + + setParamFloat("VTE_BIA_AVG_TOUT", 0.f); + setParamFloat("VTE_POS_NIS_THRE", 100.f); + _vte->updateParamsPublic(); + + const matrix::Vector3f vel_ned(5.f, -3.f, 0.f); + _vte->setMissionPosition(kUavLat, kUavLon, kUavAltM); + + const hrt_abstime vision_time = vte_test::advanceMicroseconds(kStepUs); + const hrt_abstime gps_time = vte_test::advanceMicroseconds(50_ms); + + double moved_lat = kUavLat; + double moved_lon = kUavLon; + add_vector_to_global_position(kUavLat, kUavLon, vel_ned(0) * 0.05f, vel_ned(1) * 0.05f, &moved_lat, &moved_lon); + + setLocalVelocity(vel_ned, gps_time); + publishUavGps(moved_lat, moved_lon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, gps_time); + publishVisionPos(kZeroVec, vte_test::identityQuat(), kSmallVisionCov, vision_time); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto state = _vte_state_sub->get(); + + EXPECT_NEAR(state.bias[0], 0.f, 0.1f); + EXPECT_NEAR(state.bias[1], 0.f, 0.1f); + EXPECT_NEAR(state.bias[2], 0.f, 0.1f); +} + +TEST_F(VTEPositionTest, BiasAveragingUsesTimeoutWhenSamplesDoNotStabilize) +{ + // WHY: Bias averaging must not wait forever when GNSS/vision agreement stays poor. + // WHAT: Force a tiny timeout with inconsistent vision samples and expect the LPF bias to activate once the timeout expires. + enableMask({FusionMaskOption::kVisionPos, FusionMaskOption::kMissionPos}); + + setParamFloat("VTE_BIA_AVG_THR", 0.001f); + setParamFloat("VTE_BIA_AVG_TOUT", 0.002f); + _vte->updateParamsPublic(); + + const matrix::Vector3f vel_ned(0.5f, -0.5f, 0.1f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + const hrt_abstime init_gps_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, init_gps_time); + _vte->setMissionPosition(kUavLat, kUavLon, kTargetAltM); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + vte_test::flushSubscription(_aid_fiducial_sub); + vte_test::flushSubscription(_aid_gps_mission_sub); + vte_test::flushSubscription(_vte_state_sub); + + const matrix::Vector3f rel_pos_first(1.f, 0.f, 3.f); + const hrt_abstime gps_time_1 = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, gps_time_1); + const hrt_abstime vision_time_1 = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos_first, vte_test::identityQuat(), kSmallVisionCov, vision_time_1); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + EXPECT_FALSE(_aid_fiducial_sub->update()); + vte_test::flushSubscription(_vte_state_sub); + + vte_test::advanceMicroseconds(3_ms); + setParamFloat("VTE_POS_NIS_THRE", 0.11f); + _vte->updateParamsPublic(); + + const matrix::Vector3f rel_pos_second(4.f, -2.f, 6.f); + const hrt_abstime gps_time_2 = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, gps_time_2); + const hrt_abstime vision_time_2 = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos_second, vte_test::identityQuat(), kSmallVisionCov, vision_time_2); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_fiducial_sub->update()); + ASSERT_TRUE(_vte_state_sub->update()); + const auto state = _vte_state_sub->get(); + const float altitude_bias_first = (kUavAltM - kTargetAltM) - rel_pos_first(2); + const matrix::Vector3f expected_bias_first(-rel_pos_first(0), -rel_pos_first(1), altitude_bias_first); + const matrix::Vector3f expected_gnss_rel(0.f, 0.f, kUavAltM - kTargetAltM); + + EXPECT_NEAR(state.bias[0], expected_bias_first(0), 0.3f); + EXPECT_NEAR(state.bias[1], expected_bias_first(1), 0.3f); + EXPECT_NEAR(state.bias[2], expected_bias_first(2), 0.3f); + EXPECT_NEAR(state.rel_pos[0] + state.bias[0], expected_gnss_rel(0), 0.3f); + EXPECT_NEAR(state.rel_pos[1] + state.bias[1], expected_gnss_rel(1), 0.3f); + EXPECT_NEAR(state.rel_pos[2] + state.bias[2], expected_gnss_rel(2), 0.3f); + EXPECT_NEAR(state.rel_pos[0], rel_pos_first(0), 0.3f); + EXPECT_NEAR(state.rel_pos[1], rel_pos_first(1), 0.3f); + EXPECT_NEAR(state.rel_pos[2], rel_pos_first(2), 0.3f); +} + +TEST_F(VTEPositionTest, BiasAveragingFallsBackToVisionWhenGnssBecomesStale) +{ + // WHY: If the GNSS-relative sample expires during bias averaging, vision fusion should resume immediately. + // WHAT: Start averaging from GNSS, let the GNSS-relative sample go stale without refreshing it, + // then verify the estimator switches to the current vision position and fuses that sample. + enableMask({FusionMaskOption::kVisionPos, FusionMaskOption::kMissionPos}); + + setParamFloat("VTE_BIA_AVG_THR", 0.05f); + setParamFloat("VTE_BIA_AVG_TOUT", 1.0f); + _vte->updateParamsPublic(); + + const matrix::Vector3f vel_ned(0.4f, -0.3f, 0.1f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + const hrt_abstime init_gps_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, init_gps_time); + _vte->setMissionPosition(kUavLat, kUavLon, kTargetAltM); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + vte_test::flushSubscription(_aid_fiducial_sub); + vte_test::flushSubscription(_aid_gps_mission_sub); + vte_test::flushSubscription(_vte_state_sub); + + const matrix::Vector3f rel_pos(3.f, -2.f, 4.f); + const hrt_abstime gps_time_1 = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, gps_time_1); + const hrt_abstime vision_time_1 = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, vision_time_1); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + EXPECT_FALSE(_aid_fiducial_sub->update()); + vte_test::flushSubscription(_bias_init_status_sub); + vte_test::flushSubscription(_vte_state_sub); + + for (int i = 0; i < 3; ++i) { + const hrt_abstime now = vte_test::advanceMicroseconds(50_ms); + setLocalVelocity(vel_ned, now); + _vte->update(matrix::Vector3f{}); + vte_test::flushSubscription(_vte_state_sub); + } + + const hrt_abstime stale_vision_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, stale_vision_time); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, stale_vision_time); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_fiducial_sub->update()); + const auto aid = _aid_fiducial_sub->get(); + ASSERT_TRUE(_vte_state_sub->update()); + const auto state = _vte_state_sub->get(); + const float altitude_bias = (kUavAltM - kTargetAltM) - rel_pos(2); + const matrix::Vector3f expected_bias(-rel_pos(0), -rel_pos(1), altitude_bias); + + EXPECT_EQ(aid.fusion_status[0], static_cast(vte::FusionStatus::kFusedCurrent)); + EXPECT_EQ(aid.fusion_status[1], static_cast(vte::FusionStatus::kFusedCurrent)); + EXPECT_EQ(aid.fusion_status[2], static_cast(vte::FusionStatus::kFusedCurrent)); + EXPECT_NEAR(state.rel_pos[0], rel_pos(0), 0.1f); + EXPECT_NEAR(state.rel_pos[1], rel_pos(1), 0.1f); + EXPECT_NEAR(state.rel_pos[2], rel_pos(2), 0.1f); + EXPECT_NEAR(state.bias[0], expected_bias(0), 0.1f); + EXPECT_NEAR(state.bias[1], expected_bias(1), 0.1f); + EXPECT_NEAR(state.bias[2], expected_bias(2), 0.1f); +} + +TEST_F(VTEPositionTest, BiasSetImmediatelyWhenGnssArrivesAfterVisionTrusted) +{ + // WHY: Once vision has already been fused, the estimator can use it directly to initialize GNSS bias. + // WHAT: Initialize from vision, let vision disappear for one cycle, then introduce GNSS and verify bias is set + // on the first sample where GNSS and vision are both available again. + enableMask({FusionMaskOption::kVisionPos, FusionMaskOption::kMissionPos}); + setParamFloat("VTE_POS_NIS_THRE", 100.f); + _vte->updateParamsPublic(); + + const matrix::Vector3f vel_ned(0.5f, -0.5f, 0.1f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + const matrix::Vector3f rel_pos(1.f, 2.f, 3.f); + const hrt_abstime vision_init_time = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, vision_init_time); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + vte_test::flushSubscription(_aid_fiducial_sub); + vte_test::flushSubscription(_aid_gps_mission_sub); + vte_test::flushSubscription(_bias_init_status_sub); + vte_test::flushSubscription(_vte_state_sub); + + const hrt_abstime gps_only_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, gps_only_time); + _vte->setMissionPosition(kUavLat, kUavLon, kTargetAltM); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto gps_only_state = _vte_state_sub->get(); + EXPECT_FALSE(_aid_fiducial_sub->update()); + EXPECT_FALSE(_aid_gps_mission_sub->update()); + EXPECT_FALSE(_bias_init_status_sub->update()); + EXPECT_NEAR(gps_only_state.rel_pos[0], rel_pos(0), 0.1f); + EXPECT_NEAR(gps_only_state.rel_pos[1], rel_pos(1), 0.1f); + EXPECT_NEAR(gps_only_state.rel_pos[2], rel_pos(2), 0.1f); + EXPECT_NEAR(gps_only_state.bias[0], 0.f, kTolerance); + EXPECT_NEAR(gps_only_state.bias[1], 0.f, kTolerance); + EXPECT_NEAR(gps_only_state.bias[2], 0.f, kTolerance); + vte_test::flushSubscription(_aid_gps_mission_sub); + vte_test::flushSubscription(_vte_state_sub); + + const hrt_abstime gps_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, gps_time); + const hrt_abstime vision_time = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, vision_time); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_fiducial_sub->update()); + ASSERT_TRUE(_aid_gps_mission_sub->update()); + ASSERT_TRUE(_vte_state_sub->update()); + EXPECT_FALSE(_bias_init_status_sub->update()); + const auto state = _vte_state_sub->get(); + const float altitude_bias = (kUavAltM - kTargetAltM) - rel_pos(2); + const matrix::Vector3f expected_bias(-rel_pos(0), -rel_pos(1), altitude_bias); + EXPECT_NEAR(state.bias[0], expected_bias(0), 0.1f); + EXPECT_NEAR(state.bias[1], expected_bias(1), 0.1f); + EXPECT_NEAR(state.bias[2], expected_bias(2), 0.1f); +} + +TEST_F(VTEPositionTest, VisionReturnAfterTemporaryLossDoesNotReinitializeBias) +{ + // WHY: Once the GNSS/vision bias has been set, temporary vision loss must not restart bias initialization. + // WHAT: Initialize the bias immediately, fuse GNSS alone for one cycle, then restore vision and verify + // that the same bias stays active without re-entering the initialization path. + enableMask({FusionMaskOption::kVisionPos, FusionMaskOption::kMissionPos}); + + setParamFloat("VTE_BIA_AVG_TOUT", 0.f); + setParamFloat("VTE_POS_NIS_THRE", 100.f); + _vte->updateParamsPublic(); + + const matrix::Vector3f vel_ned(0.5f, -0.5f, 0.1f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + const matrix::Vector3f rel_pos(1.f, 2.f, 3.f); + _vte->setMissionPosition(kUavLat, kUavLon, kTargetAltM); + + const hrt_abstime init_gps_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, init_gps_time); + const hrt_abstime init_vision_time = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, init_vision_time); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_fiducial_sub->update()); + ASSERT_TRUE(_aid_gps_mission_sub->update()); + ASSERT_TRUE(_vte_state_sub->update()); + EXPECT_FALSE(_bias_init_status_sub->update()); + const auto initial_state = _vte_state_sub->get(); + const float altitude_bias = (kUavAltM - kTargetAltM) - rel_pos(2); + const matrix::Vector3f expected_bias(-rel_pos(0), -rel_pos(1), altitude_bias); + EXPECT_NEAR(initial_state.bias[0], expected_bias(0), 0.1f); + EXPECT_NEAR(initial_state.bias[1], expected_bias(1), 0.1f); + EXPECT_NEAR(initial_state.bias[2], expected_bias(2), 0.1f); + + vte_test::flushSubscription(_aid_fiducial_sub); + vte_test::flushSubscription(_aid_gps_mission_sub); + vte_test::flushSubscription(_bias_init_status_sub); + vte_test::flushSubscription(_vte_state_sub); + + const hrt_abstime gnss_only_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, gnss_only_time); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_gps_mission_sub->update()); + ASSERT_TRUE(_vte_state_sub->update()); + EXPECT_FALSE(_aid_fiducial_sub->update()); + EXPECT_FALSE(_bias_init_status_sub->update()); + const auto gnss_only_state = _vte_state_sub->get(); + EXPECT_GT(fabsf(gnss_only_state.bias[0]), 0.5f); + EXPECT_GT(fabsf(gnss_only_state.bias[1]), 1.0f); + EXPECT_GT(fabsf(gnss_only_state.bias[2]), 0.5f); + + vte_test::flushSubscription(_aid_fiducial_sub); + vte_test::flushSubscription(_aid_gps_mission_sub); + vte_test::flushSubscription(_bias_init_status_sub); + vte_test::flushSubscription(_vte_state_sub); + + const hrt_abstime return_gps_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, return_gps_time); + const hrt_abstime return_vision_time = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, return_vision_time); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_fiducial_sub->update()); + ASSERT_TRUE(_aid_gps_mission_sub->update()); + ASSERT_TRUE(_vte_state_sub->update()); + EXPECT_FALSE(_bias_init_status_sub->update()); + const auto state = _vte_state_sub->get(); + EXPECT_NEAR(state.bias[0], gnss_only_state.bias[0], 0.5f); + EXPECT_NEAR(state.bias[1], gnss_only_state.bias[1], 0.5f); + EXPECT_NEAR(state.bias[2], gnss_only_state.bias[2], 0.5f); +} + +TEST_F(VTEPositionTest, VisionNoiseFloorAndRotation) +{ + // WHY: Vision measurements must be rotated to NED and respect noise floors. + // WHAT: Rotate a vector by 180deg yaw and check floor in the innovation message. + enableMask({FusionMaskOption::kVisionPos}); + + const matrix::Vector3f vel_ned(0.1f, 0.1f, 0.1f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + const matrix::Quaternionf q{0.f, 0.f, 0.f, 1.f}; + + const matrix::Vector3f rel_pos(1.f, 2.f, 3.f); + const hrt_abstime vision_time = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos, q, kZeroVec, vision_time); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_fiducial_sub->update()); + const auto aid = _aid_fiducial_sub->get(); + + const matrix::Vector3f expected_obs = q.rotateVector(rel_pos); + EXPECT_NEAR(aid.observation[0], expected_obs(0), kTolerance); + EXPECT_NEAR(aid.observation[1], expected_obs(1), kTolerance); + EXPECT_NEAR(aid.observation[2], expected_obs(2), kTolerance); + EXPECT_NEAR(aid.observation_variance[0], kDefaultEvPosVar, kTolerance); + EXPECT_NEAR(aid.observation_variance[1], kDefaultEvPosVar, kTolerance); + EXPECT_NEAR(aid.observation_variance[2], kDefaultEvPosVar, kTolerance); +} + +TEST_F(VTEPositionTest, RejectsVisionWithInvalidCovariance) +{ + // WHY: Vision covariance must be finite. + // WHAT: Publish NaN covariance and ensure the measurement is rejected. + enableMask({FusionMaskOption::kVisionPos}); + + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(matrix::Vector3f(0.1f, 0.1f, 0.1f), vel_time); + + const matrix::Vector3f rel_pos(1.f, 2.f, 3.f); + const matrix::Vector3f cov(NAN, NAN, NAN); + const hrt_abstime vision_time = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos, vte_test::identityQuat(), cov, vision_time); + + _vte->update(matrix::Vector3f{}); + + EXPECT_FALSE(_aid_fiducial_sub->update()); + EXPECT_FALSE(_vte_state_sub->update()); +} + +TEST_F(VTEPositionTest, RejectsVisionWithInvalidQuaternion) +{ + // WHY: Vision quaternion must be finite for rotation into NED. + // WHAT: Publish NaN quaternion and ensure the measurement is rejected. + enableMask({FusionMaskOption::kVisionPos}); + + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(matrix::Vector3f(0.1f, 0.1f, 0.1f), vel_time); + + matrix::Quaternionf q = vte_test::identityQuat(); + q(0) = NAN; + + const hrt_abstime vision_time = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(matrix::Vector3f(1.f, 0.f, 0.f), q, kSmallVisionCov, vision_time); + _vte->update(matrix::Vector3f{}); + + EXPECT_FALSE(_aid_fiducial_sub->update()); + EXPECT_FALSE(_vte_state_sub->update()); +} + +TEST_F(VTEPositionTest, RejectsVisionWithInvalidPosition) +{ + // WHY: Position measurements must be finite to compute innovations. + // WHAT: Publish NaN position and verify the update is rejected. + enableMask({FusionMaskOption::kVisionPos}); + + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(matrix::Vector3f(0.1f, 0.1f, 0.1f), vel_time); + + matrix::Vector3f rel_pos = matrix::Vector3f(1.f, 2.f, 3.f); + rel_pos(1) = NAN; + + const hrt_abstime vision_time = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(rel_pos, vte_test::identityQuat(), kSmallVisionCov, vision_time); + _vte->update(matrix::Vector3f{}); + + EXPECT_FALSE(_aid_fiducial_sub->update()); + EXPECT_FALSE(_vte_state_sub->update()); +} + +TEST_F(VTEPositionTest, MissionGpsRelativePositionAndOffset) +{ + // WHY: Mission GNSS measurements must apply offset and variance floors. + // WHAT: Publish GPS without offset, then apply an offset and verify + // observation changes. + enableMask({FusionMaskOption::kMissionPos}); + + const matrix::Vector3f vel_ned(0.1f, 0.1f, 0.1f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + const hrt_abstime t0 = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.01f, 0.01f, vel_ned, 0.1f, true, t0); + _vte->setMissionPosition(kUavLat, kUavLon, kTargetAltM); + + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_gps_mission_sub->update()); + const auto aid_no_offset = _aid_gps_mission_sub->get(); + + const matrix::Vector3f gps_offset(1.f, 2.f, 3.f); + _vte->setGpsPosOffset(gps_offset, true); + const hrt_abstime t1 = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.01f, 0.01f, vel_ned, 0.1f, true, t1); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_gps_mission_sub->update()); + const auto aid_offset = _aid_gps_mission_sub->get(); + + EXPECT_NEAR(aid_offset.observation[0], aid_no_offset.observation[0] + gps_offset(0), kTolerance); + EXPECT_NEAR(aid_offset.observation[1], aid_no_offset.observation[1] + gps_offset(1), kTolerance); + EXPECT_NEAR(aid_offset.observation[2], aid_no_offset.observation[2] + gps_offset(2), kTolerance); + EXPECT_NEAR(aid_offset.observation_variance[0], kDefaultGpsPosVar, kTolerance); + EXPECT_NEAR(aid_offset.observation_variance[1], kDefaultGpsPosVar, kTolerance); + EXPECT_NEAR(aid_offset.observation_variance[2], kDefaultGpsPosVar, kTolerance); +} + +TEST_F(VTEPositionTest, MissionGpsOffsetTimeoutRejectsMeasurement) +{ + // WHY: GPS offset should expire if it becomes stale. + // WHAT: Force an offset timeout and ensure the mission GPS measurement is + // rejected. + enableMask({FusionMaskOption::kMissionPos}); + + const matrix::Vector3f vel_ned(0.1f, 0.1f, 0.1f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + _vte->setMeasUpdatedTimeout(1_ms); + + const matrix::Vector3f gps_offset(1.f, 2.f, 3.f); + _vte->setGpsPosOffset(gps_offset, true); + _vte->setMissionPosition(kUavLat, kUavLon, kTargetAltM); + + const hrt_abstime gps_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.01f, 0.01f, vel_ned, 0.1f, true, gps_time); + _vte->update(matrix::Vector3f{}); + + EXPECT_FALSE(_aid_gps_mission_sub->update()); +} + +TEST_F(VTEPositionTest, UavGpsVelocityOffsetTimeoutRejectsMeasurement) +{ + // WHY: UAV GPS velocity compensation must not fuse if the antenna rotation + // offset is stale. + // WHAT: Let the velocity offset expire before the next GPS + // sample and expect no velocity aid update. + enableMask({FusionMaskOption::kVisionPos, FusionMaskOption::kUavGpsVel}); + + const hrt_abstime init_vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(matrix::Vector3f{}, init_vel_time); + + const hrt_abstime vision_time = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(matrix::Vector3f(2.f, 0.f, -1.f), vte_test::identityQuat(), + kSmallVisionCov, vision_time); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + vte_test::flushSubscription(_aid_gps_vel_uav_sub); + + _vte->setMeasUpdatedTimeout(1_ms); + _vte->setGpsPosOffset(matrix::Vector3f(1.f, 0.f, 0.f), true); + _vte->setVelOffset(matrix::Vector3f(0.2f, 0.3f, 0.4f)); + + vte_test::advanceMicroseconds(2_ms); + + const matrix::Vector3f vel_ned(0.8f, -1.2f, 0.4f); + const hrt_abstime gps_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, + gps_time); + _vte->update(matrix::Vector3f{}); + + EXPECT_FALSE(_aid_gps_vel_uav_sub->update()); +} + +TEST_F(VTEPositionTest, UavGpsVelocityFusionAndSign) +{ + // WHY: UAV GPS velocity fusion should update state and sign-converted outputs. + // WHAT: Fuse GPS velocity and verify landing_target_pose uses negative sign. + enableMask({FusionMaskOption::kVisionPos, FusionMaskOption::kUavGpsVel}); + + const matrix::Vector3f init_vel(0.f, 0.f, 0.f); + const hrt_abstime init_vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(init_vel, init_vel_time); + const hrt_abstime init_pos_time = vte_test::advanceMicroseconds(kStepUs); + setLocalPosition(matrix::Vector3f{}, init_pos_time); + + const hrt_abstime vision_time = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(matrix::Vector3f(2.f, 0.f, -1.f), vte_test::identityQuat(), + kSmallVisionCov, vision_time); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto state_before = _vte_state_sub->get(); + + const matrix::Vector3f vel_ned(0.8f, -1.2f, 0.4f); + const hrt_abstime gps_time = vte_test::advanceMicroseconds(kStepUs); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.2f, 0.2f, vel_ned, 0.1f, true, gps_time); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_gps_vel_uav_sub->update()); + const auto aid = _aid_gps_vel_uav_sub->get(); + EXPECT_NEAR(aid.observation_variance[0], kDefaultGpsVelVar, kTolerance); + EXPECT_NEAR(aid.observation_variance[1], kDefaultGpsVelVar, kTolerance); + EXPECT_NEAR(aid.observation_variance[2], kDefaultGpsVelVar, kTolerance); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto state = _vte_state_sub->get(); + ASSERT_TRUE(_target_pose_sub->update()); + const auto pose = _target_pose_sub->get(); + + EXPECT_GT(fabsf(state.vel_uav[0] - state_before.vel_uav[0]), 0.1f); + EXPECT_GT(fabsf(state.vel_uav[1] - state_before.vel_uav[1]), 0.1f); + EXPECT_GT(fabsf(state.vel_uav[2] - state_before.vel_uav[2]), 0.1f); + + EXPECT_NEAR(pose.vx_rel, -state.vel_uav[0], 0.5f); + EXPECT_NEAR(pose.vy_rel, -state.vel_uav[1], 0.5f); + EXPECT_NEAR(pose.vz_rel, -state.vel_uav[2], 0.5f); + +#if defined(CONFIG_VTEST_MOVING) + EXPECT_FALSE(pose.is_static); +#else + EXPECT_TRUE(pose.is_static); +#endif +} + +TEST_F(VTEPositionTest, TargetGpsInterpolationVariance) +{ + // WHY: GNSS time misalignment should increase measurement variance. + // WHAT: Publish delayed UAV GPS and verify propagation uncertainty is included. + enableMask({FusionMaskOption::kTargetGpsPos}); + + static constexpr float kGpsPosNoise = 0.02f; + setParamFloat("VTE_GPS_P_NOISE", kGpsPosNoise); + _vte->updateParamsPublic(); + + const matrix::Vector3f vel_ned(10.f, 0.f, 0.f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + const hrt_abstime now = vte_test::nowUs(); + const hrt_abstime uav_time = now - 300_ms; + const hrt_abstime target_time = now - 100_ms; + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.01f, 0.01f, vel_ned, 1.0f, true, uav_time); + publishTargetGnss(kUavLat, kUavLon, kTargetAltM, 0.01f, 0.01f, target_time, true); + + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_gps_target_sub->update()); + const auto aid = _aid_gps_target_sub->get(); + const float base_var = 2.f * kGpsPosNoise * kGpsPosNoise; + const float time_diff_s = (target_time - uav_time) * 1e-6f; + const float expected_shift = -vel_ned(0) * time_diff_s; + + EXPECT_NEAR(aid.observation[0], expected_shift, 0.05f); + EXPECT_GT(aid.observation_variance[0], base_var); + EXPECT_GT(aid.observation_variance[1], base_var); + EXPECT_GT(aid.observation_variance[2], base_var); +} + +TEST_F(VTEPositionTest, TargetGpsInterpolationUsesLeverArmCorrectedVelocity) +{ + // WHY: GNSS latency compensation should use vehicle CoM velocity rather than + // antenna velocity. + // WHAT: Apply a lever-arm velocity correction and verify + // the propagated target observation reflects it. + enableMask({FusionMaskOption::kTargetGpsPos}); + _vte->setMeasUpdatedTimeout(400_ms); + + const matrix::Vector3f vel_com_ned(7.f, 0.f, 0.f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_com_ned, vel_time); + + _vte->setGpsPosOffset(matrix::Vector3f(0.5f, 0.f, 0.f), true); + _vte->setVelOffset(matrix::Vector3f(3.f, 0.f, 0.f)); + + const hrt_abstime now = vte_test::nowUs(); + const hrt_abstime uav_time = now - 300_ms; + const hrt_abstime target_time = now - 100_ms; + const matrix::Vector3f antenna_vel_ned(10.f, 0.f, 0.f); + publishUavGps(kUavLat, kUavLon, kUavAltM, 0.01f, 0.01f, antenna_vel_ned, 1.0f, true, + uav_time); + publishTargetGnss(kUavLat, kUavLon, kTargetAltM, 0.01f, 0.01f, target_time, true); + + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_gps_target_sub->update()); + const auto aid = _aid_gps_target_sub->get(); + const float time_diff_s = (target_time - uav_time) * 1e-6f; + const float expected_shift = -vel_com_ned(0) * time_diff_s + 0.5f; + + EXPECT_NEAR(aid.observation[0], expected_shift, 0.05f); +} + +TEST_F(VTEPositionTest, RejectsStaleMeasurement) +{ + // WHY: Old measurements must be dropped to avoid stale fusion. + // WHAT: Set a tiny timeout and ensure stale vision data is ignored. + enableMask({FusionMaskOption::kVisionPos}); + + const hrt_abstime meas_timeout = 1_ms; + _vte->setMeasRecentTimeout(meas_timeout); + setLocalVelocity(matrix::Vector3f(0.1f, 0.1f, 0.1f), vte_test::advanceMicroseconds(kStepUs)); + + const hrt_abstime stale_time = vte_test::nowUs() - (meas_timeout + 1_ms); + publishVisionPos(matrix::Vector3f(1.f, 1.f, 1.f), vte_test::identityQuat(), + kSmallVisionCov, stale_time); + _vte->update(matrix::Vector3f{}); + + EXPECT_FALSE(_aid_fiducial_sub->update()); + EXPECT_FALSE(_vte_state_sub->update()); +} + +TEST_F(VTEPositionTest, ResetsFilterOnStalePredictionGap) +{ + // WHY: A long scheduling gap should reset the filter instead of predicting across stale time. + // WHAT: Initialize once, wait past the allowed prediction gap, then verify a fresh measurement re-initializes cleanly. + const matrix::Vector3f initial_rel_pos(1.f, 0.5f, -2.f); + const matrix::Vector3f fresh_rel_pos(-0.5f, 1.25f, -1.5f); + + enableMask({FusionMaskOption::kVisionPos}); + _vte->setMeasUpdatedTimeout(500_ms); + setLocalVelocity(matrix::Vector3f{}, vte_test::advanceMicroseconds(kStepUs)); + + publishVisionPos(initial_rel_pos, vte_test::identityQuat(), kSmallVisionCov, + vte_test::advanceMicroseconds(kStepUs)); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto initial_state = _vte_state_sub->get(); + EXPECT_NEAR(initial_state.rel_pos[0], initial_rel_pos(0), kTolerance); + EXPECT_NEAR(initial_state.rel_pos[1], initial_rel_pos(1), kTolerance); + EXPECT_NEAR(initial_state.rel_pos[2], initial_rel_pos(2), kTolerance); + + vte_test::flushSubscription(_aid_fiducial_sub); + vte_test::flushSubscription(_vte_state_sub); + + vte_test::advanceMicroseconds(120_ms); + _vte->update(matrix::Vector3f{}); + + EXPECT_FALSE(_aid_fiducial_sub->update()); + EXPECT_FALSE(_vte_state_sub->update()); + + publishVisionPos(fresh_rel_pos, vte_test::identityQuat(), kSmallVisionCov, + vte_test::advanceMicroseconds(kStepUs)); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_fiducial_sub->update()); + const auto aid = _aid_fiducial_sub->get(); + EXPECT_EQ(aid.fusion_status[0], static_cast(vte::FusionStatus::kFusedCurrent)); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto state = _vte_state_sub->get(); + EXPECT_NEAR(state.rel_pos[0], fresh_rel_pos(0), kTolerance); + EXPECT_NEAR(state.rel_pos[1], fresh_rel_pos(1), kTolerance); + EXPECT_NEAR(state.rel_pos[2], fresh_rel_pos(2), kTolerance); + EXPECT_NEAR(state.vel_uav[0], 0.f, kTolerance); + EXPECT_NEAR(state.vel_uav[1], 0.f, kTolerance); + EXPECT_NEAR(state.vel_uav[2], 0.f, kTolerance); +} + +TEST_F(VTEPositionTest, TargetValidityTimeout) +{ + // WHY: Position validity must reflect the configured timeout. + // WHAT: Set timeout to zero and confirm the published relative position is invalid. + enableMask({FusionMaskOption::kVisionPos}); + _vte->setTargetValidTimeout(0); + setLocalVelocity(matrix::Vector3f{}, vte_test::advanceMicroseconds(kStepUs)); + + publishVisionPos(matrix::Vector3f(0.2f, -0.4f, -1.f), vte_test::identityQuat(), + kSmallVisionCov, vte_test::advanceMicroseconds(kStepUs)); + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_vte_state_sub->update()); + const auto state = _vte_state_sub->get(); + EXPECT_FALSE(state.rel_pos_valid); + EXPECT_FALSE(state.rel_vel_valid); + EXPECT_FALSE(_target_pose_sub->update()); +} + +#if defined(CONFIG_VTEST_MOVING) +TEST_F(VTEPositionTest, TargetVelocityFusionMoving) +{ + // WHY: Moving-target mode must fuse target velocity measurements. + // WHAT: Provide target GNSS velocity and verify fusion output. + enableMask({FusionMaskOption::kVisionPos, FusionMaskOption::kTargetGpsVel}); + + const matrix::Vector3f vel_ned(0.1f, 0.1f, 0.1f); + const hrt_abstime vel_time = vte_test::advanceMicroseconds(kStepUs); + setLocalVelocity(vel_ned, vel_time); + + const hrt_abstime vision_time = vte_test::advanceMicroseconds(kStepUs); + publishVisionPos(matrix::Vector3f(0.f, 0.f, 0.f), vte_test::identityQuat(), + kSmallVisionCov, vision_time); + _vte->update(matrix::Vector3f{}); + + const matrix::Vector3f target_vel(1.f, 2.f, 3.f); + const hrt_abstime target_time = vte_test::advanceMicroseconds(kStepUs); + publishTargetGnss(kUavLat, kUavLon, kTargetAltM, 0.5f, 0.5f, target_time, + true, true, target_vel, 0.1f); + + _vte->update(matrix::Vector3f{}); + + ASSERT_TRUE(_aid_gps_vel_target_sub->update()); + const auto aid = _aid_gps_vel_target_sub->get(); + EXPECT_EQ(aid.fusion_status[0], static_cast(vte::FusionStatus::kFusedCurrent)); + EXPECT_NEAR(aid.observation_variance[0], kDefaultGpsVelVar, kTolerance); + EXPECT_NEAR(aid.observation_variance[1], kDefaultGpsVelVar, kTolerance); + EXPECT_NEAR(aid.observation_variance[2], kDefaultGpsVelVar, kTolerance); +} +#endif diff --git a/src/modules/vision_target_estimator/test/TEST_VTE_KF_orientation.cpp b/src/modules/vision_target_estimator/test/TEST_VTE_KF_orientation.cpp new file mode 100644 index 000000000000..5e014ad62361 --- /dev/null +++ b/src/modules/vision_target_estimator/test/TEST_VTE_KF_orientation.cpp @@ -0,0 +1,359 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file TEST_VTE_KF_orientation.cpp + * @brief Unit test KF_orientation.cpp + * + * @author Jonas Perolini + * + */ + +#include + +#include +#include +#include + +#include "Orientation/KF_orientation.h" + +namespace vte = vision_target_estimator; + +namespace +{ +using namespace time_literals; + +static constexpr float kTolerance = 1e-4f; +static constexpr float kPi = 3.14159265358979323846f; +static constexpr float kYawNearPi = 3.13f; +static constexpr hrt_abstime kNowOffset = 10_us; + +using StateVec = vte::KF_orientation::VectorState; +using ScalarMeas = vte::KF_orientation::ScalarMeas; + +StateVec makeState(float yaw, float yaw_rate) +{ + StateVec state{}; + state(vte::KF_orientation::kYaw) = yaw; + state(vte::KF_orientation::kYawRate) = yaw_rate; + return state; +} + +StateVec makeDiag(float d0, float d1) +{ + StateVec diag{}; + diag(0) = d0; + diag(1) = d1; + return diag; +} + +ScalarMeas makeMeas(uint64_t time_us, float val, float unc, const StateVec &H) +{ + ScalarMeas meas{}; + meas.time_us = time_us; + meas.val = val; + meas.unc = unc; + meas.H = H; + return meas; +} + +StateVec makeH(size_t index) +{ + StateVec H{}; + H(index) = 1.f; + return H; +} + +} // namespace + +TEST(KFOrientationTest, PredictWrapsYaw) +{ + // WHY: Yaw must remain in [-pi, pi] to avoid discontinuities. + // WHAT: Predict across the wrap and ensure yaw is normalized. + vte::KF_orientation filter; + const float yaw = 3.1f; + const float yaw_rate = 1.0f; + const float dt = 1.0f; + filter.setState(makeState(yaw, yaw_rate)); + filter.setStateCovarianceDiag(makeDiag(0.f, 0.f)); + + filter.predict(dt); + + const StateVec state = filter.getState(); + const float expected_yaw = matrix::wrap_pi(yaw + yaw_rate * dt); + + EXPECT_NEAR(state(vte::KF_orientation::kYaw), expected_yaw, kTolerance); + EXPECT_NEAR(state(vte::KF_orientation::kYawRate), yaw_rate, kTolerance); +} + +TEST(KFOrientationTest, PredictAddsProcessNoise) +{ + // WHY: Process noise must be injected to model yaw acceleration uncertainty. + // WHAT: Verify Q terms are added when yaw_acc_var > 0. + vte::KF_orientation filter; + filter.setState(makeState(0.f, 0.f)); + filter.setStateCovarianceDiag(makeDiag(0.f, 0.f)); + filter.setYawAccVar(0.4f); + + filter.predict(2.0f); + + const StateVec cov_diag = filter.getStateCovarianceDiag(); + + const float expected_p00 = (8.f / 3.f) * 0.4f; + const float expected_p11 = 2.0f * 0.4f; + + EXPECT_NEAR(cov_diag(vte::KF_orientation::kYaw), expected_p00, kTolerance); + EXPECT_NEAR(cov_diag(vte::KF_orientation::kYawRate), expected_p11, kTolerance); +} + +TEST(KFOrientationTest, PredictAddsOffDiagonalCovariance) +{ + // WHY: The white-noise yaw acceleration model introduces cross-covariance terms. + // WHAT: Ensure off-diagonal terms match dt^2/2 * var and covariance remains symmetric. + vte::KF_orientation filter; + filter.setState(makeState(0.f, 0.f)); + filter.setStateCovarianceDiag(makeDiag(0.f, 0.f)); + filter.setYawAccVar(0.4f); + + const float dt = 0.5f; + filter.predict(dt); + + const auto cov = filter.getStateCovariance(); + const float expected_off_diag = 0.5f * dt * dt * 0.4f; + + EXPECT_NEAR(cov(vte::KF_orientation::kYaw, vte::KF_orientation::kYawRate), expected_off_diag, kTolerance); + EXPECT_NEAR(cov(vte::KF_orientation::kYawRate, vte::KF_orientation::kYaw), expected_off_diag, kTolerance); + EXPECT_NEAR(cov(vte::KF_orientation::kYaw, vte::KF_orientation::kYawRate), cov(vte::KF_orientation::kYawRate, vte::KF_orientation::kYaw), + kTolerance); +} + +TEST(KFOrientationTest, PredictIgnoresInvalidYawAccVar) +{ + // WHY: Invalid noise parameters must not corrupt the covariance. + // WHAT: Confirm negative yaw_acc_var bypasses the Q addition. + vte::KF_orientation filter; + filter.setState(makeState(0.f, 0.f)); + filter.setStateCovarianceDiag(makeDiag(1.f, 2.f)); + filter.setYawAccVar(-1.f); + + filter.predict(1.0f); + + const StateVec cov_diag = filter.getStateCovarianceDiag(); + + EXPECT_NEAR(cov_diag(vte::KF_orientation::kYaw), 3.f, kTolerance); + EXPECT_NEAR(cov_diag(vte::KF_orientation::kYawRate), 2.f, kTolerance); +} + +TEST(KFOrientationTest, PredictZeroDtNoChange) +{ + // WHY: Zero dt should not change state or covariance. + // WHAT: Predict with dt=0 and ensure state/cov stay unchanged. + vte::KF_orientation filter; + filter.setState(makeState(1.2f, -0.5f)); + filter.setStateCovarianceDiag(makeDiag(0.3f, 0.2f)); + filter.setYawAccVar(0.4f); + + const StateVec state_before = filter.getState(); + const auto cov_before = filter.getStateCovariance(); + + filter.predict(0.f); + + const StateVec state_after = filter.getState(); + const auto cov_after = filter.getStateCovariance(); + + EXPECT_NEAR(state_after(vte::KF_orientation::kYaw), state_before(vte::KF_orientation::kYaw), kTolerance); + EXPECT_NEAR(state_after(vte::KF_orientation::kYawRate), state_before(vte::KF_orientation::kYawRate), kTolerance); + EXPECT_NEAR(cov_after(vte::KF_orientation::kYaw, vte::KF_orientation::kYaw), cov_before(vte::KF_orientation::kYaw, + vte::KF_orientation::kYaw), kTolerance); + EXPECT_NEAR(cov_after(vte::KF_orientation::kYawRate, vte::KF_orientation::kYawRate), cov_before(vte::KF_orientation::kYawRate, + vte::KF_orientation::kYawRate), kTolerance); + EXPECT_NEAR(cov_after(vte::KF_orientation::kYaw, vte::KF_orientation::kYawRate), cov_before(vte::KF_orientation::kYaw, + vte::KF_orientation::kYawRate), kTolerance); +} + +TEST(KFOrientationTest, PredictNegativeDtReturnsEarly) +{ + // WHY: Negative dt is invalid and should be ignored. + // WHAT: Verify predict() does not change state or covariance for dt < 0. + vte::KF_orientation filter; + filter.setState(makeState(-1.0f, 0.7f)); + filter.setStateCovarianceDiag(makeDiag(0.6f, 0.4f)); + filter.setYawAccVar(0.4f); + + const StateVec state_before = filter.getState(); + const auto cov_before = filter.getStateCovariance(); + + filter.predict(-0.1f); + + const StateVec state_after = filter.getState(); + const auto cov_after = filter.getStateCovariance(); + + EXPECT_NEAR(state_after(vte::KF_orientation::kYaw), state_before(vte::KF_orientation::kYaw), kTolerance); + EXPECT_NEAR(state_after(vte::KF_orientation::kYawRate), state_before(vte::KF_orientation::kYawRate), kTolerance); + EXPECT_NEAR(cov_after(vte::KF_orientation::kYaw, vte::KF_orientation::kYaw), cov_before(vte::KF_orientation::kYaw, + vte::KF_orientation::kYaw), kTolerance); + EXPECT_NEAR(cov_after(vte::KF_orientation::kYawRate, vte::KF_orientation::kYawRate), cov_before(vte::KF_orientation::kYawRate, + vte::KF_orientation::kYawRate), kTolerance); +} + +TEST(KFOrientationTest, InnovationWrapsAcrossPi) +{ + // WHY: Innovations across the wrap should be small, not ~2pi. + // WHAT: Check innovation for +pi to -pi transitions is wrapped. + vte::KF_orientation filter; + filter.setState(makeState(kYawNearPi, 0.f)); + filter.setStateCovarianceDiag(makeDiag(1.f, 1.f)); + + const StateVec H = makeH(vte::KF_orientation::kYaw); + static constexpr hrt_abstime kMeasTime = 1_s; + const vte::KF_orientation::ScalarMeas meas = makeMeas(kMeasTime, -kYawNearPi, 0.1f, H); + const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 100.f); + + const float expected_innov = matrix::wrap_pi(-kYawNearPi - kYawNearPi); + + EXPECT_EQ(res.status, vte::FusionStatus::kFusedCurrent); + EXPECT_NEAR(res.innov, expected_innov, kTolerance); +} + +TEST(KFOrientationTest, InnovationDoesNotWrapYawRateMeasurement) +{ + // WHY: Only yaw innovations should be wrapped; yaw-rate is linear. + // WHAT: Ensure yaw-rate innovation uses the raw difference. + vte::KF_orientation filter; + filter.setState(makeState(0.f, kYawNearPi)); + filter.setStateCovarianceDiag(makeDiag(1.f, 1.f)); + + const StateVec H = makeH(vte::KF_orientation::kYawRate); + static constexpr hrt_abstime kMeasTime = 1500_ms; + const vte::KF_orientation::ScalarMeas meas = makeMeas(kMeasTime, -kYawNearPi, 0.1f, H); + const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 100.f); + + const float expected_innov = -kYawNearPi - kYawNearPi; + + EXPECT_EQ(res.status, vte::FusionStatus::kFusedCurrent); + EXPECT_NEAR(res.innov, expected_innov, kTolerance); +} + +TEST(KFOrientationTest, RejectsOutlierNis) +{ + // WHY: NIS gating is critical to rejecting yaw outliers. + // WHAT: Provide a large innovation and expect REJECT_NIS. + vte::KF_orientation filter; + filter.setState(makeState(0.f, 0.f)); + filter.setStateCovarianceDiag(makeDiag(0.01f, 0.01f)); + + const StateVec H = makeH(vte::KF_orientation::kYaw); + static constexpr hrt_abstime kMeasTime = 2_s; + const vte::KF_orientation::ScalarMeas meas = makeMeas(kMeasTime, kPi, 0.01f, H); + const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 0.1f); + + const StateVec state = filter.getState(); + + EXPECT_EQ(res.status, vte::FusionStatus::kRejectNis); + EXPECT_NEAR(state(vte::KF_orientation::kYaw), 0.f, kTolerance); + EXPECT_NEAR(state(vte::KF_orientation::kYawRate), 0.f, kTolerance); +} + +TEST(KFOrientationTest, ClampCovarianceToMin) +{ + // WHY: Very small variances can destabilize future updates. + // WHAT: Ensure correction enforces a minimum covariance floor. + vte::KF_orientation filter; + filter.setState(makeState(0.f, 0.f)); + filter.setStateCovarianceDiag(makeDiag(1e-12f, 1e-12f)); + + const StateVec H = makeH(vte::KF_orientation::kYaw); + // Keep innovation small to avoid NIS rejection while exercising the clamp. + static constexpr hrt_abstime kMeasTime = 3_s; + const vte::KF_orientation::ScalarMeas meas = makeMeas(kMeasTime, 0.f, 1e-4f, H); + const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 100.f); + const StateVec cov_diag = filter.getStateCovarianceDiag(); + + EXPECT_EQ(res.status, vte::FusionStatus::kFusedCurrent); + EXPECT_GE(cov_diag(vte::KF_orientation::kYaw), vte::KF_orientation::kMinVar); + EXPECT_GE(cov_diag(vte::KF_orientation::kYawRate), vte::KF_orientation::kMinVar); +} + +TEST(KFOrientationTest, CorrectionWrapsYaw) +{ + // WHY: Correction should normalize yaw after applying the gain. + // WHAT: Force a correction that would exceed pi without wrapping. + vte::KF_orientation filter; + filter.setState(makeState(kYawNearPi, 0.f)); + filter.setStateCovarianceDiag(makeDiag(0.5f, 0.2f)); + + const StateVec H = makeH(vte::KF_orientation::kYaw); + static constexpr hrt_abstime kMeasTime = 4_s; + const vte::KF_orientation::ScalarMeas meas = makeMeas(kMeasTime, -kYawNearPi, 0.2f, H); + const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 100.f); + const StateVec state = filter.getState(); + + const float expected_innov = matrix::wrap_pi(-kYawNearPi - kYawNearPi); + const float expected_k = 0.5f / (0.5f + 0.2f); + const float expected_yaw = matrix::wrap_pi(kYawNearPi + expected_k * expected_innov); + + EXPECT_EQ(res.status, vte::FusionStatus::kFusedCurrent); + EXPECT_NEAR(res.innov, expected_innov, kTolerance); + EXPECT_NEAR(state(vte::KF_orientation::kYaw), expected_yaw, kTolerance); + EXPECT_LE(state(vte::KF_orientation::kYaw), kPi); + EXPECT_GE(state(vte::KF_orientation::kYaw), -kPi); +} + +TEST(KFOrientationTest, CorrectionMaintainsSymmetricCovariance) +{ + // WHY: Corrections must keep covariance symmetric and bounded. + // WHAT: Perform a correction and validate off-diagonal symmetry and magnitude. + vte::KF_orientation filter; + filter.setState(makeState(0.2f, 0.1f)); + filter.setStateCovarianceDiag(makeDiag(0.6f, 0.4f)); + filter.setYawAccVar(0.3f); + + filter.predict(0.2f); + const auto cov_before = filter.getStateCovariance(); + + const StateVec H = makeH(vte::KF_orientation::kYaw); + static constexpr hrt_abstime kMeasTime = 5_s; + const vte::KF_orientation::ScalarMeas meas = makeMeas(kMeasTime, 0.25f, 0.1f, H); + const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 100.f); + const auto cov_after = filter.getStateCovariance(); + + EXPECT_EQ(res.status, vte::FusionStatus::kFusedCurrent); + EXPECT_NEAR(cov_after(vte::KF_orientation::kYaw, vte::KF_orientation::kYawRate), cov_after(vte::KF_orientation::kYawRate, + vte::KF_orientation::kYaw), kTolerance); + + const float bound = sqrtf(cov_after(vte::KF_orientation::kYaw, vte::KF_orientation::kYaw) * cov_after(vte::KF_orientation::kYawRate, + vte::KF_orientation::kYawRate)) + kTolerance; + EXPECT_LE(fabsf(cov_after(vte::KF_orientation::kYaw, vte::KF_orientation::kYawRate)), bound); + EXPECT_LE(fabsf(cov_after(vte::KF_orientation::kYaw, vte::KF_orientation::kYawRate)), fabsf(cov_before(vte::KF_orientation::kYaw, + vte::KF_orientation::kYawRate)) + 1.0f); +} diff --git a/src/modules/vision_target_estimator/test/TEST_VTE_KF_position.cpp b/src/modules/vision_target_estimator/test/TEST_VTE_KF_position.cpp new file mode 100644 index 000000000000..72c3c932b643 --- /dev/null +++ b/src/modules/vision_target_estimator/test/TEST_VTE_KF_position.cpp @@ -0,0 +1,392 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file TEST_VTE_KF_position.cpp + * @brief Unit test KF_position.cpp + * + * @author Jonas Perolini + * + */ + +#include + +#include +#include +#include + +#include "Position/KF_position.h" + +namespace vte = vision_target_estimator; + +namespace +{ +using namespace time_literals; + +static constexpr float kTolerance = 1e-4f; +static constexpr hrt_abstime kNowOffset = 10_us; + +using StateVec = vte::KF_position::VectorState; +using ScalarMeas = vte::KF_position::ScalarMeas; + +#if !defined(CONFIG_VTEST_MOVING) +StateVec makeState(float pos_rel, float vel_uav, float bias) +{ + StateVec state{}; + state(vtest::State::pos_rel) = pos_rel; + state(vtest::State::vel_uav) = vel_uav; + state(vtest::State::bias) = bias; + return state; +} + +StateVec makeDiag(float d0, float d1, float d2) +{ + StateVec diag{}; + diag(0) = d0; + diag(1) = d1; + diag(2) = d2; + return diag; +} +#endif + +#if defined(CONFIG_VTEST_MOVING) +StateVec makeStateMoving(float pos_rel, float vel_uav, float bias, float acc_target, float vel_target) +{ + StateVec state{}; + state(vtest::State::pos_rel) = pos_rel; + state(vtest::State::vel_uav) = vel_uav; + state(vtest::State::bias) = bias; + state(vtest::State::acc_target) = acc_target; + state(vtest::State::vel_target) = vel_target; + return state; +} + +StateVec makeDiagMoving(float d0, float d1, float d2, float d3, float d4) +{ + StateVec diag{}; + diag(0) = d0; + diag(1) = d1; + diag(2) = d2; + diag(3) = d3; + diag(4) = d4; + return diag; +} +#endif + +ScalarMeas makeMeas(uint64_t time_us, float val, float unc, const StateVec &H) +{ + ScalarMeas meas{}; + meas.time_us = time_us; + meas.val = val; + meas.unc = unc; + meas.H = H; + return meas; +} + +StateVec makeH(uint8_t index) +{ + StateVec H{}; + H(index) = 1.f; + return H; +} + +} // namespace + +#if !defined(CONFIG_VTEST_MOVING) + +TEST(KFPositionTest, PredictUpdatesStateAndCovariance) +{ + // WHY: State propagation is the backbone of the position filter. + // WHAT: Verify predict() matches the generated model for state and variance. + + vte::KF_position filter; + filter.setState(makeState(1.f, 2.f, 3.f)); + filter.setStateCovarianceDiag(makeDiag(1.f, 2.f, 3.f)); + filter.setInputVar(0.4f); + filter.setBiasVar(0.1f); + + const float dt = 0.5f; + const float acc = 1.5f; + + filter.predict(dt, acc); + + const StateVec state = filter.getState(); + const StateVec cov_diag = filter.getStateCovarianceDiag(); + + const float expected_pos = 1.f - 2.f * dt - 0.5f * acc * dt * dt; + const float expected_vel = 2.f + acc * dt; + const float expected_bias = 3.f; + + EXPECT_NEAR(state(vtest::State::pos_rel), expected_pos, kTolerance); + EXPECT_NEAR(state(vtest::State::vel_uav), expected_vel, kTolerance); + EXPECT_NEAR(state(vtest::State::bias), expected_bias, kTolerance); + + const float dt2 = dt * dt; + const float dt4 = dt2 * dt2; + const float expected_p00 = 1.f + 2.f * dt2 + 0.25f * dt4 * 0.4f; + const float expected_p11 = 2.f + dt2 * 0.4f; + const float expected_p22 = 3.f + 0.1f * dt; + + EXPECT_NEAR(cov_diag(vtest::State::pos_rel), expected_p00, kTolerance); + EXPECT_NEAR(cov_diag(vtest::State::vel_uav), expected_p11, kTolerance); + EXPECT_NEAR(cov_diag(vtest::State::bias), expected_p22, kTolerance); +} + +TEST(KFPositionTest, InnovationMatchesH) +{ + // WHY: Innovation consistency ensures measurement mapping is correct. + // WHAT: Check innov and innov_var against an analytic H*x and HPH^T + R. + + vte::KF_position filter; + filter.setState(makeState(1.f, 2.f, 3.f)); + filter.setStateCovarianceDiag(makeDiag(4.f, 1.f, 1.f)); + + const StateVec H = makeH(vtest::State::pos_rel); + static constexpr hrt_abstime kMeasTime = 1_s; + const ScalarMeas meas = makeMeas(kMeasTime, 2.5f, 0.5f, H); + + const StateVec state_before = filter.getState(); + const StateVec cov_diag_before = filter.getStateCovarianceDiag(); + const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 1e6f); + + EXPECT_EQ(res.status, vte::FusionStatus::kFusedCurrent); + const float expected_innov = meas.val - state_before(vtest::State::pos_rel); + const float expected_innov_var = cov_diag_before(vtest::State::pos_rel) + meas.unc; + EXPECT_NEAR(res.innov, expected_innov, kTolerance); + EXPECT_NEAR(res.innov_var, expected_innov_var, kTolerance); +} + +TEST(KFPositionTest, FusesCurrentMeasurement) +{ + // WHY: The fast-path (non-OOSM) fusion is used for most measurements. + // WHAT: Verify current fusion updates state and reduces covariance as expected. + + vte::KF_position filter; + filter.setState(makeState(0.f, 0.f, 0.f)); + filter.setStateCovarianceDiag(makeDiag(4.f, 1.f, 1.f)); + + const StateVec H = makeH(vtest::State::pos_rel); + static constexpr hrt_abstime kMeasTime = 2_s; + const hrt_abstime now = kMeasTime + kNowOffset; + const ScalarMeas meas = makeMeas(kMeasTime, 5.f, 1.f, H); + + const float state_pos = filter.getState()(vtest::State::pos_rel); + const float p00 = filter.getStateCovarianceDiag()(vtest::State::pos_rel); + const float innov = meas.val - state_pos; + const float innov_var = p00 + meas.unc; + const float k_pos = p00 / innov_var; + const float expected_pos = k_pos * innov; + const float expected_p00 = (1.f - k_pos) * p00; + + const vte::FusionResult res = filter.fuseScalarAtTime(meas, now, 100.f); + const StateVec state = filter.getState(); + const StateVec cov_diag = filter.getStateCovarianceDiag(); + + EXPECT_EQ(res.status, vte::FusionStatus::kFusedCurrent); + EXPECT_NEAR(state(vtest::State::pos_rel), expected_pos, kTolerance); + EXPECT_NEAR(cov_diag(vtest::State::pos_rel), expected_p00, kTolerance); + EXPECT_NEAR(cov_diag(vtest::State::vel_uav), 1.f, kTolerance); + EXPECT_NEAR(cov_diag(vtest::State::bias), 1.f, kTolerance); + + const hrt_abstime future_meas_time = now + 10_ms; + const ScalarMeas future_meas = makeMeas(future_meas_time, 6.f, 1.f, H); + const vte::FusionResult future_res = filter.fuseScalarAtTime(future_meas, now, 100.f); + EXPECT_EQ(future_res.status, vte::FusionStatus::kFusedCurrent); +} + +TEST(KFPositionTest, RejectsInvalidInnovationVariance) +{ + // WHY: Invalid innovation variance must stop the correction to avoid NaNs. + // WHAT: Ensure REJECT_COV and unchanged state when S is near zero. + + vte::KF_position filter; + filter.setState(makeState(1.f, 2.f, 3.f)); + filter.setStateCovarianceDiag(makeDiag(0.f, 0.f, 0.f)); + + const StateVec H = makeH(vtest::State::pos_rel); + static constexpr hrt_abstime kMeasTime = 3_s; + const ScalarMeas meas = makeMeas(kMeasTime, 1.f, 0.f, H); + + const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 100.f); + const StateVec state = filter.getState(); + + EXPECT_EQ(res.status, vte::FusionStatus::kRejectCov); + EXPECT_NEAR(state(vtest::State::pos_rel), 1.f, kTolerance); + EXPECT_NEAR(state(vtest::State::vel_uav), 2.f, kTolerance); + EXPECT_NEAR(state(vtest::State::bias), 3.f, kTolerance); +} + +TEST(KFPositionTest, RejectsOutlierNis) +{ + // WHY: NIS gating protects against large outliers. + // WHAT: Provide a large innovation with small variance and expect REJECT_NIS. + vte::KF_position filter; + filter.setState(makeState(0.f, 0.f, 0.f)); + filter.setStateCovarianceDiag(makeDiag(0.01f, 0.01f, 0.01f)); + + const StateVec H = makeH(vtest::State::pos_rel); + static constexpr hrt_abstime kMeasTime = 3500_ms; + const ScalarMeas meas = makeMeas(kMeasTime, 10.f, 0.01f, H); + const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 0.1f); + const StateVec state = filter.getState(); + + EXPECT_EQ(res.status, vte::FusionStatus::kRejectNis); + EXPECT_NEAR(state(vtest::State::pos_rel), 0.f, kTolerance); + EXPECT_NEAR(state(vtest::State::vel_uav), 0.f, kTolerance); + EXPECT_NEAR(state(vtest::State::bias), 0.f, kTolerance); +} + +TEST(KFPositionTest, FusesMeasurementWithBiasInH) +{ + // WHY: GNSS with bias must use H(pos_rel)=1 and H(bias)=1. + // WHAT: Verify innovation and corrections for a joint position+bias observation. + vte::KF_position filter; + filter.setState(makeState(1.f, 0.f, 2.f)); + filter.setStateCovarianceDiag(makeDiag(2.f, 1.f, 3.f)); + + StateVec H{}; + H(vtest::State::pos_rel) = 1.f; + H(vtest::State::bias) = 1.f; + + static constexpr hrt_abstime kMeasTime = 3600_ms; + const ScalarMeas meas = makeMeas(kMeasTime, 10.f, 1.f, H); + const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 100.f); + const StateVec state = filter.getState(); + + const float expected_innov = 10.f - (1.f + 2.f); + const float S = 2.f + 3.f + 1.f; + const float k_pos = 2.f / S; + const float k_bias = 3.f / S; + + EXPECT_EQ(res.status, vte::FusionStatus::kFusedCurrent); + EXPECT_NEAR(res.innov, expected_innov, kTolerance); + EXPECT_NEAR(state(vtest::State::pos_rel), 1.f + k_pos * expected_innov, kTolerance); + EXPECT_NEAR(state(vtest::State::bias), 2.f + k_bias * expected_innov, kTolerance); +} + +TEST(KFPositionTest, FusesOosmMeasurementWithHistory) +{ + // WHY: OOSM fusion must handle delayed measurements consistently. + // WHAT: Provide history and fuse a delayed measurement to trigger FUSED_OOSM. + + vte::KF_position filter; + filter.setState(makeState(0.f, 0.f, 0.f)); + filter.setStateCovarianceDiag(makeDiag(1.f, 1.f, 1.f)); + filter.setInputVar(0.2f); + filter.setBiasVar(0.1f); + + static constexpr hrt_abstime kT0 = 1_s; + static constexpr hrt_abstime kHistoryStep = 20_ms; + static constexpr hrt_abstime kMeasOffset = 10_ms; + static constexpr hrt_abstime kNowOffsetLocal = 60_ms; + + filter.predict(0.02f, 1.f); + filter.pushHistory(kT0); + filter.predict(0.02f, 1.f); + filter.pushHistory(kT0 + kHistoryStep); + filter.predict(0.02f, 1.f); + filter.pushHistory(kT0 + kHistoryStep * 2); + + const StateVec state_before = filter.getState(); + const StateVec H = makeH(vtest::State::pos_rel); + const ScalarMeas meas = makeMeas(kT0 + kMeasOffset, 5.f, 0.5f, H); + + const vte::FusionResult res = filter.fuseScalarAtTime(meas, kT0 + kNowOffsetLocal, 100.f); + const StateVec state_after = filter.getState(); + + EXPECT_EQ(res.status, vte::FusionStatus::kFusedOosm); + EXPECT_GT(res.history_steps, 0u); + EXPECT_GT(fabsf(state_after(vtest::State::pos_rel) - state_before(vtest::State::pos_rel)), 1e-4f); +} + +TEST(KFPositionTest, ClampCovarianceToMin) +{ + // WHY: Very small covariances can destabilize future updates. + // WHAT: Verify covariance diagonals are clamped to a minimum after correction. + + vte::KF_position filter; + filter.setState(makeState(0.f, 0.f, 0.f)); + filter.setStateCovarianceDiag(makeDiag(1e-12f, 1e-12f, 1e-12f)); + + const StateVec H = makeH(vtest::State::pos_rel); + // Use a near-zero innovation to avoid NIS rejection while exercising the clamp. + static constexpr hrt_abstime kMeasTime = 4_s; + const ScalarMeas meas = makeMeas(kMeasTime, 0.f, 1e-4f, H); + + const vte::FusionResult res = filter.fuseScalarAtTime(meas, kMeasTime + kNowOffset, 100.f); + const StateVec cov_diag = filter.getStateCovarianceDiag(); + + EXPECT_EQ(res.status, vte::FusionStatus::kFusedCurrent); + EXPECT_GE(cov_diag(vtest::State::pos_rel), vte::KF_position::kMinVar); + EXPECT_GE(cov_diag(vtest::State::vel_uav), vte::KF_position::kMinVar); + EXPECT_GE(cov_diag(vtest::State::bias), vte::KF_position::kMinVar); +} + +#endif // !defined(CONFIG_VTEST_MOVING) + +#if defined(CONFIG_VTEST_MOVING) +TEST(KFPositionTest, PredictMovingTargetAddsTargetTerms) +{ + // WHY: Moving-target model adds target velocity/acceleration to relative position. + // WHAT: Validate state propagation and acc_target random-walk covariance. + vte::KF_position filter; + filter.setState(makeStateMoving(1.f, 2.f, 3.f, 0.4f, -0.5f)); + filter.setStateCovarianceDiag(makeDiagMoving(0.f, 0.f, 0.f, 0.f, 0.f)); + filter.setInputVar(0.f); + filter.setBiasVar(0.f); + filter.setTargetAccVar(0.2f); + + const float dt = 0.5f; + const float acc_uav = 1.2f; + + filter.predict(dt, acc_uav); + + const StateVec state = filter.getState(); + const StateVec cov_diag = filter.getStateCovarianceDiag(); + + const float dt2 = dt * dt; + const float expected_pos = 1.f - 2.f * dt - 0.5f * acc_uav * dt2 + (-0.5f) * dt + 0.5f * dt2 * 0.4f; + const float expected_vel = 2.f + acc_uav * dt; + const float expected_bias = 3.f; + const float expected_acc = 0.4f; + const float expected_vel_target = -0.5f + dt * 0.4f; + + EXPECT_NEAR(state(vtest::State::pos_rel), expected_pos, kTolerance); + EXPECT_NEAR(state(vtest::State::vel_uav), expected_vel, kTolerance); + EXPECT_NEAR(state(vtest::State::bias), expected_bias, kTolerance); + EXPECT_NEAR(state(vtest::State::acc_target), expected_acc, kTolerance); + EXPECT_NEAR(state(vtest::State::vel_target), expected_vel_target, kTolerance); + + EXPECT_NEAR(cov_diag(vtest::State::acc_target), 0.2f * dt, kTolerance); +} +#endif // defined(CONFIG_VTEST_MOVING) diff --git a/src/modules/vision_target_estimator/test/TEST_VTE_VisionTargetEst.cpp b/src/modules/vision_target_estimator/test/TEST_VTE_VisionTargetEst.cpp new file mode 100644 index 000000000000..7726dfba6a39 --- /dev/null +++ b/src/modules/vision_target_estimator/test/TEST_VTE_VisionTargetEst.cpp @@ -0,0 +1,783 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file TEST_VTE_VisionTargetEst.cpp + * @brief Unit test VisionTargetEst.cpp + * + * @author Jonas Perolini + * + */ + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(CONFIG_MODULES_VISION_TARGET_ESTIMATOR) && CONFIG_MODULES_VISION_TARGET_ESTIMATOR +#include +#endif // CONFIG_MODULES_VISION_TARGET_ESTIMATOR +#include +#include + +#include "VTETestHelper.hpp" +#include "VisionTargetEst.h" + +namespace vte = vision_target_estimator; + +namespace +{ +using namespace time_literals; + +static constexpr float kTolerance = 1e-4f; +static constexpr float kGravity = 9.80665f; +static constexpr hrt_abstime kStepUs = 1_ms; + +} // namespace + +class VisionTargetEstTestable : public vte::VisionTargetEst +{ +public: + using vte::VisionTargetEst::_acc_sample_count; + using vte::VisionTargetEst::_acc_sample_warn_last; + using vte::VisionTargetEst::_current_task; + using vte::VisionTargetEst::_gps_pos_is_offset; + using vte::VisionTargetEst::_gps_pos_offset_xyz; + using vte::VisionTargetEst::_is_in_prec_land; + using vte::VisionTargetEst::_last_acc_reset; + using vte::VisionTargetEst::_last_update_pos; + using vte::VisionTargetEst::_orientation_estimator_running; + using vte::VisionTargetEst::_pos_sp_triplet_buffer; + using vte::VisionTargetEst::_pos_sp_triplet_sub; + using vte::VisionTargetEst::_vte_orientation; + using vte::VisionTargetEst::_position_estimator_running; + using vte::VisionTargetEst::_vehicle_acc_body; + using vte::VisionTargetEst::_vehicle_acc_ned_sum; + using vte::VisionTargetEst::_vehicle_acceleration_sub; + using vte::VisionTargetEst::_vehicle_angular_velocity_sub; + using vte::VisionTargetEst::_vehicle_attitude_sub; + using vte::VisionTargetEst::_vehicle_gps_position_sub; + using vte::VisionTargetEst::_vehicle_land_detected_sub; + using vte::VisionTargetEst::_vehicle_local_position_sub; + using vte::VisionTargetEst::_vte_orientation_enabled; + using vte::VisionTargetEst::_vte_orientation_stop_time; + using vte::VisionTargetEst::_vte_position; + using vte::VisionTargetEst::_vte_position_enabled; + using vte::VisionTargetEst::_vte_position_stop_time; + using vte::VisionTargetEst::_vte_task_mask; + using vte::VisionTargetEst::adjustAidMask; + using vte::VisionTargetEst::allEstStoppedDueToTimeout; + using vte::VisionTargetEst::computeGpsVelocityOffset; + using vte::VisionTargetEst::findLandSetpoint; + using vte::VisionTargetEst::isCurrentTaskComplete; + using vte::VisionTargetEst::isNewTaskAvailable; + using vte::VisionTargetEst::kAccUpdatedTimeoutUs; + using vte::VisionTargetEst::kEstRestartTimeUs; + using vte::VisionTargetEst::kMinAccDownsampleTimeoutUs; + using vte::VisionTargetEst::kPosUpdatePeriodUs; + using vte::VisionTargetEst::kYawUpdatePeriodUs; + using vte::VisionTargetEst::LocalPose; + using vte::VisionTargetEst::pollEstimatorInput; + using vte::VisionTargetEst::publishVteInput; + using vte::VisionTargetEst::startPosEst; + using vte::VisionTargetEst::startYawEst; + using vte::VisionTargetEst::stopPosEst; + using vte::VisionTargetEst::stopYawEst; + using vte::VisionTargetEst::updateGpsAntennaOffset; + using vte::VisionTargetEst::updateEstimators; + using vte::VisionTargetEst::updateParams; + using vte::VisionTargetEst::updatePosEst; + using vte::VisionTargetEst::updateTaskTopics; + using vte::VisionTargetEst::updateWhenIntervalElapsed; +#if !defined(CONSTRAINED_FLASH) + using vte::VisionTargetEst::_prec_land_status_sub; +#endif +}; + +class VisionTargetEstTest : public ::testing::Test +{ +protected: + static void SetUpTestSuite() { uORB::Manager::initialize(); } + + void SetUp() override + { + param_control_autosave(false); + + setParamInt("VTE_POS_EN", 1); + setParamInt("VTE_YAW_EN", 1); + setParamInt("VTE_TASK_MASK", 0); + setParamInt("VTE_AID_MASK", 0); + setParamFloat("VTE_BTOUT", 3.f); + setParamFloat("VTE_TGT_TOUT", 2.f); + setParamFloat("VTE_M_REC_TOUT", 1.f); + setParamFloat("VTE_M_UPD_TOUT", 0.1f); + + _vte = std::make_unique(); + + _attitude_pub = std::make_unique>(ORB_ID(vehicle_attitude)); + _accel_pub = std::make_unique>(ORB_ID(vehicle_acceleration)); + _ang_vel_pub = std::make_unique>(ORB_ID(vehicle_angular_velocity)); + _uav_gps_pub = std::make_unique>(ORB_ID(vehicle_gps_position)); + _pos_sp_triplet_pub = std::make_unique>(ORB_ID(position_setpoint_triplet)); + _land_detected_pub = std::make_unique>(ORB_ID(vehicle_land_detected)); +#if !defined(CONSTRAINED_FLASH) + _prec_land_status_pub = std::make_unique>(ORB_ID(prec_land_status)); +#endif + + _vte_input_sub = std::make_unique>(ORB_ID(vision_target_est_input)); + + flushInternalSubscriptions(); + vte_test::flushSubscription(_vte_input_sub); + } + + void TearDown() override + { + _param_guard.restore(); + param_control_autosave(true); + + _vte_input_sub.reset(); +#if !defined(CONSTRAINED_FLASH) + _prec_land_status_pub.reset(); +#endif + _land_detected_pub.reset(); + _pos_sp_triplet_pub.reset(); + _uav_gps_pub.reset(); + _ang_vel_pub.reset(); + _accel_pub.reset(); + _attitude_pub.reset(); + + _vte.reset(); + } + + void setParamFloat(const char *name, float value) + { + ASSERT_TRUE(_param_guard.setFloat(name, value)); + } + + void setParamInt(const char *name, int32_t value) + { + ASSERT_TRUE(_param_guard.setInt(name, value)); + } + + void publishAttitude(const matrix::Quaternionf &q, hrt_abstime timestamp) + { + vehicle_attitude_s msg{}; + msg.timestamp = timestamp; + q.copyTo(msg.q); + ASSERT_TRUE(_attitude_pub->publish(msg)); + } + + void publishAcceleration(const matrix::Vector3f &accel, hrt_abstime timestamp) + { + vehicle_acceleration_s msg{}; + msg.timestamp = timestamp; + msg.xyz[0] = accel(0); + msg.xyz[1] = accel(1); + msg.xyz[2] = accel(2); + ASSERT_TRUE(_accel_pub->publish(msg)); + } + + void publishAngularVelocity(const matrix::Vector3f &ang_vel, hrt_abstime timestamp) + { + vehicle_angular_velocity_s msg{}; + msg.timestamp = timestamp; + msg.xyz[0] = ang_vel(0); + msg.xyz[1] = ang_vel(1); + msg.xyz[2] = ang_vel(2); + ASSERT_TRUE(_ang_vel_pub->publish(msg)); + } + + void publishUavGps(const matrix::Vector3f &antenna_offset, hrt_abstime timestamp) + { + ASSERT_TRUE(vte_test::publishUavGps(*_uav_gps_pub, 47.0, 8.0, 500.f, 0.5f, 0.5f, + matrix::Vector3f{}, 0.1f, true, timestamp, antenna_offset)); + } + + void publishLandDetected(bool landed, hrt_abstime timestamp) + { + vehicle_land_detected_s msg{}; + msg.timestamp = timestamp; + msg.landed = landed; + ASSERT_TRUE(_land_detected_pub->publish(msg)); + } + + void publishPositionSetpointTriplet(const position_setpoint_triplet_s &triplet) + { + ASSERT_TRUE(_pos_sp_triplet_pub->publish(triplet)); + } + +#if !defined(CONSTRAINED_FLASH) + void publishPrecLandStatus(uint8_t state, hrt_abstime timestamp) + { + prec_land_status_s msg{}; + msg.timestamp = timestamp; + msg.state = state; + ASSERT_TRUE(_prec_land_status_pub->publish(msg)); + } +#endif + + void flushInternalSubscriptions() + { + vte_test::flushSubscription(_vte->_vehicle_attitude_sub); + vte_test::flushSubscription(_vte->_vehicle_acceleration_sub); + vte_test::flushSubscription(_vte->_vehicle_angular_velocity_sub); + vte_test::flushSubscription(_vte->_vehicle_gps_position_sub); + vte_test::flushSubscription(_vte->_vehicle_local_position_sub); + vte_test::flushSubscription(_vte->_pos_sp_triplet_sub); + vte_test::flushSubscription(_vte->_vehicle_land_detected_sub); +#if !defined(CONSTRAINED_FLASH) + vte_test::flushSubscription(_vte->_prec_land_status_sub); +#endif + } + + std::unique_ptr _vte; + std::unique_ptr> _attitude_pub; + std::unique_ptr> _accel_pub; + std::unique_ptr> _ang_vel_pub; + std::unique_ptr> _uav_gps_pub; + std::unique_ptr> _pos_sp_triplet_pub; + std::unique_ptr> _land_detected_pub; +#if !defined(CONSTRAINED_FLASH) + std::unique_ptr> _prec_land_status_pub; +#endif + + std::unique_ptr> _vte_input_sub; + + vte_test::ParamGuard _param_guard{}; +}; + +TEST_F(VisionTargetEstTest, AdjustAidMaskResolvesConflicts) +{ + // WHY: If there is a GNSS on the target, do not use the mission position + // WHAT: Enable mission position and GNSS on target, expect GNSS on target only + vte::SensorFusionMaskU mask{}; + mask.flags.use_target_gps_pos = 1; + mask.flags.use_mission_pos = 1; + + const uint16_t adjusted = _vte->adjustAidMask(mask.value); + vte::SensorFusionMaskU adjusted_mask{}; + adjusted_mask.value = adjusted; + + EXPECT_TRUE(adjusted_mask.flags.use_target_gps_pos); + EXPECT_FALSE(adjusted_mask.flags.use_mission_pos); +} + +TEST_F(VisionTargetEstTest, UpdateGpsAntennaOffsetTracksVehicleGpsPosition) +{ + // WHY: The active GPS antenna offset comes from vehicle_gps_position + // WHAT: Publish non-zero then zero antenna offsets and verify the cached lever arm tracks the topic output. + + const matrix::Vector3f gps_offset_gt{0.2, -0.015, 3.1}; + publishUavGps(gps_offset_gt, vte_test::advanceMicroseconds(kStepUs)); + ASSERT_TRUE(_vte->updateGpsAntennaOffset()); + + EXPECT_TRUE(_vte->_gps_pos_is_offset); + const matrix::Vector3f gps_offset = _vte->_gps_pos_offset_xyz; + EXPECT_NEAR(gps_offset(0), gps_offset_gt(0), kTolerance); + EXPECT_NEAR(gps_offset(1), gps_offset_gt(1), kTolerance); + EXPECT_NEAR(gps_offset(2), gps_offset_gt(2), kTolerance); + + publishUavGps(matrix::Vector3f{}, vte_test::advanceMicroseconds(kStepUs)); + ASSERT_TRUE(_vte->updateGpsAntennaOffset()); + + EXPECT_FALSE(_vte->_gps_pos_is_offset); + const matrix::Vector3f gps_offset_reset = _vte->_gps_pos_offset_xyz; + EXPECT_NEAR(gps_offset_reset(0), 0.f, kTolerance); + EXPECT_NEAR(gps_offset_reset(1), 0.f, kTolerance); + EXPECT_NEAR(gps_offset_reset(2), 0.f, kTolerance); +} + +TEST_F(VisionTargetEstTest, ComputeGpsVelocityOffsetRequiresOffsetAndData) +{ + // WHY: Compensate for the rotational velocity if the GNSS antenna is not at the center of mass, + // i.e. when the drone rotates around the center of mass, the GNSS will record a velocity. + // WHAT: Expect false without config/data, then true once angular velocity is published. + matrix::Vector3f vel_offset{}; + EXPECT_FALSE(_vte->computeGpsVelocityOffset(vel_offset)); + + const matrix::Vector3f gps_offset(1.f, 0.f, 0.f); + _vte->_gps_pos_is_offset = true; + _vte->_gps_pos_offset_xyz = gps_offset; + + EXPECT_FALSE(_vte->computeGpsVelocityOffset(vel_offset)); + + const matrix::Vector3f ang_vel(0.f, 0.f, 1.f); + publishAngularVelocity(ang_vel, vte_test::advanceMicroseconds(kStepUs)); + EXPECT_TRUE(_vte->computeGpsVelocityOffset(vel_offset)); + const matrix::Vector3f expected_offset = ang_vel.cross(gps_offset); + EXPECT_NEAR(vel_offset(0), expected_offset(0), kTolerance); + EXPECT_NEAR(vel_offset(1), expected_offset(1), kTolerance); + EXPECT_NEAR(vel_offset(2), expected_offset(2), kTolerance); +} + +TEST_F(VisionTargetEstTest, PollEstimatorInputRequiresAttitude) +{ + // WHY: Attitude is required to transform body-frame data into NED. + // WHAT: Call pollEstimatorInput without attitude and expect it to fail. + matrix::Vector3f acc_ned{}; + matrix::Quaternionf q_att{}; + matrix::Vector3f gps_offset{}; + matrix::Vector3f vel_offset{}; + const bool vel_offset_updated = true; + bool acc_valid = true; + + EXPECT_FALSE(_vte->pollEstimatorInput(acc_ned, q_att, gps_offset, vel_offset, + vel_offset_updated, acc_valid)); +} + +TEST_F(VisionTargetEstTest, PollEstimatorInputTransformsAccelerationAndOffsets) +{ + // WHY: Valid inputs should be rotated into NED and include gravity compensation. + // WHAT: Publish attitude/accel with offsets and verify transformed outputs. + const hrt_abstime timestamp = vte_test::nowUs(); + matrix::Quaternionf q{0.f, 0.f, 0.f, 1.f}; // 180 degrees rotation around z-axis + const matrix::Vector3f non_rotated_vec{1.f, 2.f, 3.f}; + const matrix::Vector3f rotated_vec{-1.f, -2.f, 3.f}; + + publishAttitude(q, timestamp); + publishAcceleration(non_rotated_vec, timestamp); + + _vte->_gps_pos_is_offset = true; + _vte->_gps_pos_offset_xyz = non_rotated_vec; + + matrix::Vector3f acc_ned{}; + matrix::Quaternionf q_att{}; + matrix::Vector3f gps_offset = non_rotated_vec; + matrix::Vector3f vel_offset = non_rotated_vec; + const bool vel_offset_updated = true; + bool acc_valid = false; + + ASSERT_TRUE(_vte->pollEstimatorInput(acc_ned, q_att, gps_offset, vel_offset, + vel_offset_updated, acc_valid)); + EXPECT_TRUE(acc_valid); + EXPECT_NEAR(acc_ned(0), rotated_vec(0), kTolerance); + EXPECT_NEAR(acc_ned(1), rotated_vec(1), kTolerance); + EXPECT_NEAR(acc_ned(2), rotated_vec(2) + kGravity, kTolerance); + + EXPECT_NEAR(gps_offset(0), rotated_vec(0), kTolerance); + EXPECT_NEAR(gps_offset(1), rotated_vec(1), kTolerance); + EXPECT_NEAR(gps_offset(2), rotated_vec(2), kTolerance); + + EXPECT_NEAR(vel_offset(0), rotated_vec(0), kTolerance); + EXPECT_NEAR(vel_offset(1), rotated_vec(1), kTolerance); + EXPECT_NEAR(vel_offset(2), rotated_vec(2), kTolerance); +} + +TEST_F(VisionTargetEstTest, PollEstimatorInputNoOffsetZerosVectors) +{ + // WHY: With GPS offset disabled, offset vectors must be zeroed. + // WHAT: Disable offset and verify returned GNSSS/velocity offsets are zero. + const hrt_abstime timestamp = vte_test::nowUs(); + publishAttitude(vte_test::identityQuat(), timestamp); + publishAcceleration(matrix::Vector3f(0.1f, 0.2f, 0.3f), timestamp); + + _vte->_gps_pos_is_offset = false; + _vte->_gps_pos_offset_xyz = matrix::Vector3f(1.f, 2.f, 3.f); + + matrix::Vector3f acc_ned{}; + matrix::Quaternionf q_att{}; + matrix::Vector3f gps_offset{}; + matrix::Vector3f vel_offset(4.f, 5.f, 6.f); + const bool vel_offset_updated = true; + bool acc_valid = false; + + ASSERT_TRUE(_vte->pollEstimatorInput(acc_ned, q_att, gps_offset, vel_offset, + vel_offset_updated, acc_valid)); + EXPECT_TRUE(acc_valid); + EXPECT_NEAR(gps_offset(0), 0.f, kTolerance); + EXPECT_NEAR(gps_offset(1), 0.f, kTolerance); + EXPECT_NEAR(gps_offset(2), 0.f, kTolerance); + EXPECT_NEAR(vel_offset(0), 0.f, kTolerance); + EXPECT_NEAR(vel_offset(1), 0.f, kTolerance); + EXPECT_NEAR(vel_offset(2), 0.f, kTolerance); +} + +TEST_F(VisionTargetEstTest, PublishVteInputPopulatesMessage) +{ + // WHY: Published inputs must reflect the sampled accel and attitude data. + // WHAT: Populate sample timestamp and verify the published message fields. + const hrt_abstime sample_time = vte_test::nowUs(); + _vte->_vehicle_acc_body.timestamp = sample_time; + + const matrix::Vector3f acc_ned(0.5f, -0.25f, 9.0f); + const matrix::Quaternionf q_att = vte_test::identityQuat(); + + _vte->publishVteInput(acc_ned, q_att); + + ASSERT_TRUE(_vte_input_sub->update()); + const auto msg = _vte_input_sub->get(); + + EXPECT_EQ(msg.timestamp_sample, sample_time); + EXPECT_NEAR(msg.acc_xyz[0], acc_ned(0), kTolerance); + EXPECT_NEAR(msg.acc_xyz[1], acc_ned(1), kTolerance); + EXPECT_NEAR(msg.acc_xyz[2], acc_ned(2), kTolerance); + EXPECT_NEAR(msg.q_att[0], q_att(0), kTolerance); + EXPECT_NEAR(msg.q_att[1], q_att(1), kTolerance); + EXPECT_NEAR(msg.q_att[2], q_att(2), kTolerance); + EXPECT_NEAR(msg.q_att[3], q_att(3), kTolerance); +} + +TEST_F(VisionTargetEstTest, UpdateWhenIntervalElapsedRespectsInterval) +{ + // WHY: The update gate should only allow execution after the interval elapses. + // WHAT: Expect false before the interval and true after waiting. + const hrt_abstime interval = 10_ms; + hrt_abstime last_time = vte_test::nowUs(); + EXPECT_FALSE(_vte->updateWhenIntervalElapsed(last_time, interval)); + + last_time = vte_test::nowUs() - interval - 1_ms; + EXPECT_TRUE(_vte->updateWhenIntervalElapsed(last_time, interval)); +} + +TEST_F(VisionTargetEstTest, FindLandSetpointSelectsCorrectSetpoint) +{ + // WHY: Precision landing should use the active land setpoint. + // WHAT: Publish triplets with land as current/next and verify selection. + EXPECT_EQ(_vte->findLandSetpoint(), nullptr); + + position_setpoint_triplet_s triplet{}; + triplet.timestamp = vte_test::advanceMicroseconds(kStepUs); + triplet.current.type = position_setpoint_s::SETPOINT_TYPE_LAND; + triplet.next.type = position_setpoint_s::SETPOINT_TYPE_IDLE; + publishPositionSetpointTriplet(triplet); + + const position_setpoint_s *current_sp = _vte->findLandSetpoint(); + ASSERT_NE(current_sp, nullptr); + EXPECT_EQ(current_sp, &_vte->_pos_sp_triplet_buffer.current); + + triplet = {}; + triplet.timestamp = vte_test::advanceMicroseconds(kStepUs); + triplet.current.type = position_setpoint_s::SETPOINT_TYPE_IDLE; + triplet.next.type = position_setpoint_s::SETPOINT_TYPE_LAND; + publishPositionSetpointTriplet(triplet); + + const position_setpoint_s *next_sp = _vte->findLandSetpoint(); + ASSERT_NE(next_sp, nullptr); + EXPECT_EQ(next_sp, &_vte->_pos_sp_triplet_buffer.next); + + const position_setpoint_s *cached_sp = _vte->findLandSetpoint(); + ASSERT_NE(cached_sp, nullptr); + EXPECT_EQ(cached_sp, &_vte->_pos_sp_triplet_buffer.next); +} + +TEST_F(VisionTargetEstTest, PrecisionLandTaskRequestAndCompletion) +{ + // WHY: Precision landing tasks should start once requested and complete on land detect. + // WHAT: Request the task, then publish land detected and verify completion. + _vte->_vte_task_mask.value = 0; + _vte->_vte_task_mask.flags.for_prec_land = true; + _vte->_current_task.value = 0; + _vte->_is_in_prec_land = true; + + EXPECT_TRUE(_vte->isNewTaskAvailable()); + EXPECT_TRUE(_vte->_current_task.flags.for_prec_land); + + publishLandDetected(true, vte_test::advanceMicroseconds(kStepUs)); + EXPECT_TRUE(_vte->isCurrentTaskComplete()); + EXPECT_FALSE(_vte->_is_in_prec_land); +} + +#if !defined(CONSTRAINED_FLASH) +TEST_F(VisionTargetEstTest, UpdateTaskTopicsTracksPrecisionLandState) +{ + // WHY: Precision landing status topics should drive internal task state. + // WHAT: Publish ongoing and stopped states and verify the internal flag toggles. + _vte->_vte_task_mask.value = 0; + _vte->_vte_task_mask.flags.for_prec_land = true; + _vte->_is_in_prec_land = false; + + publishPrecLandStatus(prec_land_status_s::PREC_LAND_STATE_ONGOING, vte_test::advanceMicroseconds(kStepUs)); + _vte->updateTaskTopics(); + EXPECT_TRUE(_vte->_is_in_prec_land); + + publishPrecLandStatus(prec_land_status_s::PREC_LAND_STATE_STOPPED, vte_test::advanceMicroseconds(kStepUs)); + _vte->updateTaskTopics(); + EXPECT_FALSE(_vte->_is_in_prec_land); +} +#endif + +TEST_F(VisionTargetEstTest, UpdatePosEstResetsDownsampleOnTimeout) +{ + // WHY: The acceleration downsample must reset after timeout to avoid stale averages. + // WHAT: Force a timeout and verify the accumulator resets on update. + _vte->_acc_sample_count = 3; + _vte->_vehicle_acc_ned_sum = matrix::Vector3f(50.f, 60.f, 70.f); + const hrt_abstime now = vte_test::nowUs(); + const hrt_abstime acc_downsample_timeout = VisionTargetEstTestable::kMinAccDownsampleTimeoutUs; + _vte->_last_acc_reset = now - (acc_downsample_timeout + 1_ms); + _vte->_last_update_pos = now; + + const matrix::Vector3f acc_sample(1.f, 2.f, 3.f); + VisionTargetEstTestable::LocalPose local_pose{}; + _vte->updatePosEst(local_pose, false, acc_sample, matrix::Vector3f{}, matrix::Vector3f{}, false); + + EXPECT_EQ(_vte->_acc_sample_count, 1u); + EXPECT_NEAR(_vte->_vehicle_acc_ned_sum(0), acc_sample(0), kTolerance); + EXPECT_NEAR(_vte->_vehicle_acc_ned_sum(1), acc_sample(1), kTolerance); + EXPECT_NEAR(_vte->_vehicle_acc_ned_sum(2), acc_sample(2), kTolerance); +} + +TEST_F(VisionTargetEstTest, UpdatePosEstAveragesSamplesCorrectly) +{ + // WHY: The averaged acceleration must reflect the mean of collected samples. + // WHAT: Feed multiple samples, trigger an update, and verify the published mean. + VisionTargetEstTestable::LocalPose local_pose{}; + const hrt_abstime now = vte_test::nowUs(); + _vte->_vehicle_acc_ned_sum.setAll(0.f); + _vte->_acc_sample_count = 0; + _vte->_last_acc_reset = now; + _vte->_last_update_pos = now; + + const matrix::Vector3f gps_offset{}; + const matrix::Vector3f vel_offset{}; + + const matrix::Vector3f acc1(1.f, 2.f, 3.f); + _vte->updatePosEst(local_pose, false, acc1, gps_offset, vel_offset, false); + + const matrix::Vector3f acc2(3.f, 4.f, 5.f); + _vte->updatePosEst(local_pose, false, acc2, gps_offset, vel_offset, false); + + EXPECT_EQ(_vte->_acc_sample_count, 2u); + const matrix::Vector3f acc_sum = acc1 + acc2; + EXPECT_NEAR(_vte->_vehicle_acc_ned_sum(0), acc_sum(0), kTolerance); + EXPECT_NEAR(_vte->_vehicle_acc_ned_sum(1), acc_sum(1), kTolerance); + EXPECT_NEAR(_vte->_vehicle_acc_ned_sum(2), acc_sum(2), kTolerance); + + vte_test::advanceMicroseconds(VisionTargetEstTestable::kPosUpdatePeriodUs + 1_ms); + const matrix::Vector3f acc3(2.f, 6.f, 1.f); + _vte->updatePosEst(local_pose, false, acc3, gps_offset, vel_offset, false); + + ASSERT_TRUE(_vte_input_sub->update()); + const auto msg = _vte_input_sub->get(); + const matrix::Vector3f mean_acc = (acc1 + acc2 + acc3) / 3.f; + EXPECT_NEAR(msg.acc_xyz[0], mean_acc(0), kTolerance); + EXPECT_NEAR(msg.acc_xyz[1], mean_acc(1), kTolerance); + EXPECT_NEAR(msg.acc_xyz[2], mean_acc(2), kTolerance); + + EXPECT_EQ(_vte->_acc_sample_count, 0u); +} + +TEST_F(VisionTargetEstTest, StartEstimatorsEnforceRestartTimeout) +{ + // WHY: Restart timeout prevents rapid toggling of estimators. + // WHAT: Start, stop, and verify restart is blocked until the timeout passes. + _vte->_vte_position_enabled = true; + _vte->_vte_orientation_enabled = true; + _vte->_current_task.value = 0; + + const hrt_abstime now = vte_test::nowUs(); + const hrt_abstime past_time = now - (VisionTargetEstTestable::kEstRestartTimeUs + 1_ms); + _vte->_vte_position_stop_time = past_time; + _vte->_vte_orientation_stop_time = past_time; + + EXPECT_TRUE(_vte->startPosEst()); + EXPECT_TRUE(_vte->startYawEst()); + + _vte->stopPosEst(); + _vte->stopYawEst(); + + EXPECT_FALSE(_vte->startPosEst()); + EXPECT_FALSE(_vte->startYawEst()); + + _vte->_vte_position_stop_time = + vte_test::nowUs() - (VisionTargetEstTestable::kEstRestartTimeUs - 1_ms); + _vte->_vte_orientation_stop_time = + vte_test::nowUs() - (VisionTargetEstTestable::kEstRestartTimeUs - 1_ms); + + EXPECT_FALSE(_vte->startPosEst()); + EXPECT_FALSE(_vte->startYawEst()); + + vte_test::advanceMicroseconds(2_ms); + EXPECT_TRUE(_vte->startPosEst()); + EXPECT_TRUE(_vte->startYawEst()); +} + +TEST_F(VisionTargetEstTest, UpdateParamsStopsRunningEstimatorsWhenFusionDisabled) +{ + // WHY: Disabling the aid mask at runtime should stop any estimator that no longer has inputs. + // WHAT: Mark both estimators running, then clear VTE_AID_MASK and verify they are stopped. + _vte->_vte_position_enabled = true; + _vte->_vte_orientation_enabled = true; + _vte->_position_estimator_running = true; + _vte->_orientation_estimator_running = true; + + vte::SensorFusionMaskU aid_mask{}; + aid_mask.flags.use_vision_pos = 1; + setParamInt("VTE_AID_MASK", aid_mask.value); + _vte->updateParams(); + + EXPECT_TRUE(_vte->_position_estimator_running); + EXPECT_TRUE(_vte->_orientation_estimator_running); + + setParamInt("VTE_AID_MASK", 0); + _vte->updateParams(); + + EXPECT_FALSE(_vte->_position_estimator_running); + EXPECT_FALSE(_vte->_orientation_estimator_running); + EXPECT_GT(_vte->_vte_position_stop_time, 0u); + EXPECT_GT(_vte->_vte_orientation_stop_time, 0u); +} + +TEST_F(VisionTargetEstTest, EstimatorUpdatePeriod) +{ + // WHY: Changing the frequency of the filter has consequences. + // If the period is changed, the OOSM windows: + // OOSMManager _oosm; + // OOSMManager _oosm; + // must be updated accordingly + // Other constants such as kAccUpdatedTimeoutUs or kMinAccDownsampleTimeoutUs + ASSERT_EQ(VisionTargetEstTestable::kPosUpdatePeriodUs, 20_ms); + ASSERT_EQ(VisionTargetEstTestable::kYawUpdatePeriodUs, 20_ms); + EXPECT_LT(VisionTargetEstTestable::kAccUpdatedTimeoutUs, VisionTargetEstTestable::kMinAccDownsampleTimeoutUs); +} + +TEST_F(VisionTargetEstTest, AllEstStoppedDueToTimeoutStopsPositionEstimator) +{ + // WHY: Timeout detection must stop a running estimator and record stop time. + // WHAT: Initialize, force a timeout, and verify the estimator stops. + _vte->_vte_position_enabled = true; + _vte->_position_estimator_running = true; + _vte->_orientation_estimator_running = false; + + ASSERT_TRUE(_vte->_vte_position.init()); + vte::SensorFusionMaskU aid_mask{}; + aid_mask.flags.use_vision_pos = 1; + _vte->_vte_position.setVteAidMask(aid_mask.value); + + const hrt_abstime timestamp = vte_test::nowUs(); + _vte->_vte_position.setLocalVelocity(matrix::Vector3f{}, true, timestamp); + + uORB::Publication vision_pub{ORB_ID(fiducial_marker_pos_report)}; + ASSERT_TRUE(vte_test::publishVisionPos( + vision_pub, matrix::Vector3f(1.f, 0.f, -1.f), vte_test::identityQuat(), + matrix::Vector3f(0.01f, 0.01f, 0.01f), timestamp)); + + _vte->_vte_position.update(matrix::Vector3f{0.f, 0.f, 0.f}); + const hrt_abstime time_param_update = vte_test::nowUs(); + _vte->_vte_position.setVteTimeout(1_ms); + EXPECT_LT(_vte->_vte_position_stop_time, time_param_update); // running + + vte_test::advanceMicroseconds(2_ms); + + EXPECT_TRUE(_vte->allEstStoppedDueToTimeout()); + EXPECT_FALSE(_vte->_position_estimator_running); + EXPECT_GT(_vte->_vte_position_stop_time, time_param_update); +} + +TEST_F(VisionTargetEstTest, AllEstStoppedDueToTimeoutStopsOrientationEstimator) +{ + // WHY: Timeout detection must stop a running yaw estimator and record stop time. + // WHAT: Initialize, force a timeout, and verify the estimator stops. + _vte->_vte_orientation_enabled = true; + _vte->_orientation_estimator_running = true; + _vte->_position_estimator_running = false; + + ASSERT_TRUE(_vte->_vte_orientation.init()); + vte::SensorFusionMaskU aid_mask{}; + aid_mask.flags.use_vision_pos = 1; + _vte->_vte_orientation.setVteAidMask(aid_mask.value); + + const hrt_abstime timestamp = vte_test::nowUs(); + uORB::Publication vision_yaw_pub{ORB_ID(fiducial_marker_yaw_report)}; + ASSERT_TRUE(vte_test::publishVisionYaw(vision_yaw_pub, 0.2f, 0.01f, timestamp)); + + _vte->_vte_orientation.update(); + const hrt_abstime time_param_update = vte_test::nowUs(); + _vte->_vte_orientation.setVteTimeout(1_ms); + EXPECT_LT(_vte->_vte_orientation_stop_time, time_param_update); // running + + vte_test::advanceMicroseconds(2_ms); + + EXPECT_TRUE(_vte->allEstStoppedDueToTimeout()); + EXPECT_FALSE(_vte->_orientation_estimator_running); + EXPECT_GT(_vte->_vte_orientation_stop_time, time_param_update); +} + +TEST_F(VisionTargetEstTest, UpdateEstimatorsHandlesMissingLocalPose) +{ + // WHY: Estimator updates should tolerate missing local pose data. + // WHAT: Provide attitude and acceleration only and ensure update proceeds. + _vte->_vte_position_enabled = true; + _vte->_position_estimator_running = true; + + const hrt_abstime timestamp = vte_test::nowUs(); + publishAttitude(vte_test::identityQuat(), timestamp); + publishAcceleration(matrix::Vector3f(0.1f, -0.2f, 9.5f), timestamp); + + _vte->_last_update_pos = timestamp - (VisionTargetEstTestable::kPosUpdatePeriodUs + 1_ms); + _vte->_last_acc_reset = timestamp; + + _vte->updateEstimators(); + + ASSERT_TRUE(_vte_input_sub->update()); +} + +TEST_F(VisionTargetEstTest, UpdateEstimatorsWarnsOnStaleAcceleration) +{ + // WHY: Stale acceleration should reset downsampling and update warning timestamp. + // WHAT: Publish stale accel data and verify reset and warning tracking. + _vte->_vte_position_enabled = true; + _vte->_position_estimator_running = true; + _vte->_acc_sample_count = 2; + _vte->_vehicle_acc_ned_sum = matrix::Vector3f(1.f, 2.f, 3.f); + + const hrt_abstime warn_before = _vte->_acc_sample_warn_last; + const hrt_abstime timestamp = vte_test::nowUs(); + publishAttitude(vte_test::identityQuat(), timestamp); + + const hrt_abstime stale_timestamp = + timestamp - (VisionTargetEstTestable::kAccUpdatedTimeoutUs + 1_ms); + publishAcceleration(matrix::Vector3f(0.1f, -0.2f, 9.5f), stale_timestamp); + + _vte->updateEstimators(); + + EXPECT_EQ(_vte->_acc_sample_count, 0u); + EXPECT_NEAR(_vte->_vehicle_acc_ned_sum(0), 0.f, kTolerance); + EXPECT_NEAR(_vte->_vehicle_acc_ned_sum(1), 0.f, kTolerance); + EXPECT_NEAR(_vte->_vehicle_acc_ned_sum(2), 0.f, kTolerance); + EXPECT_GT(_vte->_acc_sample_warn_last, warn_before); +} diff --git a/src/modules/vision_target_estimator/test/VTETestHelper.hpp b/src/modules/vision_target_estimator/test/VTETestHelper.hpp new file mode 100644 index 000000000000..fbee32498556 --- /dev/null +++ b/src/modules/vision_target_estimator/test/VTETestHelper.hpp @@ -0,0 +1,284 @@ +/**************************************************************************** + * + * Copyright (c) 2025 PX4 Development Team. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * 3. Neither the name PX4 nor the names of its contributors may be + * used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + ****************************************************************************/ + +/** + * @file VTETestHelper.hpp + * @brief Helper functions for Vision Target Estimator unit tests + * + * @author Jonas Perolini + * + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace vte_test +{ + +class ParamGuard +{ +public: + ParamGuard() = default; + ~ParamGuard() { restore(); } + + bool setFloat(const char *name, float value) + { + param_t handle = param_find(name); + + if (handle == PARAM_INVALID) { + return false; + } + + if (param_type(handle) != PARAM_TYPE_FLOAT) { + return false; + } + + if (!storeOriginal(handle)) { + return false; + } + + return param_set(handle, &value) == 0; + } + + bool setInt(const char *name, int32_t value) + { + param_t handle = param_find(name); + + if (handle == PARAM_INVALID) { + return false; + } + + if (param_type(handle) != PARAM_TYPE_INT32) { + return false; + } + + if (!storeOriginal(handle)) { + return false; + } + + return param_set(handle, &value) == 0; + } + + void restore() + { + for (auto it = _entries.rbegin(); it != _entries.rend(); ++it) { + if (it->type == PARAM_TYPE_FLOAT) { + (void)param_set(it->handle, &it->value.f); + + } else if (it->type == PARAM_TYPE_INT32) { + (void)param_set(it->handle, &it->value.i); + } + } + + _entries.clear(); + } + +private: + struct ParamEntry { + param_t handle{PARAM_INVALID}; + param_type_t type{PARAM_TYPE_INT32}; + + union { + float f; + int32_t i; + } value{}; + }; + + std::vector _entries{}; + + bool storeOriginal(param_t handle) + { + for (const auto &entry : _entries) { + if (entry.handle == handle) { + return true; + } + } + + ParamEntry entry{}; + entry.handle = handle; + entry.type = param_type(handle); + + if (entry.type == PARAM_TYPE_FLOAT) { + if (param_get(handle, &entry.value.f) != 0) { + return false; + } + + } else if (entry.type == PARAM_TYPE_INT32) { + if (param_get(handle, &entry.value.i) != 0) { + return false; + } + + } else { + return false; + } + + _entries.push_back(entry); + return true; + } +}; + +inline matrix::Quaternionf identityQuat() +{ + matrix::Quaternionf q{1.f, 0.f, 0.f, 0.f}; + return q; +} + +template +void flushSubscription(Subscription &sub) +{ + while (sub.update()) {} +} + +template +void flushSubscription(std::unique_ptr &sub) +{ + if (!sub) { + return; + } + + while (sub->update()) {} +} + +template +void flushSubscription(Subscription &sub) +{ + Msg msg{}; + + while (sub.update(&msg)) {} +} + +inline hrt_abstime advanceMicroseconds(hrt_abstime delta_us) +{ + if (delta_us > 0) { + px4_usleep(static_cast(delta_us)); + } + + return hrt_absolute_time(); +} + +inline hrt_abstime nowUs() +{ + return hrt_absolute_time(); +} + +inline bool publishVisionYaw(uORB::Publication &pub, + float yaw, float yaw_var, hrt_abstime timestamp) +{ + fiducial_marker_yaw_report_s msg{}; + msg.timestamp = timestamp; + msg.timestamp_sample = timestamp; + msg.yaw_ned = yaw; + msg.yaw_var_ned = yaw_var; + return pub.publish(msg); +} + +inline bool publishVisionPos(uORB::Publication &pub, + const matrix::Vector3f &rel_pos, const matrix::Quaternionf &q, + const matrix::Vector3f &cov, hrt_abstime timestamp) +{ + fiducial_marker_pos_report_s msg{}; + msg.timestamp = timestamp; + msg.timestamp_sample = timestamp; + msg.rel_pos[0] = rel_pos(0); + msg.rel_pos[1] = rel_pos(1); + msg.rel_pos[2] = rel_pos(2); + msg.cov_rel_pos[0] = cov(0); + msg.cov_rel_pos[1] = cov(1); + msg.cov_rel_pos[2] = cov(2); + q.copyTo(msg.q); + return pub.publish(msg); +} + +inline bool publishUavGps(uORB::Publication &pub, double lat, double lon, float alt, + float eph, float epv, const matrix::Vector3f &vel_ned, float vel_var, + bool vel_valid, hrt_abstime timestamp, + const matrix::Vector3f &antenna_offset = matrix::Vector3f{}) +{ + sensor_gps_s msg{}; + msg.timestamp = timestamp; + msg.timestamp_sample = timestamp; + msg.latitude_deg = static_cast(lat); + msg.longitude_deg = static_cast(lon); + msg.altitude_msl_m = static_cast(alt); + msg.eph = eph; + msg.epv = epv; + msg.vel_n_m_s = vel_ned(0); + msg.vel_e_m_s = vel_ned(1); + msg.vel_d_m_s = vel_ned(2); + msg.s_variance_m_s = vel_var; + msg.vel_ned_valid = vel_valid; + msg.antenna_offset_x = antenna_offset(0); + msg.antenna_offset_y = antenna_offset(1); + msg.antenna_offset_z = antenna_offset(2); + return pub.publish(msg); +} + +inline bool publishTargetGnss(uORB::Publication &pub, double lat, double lon, float alt, + float eph, float epv, hrt_abstime timestamp, bool abs_pos_updated, + bool vel_ned_updated = false, const matrix::Vector3f &vel_ned = matrix::Vector3f{}, + float vel_acc = NAN) +{ + target_gnss_s msg{}; + msg.timestamp = timestamp; + msg.timestamp_sample = timestamp; + msg.latitude_deg = static_cast(lat); + msg.longitude_deg = static_cast(lon); + msg.altitude_msl_m = static_cast(alt); + msg.eph = eph; + msg.epv = epv; + msg.abs_pos_updated = abs_pos_updated; + msg.vel_ned_updated = vel_ned_updated; + msg.vel_n_m_s = vel_ned(0); + msg.vel_e_m_s = vel_ned(1); + msg.vel_d_m_s = vel_ned(2); + msg.s_acc_m_s = vel_acc; + return pub.publish(msg); +} + +} // namespace vte_test diff --git a/src/modules/vision_target_estimator/vision_target_estimator_params.yaml b/src/modules/vision_target_estimator/vision_target_estimator_params.yaml new file mode 100644 index 000000000000..e9c828bb46ea --- /dev/null +++ b/src/modules/vision_target_estimator/vision_target_estimator_params.yaml @@ -0,0 +1,333 @@ +module_name: vision_target_estimator +parameters: +- group: Vision Target Estimator + definitions: + VTE_EN: + description: + short: Vision Target Estimator module enable + type: boolean + default: 1 + reboot_required: true + VTE_YAW_EN: + description: + short: Vision Target Estimator module enable orientation estimation + type: boolean + default: 0 + reboot_required: true + VTE_POS_EN: + description: + short: Vision Target Estimator module enable position estimation + type: boolean + default: 1 + reboot_required: true + VTE_EKF_AID: + description: + short: Use the target relative velocity to aid the EKF state estimation + type: boolean + default: 1 + VTE_AID_MASK: + description: + short: Integer bitmask controlling data fusion and aiding methods + long: |- + Set bits in the following positions to enable: + 0 : Set to true to use the target's GNSS position data if available. (+1) + 1 : Set to true to use the UAV GNSS velocity data if available. (+2) + 2 : Set to true to use the target relative position from vision-based data if available. (+4) + 3 : Set to true to use the mission land point. Ignored if target GNSS position enabled. (+8) + 4 : Set to true to use the target GNSS velocity if available. (+16) + type: bitmask + bit: + 0: target GNSS position + 1: UAV GNSS velocity + 2: vision relative position + 3: mission position + 4: target GNSS velocity + default: 14 + VTE_TASK_MASK: + description: + short: Integer bitmask controlling the tasks of the target estimator + long: |- + Set bits in the following positions to enable: + 0 : Set to true to use the vision target estimator for precision landing. (+1) + 1 : DEBUG, always active. (+2) + type: bitmask + bit: + 0: precision landing + 1: DEBUG, always active + default: 1 + VTE_BTOUT: + description: + short: Vision target timeout + long: Time after which the target is considered lost without any new position measurements. Velocity measurements are not considered. + type: float + default: 3.0 + unit: s + min: 0.0 + max: 50 + decimal: 1 + increment: 0.5 + VTE_TGT_TOUT: + description: + short: Target validity timeout + long: Maximum time without any fused measurements before the published relative pose/yaw is flagged invalid. + type: float + default: 2.0 + unit: s + min: 0.0 + max: 50 + decimal: 2 + increment: 0.25 + VTE_M_REC_TOUT: + description: + short: Measurement recent timeout + long: Time window for considering a raw measurement as recent and eligible for fusion. + type: float + default: 1.0 + unit: s + min: 0.0 + max: 10 + decimal: 2 + increment: 0.05 + VTE_M_UPD_TOUT: + description: + short: Measurement updated timeout + long: Maximum allowed age of a retained measurement before it is discarded from the estimator's cache. + type: float + default: 0.1 + unit: s + min: 0.0 + max: 5 + decimal: 3 + increment: 0.01 + VTE_ACC_D_UNC: + description: + short: Drone acceleration uncertainty + long: |- + Variance of the drone's acceleration used for target position prediction. + Higher values result in tighter following of the measurements and more lenient outlier rejection. + type: float + default: 1.0 + unit: (m/s^2)^2 + min: 0.01 + decimal: 2 + VTE_ACC_T_UNC: + description: + short: Target acceleration uncertainty + long: |- + Variance of target acceleration in NED frame used for target position prediction. + Higher values result in tighter following of the measurements and more lenient outlier rejection. + Default value 0.1 implies that the target acceleration can change sqrt(0.1) = 0.3 m/s^2 per second. + Unit: ((m/s^2)^2)/s + type: float + default: 0.1 + min: 0.01 + decimal: 2 + VTE_BIAS_UNC: + description: + short: Bias uncertainty + long: |- + Variance of GPS bias used for target position prediction. + Higher values result in tighter following of the measurements and more lenient outlier rejection. + Default value 0.01 implies that the bias can drift roughly sqrt(0.01) = 0.1 m = 10 cm per second. + Min value 0.001 implies that the bias can drift roughly sqrt(0.001) = 0.03 m = 3 cm per second. + Unit: m^2/s + type: float + default: 0.01 + min: 0.001 + decimal: 3 + VTE_BIAS_LIM: + description: + short: Bias limit + long: Not handled yet. Maximum bias between the drone's GNSS and the target's GNSS. + type: float + default: 1.0 + unit: m^2 + min: 0.01 + decimal: 2 + VTE_POS_UNC_IN: + description: + short: Initial target and drone relative position uncertainty + long: |- + Initial variance of the relative target position in x, y, and z direction. + Changes while the estimator is running are picked up immediately by the module but only take effect + the next time the estimator initializes or resets. + type: float + default: 0.5 + unit: m^2 + min: 0.001 + decimal: 3 + VTE_VEL_UNC_IN: + description: + short: Initial target and drone relative velocity uncertainty + long: |- + Initial variance of the relative target velocity in x, y, and z directions. + Changes while the estimator is running are picked up immediately by the module but only take effect + the next time the estimator initializes or resets. + type: float + default: 0.5 + unit: (m/s)^2 + min: 0.001 + decimal: 3 + VTE_BIA_UNC_IN: + description: + short: Initial GPS bias uncertainty + long: |- + Initial variance of the bias between the GPS on the target and the GPS on the drone. + Changes while the estimator is running are picked up immediately by the module but only take effect + the next time the estimator initializes or resets. + type: float + default: 1.0 + unit: m^2 + min: 0.001 + decimal: 3 + VTE_BIA_AVG_THR: + description: + short: Initial GNSS/vision bias averaging threshold + long: |- + When the GNSS/vision bias first becomes observable while the estimator is still referenced to GNSS, + VTE low-pass filters the first GNSS/vision bias samples before activating the bias estimate. + The LPF exit requires consecutive raw bias samples to stay within this threshold and for the LPF + to have run for at least 2 * tau, or until VTE_BIA_AVG_TOUT elapses. + type: float + default: 0.3 + unit: m + min: 0.0 + decimal: 2 + VTE_BIA_AVG_TOUT: + description: + short: Initial GNSS/vision bias averaging timeout + long: |- + Maximum time spent averaging the initial GNSS bias before activating it once GNSS and vision + are jointly observable while the estimator is still referenced to GNSS. During that phase, valid + vision samples keep updating the LPF. While the current GNSS-relative measurement stays valid, + it can be propagated with the UAV velocity estimate to match the vision timestamp. If that GNSS + sample is no longer valid, averaging stops, the current vision position is used, and the current + LPF bias is activated. + + Set to 0 to disable averaging and activate the bias immediately. + type: float + default: 1.0 + unit: s + min: 0.0 + decimal: 2 + VTE_YAW_UNC_IN: + description: + short: Initial orientation uncertainty + long: |- + Initial variance of the target orientation yaw in rad^2. + Changes while the estimator is running are picked up immediately by the module but only take effect + the next time the estimator initializes or resets. + type: float + default: 1.0 + min: 0.001 + decimal: 3 + VTE_YAW_ACC_UNC: + description: + short: Yaw acceleration uncertainty + long: |- + Variance of the yaw acceleration that drives the yaw rate state. + Unit: (rad/s^2)^2 + type: float + default: 0.04 + min: 0.0 + decimal: 3 + VTE_ACC_UNC_IN: + description: + short: Initial target absolute acceleration uncertainty + long: |- + Initial variance of the relative target acceleration in x, y, and z directions. + Changes while the estimator is running are picked up immediately by the module but only take effect + the next time the estimator initializes or resets. + type: float + default: 0.1 + unit: (m/s^2)^2 + min: 0.001 + decimal: 3 + VTE_GPS_V_NOISE: + description: + short: Measurement noise for GPS horizontal velocity + long: Minimum allowed observation noise for GPS velocity fusion. + type: float + default: 0.3 + unit: m/s + min: 0.01 + max: 5.0 + decimal: 2 + VTE_GPS_P_NOISE: + description: + short: Measurement noise for GPS position + long: Minimum allowed observation noise for GPS position fusion. + type: float + default: 0.5 + unit: m + min: 0.01 + max: 10.0 + decimal: 2 + VTE_EVA_NOISE: + description: + short: Minimum measurement noise for vision angle observations + type: float + default: 0.05 + min: 0.05 + unit: rad + decimal: 2 + VTE_EVP_NOISE: + description: + short: Minimum measurement noise for vision position observations + type: float + default: 0.1 + min: 0.01 + unit: m + decimal: 2 + VTE_MOVING_T_MAX: + description: + short: Maximum time for target forward-position estimation + long: |- + When the target is moving, the position setpoint is set where the target will be after a given time in the future. + This parameter should be set depending on the expected target speed. The greater the speed, the greater VTE_MOVING_T_MAX. + type: float + default: 3.0 + min: 0.1 + max: 60 + unit: s + decimal: 2 + VTE_MOVING_T_MIN: + description: + short: Minimum time for target forward-position estimation + long: |- + When the target is moving, the position setpoint is set where the target will be after a given time in the future. + As the drone gets close to the target, the time of intersection between the target and the drone gets smaller. + To avoid missing the target, a minimum time is required. This parameter should be set depending on the expected + target speed. The greater the speed, the greater VTE_MOVING_T_MIN. + type: float + default: 2.0 + min: 0.1 + max: 30 + unit: s + decimal: 2 + VTE_POS_NIS_THRE: + description: + short: Normalized innovation squared threshold for the position estimator + long: |- + Lower values mean that more measurements will be rejected. Null hypothesis H0: the innovation is + consistent with the innovation covariance matrix. Values of 0.46, 1.64, 2.71, 3.84, 6.63, and 10.83 + correspond to 50%, 20%, 10%, 5%, 1%, and 0.1% probability respectively that H0 is incorrectly rejected. + The confidence interval is computed from the chi-squared distribution. + type: float + default: 3.84 + min: 0.46 + max: 10.83 + decimal: 2 + VTE_YAW_NIS_THRE: + description: + short: Normalized innovation squared threshold for the orientation estimator + long: |- + Lower values mean that more measurements will be rejected. Null hypothesis H0: the innovation is + consistent with the innovation covariance matrix. Values of 0.46, 1.64, 2.71, 3.84, 6.63, and 10.83 + correspond to 50%, 20%, 10%, 5%, 1%, and 0.1% probability respectively that H0 is incorrectly rejected. + The confidence interval is computed from the chi-squared distribution. + type: float + default: 3.84 + min: 0.46 + max: 10.83 + decimal: 2