diff --git a/README.md b/README.md index 7cd854c..ea3bca7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ This milestone does four things: 1. Detects and verifies both sensors by their identification registers. 2. Configures each sensor for a nominal 100 Hz raw output rate. 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. @@ -48,7 +49,11 @@ enclosure Y = -native X 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 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: ```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 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 -for diagnostics. +six sample columns use the enclosure frame above. Calibrated acceleration is in +integer milligravity (`mg`), and bias-corrected angular rate is in integer +millidegrees per second (`mdps`). Native columns remain available for diagnostics. Status bits: diff --git a/docs/axis-validation-2026-08-16.md b/docs/axis-validation-2026-08-16.md index f514715..91d9124 100644 --- a/docs/axis-validation-2026-08-16.md +++ b/docs/axis-validation-2026-08-16.md @@ -56,5 +56,5 @@ in firmware. 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 -gyro polarity; directed positive and negative rotations remain part of the -calibration pass. +gyro polarity. The subsequent 2026-08-17 calibration pass used directed positive +and negative rotations and confirmed polarity on all three axes. diff --git a/docs/calibration-2026-08-17.md b/docs/calibration-2026-08-17.md new file mode 100644 index 0000000..8557f75 --- /dev/null +++ b/docs/calibration-2026-08-17.md @@ -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. diff --git a/main/trikke_sensor_main.c b/main/trikke_sensor_main.c index 5249ace..2f87844 100644 --- a/main/trikke_sensor_main.c +++ b/main/trikke_sensor_main.c @@ -1,4 +1,5 @@ #include +#include #include #include "adxl345.h" @@ -19,6 +20,20 @@ #define TRIKKE_SAMPLE_RATE_HZ 100 #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"; 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) { 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. vTaskDelay(pdMS_TO_TICKS(500)); - printf("# format=trikke_freshness_validation_v2\n"); - printf("# nominal_accel_scale_g_per_lsb=0.0039," - "nominal_gyro_scale_dps_per_lsb=0.0175\n"); + printf("# format=trikke_calibrated_v3\n"); + printf("# accel_calibration=offset_counts:(%.6f,%.6f,%.6f)," + "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("# mapping=accel(x,y,z)=(native_y,-native_x,native_z);" "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"); printf("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\n"); @@ -139,8 +187,14 @@ void app_main(void) 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_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 + ",%" PRId32 ",%" PRId32 ",%" PRId32 + ",%" PRId32 ",%" PRId32 ",%" PRId32 ",%" PRId32 ",%" PRId32 ",%" PRId32 ",%" PRId16 ",%" PRId16 ",%" PRId16 ",%" PRId16 ",%" PRId16 ",%" PRId16 @@ -148,6 +202,8 @@ void app_main(void) sequence, timestamp_us, enclosure_accel.x, enclosure_accel.y, enclosure_accel.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, gyro.x, gyro.y, gyro.z, accel_int_source, gyro_status, loop_overrun_count); diff --git a/tools/capture_serial.py b/tools/capture_serial.py index 21e20f9..1d45922 100644 --- a/tools/capture_serial.py +++ b/tools/capture_serial.py @@ -19,6 +19,12 @@ EXPECTED_COLUMNS = [ "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",