Skip to content

Commit e640a14

Browse files
Ballistyxxclaude
andcommitted
steervo: pot over-travel + convergence-watchdog protections
Protect the steering pot (which rips off its coupling if driven past range): - OVER_TRAVEL fault (new STEER_STATUS fault bit 0x40): keyed on the RAW pot crossing beyond a calibrated end stop + margin, since pot_to_angle_cdeg clamps the reported angle and would otherwise hide an over-travel. Latches hard if it happens while ACTIVE; refuses to start the motor if already past. - Convergence watchdog: reframe stall detection from "did the pot move?" to "is |error| shrinking?", so a motor pushing while error grows (inverted-sign runaway) faults too — not just a jammed motor. steervo host tests 28/28 (3 new). can-ids.md documents the new fault bit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 595d013 commit e640a14

5 files changed

Lines changed: 133 additions & 25 deletions

File tree

docs/protocols/can-ids.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ fault bit `SETPOINT_STALE`, and stays in READY until fresh frames resume.
6161
| Offset | Size | Field | Notes |
6262
|---|---|---|---|
6363
| 0 | 1 | `state` | 0 INIT · 1 READY · 2 ACTIVE · 3 FAULT · 4 CALIBRATING |
64-
| 1 | 1 | `fault_bits` | bit0 `POT_RANGE` · bit1 `POT_FROZEN` · bit2 `STALL` · bit3 `SETPOINT_STALE` · bit4 `TALON_LOST` · bit5 `NOT_CALIBRATED` |
64+
| 1 | 1 | `fault_bits` | bit0 `POT_RANGE` · bit1 `POT_FROZEN` · bit2 `STALL` · bit3 `SETPOINT_STALE` · bit4 `TALON_LOST` · bit5 `NOT_CALIBRATED` · bit6 `OVER_TRAVEL` (pot driven past a calibrated end stop — latched) |
6565
| 2 | 2 | `measured_cdeg` (int16) | Current angle from the pot (calibrated). |
6666
| 4 | 1 | `output_pct` (int8) | Motor command, −100…+100. |
6767
| 5 | 1 | `seq_echo` (uint8) | `seq` of the last accepted `STEER_SET`. |

firmware/common/kart_common/kart_can.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ constexpr uint8_t kSteerFaultStall = 1 << 2;
9494
constexpr uint8_t kSteerFaultSetpointStale = 1 << 3;
9595
constexpr uint8_t kSteerFaultTalonLost = 1 << 4;
9696
constexpr uint8_t kSteerFaultNotCalibrated = 1 << 5;
97+
constexpr uint8_t kSteerFaultOverTravel = 1 << 6; // pot past a calibrated stop
9798

9899
struct SteerStatus {
99100
SteerState state;

firmware/steervo/lib/steervo/steer_controller.cpp

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ bool SteerController::on_cal(kart::SteerCalCmd cmd, uint16_t pot_raw) {
8383
return false;
8484
}
8585

86+
bool SteerController::raw_over_travel(uint16_t raw) const {
87+
if (!cal_.valid) return false;
88+
int32_t lo = cal_.raw_left < cal_.raw_right ? cal_.raw_left : cal_.raw_right;
89+
int32_t hi = cal_.raw_left < cal_.raw_right ? cal_.raw_right : cal_.raw_left;
90+
int32_t m = cfg_.over_travel_margin_raw;
91+
return (int32_t)raw < lo - m || (int32_t)raw > hi + m;
92+
}
93+
8694
int16_t SteerController::clamp_to_soft_limits(int16_t setpoint_cdeg) const {
8795
int32_t lo = cal_.angle_left_cdeg + cfg_.soft_limit_margin_cdeg;
8896
int32_t hi = cal_.angle_right_cdeg - cfg_.soft_limit_margin_cdeg;
@@ -104,6 +112,16 @@ float SteerController::tick(uint32_t now_ms, uint16_t pot_raw) {
104112
measured_cdeg_ = pot_to_angle_cdeg(cal_, pot_raw);
105113
}
106114

115+
// Over-travel: the steering went past a calibrated end stop. If the motor was
116+
// actively driving, this is a hard latched fault (it should have held inside
117+
// the soft limits) — cut power and require inspection. If the motor was not
118+
// driving (e.g. hand-moved during setup), don't latch, but block activation
119+
// below until the pot comes back inside range.
120+
bool over_travel_now = raw_over_travel(pot_raw);
121+
if (over_travel_now && state_ == kart::SteerState::kActive) {
122+
over_travel_fault_ = true;
123+
}
124+
107125
if (hard_faulted()) {
108126
state_ = kart::SteerState::kFault;
109127
last_output_ = 0.0f;
@@ -132,7 +150,8 @@ float SteerController::tick(uint32_t now_ms, uint16_t pot_raw) {
132150
enable_ = false;
133151
}
134152

135-
bool want_active = enable_ && fresh && cal_.valid;
153+
// Never (re)start the motor while the pot is sitting past a stop.
154+
bool want_active = enable_ && fresh && cal_.valid && !over_travel_now;
136155
if (!want_active) {
137156
if (state_ == kart::SteerState::kActive) {
138157
pid_.reset();
@@ -149,30 +168,33 @@ float SteerController::tick(uint32_t now_ms, uint16_t pot_raw) {
149168
float error = (float)(target - measured_cdeg_);
150169
float out = pid_.update(error, 10); // caller runs a fixed 100 Hz tick
151170

152-
// Stall detection: sustained near-saturated output with no movement.
171+
// Convergence watchdog: while pushing hard the |error| must keep shrinking.
172+
// A jammed motor (no movement) holds |error| constant; a wrong-way runaway
173+
// (e.g. inverted feedback sign / swapped motor leads) grows it. Either way,
174+
// if we push for stall_timeout_ms without making stall_min_delta_cdeg of
175+
// progress, fault and cut the motor — this catches a runaway *before* it
176+
// reaches a stop, in addition to the over-travel guard above.
177+
int32_t abs_err = error < 0.0f ? (int32_t)-error : (int32_t)error;
153178
bool pushing = (out > cfg_.stall_output_frac * cfg_.output_limit) ||
154179
(out < -cfg_.stall_output_frac * cfg_.output_limit);
155180
if (pushing) {
156181
if (!stall_window_open_) {
157182
stall_window_open_ = true;
158183
stall_window_start_ms_ = now_ms;
159-
stall_window_start_cdeg_ = measured_cdeg_;
160-
} else {
161-
int16_t delta = (int16_t)(measured_cdeg_ - stall_window_start_cdeg_);
162-
if (delta < 0) delta = (int16_t)-delta;
163-
if (delta >= cfg_.stall_min_delta_cdeg) {
164-
// It is moving; restart the window.
165-
stall_window_start_ms_ = now_ms;
166-
stall_window_start_cdeg_ = measured_cdeg_;
167-
} else if ((uint32_t)(now_ms - stall_window_start_ms_) >=
168-
cfg_.stall_timeout_ms) {
169-
stall_fault_ = true;
170-
state_ = kart::SteerState::kFault;
171-
pid_.reset();
172-
stall_window_open_ = false;
173-
last_output_ = 0.0f;
174-
return 0.0f;
175-
}
184+
stall_window_start_abserr_ = abs_err;
185+
} else if (stall_window_start_abserr_ - abs_err >=
186+
cfg_.stall_min_delta_cdeg) {
187+
// Error is shrinking: the loop is converging. Restart the window.
188+
stall_window_start_ms_ = now_ms;
189+
stall_window_start_abserr_ = abs_err;
190+
} else if ((uint32_t)(now_ms - stall_window_start_ms_) >=
191+
cfg_.stall_timeout_ms) {
192+
stall_fault_ = true;
193+
state_ = kart::SteerState::kFault;
194+
pid_.reset();
195+
stall_window_open_ = false;
196+
last_output_ = 0.0f;
197+
return 0.0f;
176198
}
177199
} else {
178200
stall_window_open_ = false;
@@ -186,6 +208,7 @@ uint8_t SteerController::fault_bits() const {
186208
uint8_t bits = 0;
187209
if (pot_range_fault_) bits |= kart::kSteerFaultPotRange;
188210
if (stall_fault_) bits |= kart::kSteerFaultStall;
211+
if (over_travel_fault_) bits |= kart::kSteerFaultOverTravel;
189212
if (setpoint_stale_) bits |= kart::kSteerFaultSetpointStale;
190213
if (talon_lost_) bits |= kart::kSteerFaultTalonLost;
191214
if (!cal_.valid) bits |= kart::kSteerFaultNotCalibrated;

firmware/steervo/lib/steervo/steer_controller.h

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,13 @@ struct SteerConfig {
2727
int16_t soft_limit_margin_cdeg = 100;
2828
uint32_t setpoint_timeout_ms = kart::kSteerSetTimeoutMs;
2929
uint32_t stall_timeout_ms = 800;
30-
float stall_output_frac = 0.9f; // of output_limit
31-
int16_t stall_min_delta_cdeg = 25;
30+
float stall_output_frac = 0.9f; // of output_limit
31+
int16_t stall_min_delta_cdeg = 25; // min error improvement to "still converging"
32+
// Pot protection: a raw reading this far beyond a calibrated end stop means
33+
// the steering has been driven past its safe range (the #1 way to rip the
34+
// pot off its coupling). Caught on the RAW value because pot_to_angle_cdeg
35+
// clamps the reported angle at the stops and would otherwise hide it.
36+
uint16_t over_travel_margin_raw = 80;
3237
};
3338

3439
class SteerController {
@@ -63,8 +68,13 @@ class SteerController {
6368
int16_t measured_cdeg() const { return measured_cdeg_; }
6469

6570
private:
66-
bool hard_faulted() const { return pot_range_fault_ || stall_fault_; }
71+
bool hard_faulted() const {
72+
return pot_range_fault_ || stall_fault_ || over_travel_fault_;
73+
}
6774
int16_t clamp_to_soft_limits(int16_t setpoint_cdeg) const;
75+
// True when the raw pot is beyond a calibrated end stop by more than the
76+
// over-travel margin (only meaningful once calibrated).
77+
bool raw_over_travel(uint16_t raw) const;
6878

6979
SteerConfig cfg_;
7080
Pid pid_;
@@ -76,6 +86,7 @@ class SteerController {
7686
// fault-clear path via STEER_CAL).
7787
bool pot_range_fault_ = false;
7888
bool stall_fault_ = false;
89+
bool over_travel_fault_ = false;
7990
// Soft conditions
8091
bool setpoint_stale_ = false;
8192
bool talon_lost_ = false;
@@ -90,10 +101,13 @@ class SteerController {
90101
int16_t measured_cdeg_ = 0;
91102
float last_output_ = 0.0f;
92103

93-
// Stall detection window
104+
// Convergence watchdog window: while the motor pushes hard, the |error| must
105+
// keep shrinking. If it does not (jammed = stall, or growing = wrong-way
106+
// runaway) for stall_timeout_ms, fault. Tracks the |error| the window opened
107+
// at; resets whenever the loop makes stall_min_delta_cdeg of progress.
94108
bool stall_window_open_ = false;
95109
uint32_t stall_window_start_ms_ = 0;
96-
int16_t stall_window_start_cdeg_ = 0;
110+
int32_t stall_window_start_abserr_ = 0;
97111

98112
// In-progress calibration capture (committed to cal_ on SAVE_EXIT).
99113
PotCalibration cal_capture_{0, 0, 0, 0, 0, false};

firmware/steervo/test/test_steer_controller/test_steer_controller.cpp

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,73 @@ void test_movement_resets_stall_window() {
197197
}
198198
}
199199

200+
void test_runaway_wrong_way_faults() {
201+
// Motor pushes (saturated) but the pot moves AWAY from the target: an
202+
// inverted-sign runaway. The convergence watchdog must fault even though the
203+
// pot IS moving (the old movement-only stall check would have missed this).
204+
SteerConfig cfg;
205+
cfg.kp = 1.0f; // saturate on any error
206+
cfg.stall_timeout_ms = 800;
207+
SteerController c(cfg);
208+
c.set_calibration(test_cal());
209+
c.tick(0, 2048);
210+
211+
uint16_t pot = 2048; // 0 deg
212+
bool faulted = false;
213+
uint32_t t = 10;
214+
for (; t < 2000; t += 10) {
215+
feed_setpoint(c, 1000, t); // want +10 deg (right of center)
216+
c.tick(t, pot);
217+
// Runaway: drifts left (away from the +target), staying inside the pot
218+
// range so this is the watchdog firing, not over-travel / pot-range.
219+
if (t % 50 == 0 && pot > 1600) pot -= 10;
220+
if (c.state() == SteerState::kFault) {
221+
faulted = true;
222+
break;
223+
}
224+
}
225+
TEST_ASSERT_TRUE(faulted);
226+
TEST_ASSERT_TRUE(c.fault_bits() & kart::kSteerFaultStall);
227+
TEST_ASSERT_TRUE(t >= cfg.stall_timeout_ms);
228+
}
229+
230+
// -------------------- over-travel (pot protection) --------------------
231+
232+
void test_over_travel_while_active_is_latched_fault() {
233+
SteerController c = make_ready();
234+
feed_setpoint(c, 1500, 105);
235+
c.tick(110, 2048);
236+
TEST_ASSERT_EQUAL((int)SteerState::kActive, (int)c.state());
237+
238+
// Pot driven past the right stop (raw_right=3072, margin 80 -> >3152),
239+
// still a plausible ADC reading (not a rail), so this is over-travel.
240+
feed_setpoint(c, 1500, 118);
241+
TEST_ASSERT_EQUAL_FLOAT(0.0f, c.tick(120, 3200));
242+
TEST_ASSERT_EQUAL((int)SteerState::kFault, (int)c.state());
243+
TEST_ASSERT_TRUE(c.fault_bits() & kart::kSteerFaultOverTravel);
244+
TEST_ASSERT_FALSE(c.fault_bits() & kart::kSteerFaultPotRange); // not a rail
245+
246+
// Latched: a return to a valid in-range reading does not clear it.
247+
feed_setpoint(c, 0, 130);
248+
TEST_ASSERT_EQUAL_FLOAT(0.0f, c.tick(140, 2048));
249+
TEST_ASSERT_EQUAL((int)SteerState::kFault, (int)c.state());
250+
}
251+
252+
void test_over_travel_while_idle_blocks_activation_without_latching() {
253+
SteerController c = make_ready(); // READY, motor never driven
254+
// Pot sitting past the stop while idle (e.g. hand-moved during setup).
255+
feed_setpoint(c, 1500, 105);
256+
TEST_ASSERT_EQUAL_FLOAT(0.0f, c.tick(110, 3200));
257+
TEST_ASSERT_EQUAL((int)SteerState::kReady, (int)c.state()); // refused, not faulted
258+
TEST_ASSERT_FALSE(c.fault_bits() & kart::kSteerFaultOverTravel);
259+
260+
// Back inside range: the motor may now activate (no latch held it down).
261+
feed_setpoint(c, 1500, 120, 1);
262+
float out = c.tick(125, 2048);
263+
TEST_ASSERT_EQUAL((int)SteerState::kActive, (int)c.state());
264+
TEST_ASSERT_TRUE(out > 0.0f);
265+
}
266+
200267
// -------------------- status reporting --------------------
201268

202269
void test_status_reflects_state_and_seq_echo() {
@@ -280,6 +347,9 @@ int main(int, char **) {
280347
RUN_TEST(test_setpoint_clamped_to_soft_limits);
281348
RUN_TEST(test_stall_faults_after_sustained_saturated_output);
282349
RUN_TEST(test_movement_resets_stall_window);
350+
RUN_TEST(test_runaway_wrong_way_faults);
351+
RUN_TEST(test_over_travel_while_active_is_latched_fault);
352+
RUN_TEST(test_over_travel_while_idle_blocks_activation_without_latching);
283353
RUN_TEST(test_status_reflects_state_and_seq_echo);
284354
RUN_TEST(test_calibration_sequence_commits_and_enables);
285355
RUN_TEST(test_incomplete_calibration_does_not_commit);

0 commit comments

Comments
 (0)