Skip to content

BiSS-C single-turn absolute encoder does not accumulate turns across the zero rollover (runaway / limit trip) #123

Description

@weathermon83

BiSS-C single-turn absolute encoder does not accumulate turns across the zero rollover (runaway / limit trip)


Summary

The mainline BiSS-C driver does not accumulate turns for single-turn absolute encoders (BISSC_SINGLE_TURN == ON). When the raw count crosses the encoder's full-scale→zero boundary, the reported position jumps a full revolution. The KTech/serial absolute driver already handles this (turn accumulation in KTech.cpp), so single-turn BiSS-C is the outlier.

This is latent for most users because the rollover point usually sits outside the axis working range. On a mount where it falls inside the working range it is reproducible and serious.

Symptoms observed (JTW GTR, 24-bit single-turn BiSS-C on axis1, Manticore/ESP32):

  1. Servo runaway → motor fault. Crossing the rollover produces an instantaneous ~full-scale "following error"; the servo drives at max velocity to chase it.
  2. Coordinate jump past the axis limit. Once the servo path is guarded, the reported coordinate still jumps ~360°, tripping the axis1 limit (presents as an under-pole / limit stall).

Both trace to the same missing turn-accumulation and are fixed together.

Proposed fix (mirrors what KTech already does; purely additive, gated, inert otherwise):

  • EncoderBase.h: new virtual int32_t countsPerTurn() { return 0; } (0 = disabled; every existing encoder inherits it unchanged).
  • Bissc.h: override countsPerTurn() to report full scale, plus 3 accumulator state members.
  • Bissc.cpp (inside #if BISSC_SINGLE_TURN == ON): detect the wrap (raw jumps between the top and bottom quarter of full-scale in one poll), keep a running turn count, fold it back into the returned count so the coordinate stays continuous. Fixes the limit/coordinate jump.
  • Servo.cpp (ServoMotor::poll(), guarded by isAbsolute() && countsPerTurn() > 0): fold the encoder reading to within half a turn of the motor position so a raw wrap can never present as a giant following error. Fast defense-in-depth on the hot path; inert unless |diff| > half a turn.

Safety / scope

Nothing removed or modified - 38 added lines. Three independent gates (isAbsolute(), countsPerTurn() > 0, #if BISSC_SINGLE_TURN == ON) mean multi-turn BiSS-C and all non-BiSS encoders compile and behave exactly as before.

Testing

Full-sky slews repeatedly through the previously-failing rollover spot: axis error stays 0, servo delta stays bounded, pointing correct. Regression-free on the axis that does not cross a rollover. Baseline commit 89c9ca4.

Proposed diff
diff --git a/src/lib/axis/motor/servo/Servo.cpp b/src/lib/axis/motor/servo/Servo.cpp
index 8ac8564..e9832da 100644
--- a/src/lib/axis/motor/servo/Servo.cpp
+++ b/src/lib/axis/motor/servo/Servo.cpp
@@ -308,6 +308,18 @@ void ServoMotor::poll() {
     interrupts();
   }
 
+  // Single-turn absolute encoder wrap handling: fold the encoder reading to within half a
+  // turn of the motor position so crossing the encoder full-scale boundary does not create a
+  // huge false following error (which would drive a run-away). Inert unless |diff| > half turn,
+  // which only happens at the physical rollover.
+  if (encoder->isAbsolute()) {
+    int32_t cpt = encoder->countsPerTurn();
+    if (cpt > 0) {
+      while ((int32_t)(encoderCounts - (int32_t)motorCounts) >  (cpt >> 1)) encoderCounts -= cpt;
+      while ((int32_t)(encoderCounts - (int32_t)motorCounts) < -(cpt >> 1)) encoderCounts += cpt;
+    }
+  }
+
   long unfilteredEncoderCounts = encoderCounts;
   UNUSED(unfilteredEncoderCounts);
 
diff --git a/src/lib/encoder/EncoderBase.h b/src/lib/encoder/EncoderBase.h
index 50b1826..0e41b29 100644
--- a/src/lib/encoder/EncoderBase.h
+++ b/src/lib/encoder/EncoderBase.h
@@ -139,6 +139,9 @@ class Encoder {
     // true when this encoder can report absolute position after boot
     virtual bool isAbsolute() const { return false; }
 
+    // full-scale counts per revolution for single-turn absolute encoders (0 = disabled)
+    virtual int32_t countsPerTurn() { return 0; }
+
     // set encoder origin
     virtual void setOrigin(int32_t counts) { origin = counts; }
 
diff --git a/src/lib/encoder/bissc/Bissc.cpp b/src/lib/encoder/bissc/Bissc.cpp
index 8a6eb9d..7e68c89 100644
--- a/src/lib/encoder/bissc/Bissc.cpp
+++ b/src/lib/encoder/bissc/Bissc.cpp
@@ -202,6 +202,20 @@ bool Bissc::getCount(uint32_t &count) {
     return false;
   }
 
+#if BISSC_SINGLE_TURN == ON
+  // accumulate turns so the count stays continuous across the encoder zero rollover
+  // (otherwise the reported coordinate jumps a full turn and trips axis limits)
+  {
+    uint32_t hi = (encoderCounts >> 1) + (encoderCounts >> 2); // 3/4 scale
+    uint32_t lo = (encoderCounts >> 2);                        // 1/4 scale
+    if (turnAccumInit) {
+      if (lastRawSingle > hi && count < lo) accumTurns++;
+      else if (lastRawSingle < lo && count > hi) accumTurns--;
+    } else turnAccumInit = true;
+    lastRawSingle = count;
+  }
+#endif
+
   #if BISSC_SINGLE_TURN != ON
     // combine absolute and low order bits of multi-turn count for a 32 bit count
     count = count | ((turns & encoderMultiTurnMask) << encoderBits);
@@ -213,6 +227,9 @@ bool Bissc::getCount(uint32_t &count) {
   // center count about the +/- half counts range (a signed value stored unsigned)
   // note: instead shifts an multiTurn count but for historical reasons thats preferred
   count = count - (encoderCounts >> 1);
+#if BISSC_SINGLE_TURN == ON
+  count += (uint32_t)(accumTurns * (int32_t)encoderCounts);
+#endif
 
   #ifdef BISSC_RESOLUTION_DIVISOR
     count = (int32_t)count/(int32_t)(BISSC_RESOLUTION_DIVISOR);
diff --git a/src/lib/encoder/bissc/Bissc.h b/src/lib/encoder/bissc/Bissc.h
index 0991d64..86359ea 100644
--- a/src/lib/encoder/bissc/Bissc.h
+++ b/src/lib/encoder/bissc/Bissc.h
@@ -75,6 +75,7 @@
       bool init();
 
       bool isAbsolute() const override { return true; }
+      int32_t countsPerTurn() override { return (int32_t)encoderCounts; }
 
       // set encoder origin
       void setOrigin(int32_t counts);
@@ -109,6 +110,11 @@
 
       uint32_t turns = 0;
 
+      // single-turn wrap -> continuous count accumulation
+      int32_t  accumTurns = 0;
+      uint32_t lastRawSingle = 0;
+      bool     turnAccumInit = false;
+
       // this design allows for 8 to 31 bit encoders
       char encoderName[16] = {0};
       uint8_t encoderBits = 0;

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions