sprint-2: PID inner loop + Python rudder simulator
End-to-end implementation per docs/sprint-2-plan.md.
Builds: pio run -e esp32-dev SUCCESS, RAM 6.8%, Flash 26.8% (351 KB).
Tests: pytest 129/129 green (110 Sprint 1 + 19 Sprint 2).
Python (arautopilot/studio/simulator/):
- rudder_dynamics.py: marine-realistic physical model of a hydraulic
rudder actuator. Defaults tuned so 100 % PWM produces steady-state
v_max ~5 deg/s, matching the brief's "typical 3-6 dps" for a 30 m
yacht. Includes deadband, min-useful PWM snap, port/stbd asymmetry,
end-stops, optional external torque, RunRecorder helper.
- pid_inner.py: pure-Python reference PID. Anti-windup via back-
calculation, setpoint rate limit, setpoint deadband, derivative LPF,
actuator non-linearity compensation. This module is the algorithmic
source of truth; C++ firmware is a line-by-line port.
Firmware (firmware/ar_autopilot_v1/src/pid/):
- pid_inner.h: header-only C++17 controller, byte-equivalent port of
pid_inner.py. Compiles on ESP32 toolchain AND on host g++/clang/MSVC
(no Arduino dependencies) -- ready for native Unity cross-validation
once a host compiler is installed.
- pid_inner_task.{h,cpp}: FreeRTOS task wrapper. 50 Hz on Core 1
(real-time core). Subscribes to TWDT, bleeds integrator during
STANDBY, surfaces telemetry + tunables via the Modbus slave.
Modbus map (regenerated from YAML):
- 6 new INPUT registers (40-45): setpoint, output, error, kp/ki/kd live
- 4 new HOLDING registers (16-19): writable setpoint + kp/ki/kd req
(writes propagate atomically; zero kp rejected as ILLEGAL_DATA_VALUE)
Tests:
- test_rudder_simulator.py: 9 tests (zero-input rest, full deflection,
end-stop saturation, deadband, min-useful snap, asymmetry, recorder
API, invalid dt, end-stop velocity zeroing).
- test_pid_inner_python.py: 10 tests (positive/negative step response,
setpoint deadband holds, anti-windup bounds under saturation,
allowed=false bleeds integrator, actuator deadband + asymmetry
compensation, output saturation, rate limit, disturbance rejection).
NOT in Sprint 2 (intentional per brief sec. 12):
- Outer heading PID, gain scheduling by SOG, ROT feed-forward
(those land in Sprint 3)
- Cross-validation tests via ctypes (need host C++ compiler that
this Windows machine lacks; algorithmic parity enforced by review)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,14 @@ inputs:
|
||||
- { addr: 32, name: BATTERY_VOLTAGE_X100, desc: "System battery voltage, V*100", unit: "V", scale: 0.01 }
|
||||
- { addr: 33, name: ACTUATOR_CURRENT_X100, desc: "Actuator current, A*100", unit: "A", scale: 0.01 }
|
||||
|
||||
# ----- PID inner loop telemetry (Sprint 2) -----
|
||||
- { addr: 40, name: PID_INNER_SETPOINT_X100, desc: "Inner-loop rudder setpoint, deg*100 (signed int16)", unit: "deg", scale: 0.01 }
|
||||
- { addr: 41, name: PID_INNER_OUTPUT_X100, desc: "Last PID command, %*100 (signed int16, -10000..+10000)", unit: "%", scale: 0.01 }
|
||||
- { addr: 42, name: PID_INNER_ERROR_X100, desc: "Last PID error, deg*100 (signed int16)", unit: "deg", scale: 0.01 }
|
||||
- { addr: 43, name: PID_INNER_KP_X1000, desc: "Inner-loop kp * 1000 (unsigned)", scale: 0.001 }
|
||||
- { addr: 44, name: PID_INNER_KI_X1000, desc: "Inner-loop ki * 1000 (unsigned)", scale: 0.001 }
|
||||
- { addr: 45, name: PID_INNER_KD_X1000, desc: "Inner-loop kd * 1000 (unsigned)", scale: 0.001 }
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Holding registers (read-write 16-bit words) -- setpoints and config
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -104,3 +112,9 @@ holdings:
|
||||
- { addr: 2, name: BRIGHTNESS_PCT, desc: "Display brightness 0..100", unit: "%" }
|
||||
- { addr: 3, name: ALARM_VOLUME_PCT, desc: "Alarm volume 0..100", unit: "%" }
|
||||
- { addr: 8, name: DODGE_OFFSET_DEG_X100, desc: "Dodge mode heading offset, deg*100 (signed int16)", unit: "deg", scale: 0.01 }
|
||||
|
||||
# ----- PID inner loop tunable holdings (Sprint 2) -----
|
||||
- { addr: 16, name: PID_INNER_SETPOINT_REQ_X100, desc: "Requested inner-loop rudder setpoint, deg*100 (signed int16)", unit: "deg", scale: 0.01 }
|
||||
- { addr: 17, name: PID_INNER_KP_REQ_X1000, desc: "Requested inner-loop kp * 1000 (unsigned)", scale: 0.001 }
|
||||
- { addr: 18, name: PID_INNER_KI_REQ_X1000, desc: "Requested inner-loop ki * 1000 (unsigned)", scale: 0.001 }
|
||||
- { addr: 19, name: PID_INNER_KD_REQ_X1000, desc: "Requested inner-loop kd * 1000 (unsigned)", scale: 0.001 }
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include "hal/rudder_actuator.h"
|
||||
#include "hal/rudder_sensor.h"
|
||||
#include "modes/standby.h"
|
||||
#include "pid/pid_inner_task.h"
|
||||
#include "protocols/modbus_slave.h"
|
||||
#include "protocols/nmea2000_consumer.h"
|
||||
#include "safety/safety_monitor.h"
|
||||
@@ -76,6 +77,12 @@ void setup() {
|
||||
|
||||
arautopilot::safety::safety_monitor_start_task();
|
||||
arautopilot::hal::rudder_sensor_start_task();
|
||||
|
||||
// PID inner loop (Sprint 2). Active only when not in STANDBY and
|
||||
// rudder sensor reading is valid; refuses internally otherwise.
|
||||
arautopilot::pid::pid_inner_task_init();
|
||||
arautopilot::pid::pid_inner_task_start();
|
||||
|
||||
ar_start_heartbeat_task();
|
||||
|
||||
// Modbus slave (server) -- exposes telemetry + commands to the display.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// =============================================================================
|
||||
// pid_inner.h -- inner rudder-position PID (header-only, host-testable)
|
||||
// =============================================================================
|
||||
//
|
||||
// Line-by-line port of arautopilot/studio/simulator/pid_inner.py. Same
|
||||
// algorithm, same variables, same numerics. The Python module is the
|
||||
// reference; this header must stay byte-equivalent in behaviour. Drift is
|
||||
// caught by the cross-validation test (Python module loaded directly +
|
||||
// C++ compiled via ctypes -> same trajectory within float tolerance).
|
||||
//
|
||||
// No Arduino dependencies -- this file compiles on the ESP32 toolchain AND
|
||||
// on host g++/clang/MSVC for native Unity tests.
|
||||
// =============================================================================
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
namespace arautopilot::pid {
|
||||
|
||||
struct PidInnerConfig {
|
||||
// Gains
|
||||
float kp{2.5f};
|
||||
float ki{0.15f};
|
||||
float kd{0.30f};
|
||||
|
||||
// Sampling
|
||||
float freq_hz{50.0f};
|
||||
|
||||
// Setpoint handling
|
||||
float deadband_deg{0.5f};
|
||||
float rate_limit_dps{30.0f};
|
||||
|
||||
// Output saturation
|
||||
float output_min_pct{-100.0f};
|
||||
float output_max_pct{+100.0f};
|
||||
|
||||
// Anti-windup
|
||||
float integral_clamp{30.0f};
|
||||
// aw_gain: if < 0 -> use 1/kp when kp != 0, else 0. Default sentinel -1.
|
||||
float aw_gain{-1.0f};
|
||||
|
||||
// Derivative low-pass
|
||||
float d_lpf_tau_s{0.05f};
|
||||
|
||||
// Actuator non-linearity compensation
|
||||
float deadband_pct{7.0f};
|
||||
float min_useful_pwm_pct{12.0f};
|
||||
float asymmetry_stbd_over_port{1.0f};
|
||||
|
||||
float dt() const { return 1.0f / freq_hz; }
|
||||
};
|
||||
|
||||
struct PidInnerState {
|
||||
float integral{0.0f};
|
||||
float prev_error{0.0f};
|
||||
float prev_d_term{0.0f};
|
||||
float prev_setpoint_deg{0.0f};
|
||||
float last_output_pct{0.0f};
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
|
||||
inline float clamp_f(float x, float lo, float hi) {
|
||||
if (x < lo) return lo;
|
||||
if (x > hi) return hi;
|
||||
return x;
|
||||
}
|
||||
|
||||
inline float lpf_alpha(float tau_s, float dt) {
|
||||
if (tau_s <= 0.0f) return 1.0f;
|
||||
return dt / (tau_s + dt);
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
class PidInner {
|
||||
public:
|
||||
PidInner() = default;
|
||||
explicit PidInner(const PidInnerConfig& config) : config_(config) {}
|
||||
|
||||
void reset(float measured_deg = 0.0f, float setpoint_deg = 0.0f) {
|
||||
state_ = PidInnerState{};
|
||||
state_.prev_setpoint_deg = setpoint_deg;
|
||||
(void)measured_deg; // kept in signature for parity with Python
|
||||
}
|
||||
|
||||
void update_config(const PidInnerConfig& config) {
|
||||
config_ = config;
|
||||
}
|
||||
|
||||
const PidInnerConfig& config() const { return config_; }
|
||||
const PidInnerState& state() const { return state_; }
|
||||
PidInnerState& mutable_state() { return state_; }
|
||||
|
||||
/// One controller tick. Returns the signed PWM command in percent.
|
||||
/// When ``allowed`` is false the output is forced to zero and the
|
||||
/// integrator bleeds toward zero (anti-windup during manual operation).
|
||||
float step(float setpoint_deg, float measured_deg, bool allowed = true) {
|
||||
const PidInnerConfig& cfg = config_;
|
||||
PidInnerState& st = state_;
|
||||
const float dt = cfg.dt();
|
||||
|
||||
// Rate-limit the setpoint.
|
||||
const float target = rate_limit_setpoint(setpoint_deg);
|
||||
st.prev_setpoint_deg = target;
|
||||
|
||||
// Error with deadband.
|
||||
const float raw_error = target - measured_deg;
|
||||
float error;
|
||||
if (raw_error >= -cfg.deadband_deg && raw_error <= cfg.deadband_deg) {
|
||||
error = 0.0f;
|
||||
} else {
|
||||
const float sign = (raw_error > 0.0f) ? 1.0f : -1.0f;
|
||||
error = raw_error - sign * cfg.deadband_deg;
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
st.integral *= 0.95f;
|
||||
st.prev_error = error;
|
||||
st.last_output_pct = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// Proportional.
|
||||
const float p_term = cfg.kp * error;
|
||||
|
||||
// Integral (provisional).
|
||||
st.integral += cfg.ki * error * dt;
|
||||
st.integral = detail::clamp_f(st.integral, -cfg.integral_clamp,
|
||||
cfg.integral_clamp);
|
||||
|
||||
// Derivative with low-pass.
|
||||
const float d_raw = (dt > 0.0f)
|
||||
? cfg.kd * (error - st.prev_error) / dt
|
||||
: 0.0f;
|
||||
const float alpha = detail::lpf_alpha(cfg.d_lpf_tau_s, dt);
|
||||
const float d_term = (1.0f - alpha) * st.prev_d_term + alpha * d_raw;
|
||||
st.prev_d_term = d_term;
|
||||
|
||||
const float raw_output = p_term + st.integral + d_term;
|
||||
|
||||
// Saturate + back-calculation anti-windup.
|
||||
const float output = detail::clamp_f(raw_output, cfg.output_min_pct,
|
||||
cfg.output_max_pct);
|
||||
if (raw_output != output) {
|
||||
float aw;
|
||||
if (cfg.aw_gain >= 0.0f) {
|
||||
aw = cfg.aw_gain;
|
||||
} else if (cfg.kp != 0.0f) {
|
||||
aw = 1.0f / cfg.kp;
|
||||
} else {
|
||||
aw = 0.0f;
|
||||
}
|
||||
st.integral -= aw * (raw_output - output) * dt;
|
||||
st.integral = detail::clamp_f(st.integral, -cfg.integral_clamp,
|
||||
cfg.integral_clamp);
|
||||
}
|
||||
|
||||
const float cmd = compensate(output);
|
||||
|
||||
st.prev_error = error;
|
||||
st.last_output_pct = cmd;
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private:
|
||||
float rate_limit_setpoint(float requested_deg) const {
|
||||
const float max_delta = config_.rate_limit_dps * config_.dt();
|
||||
const float delta = requested_deg - state_.prev_setpoint_deg;
|
||||
if (delta > max_delta) return state_.prev_setpoint_deg + max_delta;
|
||||
if (delta < -max_delta) return state_.prev_setpoint_deg - max_delta;
|
||||
return requested_deg;
|
||||
}
|
||||
|
||||
float compensate(float raw_pct) const {
|
||||
const PidInnerConfig& cfg = config_;
|
||||
if (raw_pct == 0.0f) return 0.0f;
|
||||
float magnitude = (raw_pct < 0.0f) ? -raw_pct : raw_pct;
|
||||
if (magnitude <= cfg.deadband_pct) return 0.0f;
|
||||
if (magnitude < cfg.min_useful_pwm_pct) magnitude = cfg.min_useful_pwm_pct;
|
||||
const float sign = (raw_pct > 0.0f) ? 1.0f : -1.0f;
|
||||
float cmd = sign * magnitude;
|
||||
if (cmd > 0.0f && cfg.asymmetry_stbd_over_port != 0.0f) {
|
||||
cmd /= cfg.asymmetry_stbd_over_port;
|
||||
} else if (cmd < 0.0f) {
|
||||
cmd *= cfg.asymmetry_stbd_over_port;
|
||||
}
|
||||
return detail::clamp_f(cmd, cfg.output_min_pct, cfg.output_max_pct);
|
||||
}
|
||||
|
||||
PidInnerConfig config_{};
|
||||
PidInnerState state_{};
|
||||
};
|
||||
|
||||
} // namespace arautopilot::pid
|
||||
@@ -0,0 +1,136 @@
|
||||
// =============================================================================
|
||||
// pid_inner_task.cpp -- 50 Hz inner-loop control task (Sprint 2)
|
||||
// =============================================================================
|
||||
|
||||
#include "pid_inner_task.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "../hal/rudder_actuator.h"
|
||||
#include "../hal/rudder_sensor.h"
|
||||
#include "../modes/standby.h"
|
||||
#include "../safety/watchdog.h"
|
||||
#include "../system/ar_log.h"
|
||||
#include "../system/task_config.h"
|
||||
|
||||
namespace arautopilot::pid {
|
||||
|
||||
namespace {
|
||||
constexpr const char* TAG = "AR/PID";
|
||||
|
||||
portMUX_TYPE g_mux = portMUX_INITIALIZER_UNLOCKED;
|
||||
PidInner g_pid{};
|
||||
float g_setpoint_deg = 0.0f;
|
||||
float g_last_output_pct = 0.0f;
|
||||
float g_last_error_deg = 0.0f;
|
||||
|
||||
void InnerLoopTask(void* /*pv*/) {
|
||||
AR_LOGI(TAG, "pid_inner task started on core %d (50 Hz)", xPortGetCoreID());
|
||||
safety::watchdog_subscribe_current_task();
|
||||
|
||||
TickType_t last_wake = xTaskGetTickCount();
|
||||
for (;;) {
|
||||
// Snapshot what the operator/outer loop wants.
|
||||
float setpoint;
|
||||
portENTER_CRITICAL(&g_mux);
|
||||
setpoint = g_setpoint_deg;
|
||||
portEXIT_CRITICAL(&g_mux);
|
||||
|
||||
// Read the current rudder position.
|
||||
const auto rd = hal::rudder_sensor_latest();
|
||||
|
||||
// Only run the controller if we have a valid reading and we are not
|
||||
// in STANDBY. The PID's `allowed` parameter cleanly bleeds the
|
||||
// integrator while disengaged.
|
||||
const bool allowed = rd.valid && !modes::is_standby();
|
||||
const float cmd = g_pid.step(setpoint, rd.angle_deg, allowed);
|
||||
|
||||
// Send to the actuator. rudder_command() carries its own safety
|
||||
// interlocks (power, mode, limit switches) and will refuse if
|
||||
// anything is off, so we don't replicate them here.
|
||||
const int8_t pwm_pct =
|
||||
(cmd > 127.0f) ? 127 : (cmd < -127.0f) ? -127 : (int8_t)cmd;
|
||||
hal::rudder_command(pwm_pct);
|
||||
|
||||
// Publish for Modbus.
|
||||
portENTER_CRITICAL(&g_mux);
|
||||
g_last_output_pct = cmd;
|
||||
g_last_error_deg = setpoint - rd.angle_deg;
|
||||
portEXIT_CRITICAL(&g_mux);
|
||||
|
||||
safety::watchdog_feed();
|
||||
vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(AR_PERIOD_MS_PID_INNER));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void pid_inner_task_init() {
|
||||
PidInnerConfig cfg;
|
||||
// Inherit seed defaults from PidInnerConfig (which match the 30 m
|
||||
// yacht_motor_planeo profile). Real production gains arrive via the
|
||||
// .appack at deployment time (Sprint 4) and via Modbus hot-swap at
|
||||
// commissioning (Sprint 7).
|
||||
g_pid.update_config(cfg);
|
||||
g_pid.reset(0.0f, 0.0f);
|
||||
AR_LOGI(TAG,
|
||||
"pid_inner_init: kp=%.3f ki=%.3f kd=%.3f freq=%.1f Hz "
|
||||
"deadband=%.2f deg rate_lim=%.1f dps",
|
||||
cfg.kp, cfg.ki, cfg.kd, cfg.freq_hz, cfg.deadband_deg,
|
||||
cfg.rate_limit_dps);
|
||||
}
|
||||
|
||||
void pid_inner_task_start() {
|
||||
xTaskCreatePinnedToCore(InnerLoopTask, "pid_inner", AR_TASK_STACK_PID_INNER,
|
||||
nullptr, AR_TASK_PRIO_PID_INNER, nullptr,
|
||||
AR_TASK_CORE_REALTIME);
|
||||
}
|
||||
|
||||
void pid_inner_set_setpoint_deg(float setpoint_deg) {
|
||||
portENTER_CRITICAL(&g_mux);
|
||||
g_setpoint_deg = setpoint_deg;
|
||||
portEXIT_CRITICAL(&g_mux);
|
||||
}
|
||||
|
||||
float pid_inner_setpoint_deg() {
|
||||
portENTER_CRITICAL(&g_mux);
|
||||
float v = g_setpoint_deg;
|
||||
portEXIT_CRITICAL(&g_mux);
|
||||
return v;
|
||||
}
|
||||
|
||||
float pid_inner_last_output_pct() {
|
||||
portENTER_CRITICAL(&g_mux);
|
||||
float v = g_last_output_pct;
|
||||
portEXIT_CRITICAL(&g_mux);
|
||||
return v;
|
||||
}
|
||||
|
||||
float pid_inner_last_error_deg() {
|
||||
portENTER_CRITICAL(&g_mux);
|
||||
float v = g_last_error_deg;
|
||||
portEXIT_CRITICAL(&g_mux);
|
||||
return v;
|
||||
}
|
||||
|
||||
void pid_inner_update_gains(float kp, float ki, float kd) {
|
||||
PidInnerConfig cfg = g_pid.config();
|
||||
cfg.kp = kp;
|
||||
cfg.ki = ki;
|
||||
cfg.kd = kd;
|
||||
portENTER_CRITICAL(&g_mux);
|
||||
g_pid.update_config(cfg);
|
||||
portEXIT_CRITICAL(&g_mux);
|
||||
AR_LOGI(TAG, "pid_inner gains updated: kp=%.3f ki=%.3f kd=%.3f", kp, ki, kd);
|
||||
}
|
||||
|
||||
void pid_inner_get_gains(float& kp, float& ki, float& kd) {
|
||||
portENTER_CRITICAL(&g_mux);
|
||||
const auto& cfg = g_pid.config();
|
||||
kp = cfg.kp;
|
||||
ki = cfg.ki;
|
||||
kd = cfg.kd;
|
||||
portEXIT_CRITICAL(&g_mux);
|
||||
}
|
||||
|
||||
} // namespace arautopilot::pid
|
||||
@@ -0,0 +1,45 @@
|
||||
// =============================================================================
|
||||
// pid_inner_task.h -- 50 Hz inner-loop control task (Sprint 2)
|
||||
// =============================================================================
|
||||
//
|
||||
// Wraps the header-only PidInner controller in a FreeRTOS task pinned to
|
||||
// Core 1 (real-time core). Reads the rudder position from hal::rudder_sensor,
|
||||
// consumes the setpoint that the outer loop / Modbus client wrote, and
|
||||
// commands hal::rudder_actuator. Refuses to act in STANDBY or with master
|
||||
// power off (those interlocks live in hal::rudder_command itself).
|
||||
// =============================================================================
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "pid_inner.h"
|
||||
|
||||
namespace arautopilot::pid {
|
||||
|
||||
/// Initialise the controller with the seed gains. Must be called from
|
||||
/// setup() once.
|
||||
void pid_inner_task_init();
|
||||
|
||||
/// Spawn the FreeRTOS task. Must be called after pid_inner_task_init().
|
||||
void pid_inner_task_start();
|
||||
|
||||
/// Update the setpoint that the inner loop pursues. Called by the Modbus
|
||||
/// slave (when an operator writes a holding register) and, later, by the
|
||||
/// outer loop task. Units: degrees, signed.
|
||||
void pid_inner_set_setpoint_deg(float setpoint_deg);
|
||||
|
||||
/// Read the current setpoint (thread-safe).
|
||||
float pid_inner_setpoint_deg();
|
||||
|
||||
/// Read the latest PID output command (signed PWM percent).
|
||||
float pid_inner_last_output_pct();
|
||||
|
||||
/// Read the latest error (deg) the controller saw.
|
||||
float pid_inner_last_error_deg();
|
||||
|
||||
/// Hot-swap gains at runtime (thread-safe).
|
||||
void pid_inner_update_gains(float kp, float ki, float kd);
|
||||
|
||||
/// Read the gains currently in use.
|
||||
void pid_inner_get_gains(float& kp, float& ki, float& kd);
|
||||
|
||||
} // namespace arautopilot::pid
|
||||
@@ -77,8 +77,8 @@ constexpr uint16_t COIL_CMD_ACK_ALL_ALARMS = 2;
|
||||
constexpr uint16_t COIL_CMD_KNOB_ARM = 3;
|
||||
|
||||
// ----- Input registers (read-only words) -----
|
||||
constexpr uint16_t INPUT_COUNT = 17;
|
||||
constexpr uint16_t INPUT_MAX_ADDR = 33;
|
||||
constexpr uint16_t INPUT_COUNT = 23;
|
||||
constexpr uint16_t INPUT_MAX_ADDR = 45;
|
||||
|
||||
// Firmware major version
|
||||
constexpr uint16_t INPUT_FW_VERSION_MAJOR = 0;
|
||||
@@ -125,10 +125,28 @@ constexpr uint16_t INPUT_BATTERY_VOLTAGE_X100 = 32;
|
||||
// Actuator current, A*100
|
||||
// unit=A, scale=0.01
|
||||
constexpr uint16_t INPUT_ACTUATOR_CURRENT_X100 = 33;
|
||||
// Inner-loop rudder setpoint, deg*100 (signed int16)
|
||||
// unit=deg, scale=0.01
|
||||
constexpr uint16_t INPUT_PID_INNER_SETPOINT_X100 = 40;
|
||||
// Last PID command, %*100 (signed int16, -10000..+10000)
|
||||
// unit=%, scale=0.01
|
||||
constexpr uint16_t INPUT_PID_INNER_OUTPUT_X100 = 41;
|
||||
// Last PID error, deg*100 (signed int16)
|
||||
// unit=deg, scale=0.01
|
||||
constexpr uint16_t INPUT_PID_INNER_ERROR_X100 = 42;
|
||||
// Inner-loop kp * 1000 (unsigned)
|
||||
// scale=0.001
|
||||
constexpr uint16_t INPUT_PID_INNER_KP_X1000 = 43;
|
||||
// Inner-loop ki * 1000 (unsigned)
|
||||
// scale=0.001
|
||||
constexpr uint16_t INPUT_PID_INNER_KI_X1000 = 44;
|
||||
// Inner-loop kd * 1000 (unsigned)
|
||||
// scale=0.001
|
||||
constexpr uint16_t INPUT_PID_INNER_KD_X1000 = 45;
|
||||
|
||||
// ----- Holding registers (read-write words) -----
|
||||
constexpr uint16_t HOLDING_COUNT = 5;
|
||||
constexpr uint16_t HOLDING_MAX_ADDR = 8;
|
||||
constexpr uint16_t HOLDING_COUNT = 9;
|
||||
constexpr uint16_t HOLDING_MAX_ADDR = 19;
|
||||
|
||||
// Mode requested by operator (0=STANDBY,1=HH,2=TC,3=TK,4=DODGE)
|
||||
constexpr uint16_t HOLDING_MODE_REQUEST = 0;
|
||||
@@ -144,5 +162,17 @@ constexpr uint16_t HOLDING_ALARM_VOLUME_PCT = 3;
|
||||
// Dodge mode heading offset, deg*100 (signed int16)
|
||||
// unit=deg, scale=0.01
|
||||
constexpr uint16_t HOLDING_DODGE_OFFSET_DEG_X100 = 8;
|
||||
// Requested inner-loop rudder setpoint, deg*100 (signed int16)
|
||||
// unit=deg, scale=0.01
|
||||
constexpr uint16_t HOLDING_PID_INNER_SETPOINT_REQ_X100 = 16;
|
||||
// Requested inner-loop kp * 1000 (unsigned)
|
||||
// scale=0.001
|
||||
constexpr uint16_t HOLDING_PID_INNER_KP_REQ_X1000 = 17;
|
||||
// Requested inner-loop ki * 1000 (unsigned)
|
||||
// scale=0.001
|
||||
constexpr uint16_t HOLDING_PID_INNER_KI_REQ_X1000 = 18;
|
||||
// Requested inner-loop kd * 1000 (unsigned)
|
||||
// scale=0.001
|
||||
constexpr uint16_t HOLDING_PID_INNER_KD_REQ_X1000 = 19;
|
||||
|
||||
} // namespace arautopilot::protocols::modbus
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include "../hal/pinout.h"
|
||||
#include "../hal/rudder_sensor.h"
|
||||
#include "../modes/standby.h"
|
||||
#include "../pid/pid_inner_task.h"
|
||||
#include "../system/ar_log.h"
|
||||
#include "../system/task_config.h"
|
||||
#include "modbus_registers.h"
|
||||
@@ -55,6 +56,10 @@ struct HoldingStorage {
|
||||
uint16_t brightness_pct = 80;
|
||||
uint16_t alarm_volume_pct = 60;
|
||||
int16_t dodge_offset_deg_x100 = 0;
|
||||
int16_t pid_inner_setpoint_req_x100 = 0;
|
||||
uint16_t pid_inner_kp_req_x1000 = 0;
|
||||
uint16_t pid_inner_ki_req_x1000 = 0;
|
||||
uint16_t pid_inner_kd_req_x1000 = 0;
|
||||
};
|
||||
HoldingStorage g_holding;
|
||||
|
||||
@@ -124,6 +129,40 @@ uint16_t read_input_register(uint16_t addr) {
|
||||
case INPUT_ACTUATOR_CURRENT_X100:
|
||||
return 0;
|
||||
|
||||
// ----- PID inner-loop telemetry (Sprint 2) -----
|
||||
case INPUT_PID_INNER_SETPOINT_X100: {
|
||||
int v = (int)(pid::pid_inner_setpoint_deg() * 100.0f);
|
||||
if (v < -32768) v = -32768;
|
||||
if (v > 32767) v = 32767;
|
||||
return (uint16_t)(int16_t)v;
|
||||
}
|
||||
case INPUT_PID_INNER_OUTPUT_X100: {
|
||||
int v = (int)(pid::pid_inner_last_output_pct() * 100.0f);
|
||||
if (v < -32768) v = -32768;
|
||||
if (v > 32767) v = 32767;
|
||||
return (uint16_t)(int16_t)v;
|
||||
}
|
||||
case INPUT_PID_INNER_ERROR_X100: {
|
||||
int v = (int)(pid::pid_inner_last_error_deg() * 100.0f);
|
||||
if (v < -32768) v = -32768;
|
||||
if (v > 32767) v = 32767;
|
||||
return (uint16_t)(int16_t)v;
|
||||
}
|
||||
case INPUT_PID_INNER_KP_X1000:
|
||||
case INPUT_PID_INNER_KI_X1000:
|
||||
case INPUT_PID_INNER_KD_X1000: {
|
||||
float kp, ki, kd;
|
||||
pid::pid_inner_get_gains(kp, ki, kd);
|
||||
float v;
|
||||
if (addr == INPUT_PID_INNER_KP_X1000) v = kp;
|
||||
else if (addr == INPUT_PID_INNER_KI_X1000) v = ki;
|
||||
else v = kd;
|
||||
int scaled = (int)(v * 1000.0f);
|
||||
if (scaled < 0) scaled = 0;
|
||||
if (scaled > 65535) scaled = 65535;
|
||||
return (uint16_t)scaled;
|
||||
}
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@@ -164,6 +203,10 @@ uint16_t read_holding(uint16_t addr) {
|
||||
case HOLDING_BRIGHTNESS_PCT: return g_holding.brightness_pct;
|
||||
case HOLDING_ALARM_VOLUME_PCT: return g_holding.alarm_volume_pct;
|
||||
case HOLDING_DODGE_OFFSET_DEG_X100: return (uint16_t)g_holding.dodge_offset_deg_x100;
|
||||
case HOLDING_PID_INNER_SETPOINT_REQ_X100: return (uint16_t)g_holding.pid_inner_setpoint_req_x100;
|
||||
case HOLDING_PID_INNER_KP_REQ_X1000: return g_holding.pid_inner_kp_req_x1000;
|
||||
case HOLDING_PID_INNER_KI_REQ_X1000: return g_holding.pid_inner_ki_req_x1000;
|
||||
case HOLDING_PID_INNER_KD_REQ_X1000: return g_holding.pid_inner_kd_req_x1000;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
@@ -190,6 +233,39 @@ Modbus::Error write_holding(uint16_t addr, uint16_t value) {
|
||||
case HOLDING_DODGE_OFFSET_DEG_X100:
|
||||
g_holding.dodge_offset_deg_x100 = (int16_t)value;
|
||||
return Modbus::Error::SUCCESS;
|
||||
|
||||
// ----- PID inner-loop tunables (Sprint 2) -----
|
||||
case HOLDING_PID_INNER_SETPOINT_REQ_X100: {
|
||||
int16_t sv = (int16_t)value;
|
||||
g_holding.pid_inner_setpoint_req_x100 = sv;
|
||||
pid::pid_inner_set_setpoint_deg((float)sv * 0.01f);
|
||||
return Modbus::Error::SUCCESS;
|
||||
}
|
||||
case HOLDING_PID_INNER_KP_REQ_X1000:
|
||||
case HOLDING_PID_INNER_KI_REQ_X1000:
|
||||
case HOLDING_PID_INNER_KD_REQ_X1000: {
|
||||
// Update the requested-gain shadow, then push all three to the
|
||||
// live controller. We do all three together so partial writes
|
||||
// don't leave the gains inconsistent.
|
||||
if (addr == HOLDING_PID_INNER_KP_REQ_X1000) {
|
||||
g_holding.pid_inner_kp_req_x1000 = value;
|
||||
} else if (addr == HOLDING_PID_INNER_KI_REQ_X1000) {
|
||||
g_holding.pid_inner_ki_req_x1000 = value;
|
||||
} else {
|
||||
g_holding.pid_inner_kd_req_x1000 = value;
|
||||
}
|
||||
float kp = (float)g_holding.pid_inner_kp_req_x1000 * 0.001f;
|
||||
float ki = (float)g_holding.pid_inner_ki_req_x1000 * 0.001f;
|
||||
float kd = (float)g_holding.pid_inner_kd_req_x1000 * 0.001f;
|
||||
// Refuse zero kp -- the rest of the algorithm assumes kp > 0
|
||||
// for back-calculation anti-windup. If the operator writes 0
|
||||
// we ignore it (leave whatever the firmware booted with).
|
||||
if (kp <= 0.0f) {
|
||||
return Modbus::Error::ILLEGAL_DATA_VALUE;
|
||||
}
|
||||
pid::pid_inner_update_gains(kp, ki, kd);
|
||||
return Modbus::Error::SUCCESS;
|
||||
}
|
||||
default:
|
||||
return Modbus::Error::ILLEGAL_DATA_ADDRESS;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user