Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LUA example Glide into wind #27413

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
399 changes: 399 additions & 0 deletions libraries/AP_Scripting/examples/glide_into_wind.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,399 @@
-- Glide into wind, LUA script for glide into wind functionality

-- Background
-- When flying a fixed-wing drone on ad-hoc BVLOS missions, it might not be
-- suitable for the drone to return to home if the C2 link is lost, since that
-- might mean flying without control for an extended time and distance. One
-- option in ArduPlane is to set FS_Long to Glide, which makes the drone glide
-- and land in the direction it happened to have when the command was invoked,
-- without regard to the wind. This script offers a way to decrease the kinetic
-- energy in this blind landing by means of steering the drone towards the wind
-- as GLIDE is initiated, hence lowering the ground speed. The intention is to
-- minimize impact energy at landing - foremost for any third party, but also to
-- minimize damage to the drone.

-- Functionality and setup
-- 1. Set SCR_ENABLE = 1
-- 2. Put script in scripts folder, boot twice
-- 3. A new parameter has appeared:
-- - GLIDE_WIND_ENABL (0=disable, 1=enable)
-- 4. Set GLIDE_WIND_ENABL = 1
-- 5. Read the docs on FS:
-- https://ardupilot.org/plane/docs/apms-failsafe-function.html#failsafe-parameters-and-their-meanings
-- 6. Set FS_LONG_ACTN = 2
-- 7. Set FS_LONG_TIMEOUT as appropriate
-- 8. Set FS_GCS_ENABL = 1
-- 9. If in simulation, set SIM_WIND_SPD = 4 to get a reliable wind direction.
-- 10. Test in simulation: Fly a mission, disable heartbeats by typing 'set
-- heartbeat 0' into mavproxy/SITL, monitor what happens in the console. If
-- QGC or similar GCS is used, make sure it does not send heartbeats.
-- 11. Test in flight: Fly a mission, monitor estimated wind direction from GCS,
-- then fail GCS link and see what happens.
-- 12. Once heading is into wind script will stop steering and not steer again
-- until state machine is reset and failsafe is triggered again. Steering in low
-- airspeeds (thr=0) increases risks of stall and it is preferable touch
-- ground in level roll attitude. If the script parameter hdg_ok_lim is set
-- to tight or the wind estimate is not stable, the script will anyhow stop
-- steering after override_time_lim and enter FBWA - otherwise the script
-- would hinder the GLIDE fail safe.
-- 13. Script will stop interfering as soon as a new goto-point is received or
-- the flight mode is changed by the operator or the remote pilot.

-- During the fail safe maneuver a warning tune is played.

-- State machine
-- CAN_TRIGGER
-- - Do: Nothing
-- - Change state: If the failsafe GLIDE is triggered: if FS_GCS_ENABL is set
-- and FS_LONG_ACTN is 2, change to TRIGGERED else change to CANCELED
--
-- TRIGGERED
-- - Do: First use GUIDED mode to steer into wind, then switch to FBWA to
-- Glide into wind. Play warning tune.
-- - Change state: If flight mode is changed by operator/remote pilot or
-- operator/remote pilot sends a new goto point, change state to CANCELED
--
-- CANCELED
-- - Do: Nothing
-- - Change state: When new heart beat arrive, change state to CAN_TRIGGER

-- Credits
-- This script is developed by agising at UASolutions, commissioned by, and in
-- cooperation with Remote.aero, with funding from Swedish AeroEDIH, in response
-- to a need from the Swedish Sea Rescue Society (Sjöräddningssällskapet, SSRS).

-- Disable diagnostics related to reading parameters to pass linter
---@diagnostic disable: need-check-nil
---@diagnostic disable: param-type-mismatch

-- Tuning parameters
local looptime = 250 -- Short looptime
local long_looptime = 2000 -- Long looptime, GLIDE_WIND is not enabled
local tune_repeat_t = 1000 -- How often to play tune in glide into wind, [ms]
local hdg_ok_lim = 15 -- Acceptable heading error in deg (when to stop steering)
local hdg_ok_t_lim = 5000 -- Stop steering towards wind after hdg_ok_t_lim ms with error less than hdg_ok_lim
local override_time_lim = 15000 -- Max time in GUIDED during GLIDE, after limit set FBWA independent of hdg

-- GCS text levels
local _INFO = 6
local _WARNING = 4

-- Plane flight modes mapping
local mode_FBWA = 5
local mode_GUIDED = 15

-- Tunes
local _tune_glide_warn = "MFT240 L16 cdefgfgfgfg" -- The warning tone played during GLIDE_WIND

--State variable
local fs_state = nil

-- Flags
local override_enable = false -- Flag to allow RC channel loverride

-- Variables
local wind_dir_rad = nil -- param for wind dir in rad
local wind_dir_180 = nil -- param for wind dir in deg
local hdg_error = nil -- Heading error, hdg vs wind_dir_180
local gw_enable = nil -- glide into wind enable flag
local hdg = nil -- vehicle heading
local wind = Vector3f() -- wind 3Dvector
local link_lost_for = nil -- link loss time counter
local last_seen = nil -- timestamp last received heartbeat
local tune_time_since = 0 -- Timer for last played tune
local hdg_ok_t = 0 -- Timer
local expected_flight_mode = nil -- Flight mode set by this script
local location_here = nil -- Current location
local location_upwind = nil -- Location to hold the target location
local user_notified = false -- Flag to keep track user being notified or not
local failed_location_counter = 0 -- Counter for failed location requests, possible GPS denied
local upwind_distance = 500 -- Distance to the upwind location, minimum 4x turn radius
local override_time = 0 -- Time since override started in ms

-- Add param table
local PARAM_TABLE_KEY = 74
local PARAM_TABLE_PREFIX = "GLIDE_WIND_"
assert(param:add_table(PARAM_TABLE_KEY, PARAM_TABLE_PREFIX, 30), 'could not add param table')

-------
-- Init
-------

function _init()
-- Add and init paramters
GLIDE_WIND_ENABL = bind_add_param('ENABL', 1, 0)

-- Init parameters
FS_GCS_ENABL = bind_param('FS_GCS_ENABL') -- Is set to 1 if GCS lol should trigger FS after FS_LONG_TIMEOUT
FS_LONG_TIMEOUT = bind_param('FS_LONG_TIMEOUT') -- FS long timeout in seconds
FS_LONG_ACTN = bind_param('FS_LONG_ACTN') -- Is set to 2 for Glide

send_to_gcs(_INFO, 'LUA: FS_LONG_TIMEOUT timeout: ' .. FS_LONG_TIMEOUT:get() .. 's')

-- Test paramter
if GLIDE_WIND_ENABL:get() == nil then
send_to_gcs(_INFO, 'LUA: Something went wrong, GLIDE_WIND_ENABL not created')
return _init(), looptime
else
gw_enable = GLIDE_WIND_ENABL:get()
send_to_gcs(_INFO, 'LUA: GLIDE_WIND_ENABL: ' .. gw_enable)
end

-- Init last_seen.
last_seen = gcs:last_seen()

-- Init link_lost_for [ms] to FS_LONG_TIMEOUT [s] to prevent link to recover without
-- new heartbeat. This is to properly init the state machine.
link_lost_for = FS_LONG_TIMEOUT:get() * 1000

-- Warn if GLIDE_WIND_ENABL is set and FS_LONG_ACTN is not GLIDE
if gw_enable == 1 and FS_LONG_ACTN:get() ~= 2 then
send_to_gcs(_WARNING, 'GLIDE_WIND_ENABL is set, but FS_LONG_ACTN is not GLIDE.')
end

-- Init fs_state machine to CANCELED. A heartbeat is required to set the state
-- to CAN_TRIGGER from where Glide into wind can be triggered.
fs_state = 'CANCELED'

-- All set, go to update
return update(), long_looptime
end


------------
-- Main loop
------------

function update()
-- Check if state of GLIDE_WIND_ENABL parameter changed, print every change
if gw_enable ~= GLIDE_WIND_ENABL:get() then
gw_enable = GLIDE_WIND_ENABL:get()
send_to_gcs(_INFO, 'LUA: GLIDE_WIND_ENABL: ' .. gw_enable)
-- If GLIDE_WIND_ENABL was enabled, warn if not FS_LONG_ACTN is set accordingly
if gw_enable == 1 then
if FS_LONG_ACTN:get() ~=2 then
send_to_gcs(_WARNING, 'GLIDE_WIND_ENABL is set, but FS_LONG_ACTN is not GLIDE.')
end
end
end

-- -- If feature is not enabled, loop slowly
if gw_enable == 0 then
return update, long_looptime
end

-- GLIDE_WIND_ENABL is enabled, look for triggers
-- Monitor time since last gcs heartbeat
if last_seen == gcs:last_seen() then
link_lost_for = link_lost_for + looptime
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding times is usually a pattern considered harmful as things go really wonky when the numbers wrap.

It's not a problem here, but a pattern we do try to avoid.

I can't see why you haven't calculated link_lost_for where needed by subtracting gcs:last_seen() from the current time. That does mean finding a time in the same "frame" as gcs:last_seen(), but I think you'll find the current time routines will return that.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that adding up time like this builds bigger and bigger errors and in general is not a good solution.

The reason is that I cannot properly find the time, it baffles me but I'm not the only one. There is GNSS dependent solution to extract time from the GNSS signals, but does not make sense either.
The highest resolution of os.time() is seconds.

For this use, I think it is ok. As a general solution for checking how much time has elapsed it is not a good practice.

else
-- There has been a new heartbeat, update last_seen and reset link_lost_for
last_seen = gcs:last_seen()
link_lost_for = 0
end

-- Run the state machine
-- State CAN_TRIGGER
if fs_state == 'CAN_TRIGGER' then
if link_lost_for > FS_LONG_TIMEOUT:get() * 1000 then
-- Double check that FS_GCS_ENABL is set
if FS_GCS_ENABL:get() == 1 and FS_LONG_ACTN:get() == 2 then
fs_state = "TRIGGERED"
-- Reset some variables
hdg_ok_t = 0
user_notified = false
override_enable = true
override_time = 0
failed_location_counter = 0
-- Set mode to GUIDED before entering TRIGGERED state
set_flight_mode(mode_GUIDED, 'LUA: Glide into wind state TRIGGERED')
else
-- Do not trigger glide into wind, require new heart beats to get here again
fs_state = "CANCELED"
end
end
-- State TRIGGERED
elseif fs_state == "TRIGGERED" then
-- Check for flight mode changes from outside script
if vehicle:get_mode() ~= expected_flight_mode then
fs_state = "CANCELED"
send_to_gcs(_INFO, 'LUA: Glide into wind state CANCELED: flight mode change')
end

-- In GUIDED, check for target location changes from outside script (operator)
if vehicle:get_mode() == mode_GUIDED then
if not locations_are_equal(vehicle:get_target_location(), location_upwind) then
fs_state = "CANCELED"
send_to_gcs(_INFO, 'LUA: Glide into wind state CANCELED: new goto-point')
end
end

-- State CANCELED
elseif fs_state == "CANCELED" then
-- Await link is not lost
if link_lost_for < FS_LONG_TIMEOUT:get() * 1000 then
fs_state = "CAN_TRIGGER"
send_to_gcs(_INFO, 'LUA: Glide into wind state CAN_TRIGGER')
end
end

-- State TRIGGERED actions
if fs_state == "TRIGGERED" then
-- Get the heading angle
hdg = math.floor(math.deg(ahrs:get_yaw()))

-- Get wind direction. Function wind_estimate returns x and y for direction wind blows in, add pi to get true wind dir
wind = ahrs:wind_estimate()
wind_dir_rad = math.atan(wind:y(), wind:x())+math.pi
wind_dir_180 = math.floor(wrap_180(math.deg(wind_dir_rad)))
hdg_error = wrap_180(wind_dir_180 - hdg)

-- Check if we are close to target heading
if math.abs(hdg_error) < hdg_ok_lim then
-- If we have been close to target heading for hdg_ok_t_lim, switch back to FBWA
if hdg_ok_t > hdg_ok_t_lim then
if override_enable then
set_flight_mode(mode_FBWA,'LUA: Glide into wind steering complete, GLIDE in FBWA')
end
-- Do not override again until state machine has triggered again
override_enable = false
else
hdg_ok_t = hdg_ok_t + looptime
end
-- Heading error is big, reset timer hdg_ok_t
else
hdg_ok_t = 0
end

-- Play tune every tune_repeat_t [ms]
if tune_time_since > tune_repeat_t then
-- Play tune and reset timer
send_to_gcs(_INFO, 'LUA: Play warning tune')
play_tune(_tune_glide_warn)
tune_time_since = 0
else
tune_time_since = tune_time_since + looptime
end

-- If not steered into wind yet, update goto point into wind
if override_enable then
-- Check override time, if above limit, switch back to FBWA
override_time = override_time + looptime
if override_time > override_time_lim then
set_flight_mode(mode_FBWA, "LUA: Glide into wind override time out, GLIDE in current heading")
override_enable = false
end
-- Get current position and handle if not valid
location_here = ahrs:get_location()
if location_here == nil then
-- In case we cannot get location for some time we must give up and continue with GLIDE
failed_location_counter = failed_location_counter + 1
if failed_location_counter > 5 then
set_flight_mode(mode_FBWA, "LUA: Glide failed to get location, GLIDE in current heading")
override_enable = false
return update, looptime
end
gcs:send_text(_WARNING, "LUA: Glide failed to get location")
return update, looptime
end
-- Calc upwind position, copy and modify location_here
location_upwind = location_here:copy()
location_upwind:offset_bearing(wind_dir_180, upwind_distance)

-- Set location_upwind as GUIDED target
if vehicle:set_target_location(location_upwind) then
if not user_notified then
send_to_gcs(_INFO, "LUA: Guided target set " .. upwind_distance .. "m away at bearing " .. wind_dir_180)
-- Just notify once
user_notified = true
end
else
-- Most likely we are not in GUIDED anymore (operator changed mode), state machine will handle this in next loop.
gcs:send_text(_WARNING, "LUA: Glide failed to set upwind target")
end
end
end
return update, looptime
end


-------------------
-- Helper functions
-------------------

-- Set mode and wait for mode change
function set_flight_mode(mode, message)
expected_flight_mode = mode
vehicle:set_mode(expected_flight_mode)
return wait_for_mode_change(mode, message, 0)
end

-- Wait for mode change
function wait_for_mode_change(mode, message, attempt)
-- If mode change does not go through after 10 attempts, give up
if attempt > 10 then
send_to_gcs(_WARNING, 'LUA: Glide into wind mode change failed.')
return update, looptime
-- If mode change does not go through, wait and try again
elseif vehicle:get_mode() ~= mode then
return wait_for_mode_change(mode, message, attempt + 1), 5
-- Mode change has gone through
else
send_to_gcs(_INFO, message)
return update, looptime
end
end

-- Function to compare two Location objects
function locations_are_equal(loc1, loc2)
-- If either location is nil, they are not equal
if not loc1 or not loc2 then
return false
end
-- Compare latitude and longitude, return bool
return loc1:lat() == loc2:lat() and loc1:lng() == loc2:lng()
end

-- bind a parameter to a variable
function bind_param(name)
local p = Parameter()
assert(p:init(name), string.format('could not find %s parameter', name))
return p
end

-- Add a parameter and bind it to a variable
function bind_add_param(name, idx, default_value)
assert(param:add_param(PARAM_TABLE_KEY, idx, name, default_value), string.format('could not add param %s', name))
return bind_param(PARAM_TABLE_PREFIX .. name)
end

-- Print to GCS
function send_to_gcs(level, mess)
gcs:send_text(level, mess)
end

-- Play tune
function play_tune(tune)
notify:play_tune(tune)
end

-- Returns the angle in range 0-360
function wrap_360(angle)
local res = math.fmod(angle, 360.0)
if res < 0 then
res = res + 360.0
end
return res
end

-- Returns the angle in range -180-180
function wrap_180(angle)
local res = wrap_360(angle)
if res > 180 then
res = res - 360
end
return res
end

-- Start up the script
return _init, 2000
Loading