--[[----------------------------------------------------------------------------

TerrainDetector ArduPilot Lua script

Uses gyro and accelerometer data to detect rough terrain and slow the
vehicle's speed accordingly during auto missions.

Provides a set of custom tuning parameters:
ROUGH_SPEED     : (m/s) rough terrain desired speed, set to -1 to disable detection
ROUGH_TIMEOUT_MS: (ms) min time to remain slowed down for rough terrain
ROUGH_GZ_MAX    : (G) slow down on Gz impulses greater than this value
ROUGH_RATE_MAX  : (deg/s) slow down on rate transients greater than this value

These gain values are used for smooth terrain detection.
Smaller values (less than 1.0) are more restrictive about resuming normal speed.
ROUGH_GZ_GAIN   : Gz impulse gain
ROUGH_RATE_GAIN : gyro transient rate gain

CAUTION: This script is capable of engaging and disengaging autonomous control
of a vehicle.  Use this script AT YOUR OWN RISK.

-- Yuri -- Jul 2022

LICENSE - GNU GPLv3 https://www.gnu.org/licenses/gpl-3.0.en.html

Concept first presented during this live stream:
https://www.youtube.com/watch?v=UdXGXjigxAo&t=7155s
Credit to @ktrussell for the idea and discussion!
------------------------------------------------------------------------------]]

local SCRIPT_NAME     = 'TerrainDetector'
local RUN_INTERVAL_MS =  25  -- needs to be pretty fast for good detection
local SBY_INTERVAL_MS = 500  -- slower interval when detection is disabled
local PARAM_TABLE_KEY = 117  -- unique index value between 0 and 200

-- GCS messages
local VERBOSE_MODE = 1 -- 0 to suppress all GCS messages,
                       -- 1 for normal status messages
                       -- 2 for additional debug messages
local MSG_NORMAL   = 1
local MSG_DEBUG    = 2

-- MAVLink values
local MAV_SEVERITY_WARNING = 4
local MAV_SEVERITY_INFO    = 6

-- mathematical/physical constants
local G          = -9.81 -- m/s/s

-- create custom parameter set
local function add_params(key, prefix, tbl)
    assert(param:add_table(key, prefix, #tbl), string.format('Could not add %s param table.', prefix))
    for num = 1, #tbl do
        assert(param:add_param(key, num,  tbl[num][1], tbl[num][2]), string.format('Could not add %s%s.', prefix, tbl[num][1]))
    end
end

add_params(PARAM_TABLE_KEY, 'ROUGH_', {
    --  { name, default value },
        { 'SPEED',        0.7 },
        { 'GZ_MAX',      1.33 },
        { 'RATE_MAX',      28 },
        { 'GZ_GAIN',      0.9 },
        { 'RATE_GAIN',    0.8 },
        { 'TIMEOUT_MS',  7500 }
    })

-- wrapper for gcs:send_text()
local function gcs_msg(msg_type, severity, txt)
    if type(msg_type) == 'string' then
    -- allow just a string to be passed for simple/routine messages
        txt      = msg_type
        msg_type = MSG_NORMAL
        severity = MAV_SEVERITY_INFO
    end
    if type(severity) == 'string' then
    -- allow just severity and string to be passed for normal messages
        txt = severity
        severity = msg_type
        msg_type = MSG_NORMAL
    end
    if msg_type <= VERBOSE_MODE then
        gcs:send_text(severity, string.format('%s: %s', SCRIPT_NAME, txt))
    end
end

local last_g_z   = 0  -- to calculate impulse Gz
local timeout_ms = 0

-- debug values
local max_g_z       = 0
local max_gyro_rate = 0

local wp_speed_normal = nil
local wp_speed_rough = nil
local impulse_gz_threshold = nil
local gyro_rate_threshold = nil
local rough_terrain_timeout_ms = nil
local impulse_gain = nil
local gyro_gain = nil

function standby()
    wp_speed_rough = param:get('ROUGH_SPEED')
    if wp_speed_rough < 0 then return standby, SBY_INTERVAL_MS end

    if mission:state() == mission.MISSION_RUNNING then
        -- only poll remaining parameters at start of mission
        wp_speed_normal          = param:get('WP_SPEED')
        impulse_gz_threshold     = param:get('ROUGH_GZ_MAX')
        gyro_rate_threshold      = param:get('ROUGH_RATE_MAX')
        rough_terrain_timeout_ms = param:get('ROUGH_TIMEOUT_MS')
        impulse_gain             = param:get('ROUGH_GZ_GAIN')
        gyro_gain                = param:get('ROUGH_RATE_GAIN')
        -- if ROUGH_SPEED param not set or invalid, use half of WP_SPEED
        if wp_speed_rough == 0 or wp_speed_rough > wp_speed_normal then
            wp_speed_rough = wp_speed_normal / 2
            gcs_msg(MAV_SEVERITY_WARNING, 'ROUGH_SPEED invalid, using half WP_SPEED')
        end

        last_g_z = 0
        return do_normal_speed, RUN_INTERVAL_MS
    end
    return standby, SBY_INTERVAL_MS
end

function initiate_rough_speed()
    gcs_msg(MSG_NORMAL, MAV_SEVERITY_WARNING, 'Slowing for rough terrain')
    vehicle:set_desired_speed(wp_speed_rough)
    last_g_z   = 0
    timeout_ms = millis() + rough_terrain_timeout_ms
    max_g_z       = 0
    max_gyro_rate = 0
    return do_rough_speed, RUN_INTERVAL_MS
end

function initiate_normal_speed()
    gcs_msg(MSG_NORMAL, MAV_SEVERITY_WARNING, 'Resuming normal speed')
    vehicle:set_desired_speed(wp_speed_normal)
    last_g_z = 0
    return do_normal_speed, RUN_INTERVAL_MS
end

function do_normal_speed()
    if mission:state() ~= mission.MISSION_RUNNING then
        return standby, SBY_INTERVAL_MS
    end

    local g_z         = ahrs:get_accel():z() / G  -- convert to G from m/s/s
    local gyro        = ahrs:get_gyro()
    -- sample max of roll/pitch rates
    local gyro_rate   = math.deg(math.max(math.abs(gyro:x()), math.abs(gyro:y())))
    local impulse_g_z = math.abs(g_z - last_g_z)  -- sample delta is the measured impulse
    last_g_z = g_z

    if impulse_g_z > impulse_gz_threshold then  -- slow down
        gcs_msg(MSG_DEBUG, MAV_SEVERITY_INFO, string.format('Impulse - %.2f Gz', impulse_g_z))
        return initiate_rough_speed, RUN_INTERVAL_MS
    end

    if gyro_rate > gyro_rate_threshold then  -- slow down
        gcs_msg(MSG_DEBUG, MAV_SEVERITY_INFO, string.format('Transient - %.2f deg/s', gyro_rate))
        return initiate_rough_speed, RUN_INTERVAL_MS
    end

    return do_normal_speed, RUN_INTERVAL_MS
end

function do_rough_speed()
    if mission:state() ~= mission.MISSION_RUNNING then
        return standby, SBY_INTERVAL_MS
    end

    local g_z         = ahrs:get_accel():z() / G  -- convert to G from m/s/s
    local gyro        = ahrs:get_gyro()
    -- sample max of roll/pitch rates
    local gyro_rate   = math.deg(math.max(math.abs(gyro:x()), math.abs(gyro:y())))
    local impulse_g_z = math.abs(g_z - last_g_z) -- sample delta is the measured impulse
    last_g_z = g_z

    max_g_z = math.max(g_z, max_g_z)
    max_gyro_rate = math.max(gyro_rate, max_gyro_rate)

    local now = millis()

    if impulse_g_z > impulse_gz_threshold * impulse_gain then  -- stay slow
        gcs_msg(MSG_DEBUG, MAV_SEVERITY_INFO, string.format('Timer reset - %.2fGz', impulse_g_z))
        max_g_z = 0
        timeout_ms = now + rough_terrain_timeout_ms
        return do_rough_speed, RUN_INTERVAL_MS
    end

    if gyro_rate > gyro_rate_threshold * gyro_gain then  -- stay slow
        gcs_msg(MSG_DEBUG, MAV_SEVERITY_INFO, string.format('Timer reset - %.2f deg/s', gyro_rate))
        max_gyro_rate = 0
        timeout_ms = now + rough_terrain_timeout_ms
        return do_rough_speed, RUN_INTERVAL_MS
    end

    if now > timeout_ms then  -- no longer in rough terrain
        gcs_msg(MSG_DEBUG, MAV_SEVERITY_INFO, string.format('Max - %.2fGz ... %.2f deg/s', max_g_z, max_gyro_rate))
        return initiate_normal_speed, RUN_INTERVAL_MS
    end

    return do_rough_speed, RUN_INTERVAL_MS
end

gcs_msg('Script active')

return standby, SBY_INTERVAL_MS
