calibrate enclosure motion outputs

This commit is contained in:
Jay
2026-08-17 10:44:28 -04:00
parent 07d47f99a4
commit a5c3087ee4
5 changed files with 167 additions and 10 deletions
+11 -5
View File
@@ -8,7 +8,8 @@ This milestone does four things:
1. Detects and verifies both sensors by their identification registers. 1. Detects and verifies both sensors by their identification registers.
2. Configures each sensor for a nominal 100 Hz raw output rate. 2. Configures each sensor for a nominal 100 Hz raw output rate.
3. Emits timestamped, sensor-native raw readings over the XIAO USB connection. 3. Emits timestamped, sensor-native raw readings over the XIAO USB connection.
4. Maps both sensors into a shared enclosure coordinate frame for validation. 4. Maps both sensors into a shared enclosure coordinate frame and emits both raw
and calibrated readings.
BLE transport and phone-side storage come after the wired sensor path is proven. BLE transport and phone-side storage come after the wired sensor path is proven.
@@ -48,7 +49,11 @@ enclosure Y = -native X
enclosure Z = native Z enclosure Z = native Z
``` ```
No software calibration, software filtering, or sensor fusion is performed yet. Software calibration is applied after enclosure-axis mapping. Accelerometer
offset and per-axis scale were measured with a six-face enclosure test. Gyroscope
zero-rate bias and polarity were measured; its 17.5 mdps/LSB scale remains the
nominal datasheet value. Sensor-native and mapped raw counts remain in every
record for diagnostics. No software filtering or sensor fusion is performed yet.
The ESP32-C3 polls at exactly 100 Hz, but each sensor has an independent internal The ESP32-C3 polls at exactly 100 Hz, but each sensor has an independent internal
sample clock. The status registers are read immediately before each XYZ read so a sample clock. The status registers are read immediately before each XYZ read so a
@@ -79,13 +84,14 @@ Exit the serial monitor with `Ctrl-]`.
After startup metadata, records use CSV: After startup metadata, records use CSV:
```text ```text
sequence,poll_timestamp_us,accel_x_raw,accel_y_raw,accel_z_raw,gyro_x_raw,gyro_y_raw,gyro_z_raw,accel_native_x_raw,accel_native_y_raw,accel_native_z_raw,gyro_native_x_raw,gyro_native_y_raw,gyro_native_z_raw,accel_int_source,gyro_status,loop_overrun_count sequence,poll_timestamp_us,accel_x_raw,accel_y_raw,accel_z_raw,gyro_x_raw,gyro_y_raw,gyro_z_raw,accel_x_mg,accel_y_mg,accel_z_mg,gyro_x_mdps,gyro_y_mdps,gyro_z_mdps,accel_native_x_raw,accel_native_y_raw,accel_native_z_raw,gyro_native_x_raw,gyro_native_y_raw,gyro_native_z_raw,accel_int_source,gyro_status,loop_overrun_count
``` ```
`poll_timestamp_us` is the ESP32-C3 monotonic time immediately before the status `poll_timestamp_us` is the ESP32-C3 monotonic time immediately before the status
and data reads. It is not the sensors' physical sample time. The axes in the first and data reads. It is not the sensors' physical sample time. The axes in the first
six sample columns use the enclosure frame above. Native columns remain available six sample columns use the enclosure frame above. Calibrated acceleration is in
for diagnostics. integer milligravity (`mg`), and bias-corrected angular rate is in integer
millidegrees per second (`mdps`). Native columns remain available for diagnostics.
Status bits: Status bits:
+2 -2
View File
@@ -56,5 +56,5 @@ in firmware.
The mounted sensor axes and the firmware's enclosure-frame axis assignments are The mounted sensor axes and the firmware's enclosure-frame axis assignments are
consistent. These tests established the dominant gyro axis but did not establish consistent. These tests established the dominant gyro axis but did not establish
gyro polarity; directed positive and negative rotations remain part of the gyro polarity. The subsequent 2026-08-17 calibration pass used directed positive
calibration pass. and negative rotations and confirmed polarity on all three axes.
+89
View File
@@ -0,0 +1,89 @@
# Sensor Calibration — 2026-08-17
Calibration used one continuous 83,229-record hardware capture. The enclosure
was held stationary on all six faces, then returned flat for three explicitly
directed rotations around each enclosure axis. Position changes remained in the
recording so freshness behavior could also be checked during motion.
The raw capture is `captures/calibration_session_20260817.csv` and is
intentionally excluded from source control.
## Capture integrity
- Sequence gaps and resets: 0
- Timestamp anomalies: 0
- Rejected or ignored records: 0
- Acquisition-loop overruns: 0
- ADXL345 overruns: 0
- ADXL345 DATA_READY clear: 4,282 records
- L3G4200D ZYXDA clear: 0 records
- L3G4200D ZYXOR set: 2,456 records
## Accelerometer
Only records with ADXL345 DATA_READY set were used in the stationary face means.
The paired positive and negative faces produced these enclosure-frame
coefficients:
| Axis | Offset (counts) | Scale (counts/g) | Scale (mg/LSB) |
| --- | ---: | ---: | ---: |
| X | -1.417678 | 258.890661 | 3.862635 |
| Y | -4.400892 | 259.825135 | 3.848742 |
| Z | +11.776132 | 245.755573 | 4.069084 |
Firmware converts a mapped raw value to integer milligravity with:
```text
accel_mg = round((raw_counts - offset_counts) * 1000 / counts_per_g)
```
The ADXL345 hardware offset registers remain zero. Calibration is deliberately
performed in software so raw readings remain recoverable and the coefficients
remain explicit.
## Gyroscope
The central five thousand samples of the flat stationary interval produced:
| Axis | Zero-rate bias (counts) | Stationary SD (counts) |
| --- | ---: | ---: |
| X | +9.0482 | 9.8933 |
| Y | -153.6198 | 9.5714 |
| Z | -7.0238 | 10.4259 |
The instructed positive motion was top/USB-edge lift for +X, left-edge lift for
+Y, and counterclockwise rotation viewed from the cover for +Z. Every outward
stroke was positive on its intended gyro channel, and every return stroke was
negative. Axis assignment and polarity are therefore confirmed.
Firmware subtracts the measured zero-rate bias and converts with the nominal
L3G4200D +/-500 dps scale of 17.5 mdps/LSB. Gyro scale itself was not measured
because no controlled angular-rate reference was available.
## Freshness under motion
Using gyro magnitude greater than 500 counts from stationary bias as a
conservative motion selector gave 8,233 moving records. Of the 425 records with
ADXL345 DATA_READY clear, 300 (70.6%) exactly repeated the preceding XYZ tuple.
Of 7,808 records with DATA_READY set, 85 (1.1%) repeated the tuple. Identical
fresh samples remain possible from quantization, pauses, or rotation about the
gravity vector, but the strong enrichment confirms that the status flag is
practically useful under motion.
The clear flag remains conservative: 125 moving records changed values despite
DATA_READY being clear at the earlier status transaction, consistent with a new
sample arriving between the separate status and data reads.
## Firmware verification
The calibrated CSV-v3 firmware was built, flashed to the assembled prototype,
and checked with a 3,814-record flat smoke capture:
- Sequence gaps, resets, timestamp anomalies, and rejected records: 0
- Acquisition-loop and ADXL345 overruns: 0
- Independently recomputed calibrated fields differing from firmware output: 0
- Mean calibrated acceleration: (+19.5, -5.6, +999.9) mg
- Mean bias-corrected gyro: (+25.4, -62.9, -54.8) mdps
The small residual horizontal acceleration is consistent with the enclosure not
being perfectly level. The largest residual gyro mean is 0.063 dps.
+59 -3
View File
@@ -1,4 +1,5 @@
#include <inttypes.h> #include <inttypes.h>
#include <math.h>
#include <stdio.h> #include <stdio.h>
#include "adxl345.h" #include "adxl345.h"
@@ -19,6 +20,20 @@
#define TRIKKE_SAMPLE_RATE_HZ 100 #define TRIKKE_SAMPLE_RATE_HZ 100
#define TRIKKE_SAMPLE_TICKS pdMS_TO_TICKS(1000 / TRIKKE_SAMPLE_RATE_HZ) #define TRIKKE_SAMPLE_TICKS pdMS_TO_TICKS(1000 / TRIKKE_SAMPLE_RATE_HZ)
// Software calibration from the 2026-08-17 enclosure six-face capture.
// Accelerometer coefficients are measured. Gyroscope scale is nominal; its
// zero-rate biases and all three axis polarities were measured.
#define TRIKKE_ACCEL_X_OFFSET_COUNTS (-1.417678f)
#define TRIKKE_ACCEL_Y_OFFSET_COUNTS (-4.400892f)
#define TRIKKE_ACCEL_Z_OFFSET_COUNTS (11.776132f)
#define TRIKKE_ACCEL_X_COUNTS_PER_G (258.890661f)
#define TRIKKE_ACCEL_Y_COUNTS_PER_G (259.825135f)
#define TRIKKE_ACCEL_Z_COUNTS_PER_G (245.755573f)
#define TRIKKE_GYRO_X_BIAS_COUNTS (9.0482f)
#define TRIKKE_GYRO_Y_BIAS_COUNTS (-153.6198f)
#define TRIKKE_GYRO_Z_BIAS_COUNTS (-7.0238f)
#define TRIKKE_GYRO_MDPS_PER_LSB (17.5f)
static const char *TAG = "trikke"; static const char *TAG = "trikke";
typedef struct { typedef struct {
@@ -48,6 +63,30 @@ static trikke_axes_sample_t map_gyro_to_enclosure(const l3g4200d_sample_t *nativ
}; };
} }
static trikke_axes_sample_t calibrate_accel_mg(const trikke_axes_sample_t *raw)
{
return (trikke_axes_sample_t) {
.x = lroundf(((float)raw->x - TRIKKE_ACCEL_X_OFFSET_COUNTS) *
1000.0f / TRIKKE_ACCEL_X_COUNTS_PER_G),
.y = lroundf(((float)raw->y - TRIKKE_ACCEL_Y_OFFSET_COUNTS) *
1000.0f / TRIKKE_ACCEL_Y_COUNTS_PER_G),
.z = lroundf(((float)raw->z - TRIKKE_ACCEL_Z_OFFSET_COUNTS) *
1000.0f / TRIKKE_ACCEL_Z_COUNTS_PER_G),
};
}
static trikke_axes_sample_t calibrate_gyro_mdps(const trikke_axes_sample_t *raw)
{
return (trikke_axes_sample_t) {
.x = lroundf(((float)raw->x - TRIKKE_GYRO_X_BIAS_COUNTS) *
TRIKKE_GYRO_MDPS_PER_LSB),
.y = lroundf(((float)raw->y - TRIKKE_GYRO_Y_BIAS_COUNTS) *
TRIKKE_GYRO_MDPS_PER_LSB),
.z = lroundf(((float)raw->z - TRIKKE_GYRO_Z_BIAS_COUNTS) *
TRIKKE_GYRO_MDPS_PER_LSB),
};
}
static esp_err_t init_i2c(i2c_master_bus_handle_t *bus) static esp_err_t init_i2c(i2c_master_bus_handle_t *bus)
{ {
const i2c_master_bus_config_t config = { const i2c_master_bus_config_t config = {
@@ -106,9 +145,16 @@ void app_main(void)
// Discard the gyroscope's visible startup transient before beginning the stream. // Discard the gyroscope's visible startup transient before beginning the stream.
vTaskDelay(pdMS_TO_TICKS(500)); vTaskDelay(pdMS_TO_TICKS(500));
printf("# format=trikke_freshness_validation_v2\n"); printf("# format=trikke_calibrated_v3\n");
printf("# nominal_accel_scale_g_per_lsb=0.0039," printf("# accel_calibration=offset_counts:(%.6f,%.6f,%.6f),"
"nominal_gyro_scale_dps_per_lsb=0.0175\n"); "counts_per_g:(%.6f,%.6f,%.6f)\n",
TRIKKE_ACCEL_X_OFFSET_COUNTS, TRIKKE_ACCEL_Y_OFFSET_COUNTS,
TRIKKE_ACCEL_Z_OFFSET_COUNTS, TRIKKE_ACCEL_X_COUNTS_PER_G,
TRIKKE_ACCEL_Y_COUNTS_PER_G, TRIKKE_ACCEL_Z_COUNTS_PER_G);
printf("# gyro_calibration=bias_counts:(%.4f,%.4f,%.4f),"
"nominal_mdps_per_lsb:%.1f,polarity:verified\n",
TRIKKE_GYRO_X_BIAS_COUNTS, TRIKKE_GYRO_Y_BIAS_COUNTS,
TRIKKE_GYRO_Z_BIAS_COUNTS, TRIKKE_GYRO_MDPS_PER_LSB);
printf("# enclosure_axes=+x:right,+y:top,+z:toward_cover\n"); printf("# enclosure_axes=+x:right,+y:top,+z:toward_cover\n");
printf("# mapping=accel(x,y,z)=(native_y,-native_x,native_z);" printf("# mapping=accel(x,y,z)=(native_y,-native_x,native_z);"
"gyro(x,y,z)=(native_x,native_y,native_z)\n"); "gyro(x,y,z)=(native_x,native_y,native_z)\n");
@@ -116,6 +162,8 @@ void app_main(void)
"gyro_data_ready:0x08,gyro_overrun:0x80\n"); "gyro_data_ready:0x08,gyro_overrun:0x80\n");
printf("sequence,poll_timestamp_us,accel_x_raw,accel_y_raw,accel_z_raw," printf("sequence,poll_timestamp_us,accel_x_raw,accel_y_raw,accel_z_raw,"
"gyro_x_raw,gyro_y_raw,gyro_z_raw," "gyro_x_raw,gyro_y_raw,gyro_z_raw,"
"accel_x_mg,accel_y_mg,accel_z_mg,"
"gyro_x_mdps,gyro_y_mdps,gyro_z_mdps,"
"accel_native_x_raw,accel_native_y_raw,accel_native_z_raw," "accel_native_x_raw,accel_native_y_raw,accel_native_z_raw,"
"gyro_native_x_raw,gyro_native_y_raw,gyro_native_z_raw," "gyro_native_x_raw,gyro_native_y_raw,gyro_native_z_raw,"
"accel_int_source,gyro_status,loop_overrun_count\n"); "accel_int_source,gyro_status,loop_overrun_count\n");
@@ -139,8 +187,14 @@ void app_main(void)
if (accel_err == ESP_OK && gyro_err == ESP_OK) { if (accel_err == ESP_OK && gyro_err == ESP_OK) {
const trikke_axes_sample_t enclosure_accel = map_accel_to_enclosure(&accel); const trikke_axes_sample_t enclosure_accel = map_accel_to_enclosure(&accel);
const trikke_axes_sample_t enclosure_gyro = map_gyro_to_enclosure(&gyro); const trikke_axes_sample_t enclosure_gyro = map_gyro_to_enclosure(&gyro);
const trikke_axes_sample_t calibrated_accel =
calibrate_accel_mg(&enclosure_accel);
const trikke_axes_sample_t calibrated_gyro =
calibrate_gyro_mdps(&enclosure_gyro);
printf("%" PRIu32 ",%" PRId64 ",%" PRId32 ",%" PRId32 ",%" PRId32 printf("%" PRIu32 ",%" PRId64 ",%" PRId32 ",%" PRId32 ",%" PRId32
",%" PRId32 ",%" PRId32 ",%" PRId32
",%" PRId32 ",%" PRId32 ",%" PRId32
",%" PRId32 ",%" PRId32 ",%" PRId32 ",%" PRId32 ",%" PRId32 ",%" PRId32
",%" PRId16 ",%" PRId16 ",%" PRId16 ",%" PRId16 ",%" PRId16 ",%" PRId16
",%" PRId16 ",%" PRId16 ",%" PRId16 ",%" PRId16 ",%" PRId16 ",%" PRId16
@@ -148,6 +202,8 @@ void app_main(void)
sequence, timestamp_us, sequence, timestamp_us,
enclosure_accel.x, enclosure_accel.y, enclosure_accel.z, enclosure_accel.x, enclosure_accel.y, enclosure_accel.z,
enclosure_gyro.x, enclosure_gyro.y, enclosure_gyro.z, enclosure_gyro.x, enclosure_gyro.y, enclosure_gyro.z,
calibrated_accel.x, calibrated_accel.y, calibrated_accel.z,
calibrated_gyro.x, calibrated_gyro.y, calibrated_gyro.z,
accel.x, accel.y, accel.z, accel.x, accel.y, accel.z,
gyro.x, gyro.y, gyro.z, gyro.x, gyro.y, gyro.z,
accel_int_source, gyro_status, loop_overrun_count); accel_int_source, gyro_status, loop_overrun_count);
+6
View File
@@ -19,6 +19,12 @@ EXPECTED_COLUMNS = [
"gyro_x_raw", "gyro_x_raw",
"gyro_y_raw", "gyro_y_raw",
"gyro_z_raw", "gyro_z_raw",
"accel_x_mg",
"accel_y_mg",
"accel_z_mg",
"gyro_x_mdps",
"gyro_y_mdps",
"gyro_z_mdps",
"accel_native_x_raw", "accel_native_x_raw",
"accel_native_y_raw", "accel_native_y_raw",
"accel_native_z_raw", "accel_native_z_raw",