To answer my question: No, ground steering is not good for a large slow boat with passengers. PID tuning is voodoo magic.
What is good for the large slow boat: “Yaw-Rate Control Law, often used in aircraft and advanced marine autopilots” as it was proposed and then implemented in Lua script by ChatGPT. The approach is to maintain comfortable turning rate 2-3 degrees per second, and be sure that turning rate goes to zero when the boat gets to the right direction.
It works bloody beautiful. Boat goes in straight lines form WP to WP. Hours of river cruising without a need to touch the control. Everything I wanted but could not get from PID steering. There was 45kg dog walking from one board to another and changing the boat direction by moving centre of mass. The script adjusted every time the dog moved.
Code is below. No tuning. Increase KP and KR if you need a more aggressive turning. Too high numbers might result in overshot and oscillating a little bit around the direction line.
I have zero experience with ArduPilot and Lua programming, so all copyrights go to ChatGPT.
-- Yaw-Rate Control Steering Script for ArduPilot (Boat or Rover)
-- --- External Parameters ---
local PARAM_TABLE_PREFIX = "ASCR_"
local PARAM_TABLE_KEY = 22
assert(param:add_table(PARAM_TABLE_KEY, PARAM_TABLE_PREFIX, 10), "ASCR: could not add param table")
local rc_steering_channel = 1
-- --- Register and Initialize Parameters ---
local K_p_param = "ASCR_KP" -- Heading error to target yaw rate gain
local K_r_param = "ASCR_KR" -- Yaw rate error to rudder control gain
local rudder_limit_param = "ASCR_RUD_LIM" -- Max rudder angle (degrees)
local max_yaw_rate_param = "ASCR_MAX_YR" -- Max comfortable yaw rate (deg/s)
local servo_channel_param = "ASCR_SERVO_CH" -- Servo channel (0-based)
local alpha_param = "ASCR_ALPHA" -- Low-pass filter smoothing factor
local pwm_threshold_param = "ASCR_PWM_DB" -- Deadband threshold for PWM changes
local update_interval_param = "ASCR_UPD_INT" -- Update interval in ms
param:add_param(PARAM_TABLE_KEY, 1, "KP", 0.15)
param:add_param(PARAM_TABLE_KEY, 2, "KR", 0.85)
param:add_param(PARAM_TABLE_KEY, 3, "RUD_LIM", 15)
param:add_param(PARAM_TABLE_KEY, 4, "MAX_YR", 2.0)
param:add_param(PARAM_TABLE_KEY, 5, "SERVO_CH", 0)
param:add_param(PARAM_TABLE_KEY, 6, "ALPHA", 0.2)
param:add_param(PARAM_TABLE_KEY, 7, "PWM_DB", 5) -- Default 5us deadband
param:add_param(PARAM_TABLE_KEY, 8, "UPD_INT", 500) -- Default 500ms update interval
-- MAVLink Message Helper
local function send_status(text)
gcs:send_text(6, text)
end
send_status("##### Script loaded #####")
-- --- State for Low-Pass Filtering and PWM Tracking ---
local last_rudder_cmd = 0
local last_pwm_int = -1 -- Invalid initial value to ensure first write
local alpha = param:get(alpha_param) or 0.2 -- Load smoothing factor dynamically
-- --- Main Update Function ---
function update()
-- Load new control parameters
local pwm_threshold = param:get(pwm_threshold_param) or 5
local update_interval = param:get(update_interval_param) or 100
-- Load parameters dynamically
local K_p = param:get(K_p_param)
local K_r = param:get(K_r_param)
local rudder_limit = param:get(rudder_limit_param)
local max_comfortable_yaw_rate = param:get(max_yaw_rate_param)
local servo_channel = math.floor(param:get(servo_channel_param) + 0.5)
local servo_min = param:get(string.format("SERVO%d_MIN", servo_channel + 1))
local servo_max = param:get(string.format("SERVO%d_MAX", servo_channel + 1))
local servo_trim = param:get(string.format("SERVO%d_TRIM", servo_channel + 1))
-- Only run in Auto or Guided mode
local mode = vehicle:get_mode()
if mode ~= 10 and mode ~= 15 then
local rc_input_pwm = rc:get_pwm(rc_steering_channel) or servo_trim
if last_pwm_int ~= rc_input_pwm then
SRV_Channels:set_output_pwm_chan(servo_channel, rc_input_pwm)
last_pwm_int = rc_input_pwm
end
return update, update_interval
end
-- Get Current Heading (degrees)
local current_heading = ahrs:get_yaw() * 180 / math.pi
if current_heading < 0 then
current_heading = current_heading + 360
end
-- Get Desired Heading (from active waypoint)
local target_heading = vehicle:get_wp_bearing_deg()
if not target_heading then
SRV_Channels:set_output_pwm_chan(servo_channel, servo_trim)
return update, update_interval
end
-- Compute Heading Error [-180, 180]
local heading_error = target_heading - current_heading
heading_error = (heading_error + 180) % 360 - 180
-- Compute Target Yaw Rate and Clamp
local target_yaw_rate = K_p * heading_error
target_yaw_rate = math.max(math.min(target_yaw_rate, max_comfortable_yaw_rate), -max_comfortable_yaw_rate)
-- Current Yaw Rate from Gyro (deg/s)
local gyro_z = ahrs:get_gyro():z()
local current_yaw_rate = gyro_z * 180 / math.pi
-- Rudder Command
local yaw_rate_error = target_yaw_rate - current_yaw_rate
local rudder_cmd = K_r * yaw_rate_error
rudder_cmd = math.max(math.min(rudder_cmd, rudder_limit), -rudder_limit)
-- Apply Low-Pass Filter to Rudder Command
rudder_cmd = last_rudder_cmd + alpha * (rudder_cmd - last_rudder_cmd)
last_rudder_cmd = rudder_cmd
-- Map Rudder to PWM
local pwm = servo_trim + rudder_cmd / rudder_limit * (servo_max - servo_trim)
pwm = math.max(math.min(pwm, servo_max), servo_min)
local pwm_int = math.floor(pwm + 0.5)
-- Only send PWM if it changed
if math.abs(pwm_int - last_pwm_int) >= pwm_threshold then
SRV_Channels:set_output_pwm_chan(servo_channel, pwm_int)
last_pwm_int = pwm_int
end
-- MAVLink Logging
local msg = string.format("HDG_ERR: %.1f | T_YR: %.1f | C_YR: %.1f | RUD: %.1f | PWM: %d",
heading_error, target_yaw_rate, current_yaw_rate, rudder_cmd, last_pwm_int)
send_status(msg)
return update, param:get(update_interval_param) or 100
end
return update, param:get(update_interval_param) or 100