Skip to content

Commit 0ea9501

Browse files
mahouzaarekm
authored andcommitted
Add Kalman filter, sensitivity curve, and sensor validation
- Kalman filter: Replaces the simple low-pass filter with an adaptive per-axis Kalman filter. Automatically balances responsiveness vs smoothness — trusts new measurements more after idle, smooths more once stable. Tunable via KALMAN_Q and KALMAN_R in Config.h. - Non-linear sensitivity curve: Adds a cubic power curve (SENSITIVITY_EXP = 3.0) so small deflections give fine precision and large deflections give fast motion, matching how commercial spacemice feel. Set to 1.0 to restore linear behavior. - Sensor read validation: readRaw() now returns bool and rejects frames where any axis exceeds sane range (>500 mT) or a sensor reads all-zeros (I2C failure). Bad frames are skipped in both the motion pipeline and calibration. - Precomputed constants: Replaces runtime sqrt(3.0) calls and /3.0 divisions with precomputed constants, eliminating redundant FP math per frame on the Cortex-M0+. - Smooth dead zone transitions: Instead of hard-snapping the Kalman estimate to zero at the dead zone boundary, the estimate now decays gradually. Cherry-picked from sb-ocr#3 (lenkaiser/cad-mouse-mk2@a49b0b0).
1 parent 1c92d89 commit 0ea9501

6 files changed

Lines changed: 108 additions & 60 deletions

File tree

firmware/include/Config.h

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,17 @@ const int SIGN_AXIS[6] = {-1, +1, -1, +1, +1, +1};
3131
const float DEAD_T = 16.0;
3232
const float DEAD_R = 20.0;
3333

34-
// Smoothing
35-
const float SMOOTH_TAU_S = 0.08;
34+
// Kalman filter tuning
35+
// Process noise: how much we expect the true value to change per step.
36+
// Higher = more responsive but noisier.
37+
const float KALMAN_Q = 0.5;
38+
// Measurement noise: how noisy the sensor readings are.
39+
// Higher = smoother but more latency.
40+
const float KALMAN_R = 4.0;
41+
42+
// Sensitivity curve exponent.
43+
// 1.0 = linear, 3.0 = cubic (fine control at small deflections, fast at large).
44+
const float SENSITIVITY_EXP = 3.0;
3645

3746
// Final axis output range
3847
const float AXIS_LIMIT = 350.0;

firmware/include/controllers/MotionController.h

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ class MotionController {
88

99
private:
1010
static float clampf(float v, float lo, float hi);
11-
static float hardZero(float v, float thr);
12-
static float lowpass(float prev, float x, float dt, float tau);
1311
static float axisBaseDead(int i);
14-
float filt_[6] = {};
12+
static float sensitivityCurve(float value, float dead, float limit);
13+
14+
// Per-axis Kalman filter state
15+
float kalmanX_[6] = {}; // Estimated state
16+
float kalmanP_[6] = {}; // Estimate uncertainty (covariance)
17+
float kalmanStep(int axis, float measurement);
18+
1519
bool motionActive_ = false;
1620
};

firmware/include/controllers/SensorController.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class SensorController {
99
SensorController();
1010

1111
void begin();
12-
void readRaw(float out[9]);
12+
bool readRaw(float out[9]);
1313

1414
void beginCalibration();
1515
void updateCalibration();

firmware/src/controllers/MotionController.cpp

Lines changed: 66 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,24 @@ enum AxisIndex {
2626
AXIS_RY,
2727
AXIS_RZ
2828
};
29+
30+
// Precomputed geometric constants for the equilateral sensor triangle.
31+
const float kOneThird = 1.0f / 3.0f;
32+
const float kSqrt3 = 1.7320508f; // sqrt(3)
33+
const float kSqrt3Over6 = 0.28867513f; // sqrt(3)/6
34+
const float kSqrt3Over3 = 0.57735027f; // sqrt(3)/3
35+
const float kMag2PosX = -0.5f;
36+
const float kMag2PosY = kSqrt3Over6;
37+
const float kMag3PosX = 0.5f;
38+
const float kMag3PosY = kSqrt3Over6;
39+
const float kMag1PosX = 0.0f;
40+
const float kMag1PosY = -kSqrt3Over3;
2941
} // namespace
3042

3143
void MotionController::reset() {
3244
for (int i = 0; i < 6; i++) {
33-
filt_[i] = 0.0;
45+
kalmanX_[i] = 0.0f;
46+
kalmanP_[i] = 1.0f;
3447
}
3548
motionActive_ = false;
3649
}
@@ -41,20 +54,42 @@ float MotionController::clampf(float v, float lo, float hi) {
4154
return v;
4255
}
4356

44-
float MotionController::hardZero(float v, float thr) {
45-
return (fabs(v) < thr) ? 0.0 : v;
46-
}
57+
float MotionController::kalmanStep(int axis, float measurement) {
58+
// Predict step: uncertainty grows by process noise
59+
kalmanP_[axis] += Config::KALMAN_Q;
60+
61+
// Update step: compute Kalman gain
62+
const float K = kalmanP_[axis] / (kalmanP_[axis] + Config::KALMAN_R);
4763

48-
float MotionController::lowpass(float prev, float x, float dt, float tau) {
49-
if (tau <= 0.0) return x;
50-
const float a = dt / (tau + dt);
51-
return prev + a * (x - prev);
64+
// Correct estimate with measurement
65+
kalmanX_[axis] += K * (measurement - kalmanX_[axis]);
66+
67+
// Update uncertainty
68+
kalmanP_[axis] *= (1.0f - K);
69+
70+
return kalmanX_[axis];
5271
}
5372

5473
float MotionController::axisBaseDead(int i) {
5574
return (i < 3) ? Config::DEAD_T : Config::DEAD_R;
5675
}
5776

77+
float MotionController::sensitivityCurve(float value, float dead, float limit) {
78+
// Map the post-dead-zone range [dead, limit] onto [0, limit] with a power curve.
79+
// This gives fine control at small deflections and fast motion at large ones.
80+
const float sign = (value >= 0.0f) ? 1.0f : -1.0f;
81+
const float abs_val = fabs(value);
82+
if (abs_val <= dead) return 0.0f;
83+
84+
// Normalize to 0..1 within the active range
85+
const float range = limit - dead;
86+
if (range <= 0.0f) return 0.0f;
87+
const float normalized = clampf((abs_val - dead) / range, 0.0f, 1.0f);
88+
89+
// Apply power curve and scale back to output range
90+
return sign * powf(normalized, Config::SENSITIVITY_EXP) * limit;
91+
}
92+
5893
void MotionController::compute(const float raw[9], const float* baseline, float dt,
5994
float out[6]) {
6095
// Baseline subtraction converts magnetic deltas around the calibrated rest pose.
@@ -68,43 +103,21 @@ void MotionController::compute(const float raw[9], const float* baseline, float
68103
const float mag3y = raw[RAW_MAG3_Y] - baseline[RAW_MAG3_Y];
69104
const float mag3z = raw[RAW_MAG3_Z] - baseline[RAW_MAG3_Z];
70105

71-
// Translation:
72-
// Tx = (mag1x + mag2x + mag3x) / 3
73-
// Ty = (mag1y + mag2y + mag3y) / 3
74-
// Tz = (mag1z + mag2z + mag3z) / 3
75-
const float tx = (mag1x + mag2x + mag3x) / 3.0;
76-
const float ty = (mag1y + mag2y + mag3y) / 3.0;
77-
const float tz = (mag1z + mag2z + mag3z) / 3.0;
78-
79-
// Physical PCB layout:
80-
// MAG2 = top left, MAG3 = top right, MAG1 = bottom.
81-
const float mag2PosX = -0.5;
82-
const float mag2PosY = sqrt(3.0) / 6.0;
83-
84-
const float mag3PosX = 0.5;
85-
const float mag3PosY = sqrt(3.0) / 6.0;
86-
87-
const float mag1PosX = 0.0;
88-
const float mag1PosY = -sqrt(3.0) / 3.0;
89-
90-
// Rotation estimates:
91-
// Ry = mag3z - mag2z
92-
// right sensor minus left sensor
93-
// -> side to side tilt across the top edge
94-
//
95-
// Rx = sqrt(3) * (mag2z + mag3z - 2 * mag1z) / 3
96-
// top pair minus bottom sensor
97-
// -> front/back tilt of the triangle
98-
const float rx = (sqrt(3.0) * (mag2z + mag3z - 2.0 * mag1z)) / 3.0;
99-
const float ry = (mag3z - mag2z);
106+
// Translation: average of all three sensors.
107+
const float tx = (mag1x + mag2x + mag3x) * kOneThird;
108+
const float ty = (mag1y + mag2y + mag3y) * kOneThird;
109+
const float tz = (mag1z + mag2z + mag3z) * kOneThird;
100110

101-
// Rz = sum_i (posXi * magYi - posYi * magXi)
102-
// Each sensor contributes according to its x/y position in the triangle.
103-
const float swirlNum =
104-
(mag2PosX * mag2y - mag2PosY * mag2x) +
105-
(mag3PosX * mag3y - mag3PosY * mag3x) +
106-
(mag1PosX * mag1y - mag1PosY * mag1x);
107-
const float rz = swirlNum;
111+
// Rotation estimates from sensor triangle geometry.
112+
// Ry: side-to-side tilt (right sensor minus left)
113+
// Rx: front/back tilt (top pair minus bottom)
114+
// Rz: twist (cross-product per sensor position)
115+
const float rx = (kSqrt3 * (mag2z + mag3z - 2.0f * mag1z)) * kOneThird;
116+
const float ry = (mag3z - mag2z);
117+
const float rz =
118+
(kMag2PosX * mag2y - kMag2PosY * mag2x) +
119+
(kMag3PosX * mag3y - kMag3PosY * mag3x) +
120+
(kMag1PosX * mag1y - kMag1PosY * mag1x);
108121

109122
// Apply sign fixes and gains
110123
float y[6];
@@ -115,21 +128,23 @@ void MotionController::compute(const float raw[9], const float* baseline, float
115128
y[AXIS_RY] = Config::SIGN_AXIS[AXIS_RY] * ry * Config::GAIN_R[AXIS_RY - 3];
116129
y[AXIS_RZ] = Config::SIGN_AXIS[AXIS_RZ] * rz * Config::GAIN_R[AXIS_RZ - 3];
117130

118-
// Filter, clamp to range and dead zones.
131+
// Kalman filter, sensitivity curve, dead zones, and clamp.
119132
motionActive_ = false;
120133
for (int i = 0; i < 6; i++) {
121134
const float dead = axisBaseDead(i);
122135

123136
if (fabs(y[i]) < dead) {
124-
filt_[i] = 0.0;
137+
// Below dead zone: decay Kalman estimate toward zero gradually.
138+
// Preserve covariance so the filter doesn't jitter at the boundary.
139+
kalmanX_[i] *= 0.8f;
140+
kalmanP_[i] = fmin(kalmanP_[i] + Config::KALMAN_Q * 0.1f, 1.0f);
125141
} else {
126-
filt_[i] = lowpass(filt_[i], y[i], dt, Config::SMOOTH_TAU_S);
142+
kalmanStep(i, y[i]);
127143
}
128144

129-
const float limited =
130-
clampf(filt_[i], -Config::AXIS_LIMIT, Config::AXIS_LIMIT);
131-
out[i] = hardZero(limited, dead);
132-
if (out[i] != 0.0) {
145+
// Apply sensitivity curve to filtered output
146+
out[i] = sensitivityCurve(kalmanX_[i], dead, Config::AXIS_LIMIT);
147+
if (out[i] != 0.0f) {
133148
motionActive_ = true;
134149
}
135150
}

firmware/src/controllers/SensorController.cpp

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#include "controllers/SensorController.h"
22

3+
#include <math.h>
4+
35
#include "Config.h"
46

57
using namespace ifx::tlx493d;
@@ -51,7 +53,7 @@ void SensorController::begin() {
5153
delay(10);
5254
}
5355

54-
void SensorController::readRaw(float out[9]) {
56+
bool SensorController::readRaw(float out[9]) {
5557
double mag1x = 0, mag1y = 0, mag1z = 0, temp1 = 0;
5658
double mag2x = 0, mag2y = 0, mag2z = 0, temp2 = 0;
5759
double mag3x = 0, mag3y = 0, mag3z = 0, temp3 = 0;
@@ -70,6 +72,20 @@ void SensorController::readRaw(float out[9]) {
7072
out[6] = mag3x;
7173
out[7] = mag3y;
7274
out[8] = mag3z;
75+
76+
// Validate: reject frames where any axis reads exactly zero across all
77+
// components (likely I2C failure) or exceeds sane sensor range.
78+
for (int i = 0; i < 9; i++) {
79+
if (fabs(out[i]) > 500.0f) return false; // Way outside EXTRA_SHORT_RANGE
80+
}
81+
// Check for dead sensor (all three axes exactly zero)
82+
for (int s = 0; s < 3; s++) {
83+
int base = s * 3;
84+
if (out[base] == 0.0f && out[base + 1] == 0.0f && out[base + 2] == 0.0f) {
85+
return false;
86+
}
87+
}
88+
return true;
7389
}
7490

7591
void SensorController::beginCalibration() {
@@ -95,7 +111,9 @@ void SensorController::updateCalibration() {
95111
lastCalibrationSampleMs_ = now;
96112

97113
float raw[9] = {};
98-
readRaw(raw);
114+
if (!readRaw(raw)) {
115+
return; // Skip bad sample, try again next cycle
116+
}
99117

100118
for (int i = 0; i < 9; i++) {
101119
calibrationSum_[i] += raw[i];

firmware/src/states/IdleState.cpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ bool IdleState::handleCalibrationRequest() {
2222

2323
void IdleState::runMotionPipeline(float dt, unsigned long now) {
2424
float raw[9] = {};
25-
sensorController.readRaw(raw);
25+
if (!sensorController.readRaw(raw)) {
26+
return; // Skip frame on sensor read failure
27+
}
2628

2729
float motion[6] = {};
2830
motionController.compute(raw, sensorController.baseline(), dt, motion);

0 commit comments

Comments
 (0)