Kei Handa
Published © MIT

PunchSense

PunchSense uses M5Stack and IMU data to give instant, personalized feedback on punch acceleration, rotation, and timing.

IntermediateFull instructions provided4 hours23
PunchSense

Things used in this project

Hardware components

M5Stack Core2 ESP32 IoT Development Kit
M5Stack Core2 ESP32 IoT Development Kit
×1
M5Stack IMU Pro Mini Unit (BMI270, BMM150, BMP280)
Used to measure three-axis acceleration and angular velocity during punching motions.
×1
Boxing glove
×1
Hook-and-loop strap
×1
Python 3
×1

Software apps and online services

PlatformIO IDE
PlatformIO IDE
Visual Studio Code Extension for Arduino
Microsoft Visual Studio Code Extension for Arduino

Story

Read more

Schematics

M5Stack Core2 and IMU Pro Mini I2C Connection

The IMU Pro Mini Unit is connected to Port A of the M5Stack Core2 using a Grove I2C cable. SDA uses GPIO 32 and SCL uses GPIO 33.

Code

M5Stack Core2 Firmware

C/C++
Reads acceleration and angular velocity from the IMU Pro Mini, detects punches, calculates the personalized score, and displays real-time feedback.
#include <Arduino.h>
#include <SPIFFS.h>
#include <M5Unified.h>
#include <M5_IMU_PRO.h>
#include <math.h>

static const uint8_t BMI270_SENSOR_ADDR = 0x68;
static const uint8_t BMI270_SENSOR_ADDR_ALT = 0x69;
static const int SAMPLE_HZ = 100;
static const uint32_t SAMPLE_INTERVAL_US = (1000000UL + SAMPLE_HZ / 2) / SAMPLE_HZ;
static const int IMU_RETRY_INTERVAL_MS = 2000;
static const int IMU_POWER_SETTLE_MS = 2000;
static const int I2C_SETTLE_MS = 500;
static const int SERIAL_BAUD = 921600;
static const int ON_DEVICE_BUFFER_SECONDS = 12;
static const int ON_DEVICE_BUFFER_SIZE = SAMPLE_HZ * ON_DEVICE_BUFFER_SECONDS;
static const bool ARRIVAL_ONLY_MODE = false;
static const float PEAK_THRESHOLD_G = 1.5f;
static const float PEAK_MIN_INTERVAL_SEC = 0.6f;
static const float PEAK_SETTLE_SEC = 0.35f;
static const float G_TO_MS2 = 9.80665f;
static const uint8_t BMI270_CHIP_ID_REG = 0x00;
static const uint8_t BMI270_EXPECTED_CHIP_ID = 0x24;
static const uint8_t BMI270_CMD_REG = 0x7E;
static const uint8_t BMI270_SOFT_RESET_CMD = 0xB6;
static const uint8_t BMI270_ACC_CONF_REG = 0x40;
static const uint8_t BMI270_GYR_CONF_REG = 0x42;
static const uint8_t BMI270_ODR_MASK = 0x0F;
static const uint8_t BMI270_ODR_100HZ = 0x08;
static const char *PROJECT_LABEL = "IMU_Sokutei";
static const char *FIRMWARE_LABEL = "IMU_Sokutei Punch IMU v2 100Hz gyro";

BMI270::BMI270 bmi270;

uint32_t sampleIndex = 0;
uint32_t firstSampleMs = 0;
uint32_t lastSampleUs = 0;
uint32_t lastRetryMs = 0;
bool imuReady = false;
bool gyroReadWarned = false;
bool filesystemReady = false;
String serialCommand;
uint32_t arrivalShownMs = 0;
bool scoreDisplayReady = false;
float lastDisplayedScore = 0.0f;
char lastDisplayedLabel = 'D';
float lastDisplayedAccelMs2 = -1.0f;

struct OnDeviceSample {
  float timeSec;
  float dynamicG;
  float rotationDps;
};

OnDeviceSample onDeviceSamples[ON_DEVICE_BUFFER_SIZE];
int onDeviceSampleCount = 0;
float lastEvaluatedTimeSec = -1.0f;
float lastPunchTimeSec = -10.0f;

static uint8_t readBmiChipId(uint8_t addr) {
  return M5.Ex_I2C.readRegister8(addr, BMI270_CHIP_ID_REG, 100000);
}

static void softResetBmi(uint8_t addr) {
  bool resetWritten = M5.Ex_I2C.writeRegister8(addr, BMI270_CMD_REG, BMI270_SOFT_RESET_CMD, 100000);
  Serial.printf("# BMI270 soft reset addr=0x%02X written=%d\n", addr, resetWritten);
  delay(250);
}

static bool configureBmi100Hz(uint8_t addr) {
  uint8_t accConf = M5.Ex_I2C.readRegister8(addr, BMI270_ACC_CONF_REG, 100000);
  uint8_t gyrConf = M5.Ex_I2C.readRegister8(addr, BMI270_GYR_CONF_REG, 100000);
  uint8_t accConf100Hz = (accConf & ~BMI270_ODR_MASK) | BMI270_ODR_100HZ;
  uint8_t gyrConf100Hz = (gyrConf & ~BMI270_ODR_MASK) | BMI270_ODR_100HZ;

  bool accWritten = M5.Ex_I2C.writeRegister8(addr, BMI270_ACC_CONF_REG, accConf100Hz, 100000);
  bool gyrWritten = M5.Ex_I2C.writeRegister8(addr, BMI270_GYR_CONF_REG, gyrConf100Hz, 100000);
  delay(20);

  uint8_t accVerify = M5.Ex_I2C.readRegister8(addr, BMI270_ACC_CONF_REG, 100000);
  uint8_t gyrVerify = M5.Ex_I2C.readRegister8(addr, BMI270_GYR_CONF_REG, 100000);
  bool verified =
      (accVerify & BMI270_ODR_MASK) == BMI270_ODR_100HZ &&
      (gyrVerify & BMI270_ODR_MASK) == BMI270_ODR_100HZ;
  Serial.printf(
      "# BMI270 ODR 100Hz acc=0x%02X gyro=0x%02X write_acc=%d write_gyro=%d verified=%d\n",
      accVerify,
      gyrVerify,
      accWritten,
      gyrWritten,
      verified);
  return accWritten && gyrWritten && verified;
}

static void drawStatus(const char *message) {
  M5.Display.fillScreen(TFT_BLACK);
  M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
  M5.Display.setTextSize(2);
  M5.Display.setCursor(8, 8);
  M5.Display.println(PROJECT_LABEL);
  M5.Display.setCursor(8, 48);
  M5.Display.println(message);
  M5.Display.setTextSize(1);
  M5.Display.setCursor(8, 110);
  M5.Display.printf("Firmware: %s\n", FIRMWARE_LABEL);
  M5.Display.printf("Serial: %d bps\n", SERIAL_BAUD);
  M5.Display.println("Standalone scoring enabled");
}

static uint16_t labelColor(char label) {
  switch (label) {
    case 'S':
      return TFT_GOLD;
    case 'A':
      return TFT_GREEN;
    case 'B':
      return TFT_CYAN;
    case 'C':
      return TFT_ORANGE;
    case 'D':
    default:
      return TFT_RED;
  }
}

static float clampFloat(float value, float low, float high) {
  return max(low, min(value, high));
}

static float normalizeFeature(float value, float low, float high) {
  if (high <= low) {
    return 0.5f;
  }
  return clampFloat((value - low) / (high - low), 0.0f, 1.0f);
}

static char scoreLabel(float score) {
  if (score >= 85.0f) return 'S';
  if (score >= 70.0f) return 'A';
  if (score >= 50.0f) return 'B';
  if (score >= 30.0f) return 'C';
  return 'D';
}

static void drawSweatDrop(int x, int y, int size) {
  uint16_t sweat = M5.Display.color565(70, 180, 255);
  M5.Display.fillCircle(x, y + size / 3, size / 2, sweat);
  M5.Display.fillTriangle(x, y - size, x - size / 2, y + size / 4, x + size / 2, y + size / 4, sweat);
  M5.Display.drawLine(x - size / 3, y, x, y - size / 2, TFT_WHITE);
}

static void drawImpactLines(int centerX, int centerY, int radius, char label) {
  if (label == 'C' || label == 'D') {
    return;
  }

  uint16_t lineColor = (label == 'S') ? TFT_RED : TFT_ORANGE;
  int outer = radius + 18;
  int inner = radius + 6;
  for (int i = 0; i < 12; i++) {
    float angle = (float)i * 0.5235988f;
    int x1 = centerX + (int)(cosf(angle) * inner);
    int y1 = centerY + (int)(sinf(angle) * inner);
    int x2 = centerX + (int)(cosf(angle) * outer);
    int y2 = centerY + (int)(sinf(angle) * outer);
    M5.Display.drawLine(x1, y1, x2, y2, lineColor);
  }
}

static void drawSpiralEye(int x, int y, int size) {
  M5.Display.drawCircle(x, y, size, TFT_BLACK);
  M5.Display.drawArc(x, y, size, size - 2, 25, 300, TFT_BLACK);
  M5.Display.drawArc(x, y, size - 5, size - 7, 45, 330, TFT_BLACK);
  M5.Display.fillCircle(x + size / 5, y, 2, TFT_BLACK);
}

static const char *scoreComment(char label) {
  switch (label) {
    case 'S':
      return "";
    case 'A':
      return "";
    case 'B':
      return "";
    case 'C':
      return "";
    case 'D':
    default:
      return "";
  }
}

static void drawFace(char label, int centerX, int centerY, int radius) {
  uint16_t faceColor = labelColor(label);
  uint16_t shadowColor = M5.Display.color565(35, 35, 35);
  uint16_t cheekColor = M5.Display.color565(255, 150, 145);

  drawImpactLines(centerX, centerY, radius, label);

  M5.Display.fillCircle(centerX + 4, centerY + 6, radius, shadowColor);
  M5.Display.fillCircle(centerX, centerY, radius, faceColor);
  M5.Display.drawCircle(centerX, centerY, radius, TFT_WHITE);
  M5.Display.drawCircle(centerX, centerY, radius - 1, M5.Display.color565(255, 255, 210));
  M5.Display.fillCircle(centerX - radius / 3, centerY - radius / 3, radius / 5, M5.Display.color565(255, 255, 255));

  int leftEyeX = centerX - radius / 3;
  int rightEyeX = centerX + radius / 3;
  int eyeY = centerY - radius / 5;
  int mouthY = centerY + radius / 5;

  if (label == 'S') {
    M5.Display.fillCircle(leftEyeX - radius / 8, eyeY - radius / 10, radius / 5, TFT_WHITE);
    M5.Display.fillCircle(rightEyeX + radius / 8, eyeY - radius / 10, radius / 5, TFT_WHITE);
    M5.Display.drawCircle(leftEyeX - radius / 8, eyeY - radius / 10, radius / 5, TFT_BLACK);
    M5.Display.drawCircle(rightEyeX + radius / 8, eyeY - radius / 10, radius / 5, TFT_BLACK);
    M5.Display.fillCircle(leftEyeX - radius / 8, eyeY - radius / 10, radius / 12, TFT_BLACK);
    M5.Display.fillCircle(rightEyeX + radius / 8, eyeY - radius / 10, radius / 12, TFT_BLACK);
    M5.Display.drawLine(leftEyeX - radius / 3, eyeY - radius / 3, leftEyeX + radius / 8, eyeY - radius / 4, TFT_BLACK);
    M5.Display.drawLine(rightEyeX + radius / 3, eyeY - radius / 3, rightEyeX - radius / 8, eyeY - radius / 4, TFT_BLACK);
    M5.Display.fillCircle(centerX - radius / 2, centerY + radius / 8, radius / 9, cheekColor);
    M5.Display.fillCircle(centerX + radius / 2, centerY + radius / 8, radius / 9, cheekColor);
    M5.Display.fillCircle(centerX, mouthY + radius / 6, radius / 5, TFT_BLACK);
    M5.Display.fillCircle(centerX, mouthY + radius / 5, radius / 10, M5.Display.color565(150, 20, 40));
    drawSweatDrop(centerX + radius / 2, centerY - radius / 10, radius / 5);
  } else if (label == 'A') {
    M5.Display.fillCircle(leftEyeX - radius / 12, eyeY, radius / 7, TFT_WHITE);
    M5.Display.fillCircle(rightEyeX + radius / 12, eyeY, radius / 7, TFT_WHITE);
    M5.Display.drawCircle(leftEyeX - radius / 12, eyeY, radius / 7, TFT_BLACK);
    M5.Display.drawCircle(rightEyeX + radius / 12, eyeY, radius / 7, TFT_BLACK);
    M5.Display.fillCircle(leftEyeX - radius / 12, eyeY, radius / 14, TFT_BLACK);
    M5.Display.fillCircle(rightEyeX + radius / 12, eyeY, radius / 14, TFT_BLACK);
    M5.Display.fillRoundRect(centerX - radius / 5, mouthY + radius / 10, radius / 2, radius / 4, 6, TFT_BLACK);
    drawSweatDrop(centerX + radius / 2, centerY, radius / 6);
  } else if (label == 'B') {
    M5.Display.fillCircle(leftEyeX, eyeY, radius / 10, TFT_BLACK);
    M5.Display.fillCircle(rightEyeX, eyeY, radius / 10, TFT_BLACK);
    M5.Display.drawLine(leftEyeX - radius / 5, eyeY - radius / 5, leftEyeX + radius / 5, eyeY - radius / 8, TFT_BLACK);
    M5.Display.drawLine(rightEyeX + radius / 5, eyeY - radius / 5, rightEyeX - radius / 5, eyeY - radius / 8, TFT_BLACK);
    M5.Display.drawLine(centerX - radius / 3, mouthY + radius / 8, centerX + radius / 3, mouthY + radius / 8, TFT_BLACK);
    M5.Display.drawLine(centerX - radius / 3, mouthY + radius / 8 + 1, centerX + radius / 3, mouthY + radius / 8 + 1, TFT_BLACK);
  } else if (label == 'C') {
    M5.Display.fillCircle(leftEyeX, eyeY, radius / 9, TFT_BLACK);
    M5.Display.fillCircle(rightEyeX, eyeY, radius / 9, TFT_BLACK);
    M5.Display.fillCircle(leftEyeX + 2, eyeY - 2, radius / 24, TFT_WHITE);
    M5.Display.fillCircle(rightEyeX + 2, eyeY - 2, radius / 24, TFT_WHITE);
    M5.Display.drawArc(centerX, mouthY, radius / 2, radius / 2 - 3, 25, 155, TFT_BLACK);
    M5.Display.drawArc(centerX, mouthY + 1, radius / 2, radius / 2 - 3, 25, 155, TFT_BLACK);
  } else {
    M5.Display.fillCircle(leftEyeX, eyeY, radius / 8, TFT_BLACK);
    M5.Display.fillCircle(rightEyeX, eyeY, radius / 8, TFT_BLACK);
    M5.Display.fillCircle(leftEyeX + 2, eyeY - 2, radius / 20, TFT_WHITE);
    M5.Display.fillCircle(rightEyeX + 2, eyeY - 2, radius / 20, TFT_WHITE);
    M5.Display.fillCircle(centerX - radius / 2, centerY + radius / 8, radius / 9, cheekColor);
    M5.Display.fillCircle(centerX + radius / 2, centerY + radius / 8, radius / 9, cheekColor);
    M5.Display.fillArc(centerX, mouthY, radius / 2, radius / 3, 15, 165, TFT_BLACK);
    M5.Display.fillArc(centerX, mouthY - 4, radius / 2, radius / 3, 15, 165, faceColor);
    M5.Display.drawArc(centerX, mouthY, radius / 2, radius / 2 - 4, 20, 160, TFT_WHITE);
  }
}

static const char *faceImagePath(char label) {
  switch (label) {
    case 'S':
      return "/S.jpg";
    case 'A':
      return "/A.jpg";
    case 'B':
      return "/B.jpg";
    case 'C':
      return "/C.jpg";
    case 'D':
    default:
      return "/D.jpg";
  }
}

static bool drawFaceImage(char label, int x, int y, int maxWidth, int maxHeight) {
  if (!filesystemReady) {
    return false;
  }

  const char *path = faceImagePath(label);
  if (!SPIFFS.exists(path)) {
    Serial.printf("# face image not found: %s\n", path);
    return false;
  }

  bool drawn = M5.Display.drawJpgFile(SPIFFS, path, x, y, maxWidth, maxHeight, 0, 0, 1.0f);
  if (!drawn) {
    Serial.printf("# face image draw failed: %s\n", path);
  }
  return drawn;
}

static void drawScore(float score, char label, float accelMs2) {
  lastDisplayedScore = score;
  lastDisplayedLabel = label;
  lastDisplayedAccelMs2 = accelMs2;
  scoreDisplayReady = true;

  M5.Display.fillScreen(TFT_BLACK);
  uint16_t color = labelColor(label);
  M5.Display.fillRect(0, 0, 320, 34, color);
  M5.Display.fillRect(0, 206, 320, 34, M5.Display.color565(20, 20, 20));
  M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
  M5.Display.setCursor(8, 8);
  M5.Display.setTextSize(2);
  M5.Display.println("Punch Score");

  M5.Display.setTextSize(5);
  M5.Display.setCursor(12, 52);
  M5.Display.printf("%.1f", score);

  M5.Display.drawRoundRect(8, 42, 160, 74, 8, color);
  M5.Display.drawRoundRect(9, 43, 158, 72, 8, TFT_WHITE);

  M5.Display.setTextColor(color, TFT_BLACK);
  M5.Display.setTextSize(4);
  M5.Display.setCursor(190, 58);
  M5.Display.printf("%c", label);

  if (!drawFaceImage(label, 196, 92, 108, 108)) {
    drawFace(label, 250, 148, 54);
  }

  int barWidth = (int)max(0.0f, min(100.0f, score)) * 3;
  M5.Display.drawRect(8, 190, 304, 10, TFT_DARKGREY);
  M5.Display.fillRect(10, 192, barWidth, 6, color);

  if (accelMs2 >= 0.0f) {
    M5.Display.setFont(&fonts::Font0);
    M5.Display.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
    M5.Display.setTextSize(2);
    M5.Display.setCursor(8, 140);
    M5.Display.printf("%.1f m/s^2", accelMs2);
  }

  M5.Display.setFont(&fonts::efontJA_16_b);
  M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
  M5.Display.setTextSize(1);
  M5.Display.setCursor(8, 166);
  M5.Display.println(scoreComment(label));
  M5.Display.setFont(&fonts::Font0);

  M5.Display.setTextColor(TFT_LIGHTGREY, M5.Display.color565(20, 20, 20));
  M5.Display.setTextSize(1);
  M5.Display.setCursor(8, 218);
  M5.Display.println("Realtime subjective score");
}

static void drawArrivalOverlay() {
  M5.Display.fillRoundRect(214, 162, 96, 22, 5, TFT_BLUE);
  M5.Display.drawRoundRect(214, 162, 96, 22, 5, TFT_WHITE);
  M5.Display.setFont(&fonts::Font0);
  M5.Display.setTextColor(TFT_WHITE, TFT_BLUE);
  M5.Display.setTextSize(1);
  M5.Display.setCursor(226, 169);
  M5.Display.println("ARRIVAL");
}

static void showArrivalOverlay() {
  if (!scoreDisplayReady) {
    drawScore(0.0f, 'D', -1.0f);
  }
  drawArrivalOverlay();
  arrivalShownMs = millis();
}

static void drawArrivalOnlyMeasuring() {
  M5.Display.fillScreen(TFT_BLACK);
  M5.Display.fillRect(0, 0, 320, 38, TFT_DARKGREY);
  M5.Display.setTextColor(TFT_WHITE, TFT_DARKGREY);
  M5.Display.setTextSize(2);
  M5.Display.setCursor(8, 9);
  M5.Display.println("Arrival Check");

  M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
  M5.Display.setTextSize(4);
  M5.Display.setCursor(28, 76);
  M5.Display.println("MEASURING");

  M5.Display.setFont(&fonts::efontJA_16_b);
  M5.Display.setTextSize(2);
  M5.Display.setCursor(42, 138);
  M5.Display.println("...");
  M5.Display.setFont(&fonts::Font0);

  M5.Display.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
  M5.Display.setTextSize(1);
  M5.Display.setCursor(20, 214);
  M5.Display.println("dynamic peak -> steep drop only");
}

static void drawArrivalOnly(float arrivalTimeSec, float peakToArrivalMs, float displayDelayMs) {
  M5.Display.fillScreen(TFT_BLACK);
  M5.Display.fillRect(0, 0, 320, 38, TFT_BLUE);
  M5.Display.setTextColor(TFT_WHITE, TFT_BLUE);
  M5.Display.setTextSize(2);
  M5.Display.setCursor(8, 9);
  M5.Display.println("Arrival Check");

  M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
  M5.Display.setTextSize(5);
  M5.Display.setCursor(20, 58);
  M5.Display.println("ARRIVAL");

  M5.Display.setTextSize(2);
  M5.Display.setCursor(22, 132);
  M5.Display.printf("t = %.3f s", arrivalTimeSec);

  M5.Display.setCursor(22, 162);
  M5.Display.printf("Peak + %.0f ms", peakToArrivalMs);

  M5.Display.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
  M5.Display.setCursor(22, 198);
  M5.Display.printf("display lag %.0f ms", displayDelayMs);
  arrivalShownMs = millis();
}

static void handleCommand(const String &command) {
  if (!command.startsWith("DISPLAY_SCORE,")) {
    return;
  }

  int firstComma = command.indexOf(',');
  int secondComma = command.indexOf(',', firstComma + 1);
  if (firstComma < 0 || secondComma < 0) {
    return;
  }

  float score = command.substring(firstComma + 1, secondComma).toFloat();
  char label = command.charAt(secondComma + 1);
  int thirdComma = command.indexOf(',', secondComma + 1);
  float accelMs2 = -1.0f;
  if (thirdComma >= 0) {
    accelMs2 = command.substring(thirdComma + 1).toFloat();
  }
  drawScore(score, label, accelMs2);
  if (!ARRIVAL_ONLY_MODE && arrivalShownMs > 0) {
    drawArrivalOverlay();
  }
}

static void readSerialCommands() {
  while (Serial.available() > 0) {
    char ch = (char)Serial.read();
    if (ch == '\n' || ch == '\r') {
      if (serialCommand.length() > 0) {
        handleCommand(serialCommand);
        serialCommand = "";
      }
    } else if (serialCommand.length() < 80) {
      serialCommand += ch;
    } else {
      serialCommand = "";
    }
  }
}

static void resetOnDeviceScoring() {
  onDeviceSampleCount = 0;
  lastEvaluatedTimeSec = -1.0f;
  lastPunchTimeSec = -10.0f;
  firstSampleMs = 0;
}

static void addOnDeviceSample(float timeSec, float dynamicG, float rotationDps) {
  if (onDeviceSampleCount < ON_DEVICE_BUFFER_SIZE) {
    onDeviceSamples[onDeviceSampleCount++] = {timeSec, dynamicG, rotationDps};
    return;
  }

  for (int i = 1; i < ON_DEVICE_BUFFER_SIZE; i++) {
    onDeviceSamples[i - 1] = onDeviceSamples[i];
  }
  onDeviceSamples[ON_DEVICE_BUFFER_SIZE - 1] = {timeSec, dynamicG, rotationDps};
}

static float learnedScoreOnDevice(
    float dynamicPeakG,
    float accelRiseG,
    float rotationPeakDps,
    float timingOffsetSec) {
  float dynamicNorm = normalizeFeature(dynamicPeakG, 3.056457f, 5.447207f);
  float rotationNorm = normalizeFeature(rotationPeakDps, 467.510712f, 2093.918701f);
  float timingCloseness = clampFloat(1.0f - fabsf(timingOffsetSec) / 0.20f, 0.0f, 1.0f);
  float rotationPerDynamic = rotationPeakDps / max(dynamicPeakG, 0.001f);
  float rotationSpeedBalance = 1.0f - fabsf(rotationNorm - dynamicNorm);
  float coordinatedRotation = sqrtf(dynamicNorm * rotationNorm) * timingCloseness;

  float score = 0.0f;
  score += 0.23676454f * normalizeFeature(dynamicPeakG, 3.056457f, 5.447207f);
  score += 0.22083254f * normalizeFeature(accelRiseG, 3.044513f, 5.199883f);
  score += 0.15590642f * normalizeFeature(rotationPeakDps, 467.510712f, 2093.918701f);
  score += 0.02399183f * normalizeFeature(timingCloseness, 0.600000f, 1.0f);
  score += 0.08833107f * normalizeFeature(rotationPerDynamic, 116.864239f, 493.294178f);
  score += 0.12434466f * normalizeFeature(rotationSpeedBalance, 0.258137f, 0.998745f);
  score += 0.14982893f * normalizeFeature(coordinatedRotation, 0.0f, 0.862647f);
  return clampFloat(score * 100.0f, 0.0f, 100.0f);
}

static void evaluateOnDevice(float latestTimeSec) {
  if (onDeviceSampleCount < 4) {
    return;
  }

  float targetTimeSec = latestTimeSec - PEAK_SETTLE_SEC;
  int peakIndex = -1;
  float bestTimeDistance = 999.0f;
  for (int i = 1; i < onDeviceSampleCount - 1; i++) {
    float distance = fabsf(onDeviceSamples[i].timeSec - targetTimeSec);
    if (distance < bestTimeDistance) {
      bestTimeDistance = distance;
      peakIndex = i;
    }
  }

  if (peakIndex < 1 || peakIndex >= onDeviceSampleCount - 1) {
    return;
  }

  OnDeviceSample peak = onDeviceSamples[peakIndex];
  if (peak.timeSec <= lastEvaluatedTimeSec) {
    return;
  }
  lastEvaluatedTimeSec = peak.timeSec;

  if (peak.dynamicG < PEAK_THRESHOLD_G) {
    return;
  }
  if (peak.dynamicG < onDeviceSamples[peakIndex - 1].dynamicG ||
      peak.dynamicG < onDeviceSamples[peakIndex + 1].dynamicG) {
    return;
  }
  if (peak.timeSec - lastPunchTimeSec < PEAK_MIN_INTERVAL_SEC) {
    return;
  }

  float baselineDynamic = peak.dynamicG;
  float rotationPeakDps = peak.rotationDps;
  float rotationTimeSec = peak.timeSec;
  float decelDynamicG = peak.dynamicG;
  float decelTimeSec = peak.timeSec;
  for (int i = 0; i < onDeviceSampleCount; i++) {
    float dt = onDeviceSamples[i].timeSec - peak.timeSec;
    if (dt >= -0.25f && dt <= 0.0f) {
      baselineDynamic = min(baselineDynamic, onDeviceSamples[i].dynamicG);
    }
    if (dt >= -0.25f && dt <= 0.08f) {
      float rotation = fabsf(onDeviceSamples[i].rotationDps);
      if (rotation > rotationPeakDps) {
        rotationPeakDps = rotation;
        rotationTimeSec = onDeviceSamples[i].timeSec;
      }
    }
    if (dt >= 0.04f && dt <= 0.20f) {
      if (onDeviceSamples[i].dynamicG < decelDynamicG) {
        decelDynamicG = onDeviceSamples[i].dynamicG;
        decelTimeSec = onDeviceSamples[i].timeSec;
      }
    }
  }

  float arrivalTimeSec = decelTimeSec;
  float arrivalDynamicG = decelDynamicG;
  float steepestDrop = 0.0f;
  for (int i = 1; i < onDeviceSampleCount; i++) {
    float dtFromPeak = onDeviceSamples[i].timeSec - peak.timeSec;
    if (dtFromPeak < 0.02f || dtFromPeak > 0.20f) {
      continue;
    }
    float dt = onDeviceSamples[i].timeSec - onDeviceSamples[i - 1].timeSec;
    if (dt <= 0.0f) {
      continue;
    }
    float slope = (onDeviceSamples[i].dynamicG - onDeviceSamples[i - 1].dynamicG) / dt;
    if (slope < steepestDrop) {
      steepestDrop = slope;
      arrivalTimeSec = onDeviceSamples[i].timeSec;
      arrivalDynamicG = onDeviceSamples[i].dynamicG;
    }
  }

  float accelRiseG = max(0.0f, peak.dynamicG - baselineDynamic);
  float timingOffsetSec = rotationTimeSec - peak.timeSec;
  float decelOffsetSec = rotationTimeSec - decelTimeSec;
  float arrivalOffsetSec = rotationTimeSec - arrivalTimeSec;

  if (ARRIVAL_ONLY_MODE) {
    drawArrivalOnly(
        arrivalTimeSec,
        (arrivalTimeSec - peak.timeSec) * 1000.0f,
        (latestTimeSec - arrivalTimeSec) * 1000.0f);
    lastPunchTimeSec = peak.timeSec;
    Serial.printf(
        "# ARRIVAL_ONLY peak_time=%.3f arrival_time=%.3f peak_to_arrival_ms=%.0f dynamic=%.3f\n",
        peak.timeSec,
        arrivalTimeSec,
        (arrivalTimeSec - peak.timeSec) * 1000.0f,
        peak.dynamicG);
    return;
  }

  float score = learnedScoreOnDevice(peak.dynamicG, accelRiseG, rotationPeakDps, arrivalOffsetSec);
  char label = scoreLabel(score);
  drawScore(score, label, peak.dynamicG * G_TO_MS2);
  if (!ARRIVAL_ONLY_MODE) {
    showArrivalOverlay();
  }
  lastPunchTimeSec = peak.timeSec;

  Serial.printf(
      "# ON_DEVICE_SCORE time=%.3f score=%.1f label=%c dynamic=%.3f rise=%.3f rotation=%.1f peak_offset=%.3f decel_offset=%.3f arrival_offset=%.3f arrival_time=%.3f\n",
      peak.timeSec,
      score,
      label,
      peak.dynamicG,
      accelRiseG,
      rotationPeakDps,
      timingOffsetSec,
      decelOffsetSec,
      arrivalOffsetSec,
      arrivalTimeSec);
}

static bool initImu() {
  bool i2cStarted = M5.Ex_I2C.begin();
  delay(I2C_SETTLE_MS);
  bool bmiFound = M5.Ex_I2C.scanID(BMI270_SENSOR_ADDR);
  bool bmiAltFound = M5.Ex_I2C.scanID(BMI270_SENSOR_ADDR_ALT);

  Serial.print("# I2C found:");
  bool anyFound = false;
  for (uint8_t addr = 0x08; addr <= 0x77; addr++) {
    if (M5.Ex_I2C.scanID(addr)) {
      Serial.printf(" 0x%02X", addr);
      anyFound = true;
    }
  }
  if (!anyFound) {
    Serial.print(" none");
  }
  Serial.println();

  Serial.printf(
      "# Ex_I2C begin=%d port=%d sda=%d scl=%d bmi270_addr_0x%02X=%d bmi270_addr_0x%02X=%d\n",
      i2cStarted,
      (int)M5.Ex_I2C.getPort(),
      M5.Ex_I2C.getSDA(),
      M5.Ex_I2C.getSCL(),
      BMI270_SENSOR_ADDR,
      bmiFound,
      BMI270_SENSOR_ADDR_ALT,
      bmiAltFound);

  if (!bmiFound && !bmiAltFound) {
    Serial.println("# BMI270 address not found yet; waiting for sensor power");
    return false;
  }

  uint8_t bmiAddr = bmiFound ? BMI270_SENSOR_ADDR : BMI270_SENSOR_ADDR_ALT;
  uint8_t chipIdBefore = readBmiChipId(bmiAddr);
  Serial.printf("# BMI270 chip_id before reset addr=0x%02X value=0x%02X expected=0x%02X\n",
                bmiAddr,
                chipIdBefore,
                BMI270_EXPECTED_CHIP_ID);
  softResetBmi(bmiAddr);
  delay(I2C_SETTLE_MS);
  uint8_t chipIdAfter = readBmiChipId(bmiAddr);
  Serial.printf("# BMI270 chip_id after reset addr=0x%02X value=0x%02X expected=0x%02X\n",
                bmiAddr,
                chipIdAfter,
                BMI270_EXPECTED_CHIP_ID);
  delay(I2C_SETTLE_MS);
  int result = bmi270.init(I2C_NUM_0, bmiAddr);
  Serial.printf("# bmi270.init result=%d addr=0x%02X\n", result, bmiAddr);
  bool odrConfigured = configureBmi100Hz(bmiAddr);
  if (!odrConfigured) {
    Serial.println("# BMI270 100Hz configuration failed; retrying init");
    return false;
  }
  if (result != 0) {
    return true;
  }

  float testAx = 0;
  float testAy = 0;
  float testAz = 0;
  delay(100);
  bool accelReadable = bmi270.readAcceleration(testAx, testAy, testAz);
  Serial.printf(
      "# bmi270.init fallback accel_readable=%d ax=%.3f ay=%.3f az=%.3f\n",
      accelReadable,
      testAx,
      testAy,
      testAz);
  return bmiFound && accelReadable;
}

void setup() {
  auto cfg = M5.config();
  cfg.output_power = true;
  cfg.internal_imu = false;
  M5.begin(cfg);
  M5.Power.setExtOutput(false);
  delay(250);
  M5.Power.setExtOutput(true);
  Serial.begin(SERIAL_BAUD);
  delay(IMU_POWER_SETTLE_MS);
  M5.Display.setRotation(1);
  filesystemReady = SPIFFS.begin(true);
  Serial.printf("# %s sample_hz=%d\n", FIRMWARE_LABEL, SAMPLE_HZ);
  Serial.printf("# SPIFFS begin=%d\n", filesystemReady);

  drawStatus("Initializing...");
  resetOnDeviceScoring();
  imuReady = initImu();

  if (!imuReady) {
    drawStatus("IMU not found");
    Serial.println("# IMU init failed; reconnect sensor and reset or wait for retry");
    lastRetryMs = millis();
    return;
  }

  Serial.println("CSV_HEADER,sample_index,device_ms,ax_g,ay_g,az_g,mag_g,dynamic_g,gx_dps,gy_dps,gz_dps,rotation_dps");
  Serial.printf("# %s sample_hz=%d streaming=1\n", FIRMWARE_LABEL, SAMPLE_HZ);
  if (ARRIVAL_ONLY_MODE) {
    drawArrivalOnlyMeasuring();
  } else {
    drawStatus("Streaming...");
  }
}

void loop() {
  M5.update();
  readSerialCommands();

  uint32_t now = millis();
  if (ARRIVAL_ONLY_MODE && arrivalShownMs > 0 && now - arrivalShownMs >= 700) {
    arrivalShownMs = 0;
    drawArrivalOnlyMeasuring();
  } else if (!ARRIVAL_ONLY_MODE && arrivalShownMs > 0 && now - arrivalShownMs >= 500) {
    arrivalShownMs = 0;
    if (scoreDisplayReady) {
      drawScore(lastDisplayedScore, lastDisplayedLabel, lastDisplayedAccelMs2);
    }
  }

  if (!imuReady) {
    if (now - lastRetryMs >= IMU_RETRY_INTERVAL_MS) {
      lastRetryMs = now;
      Serial.println("# Retrying IMU init...");
      imuReady = initImu();
      if (imuReady) {
        sampleIndex = 0;
        resetOnDeviceScoring();
        lastSampleUs = micros();
        gyroReadWarned = false;
        Serial.println("CSV_HEADER,sample_index,device_ms,ax_g,ay_g,az_g,mag_g,dynamic_g,gx_dps,gy_dps,gz_dps,rotation_dps");
        if (ARRIVAL_ONLY_MODE) {
          drawArrivalOnlyMeasuring();
        } else {
          drawStatus("Streaming...");
        }
      }
    }
    return;
  }

  uint32_t nowUs = micros();
  uint32_t elapsedUs = nowUs - lastSampleUs;
  if (elapsedUs < SAMPLE_INTERVAL_US) return;
  lastSampleUs += SAMPLE_INTERVAL_US;
  if (elapsedUs >= SAMPLE_INTERVAL_US * 2) {
    lastSampleUs = nowUs;
  }

  float ax = 0;
  float ay = 0;
  float az = 0;
  float gx = 0;
  float gy = 0;
  float gz = 0;

  if (!bmi270.readAcceleration(ax, ay, az)) {
    imuReady = false;
    drawStatus("IMU read failed");
    Serial.println("# IMU read failed; retrying init");
    lastRetryMs = now;
    return;
  }

  if (!bmi270.readGyroscope(gx, gy, gz)) {
    if (!gyroReadWarned) {
      Serial.println("# IMU gyro read failed; continuing with gyro=0");
      gyroReadWarned = true;
    }
    gx = 0;
    gy = 0;
    gz = 0;
  }

  float mag = sqrtf(ax * ax + ay * ay + az * az);
  float dynamicG = fabsf(mag - 1.0f);
  float rotationDps = sqrtf(gx * gx + gy * gy + gz * gz);
  if (firstSampleMs == 0) {
    firstSampleMs = now;
  }
  float timeSec = (float)(now - firstSampleMs) / 1000.0f;

  Serial.printf(
      "%lu,%lu,%.6f,%.6f,%.6f,%.6f,%.6f,%.6f,%.6f,%.6f,%.6f\n",
      (unsigned long)sampleIndex,
      (unsigned long)now,
      ax,
      ay,
      az,
      mag,
      dynamicG,
      gx,
      gy,
      gz,
      rotationDps);

  addOnDeviceSample(timeSec, dynamicG, rotationDps);
  evaluateOnDevice(timeSec);

  sampleIndex++;
}

IMU Data Logger and Visualizer

Python
Records IMU data and saves CSV files and waveform images.
#!/usr/bin/env python3
"""Receive IMU CSV data over serial, then save CSV and waveform on Ctrl+C."""

from __future__ import annotations

import argparse
import csv
import json
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path

try:
    import serial
    from serial.tools import list_ports
except ImportError as exc:
    raise SystemExit(
        "pyserial is required. Install it with: python -m pip install pyserial"
    ) from exc

try:
    import matplotlib.pyplot as plt
except ImportError as exc:
    raise SystemExit(
        "matplotlib is required. Install it with: python -m pip install matplotlib"
    ) from exc

from calibrate_subjective_score import predict

plt.rcParams["font.family"] = [
    "Yu Gothic",
    "Meiryo",
    "MS Gothic",
    "Noto Sans CJK JP",
    "sans-serif",
]
plt.rcParams["axes.unicode_minus"] = False


FIELDS = [
    "sample_index",
    "device_ms",
    "time_sec",
    "ax_g",
    "ay_g",
    "az_g",
    "mag_g",
    "dynamic_g",
    "gx_dps",
    "gy_dps",
    "gz_dps",
    "rotation_dps",
]


@dataclass
class Sample:
    sample_index: int
    device_ms: int
    time_sec: float
    ax_g: float
    ay_g: float
    az_g: float
    mag_g: float
    dynamic_g: float
    gx_dps: float = 0.0
    gy_dps: float = 0.0
    gz_dps: float = 0.0
    rotation_dps: float = 0.0

    def as_row(self) -> dict[str, int | float]:
        return {
            "sample_index": self.sample_index,
            "device_ms": self.device_ms,
            "time_sec": self.time_sec,
            "ax_g": self.ax_g,
            "ay_g": self.ay_g,
            "az_g": self.az_g,
            "mag_g": self.mag_g,
            "dynamic_g": self.dynamic_g,
            "gx_dps": self.gx_dps,
            "gy_dps": self.gy_dps,
            "gz_dps": self.gz_dps,
            "rotation_dps": self.rotation_dps,
        }


@dataclass
class Peak:
    sample: Sample
    value: float


@dataclass
class PunchAnalysis:
    number: int
    peak: Peak
    accel_rise_g: float
    rotation_peak_dps: float
    rotation_time_sec: float
    timing_offset_sec: float
    decel_time_sec: float
    decel_dynamic_g: float
    decel_offset_sec: float
    arrival_time_sec: float
    arrival_dynamic_g: float
    arrival_offset_sec: float
    score: float


def auto_detect_port() -> str | None:
    ports = list(list_ports.comports())
    if not ports:
        return None

    usb_ports = [
        port
        for port in ports
        if "USB" in (port.description or "").upper()
        or "UART" in (port.description or "").upper()
        or "SERIAL" in (port.description or "").upper()
    ]
    return (usb_ports[0] if usb_ports else ports[0]).device


def parse_sample(line: str, first_device_ms: int | None) -> tuple[Sample | None, int | None]:
    text = line.strip()
    if not text or text.startswith("#") or text.startswith("CSV_HEADER"):
        return None, first_device_ms

    parts = text.split(",")
    if len(parts) not in (7, 11):
        return None, first_device_ms

    try:
        sample_index = int(parts[0])
        device_ms = int(parts[1])
        ax_g = float(parts[2])
        ay_g = float(parts[3])
        az_g = float(parts[4])
        mag_g = float(parts[5])
        dynamic_g = float(parts[6])
        gx_dps = float(parts[7]) if len(parts) == 11 else 0.0
        gy_dps = float(parts[8]) if len(parts) == 11 else 0.0
        gz_dps = float(parts[9]) if len(parts) == 11 else 0.0
        rotation_dps = float(parts[10]) if len(parts) == 11 else 0.0
    except ValueError:
        return None, first_device_ms

    if first_device_ms is None:
        first_device_ms = device_ms

    sample = Sample(
        sample_index=sample_index,
        device_ms=device_ms,
        time_sec=(device_ms - first_device_ms) / 1000.0,
        ax_g=ax_g,
        ay_g=ay_g,
        az_g=az_g,
        mag_g=mag_g,
        dynamic_g=dynamic_g,
        gx_dps=gx_dps,
        gy_dps=gy_dps,
        gz_dps=gz_dps,
        rotation_dps=rotation_dps,
    )
    return sample, first_device_ms


def sample_value(sample: Sample, field: str) -> float:
    return float(getattr(sample, field))


def detect_peaks(
    samples: list[Sample],
    field: str,
    threshold: float,
    min_interval_sec: float,
    max_peaks: int,
    max_neighbor_gap_sec: float,
) -> list[Peak]:
    if len(samples) < 3:
        return []

    candidates: list[Peak] = []
    for index in range(1, len(samples) - 1):
        prev_gap = samples[index].time_sec - samples[index - 1].time_sec
        next_gap = samples[index + 1].time_sec - samples[index].time_sec
        prev_value = (
            sample_value(samples[index - 1], field)
            if prev_gap <= max_neighbor_gap_sec
            else float("-inf")
        )
        value = sample_value(samples[index], field)
        next_value = (
            sample_value(samples[index + 1], field)
            if next_gap <= max_neighbor_gap_sec
            else float("-inf")
        )
        if value >= threshold and value >= prev_value and value >= next_value:
            candidates.append(Peak(samples[index], value))

    selected: list[Peak] = []
    for peak in sorted(candidates, key=lambda item: item.value, reverse=True):
        if all(
            abs(peak.sample.time_sec - chosen.sample.time_sec) >= min_interval_sec
            for chosen in selected
        ):
            selected.append(peak)
            if len(selected) >= max_peaks:
                break

    return sorted(selected, key=lambda item: item.sample.time_sec)


def window_samples(
    samples: list[Sample],
    center_sec: float,
    before_sec: float,
    after_sec: float,
) -> list[Sample]:
    start = center_sec - before_sec
    end = center_sec + after_sec
    return [sample for sample in samples if start <= sample.time_sec <= end]


def clamp(value: float, low: float, high: float) -> float:
    return max(low, min(value, high))


def load_subjective_model(model_path: Path) -> dict | None:
    if not model_path.exists():
        return None
    return json.loads(model_path.read_text(encoding="utf-8-sig"))


def analysis_row(analysis: PunchAnalysis) -> dict[str, str]:
    return {
        "score": f"{analysis.score}",
        "dynamic_peak_g": f"{analysis.peak.value}",
        "accel_rise_g": f"{analysis.accel_rise_g}",
        "rotation_peak_dps": f"{analysis.rotation_peak_dps}",
        "timing_offset_sec": f"{analysis.timing_offset_sec}",
        "decel_offset_sec": f"{analysis.decel_offset_sec}",
        "arrival_offset_sec": f"{analysis.arrival_offset_sec}",
    }


def display_score(analysis: PunchAnalysis, model: dict | None) -> float:
    if model is None:
        return analysis.score
    return predict(analysis_row(analysis), model)


def analyze_punches(
    samples: list[Sample],
    peaks: list[Peak],
    rotation_field: str,
) -> list[PunchAnalysis]:
    analyses: list[PunchAnalysis] = []
    for number, peak in enumerate(peaks, start=1):
        pre_samples = window_samples(samples, peak.sample.time_sec, 0.25, 0.0)
        impact_samples = window_samples(samples, peak.sample.time_sec, 0.25, 0.08)

        baseline_dynamic = min(
            (sample.dynamic_g for sample in pre_samples),
            default=peak.value,
        )
        accel_rise_g = max(0.0, peak.value - baseline_dynamic)

        rotation_sample = max(
            impact_samples,
            key=lambda sample: abs(sample_value(sample, rotation_field)),
            default=peak.sample,
        )
        rotation_peak_dps = abs(sample_value(rotation_sample, rotation_field))
        timing_offset_sec = rotation_sample.time_sec - peak.sample.time_sec

        decel_start_sec = peak.sample.time_sec + 0.04
        decel_end_sec = peak.sample.time_sec + 0.20
        decel_samples = [
            sample
            for sample in samples
            if decel_start_sec <= sample.time_sec <= decel_end_sec
        ]
        decel_sample = min(
            decel_samples,
            key=lambda sample: sample.dynamic_g,
            default=peak.sample,
        )
        decel_offset_sec = rotation_sample.time_sec - decel_sample.time_sec

        arrival_sample = decel_sample
        steepest_drop = 0.0
        for prev_sample, current_sample in zip(samples, samples[1:]):
            if not (peak.sample.time_sec + 0.02 <= current_sample.time_sec <= peak.sample.time_sec + 0.20):
                continue
            dt = current_sample.time_sec - prev_sample.time_sec
            if dt <= 0:
                continue
            slope = (current_sample.dynamic_g - prev_sample.dynamic_g) / dt
            if slope < steepest_drop:
                steepest_drop = slope
                arrival_sample = current_sample
        arrival_offset_sec = rotation_sample.time_sec - arrival_sample.time_sec

        accel_score = clamp(peak.value / 2.0, 0.0, 1.0) * 40.0
        rise_score = clamp(accel_rise_g / 1.0, 0.0, 1.0) * 25.0
        rotation_score = clamp(rotation_peak_dps / 800.0, 0.0, 1.0) * 25.0
        timing_score = clamp(1.0 - abs(arrival_offset_sec) / 0.20, 0.0, 1.0) * 10.0
        score = accel_score + rise_score + rotation_score + timing_score

        analyses.append(
            PunchAnalysis(
                number=number,
                peak=peak,
                accel_rise_g=accel_rise_g,
                rotation_peak_dps=rotation_peak_dps,
                rotation_time_sec=rotation_sample.time_sec,
                timing_offset_sec=timing_offset_sec,
                decel_time_sec=decel_sample.time_sec,
                decel_dynamic_g=decel_sample.dynamic_g,
                decel_offset_sec=decel_offset_sec,
                arrival_time_sec=arrival_sample.time_sec,
                arrival_dynamic_g=arrival_sample.dynamic_g,
                arrival_offset_sec=arrival_offset_sec,
                score=score,
            )
        )

    return analyses


def load_csv(csv_path: Path) -> list[Sample]:
    samples: list[Sample] = []
    with csv_path.open(newline="", encoding="utf-8") as fp:
        reader = csv.DictReader(fp)
        for row in reader:
            sample = Sample(
                sample_index=int(row["sample_index"]),
                device_ms=int(row["device_ms"]),
                time_sec=float(row["time_sec"]),
                ax_g=float(row["ax_g"]),
                ay_g=float(row["ay_g"]),
                az_g=float(row["az_g"]),
                mag_g=float(row["mag_g"]),
                dynamic_g=float(row["dynamic_g"]),
                gx_dps=float(row.get("gx_dps") or 0.0),
                gy_dps=float(row.get("gy_dps") or 0.0),
                gz_dps=float(row.get("gz_dps") or 0.0),
                rotation_dps=float(row.get("rotation_dps") or 0.0),
            )
            samples.append(sample)
    return samples


def prepare_live_plot() -> tuple[plt.Figure, plt.Axes]:
    plt.ion()
    fig, ax = plt.subplots(figsize=(10, 5))
    fig.canvas.manager.set_window_title("IMU")
    ax.set_title("IMU", fontsize=16)
    ax.set_xlabel(" [s]", fontsize=13)
    ax.set_ylabel(" [g]", fontsize=13)
    ax.grid(True, alpha=0.3)
    return fig, ax


def update_plot(ax: plt.Axes, samples: list[Sample], window_sec: float) -> None:
    if not samples:
        return

    latest_time = samples[-1].time_sec
    start_time = max(0.0, latest_time - window_sec)
    visible = [sample for sample in samples if sample.time_sec >= start_time]
    t = [sample.time_sec for sample in visible]

    ax.clear()
    ax.plot(t, [sample.dynamic_g for sample in visible], label="dynamic", linewidth=2.5)
    ax.plot(t, [sample.rotation_dps / 1000.0 for sample in visible], label="rotation / 1000", linewidth=2)
    ax.plot(t, [sample.ax_g for sample in visible], label="ax", alpha=0.25)
    ax.plot(t, [sample.ay_g for sample in visible], label="ay", alpha=0.25)
    ax.plot(t, [sample.az_g for sample in visible], label="az", alpha=0.25)
    ax.set_title("IMU", fontsize=16)
    ax.set_xlabel(" [s]", fontsize=13)
    ax.set_ylabel(" [g]", fontsize=13)
    ax.tick_params(axis="both", labelsize=11)
    ax.grid(True, alpha=0.3)
    ax.legend(loc="upper right")
    ax.figure.tight_layout()
    ax.figure.canvas.draw_idle()
    plt.pause(0.001)


def save_csv(samples: list[Sample], csv_path: Path) -> None:
    csv_path.parent.mkdir(parents=True, exist_ok=True)
    with csv_path.open("w", newline="", encoding="utf-8") as fp:
        writer = csv.DictWriter(fp, fieldnames=FIELDS)
        writer.writeheader()
        writer.writerows(sample.as_row() for sample in samples)


def save_plot(
    samples: list[Sample],
    image_path: Path,
    peak_field: str,
    peak_threshold: float,
    peak_min_interval_sec: float,
    max_peaks: int,
    rotation_field: str,
    peak_neighbor_gap_sec: float,
) -> list[PunchAnalysis]:
    image_path.parent.mkdir(parents=True, exist_ok=True)
    fig, (ax_accel, ax_rotation) = plt.subplots(
        2,
        1,
        figsize=(12, 8),
        sharex=True,
        gridspec_kw={"height_ratios": [2, 1]},
    )
    t = [sample.time_sec for sample in samples]
    peaks = detect_peaks(
        samples,
        peak_field,
        peak_threshold,
        peak_min_interval_sec,
        max_peaks,
        peak_neighbor_gap_sec,
    )
    analyses = analyze_punches(samples, peaks, rotation_field)

    ax_accel.plot(t, [sample.ax_g for sample in samples], label="ax", alpha=0.25)
    ax_accel.plot(t, [sample.ay_g for sample in samples], label="ay", alpha=0.25)
    ax_accel.plot(t, [sample.az_g for sample in samples], label="az", alpha=0.25)
    ax_accel.plot(t, [sample.mag_g for sample in samples], label="|a|", color="0.35", alpha=0.55)
    ax_accel.plot(
        t,
        [sample.dynamic_g for sample in samples],
        label="dynamic",
        color="tab:purple",
        linewidth=2.5,
    )
    ax_accel.axhline(
        peak_threshold,
        color="tab:purple",
        linestyle=":",
        linewidth=1,
        alpha=0.8,
        label=f"peak threshold ({peak_threshold:g}g)",
    )

    if peaks:
        peak_t = [peak.sample.time_sec for peak in peaks]
        peak_y = [peak.value for peak in peaks]
        ax_accel.scatter(
            peak_t,
            peak_y,
            s=80,
            color="tab:red",
            edgecolors="white",
            linewidths=1.5,
            zorder=5,
            label="punch peak",
        )
        ax_accel.scatter(
            [analysis.decel_time_sec for analysis in analyses],
            [analysis.decel_dynamic_g for analysis in analyses],
            s=55,
            color="black",
            marker="v",
            edgecolors="white",
            linewidths=1.0,
            zorder=5,
            label="post-peak dynamic min",
        )
        ax_accel.scatter(
            [analysis.arrival_time_sec for analysis in analyses],
            [analysis.arrival_dynamic_g for analysis in analyses],
            s=60,
            color="tab:blue",
            marker="D",
            edgecolors="white",
            linewidths=1.0,
            zorder=6,
            label="arrival proxy",
        )
        for analysis in analyses:
            ax_accel.annotate(
                f"{analysis.number}",
                xy=(analysis.peak.sample.time_sec, analysis.peak.value),
                xytext=(0, 12),
                textcoords="offset points",
                ha="center",
                va="bottom",
                fontsize=9,
                color="tab:red",
            )

    rotation_values = [abs(sample_value(sample, rotation_field)) for sample in samples]
    ax_rotation.plot(
        t,
        rotation_values,
        label=rotation_field,
        color="tab:orange",
        linewidth=2,
    )
    if analyses:
        ax_rotation.scatter(
            [analysis.rotation_time_sec for analysis in analyses],
            [analysis.rotation_peak_dps for analysis in analyses],
            s=60,
            color="tab:red",
            edgecolors="white",
            linewidths=1.2,
            zorder=5,
            label="rotation peak",
        )

    ax_accel.set_title("IMU", fontsize=18)
    ax_accel.set_ylabel(" [g]", fontsize=14)
    ax_accel.grid(True, alpha=0.3)
    ax_accel.legend(loc="upper right")
    ax_rotation.set_xlabel(" [s]", fontsize=14)
    ax_rotation.set_ylabel(" [deg/s]", fontsize=14)
    ax_accel.tick_params(axis="both", labelsize=12)
    ax_rotation.tick_params(axis="both", labelsize=12)
    ax_rotation.grid(True, alpha=0.3)
    ax_rotation.legend(loc="upper right")
    fig.tight_layout()
    fig.savefig(image_path, dpi=150)
    plt.close(fig)
    return analyses


def build_paths(output_dir: Path) -> tuple[Path, Path]:
    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    return output_dir / f"imu_{stamp}.csv", output_dir / f"imu_{stamp}.png"


def print_analysis(
    analyses: list[PunchAnalysis], rotation_field: str, model: dict | None
) -> None:
    if not analyses:
        print("Marked 0 punch peaks")
        return

    print(f"Marked {len(analyses)} punch peaks")
    print(
        "No.  time[s]  score  old  dynamic[g]  rise[g]  rotation[dps]  "
        "peak_offset[s]  decel_offset[s]  arrival_offset[s]"
    )
    for analysis in analyses:
        score = display_score(analysis, model)
        print(
            f"{analysis.number:>2}  "
            f"{analysis.peak.sample.time_sec:>7.3f}  "
            f"{score:>5.1f}  "
            f"{analysis.score:>5.1f}  "
            f"{analysis.peak.value:>10.3f}  "
            f"{analysis.accel_rise_g:>7.3f}  "
            f"{analysis.rotation_peak_dps:>13.1f}  "
            f"{analysis.timing_offset_sec:>14.3f}  "
            f"{analysis.decel_offset_sec:>15.3f}  "
            f"{analysis.arrival_offset_sec:>17.3f}"
        )
    print(f"Rotation field: {rotation_field}")


def save_analysis_csv(
    analyses: list[PunchAnalysis], csv_path: Path, model: dict | None
) -> None:
    csv_path.parent.mkdir(parents=True, exist_ok=True)
    with csv_path.open("w", newline="", encoding="utf-8") as fp:
        fieldnames = [
            "punch_number",
            "time_sec",
            "score",
            "old_score",
            "dynamic_peak_g",
            "accel_rise_g",
            "rotation_peak_dps",
            "rotation_time_sec",
            "timing_offset_sec",
            "decel_time_sec",
            "decel_dynamic_g",
            "decel_offset_sec",
            "arrival_time_sec",
            "arrival_dynamic_g",
            "arrival_offset_sec",
        ]
        writer = csv.DictWriter(fp, fieldnames=fieldnames)
        writer.writeheader()
        for analysis in analyses:
            score = display_score(analysis, model)
            writer.writerow(
                {
                    "punch_number": analysis.number,
                    "time_sec": analysis.peak.sample.time_sec,
                    "score": score,
                    "old_score": analysis.score,
                    "dynamic_peak_g": analysis.peak.value,
                    "accel_rise_g": analysis.accel_rise_g,
                    "rotation_peak_dps": analysis.rotation_peak_dps,
                    "rotation_time_sec": analysis.rotation_time_sec,
                    "timing_offset_sec": analysis.timing_offset_sec,
                    "decel_time_sec": analysis.decel_time_sec,
                    "decel_dynamic_g": analysis.decel_dynamic_g,
                    "decel_offset_sec": analysis.decel_offset_sec,
                    "arrival_time_sec": analysis.arrival_time_sec,
                    "arrival_dynamic_g": analysis.arrival_dynamic_g,
                    "arrival_offset_sec": analysis.arrival_offset_sec,
                }
            )


def print_sample_rate_warning(samples: list[Sample], expected_hz: float) -> None:
    if len(samples) < 2:
        return

    duration_sec = samples[-1].time_sec - samples[0].time_sec
    if duration_sec <= 0:
        return

    actual_hz = (len(samples) - 1) / duration_sec
    print(f"Recorded sample rate: {actual_hz:.1f} Hz ({len(samples)} samples)")
    if actual_hz < expected_hz * 0.5:
        print(
            "Warning: sample rate is very low. Punch peaks may be missed. "
            "Close other serial monitors and upload the latest firmware."
        )


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Read IMU serial CSV from M5Stack and save CSV/plot on Ctrl+C."
    )
    parser.add_argument("-p", "--port", help="Serial port, for example COM3")
    parser.add_argument("-b", "--baud", type=int, default=921600, help="Serial baud rate")
    parser.add_argument("-o", "--output-dir", default="data", help="Output directory")
    parser.add_argument(
        "--from-csv",
        type=Path,
        help="Create a peak-marked PNG from an existing CSV instead of recording serial data",
    )
    parser.add_argument(
        "--image-path",
        type=Path,
        help="Output PNG path when using --from-csv",
    )
    parser.add_argument(
        "--model",
        type=Path,
        default=Path("data/subjective_score_config.json"),
        help="Subjective score model used for the summary score column",
    )
    parser.add_argument(
        "--peak-field",
        choices=["dynamic_g", "mag_g"],
        default="dynamic_g",
        help="Signal used for punch peak detection",
    )
    parser.add_argument(
        "--rotation-field",
        choices=["gx_dps", "gy_dps", "gz_dps", "rotation_dps"],
        default="rotation_dps",
        help="Gyro signal used for fist rotation evaluation",
    )
    parser.add_argument(
        "--peak-threshold",
        type=float,
        default=1.5,
        help="Minimum peak value in g",
    )
    parser.add_argument(
        "--peak-min-interval-sec",
        type=float,
        default=0.6,
        help="Minimum time between separate punch peaks",
    )
    parser.add_argument(
        "--max-peaks",
        type=int,
        default=30,
        help="Maximum number of peaks to mark",
    )
    parser.add_argument(
        "--peak-neighbor-gap-sec",
        type=float,
        default=0.25,
        help="Do not compare peak neighbors across larger time gaps",
    )
    parser.add_argument(
        "--expected-hz",
        type=float,
        default=100.0,
        help="Expected sensor sample rate for warning messages",
    )
    parser.add_argument(
        "--window-sec",
        type=float,
        default=10.0,
        help="Live plot time window in seconds",
    )
    parser.add_argument(
        "--plot-interval",
        type=float,
        default=0.2,
        help="Live plot refresh interval in seconds",
    )
    parser.add_argument(
        "--live-plot",
        action="store_true",
        help="Show a live plot while recording. This can reduce the serial sample rate.",
    )
    parser.add_argument(
        "--no-live-plot",
        action="store_true",
        help="Deprecated; live plot is disabled by default",
    )
    parser.add_argument(
        "--no-data-warning-sec",
        type=float,
        default=5.0,
        help="Seconds before warning that no valid sample has arrived",
    )
    parser.add_argument(
        "--show-raw",
        action="store_true",
        help="Print every non-sample serial line for troubleshooting",
    )
    parser.add_argument(
        "--show-invalid",
        action="store_true",
        help="Print serial lines that are not valid CSV samples",
    )
    args = parser.parse_args()
    model = load_subjective_model(args.model)

    if args.from_csv is not None:
        samples = load_csv(args.from_csv)
        if not samples:
            print(f"No samples found in {args.from_csv}", file=sys.stderr)
            return 1
        print_sample_rate_warning(samples, args.expected_hz)
        image_path = args.image_path or args.from_csv.with_name(
            f"{args.from_csv.stem}_peaks.png"
        )
        analyses = save_plot(
            samples,
            image_path,
            args.peak_field,
            args.peak_threshold,
            args.peak_min_interval_sec,
            args.max_peaks,
            args.rotation_field,
            args.peak_neighbor_gap_sec,
        )
        print(f"Saved punch evaluation plot to {image_path}")
        summary_path = image_path.with_name(f"{image_path.stem}_summary.csv")
        save_analysis_csv(analyses, summary_path, model)
        print(f"Saved punch summary to {summary_path}")
        print_analysis(analyses, args.rotation_field, model)
        return 0

    port = args.port or auto_detect_port()
    if not port:
        print("No serial port found. Specify one with --port COMx.", file=sys.stderr)
        return 2

    output_dir = Path(args.output_dir)
    csv_path, image_path = build_paths(output_dir)
    samples: list[Sample] = []
    first_device_ms: int | None = None
    last_plot_time = 0.0
    last_status_time = 0.0
    last_warning_time = time.monotonic()
    fig = None
    ax = None

    if args.live_plot and not args.no_live_plot:
        fig, ax = prepare_live_plot()

    print(f"Opening {port} at {args.baud} bps.")
    print("Recording IMU data. Press Ctrl+C to save CSV and waveform PNG.")

    try:
        with serial.Serial(port, args.baud, timeout=1) as ser:
            while True:
                raw_line = ser.readline()
                if not raw_line:
                    now = time.monotonic()
                    if (
                        not samples
                        and now - last_warning_time >= args.no_data_warning_sec
                    ):
                        print(
                            "\nNo valid samples yet. Check that the sensor is connected, "
                            "the firmware is uploaded, and no other Serial Monitor is open."
                        )
                        last_warning_time = now
                    continue

                line = raw_line.decode("utf-8", errors="replace")
                sample, first_device_ms = parse_sample(line, first_device_ms)
                if sample is None:
                    text = line.strip()
                    if text and (
                        args.show_raw
                        or args.show_invalid
                        or text.startswith("#")
                        or text.startswith("CSV_HEADER")
                    ):
                        print(text)
                    continue

                samples.append(sample)
                last_warning_time = time.monotonic()

                now = time.monotonic()
                if now - last_status_time >= 0.5:
                    print(
                        f"\rSamples: {len(samples)}  "
                        f"t={sample.time_sec:8.3f}s  "
                        f"ax={sample.ax_g: .3f}  ay={sample.ay_g: .3f}  az={sample.az_g: .3f}",
                        end="",
                        flush=True,
                    )
                    last_status_time = now
                if ax is not None and now - last_plot_time >= args.plot_interval:
                    update_plot(ax, samples, args.window_sec)
                    last_plot_time = now
    except KeyboardInterrupt:
        print()
    finally:
        if fig is not None:
            plt.close(fig)

    if not samples:
        print("No samples were recorded; nothing was saved.")
        print(
            "Try: python scripts/read_imu.py --baud 115200 --show-raw\n"
            "If that shows CSV data, upload the latest firmware or keep using 115200."
        )
        return 1

    print_sample_rate_warning(samples, args.expected_hz)
    save_csv(samples, csv_path)
    analyses = save_plot(
        samples,
        image_path,
        args.peak_field,
        args.peak_threshold,
        args.peak_min_interval_sec,
        args.max_peaks,
        args.rotation_field,
        args.peak_neighbor_gap_sec,
    )
    print(f"Saved {len(samples)} samples to {csv_path}")
    print(f"Saved waveform plot to {image_path}")
    summary_path = image_path.with_name(f"{image_path.stem}_summary.csv")
    save_analysis_csv(analyses, summary_path, model)
    print(f"Saved punch summary to {summary_path}")
    print_analysis(analyses, args.rotation_field, model)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Personalized Score Calibration

Python
Builds a personalized scoring model from user-labeled punch data.
#!/usr/bin/env python3
"""Build and apply a simple personal punch score from labeled summaries."""

from __future__ import annotations

import argparse
import csv
import json
import math
from pathlib import Path


RAW_FEATURES = [
    "dynamic_peak_g",
    "accel_rise_g",
    "rotation_peak_dps",
]


MODEL_FEATURES = [
    *RAW_FEATURES,
    "timing_closeness",
    "rotation_per_dynamic",
    "rotation_speed_balance",
    "coordinated_rotation",
]

FEATURE_WEIGHT_MULTIPLIERS = {
    "dynamic_peak_g": 1.45,
    "accel_rise_g": 1.45,
    "rotation_peak_dps": 0.85,
    "timing_closeness": 1.10,
    "rotation_per_dynamic": 0.55,
    "rotation_speed_balance": 1.00,
    "coordinated_rotation": 0.80,
}


def row_float(row: dict[str, str], name: str) -> float:
    return float(row[name])


def row_float_optional(row: dict[str, str], name: str, fallback: float) -> float:
    value = row.get(name, "")
    if value is None or str(value).strip() == "":
        return fallback
    return float(value)


def timing_closeness(row: dict[str, str]) -> float:
    fallback_offset = row_float(row, "timing_offset_sec")
    decel_offset = row_float_optional(row, "decel_offset_sec", fallback_offset)
    offset = abs(row_float_optional(row, "arrival_offset_sec", decel_offset))
    return max(0.0, 1.0 - offset / 0.20)


def feature_value(row: dict[str, str], name: str) -> float:
    if name == "timing_closeness":
        return timing_closeness(row)
    if name == "rotation_per_dynamic":
        return row_float(row, "rotation_peak_dps") / max(
            row_float(row, "dynamic_peak_g"), 0.001
        )
    if name == "rotation_speed_balance":
        return 0.0
    if name == "coordinated_rotation":
        return 0.0
    return row_float(row, name)


def valid_labeled_row(row: dict[str, str]) -> bool:
    return row.get("user_score", "").strip() in {"1", "2", "3", "4", "5"}


def read_rows(paths: list[Path], labeled_only: bool) -> list[dict[str, str]]:
    rows: list[dict[str, str]] = []
    for path in paths:
        with path.open(newline="", encoding="utf-8-sig") as fp:
            reader = csv.DictReader(fp)
            for row in reader:
                if labeled_only and not valid_labeled_row(row):
                    continue
                row["source_file"] = path.name
                rows.append(row)
    return rows


def pearson(xs: list[float], ys: list[float]) -> float:
    if len(xs) < 2:
        return 0.0
    mx = sum(xs) / len(xs)
    my = sum(ys) / len(ys)
    num = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
    den_x = math.sqrt(sum((x - mx) ** 2 for x in xs))
    den_y = math.sqrt(sum((y - my) ** 2 for y in ys))
    if den_x == 0 or den_y == 0:
        return 0.0
    return num / (den_x * den_y)


def normalize(value: float, low: float, high: float) -> float:
    if high <= low:
        return 0.5
    return max(0.0, min(1.0, (value - low) / (high - low)))


def build_model(rows: list[dict[str, str]]) -> dict:
    user_scores = [row_float(row, "user_score") for row in rows]
    feature_stats = {}
    raw_weights = {}

    dynamic_values = [row_float(row, "dynamic_peak_g") for row in rows]
    rotation_values = [row_float(row, "rotation_peak_dps") for row in rows]
    dynamic_min = min(dynamic_values)
    dynamic_max = max(dynamic_values)
    rotation_min = min(rotation_values)
    rotation_max = max(rotation_values)

    def derived_value(row: dict[str, str], feature: str) -> float:
        dynamic_norm = normalize(
            row_float(row, "dynamic_peak_g"),
            dynamic_min,
            dynamic_max,
        )
        rotation_norm = normalize(
            row_float(row, "rotation_peak_dps"),
            rotation_min,
            rotation_max,
        )
        close = timing_closeness(row)
        if feature == "rotation_speed_balance":
            return 1.0 - abs(rotation_norm - dynamic_norm)
        if feature == "coordinated_rotation":
            return math.sqrt(dynamic_norm * rotation_norm) * close
        return feature_value(row, feature)

    for feature in MODEL_FEATURES:
        values = [derived_value(row, feature) for row in rows]
        corr = pearson(values, user_scores)
        raw_weights[feature] = max(0.0, corr) * FEATURE_WEIGHT_MULTIPLIERS[feature]
        feature_stats[feature] = {
            "min": min(values),
            "max": max(values),
            "correlation": corr,
            "weight_multiplier": FEATURE_WEIGHT_MULTIPLIERS[feature],
        }

    feature_stats["_normalization"] = {
        "dynamic_peak_g": {"min": dynamic_min, "max": dynamic_max},
        "rotation_peak_dps": {"min": rotation_min, "max": rotation_max},
    }

    total_weight = sum(raw_weights.values())
    if total_weight <= 0:
        weights = {feature: 1.0 / len(MODEL_FEATURES) for feature in MODEL_FEATURES}
    else:
        weights = {
            feature: raw_weights[feature] / total_weight for feature in MODEL_FEATURES
        }

    return {
        "version": 3,
        "score_min": 1.0,
        "score_max": 5.0,
        "features": feature_stats,
        "weights": weights,
    }


def predict(row: dict[str, str], model: dict) -> float:
    total = 0.0
    for feature, weight in model["weights"].items():
        stats = model["features"][feature]
        value = model_feature_value(row, feature, model)
        total += weight * normalize(value, stats["min"], stats["max"])
    return 100.0 * total


def model_feature_value(row: dict[str, str], feature: str, model: dict) -> float:
    normalization = model["features"].get("_normalization", {})
    dynamic_stats = normalization.get("dynamic_peak_g", {})
    rotation_stats = normalization.get("rotation_peak_dps", {})
    dynamic_norm = normalize(
        row_float(row, "dynamic_peak_g"),
        dynamic_stats.get("min", 0.0),
        dynamic_stats.get("max", 1.0),
    )
    rotation_norm = normalize(
        row_float(row, "rotation_peak_dps"),
        rotation_stats.get("min", 0.0),
        rotation_stats.get("max", 1.0),
    )
    close = timing_closeness(row)
    if feature == "rotation_speed_balance":
        return 1.0 - abs(rotation_norm - dynamic_norm)
    if feature == "coordinated_rotation":
        return math.sqrt(dynamic_norm * rotation_norm) * close
    return feature_value(row, feature)


def score_to_label(score: float) -> str:
    if score >= 85:
        return "S"
    if score >= 70:
        return "A"
    if score >= 50:
        return "B"
    if score >= 30:
        return "C"
    return "D"


def write_scored_rows(rows: list[dict[str, str]], model: dict, output: Path) -> None:
    if not rows:
        return
    fieldnames: list[str] = []
    for row in rows:
        for name in row.keys():
            if name != "score" and name not in fieldnames:
                fieldnames.append(name)
    for name in ["learned_score", "learned_label"]:
        if name not in fieldnames:
            fieldnames.append(name)
    output.parent.mkdir(parents=True, exist_ok=True)
    with output.open("w", newline="", encoding="utf-8") as fp:
        writer = csv.DictWriter(fp, fieldnames=fieldnames)
        writer.writeheader()
        for row in rows:
            learned_score = predict(row, model)
            row = dict(row)
            row.pop("score", None)
            row["learned_score"] = f"{learned_score:.1f}"
            row["learned_label"] = score_to_label(learned_score)
            writer.writerow(row)


def print_model(model: dict) -> None:
    print("Learned feature correlations and weights:")
    for feature in MODEL_FEATURES:
        stats = model["features"][feature]
        weight = model["weights"][feature]
        print(
            f"  {feature:>18}: corr={stats['correlation']: .3f} "
            f"weight={weight: .3f} range={stats['min']:.3f}..{stats['max']:.3f}"
        )


def expand_paths(paths: list[Path]) -> list[Path]:
    expanded: list[Path] = []
    for path in paths:
        text = str(path)
        if any(char in text for char in "*?[]"):
            matches = sorted(Path().glob(text))
            expanded.extend(matches)
        else:
            expanded.append(path)
    return expanded


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Calibrate a personal punch score from user_score labels."
    )
    parser.add_argument(
        "paths",
        nargs="*",
        type=Path,
        help="Labeled summary CSV files. Defaults to data/*_summary.csv",
    )
    parser.add_argument(
        "--model",
        type=Path,
        default=Path("data/subjective_score_config.json"),
        help="Output model JSON path",
    )
    parser.add_argument(
        "--scored-output",
        type=Path,
        default=Path("data/labeled_punches_scored.csv"),
        help="Output CSV with learned_score added",
    )
    args = parser.parse_args()

    paths = expand_paths(args.paths) if args.paths else sorted(Path("data").glob("*_summary.csv"))
    if not paths:
        print("No summary CSV files found.")
        return 1

    labeled_rows = read_rows(paths, labeled_only=True)
    if len(labeled_rows) < 10:
        print("Need at least 10 labeled punches to calibrate a rough score.")
        return 1

    model = build_model(labeled_rows)
    args.model.parent.mkdir(parents=True, exist_ok=True)
    args.model.write_text(json.dumps(model, indent=2), encoding="utf-8")
    write_scored_rows(labeled_rows, model, args.scored_output)

    print(f"Labeled punches used: {len(labeled_rows)}")
    print_model(model)
    print(f"Saved model to {args.model}")
    print(f"Saved scored rows to {args.scored_output}")
    if len(labeled_rows) < 40:
        print("Tip: this is still a rough calibration. Add 40-100 labeled punches.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Real-Time Personalized Punch Scoring

Python
Applies the calibrated model to incoming IMU data for real-time punch evaluation.
#!/usr/bin/env python3
"""Realtime punch evaluation using the current subjective score calibration."""

from __future__ import annotations

import argparse
import json
import sys
import time
from pathlib import Path

try:
    import serial
except ImportError as exc:
    raise SystemExit(
        "pyserial is required. Install it with: python -m pip install pyserial"
    ) from exc

from calibrate_subjective_score import build_model, predict, read_rows, score_to_label
from read_imu import (
    Sample,
    analyze_punches,
    auto_detect_port,
    detect_peaks,
    parse_sample,
    print_sample_rate_warning,
    save_csv,
    save_plot,
)

G_TO_MS2 = 9.80665


def load_or_build_model(model_path: Path, summary_glob: str) -> dict | None:
    if model_path.exists():
        return json.loads(model_path.read_text(encoding="utf-8-sig"))

    rows = read_rows(sorted(Path("data").glob(summary_glob)), labeled_only=True)
    if len(rows) < 10:
        return None

    model = build_model(rows)
    model_path.parent.mkdir(parents=True, exist_ok=True)
    model_path.write_text(json.dumps(model, indent=2), encoding="utf-8")
    return model


def analysis_row(analysis) -> dict[str, str]:
    return {
        "score": f"{analysis.score}",
        "dynamic_peak_g": f"{analysis.peak.value}",
        "accel_rise_g": f"{analysis.accel_rise_g}",
        "rotation_peak_dps": f"{analysis.rotation_peak_dps}",
        "timing_offset_sec": f"{analysis.timing_offset_sec}",
        "decel_offset_sec": f"{analysis.decel_offset_sec}",
        "arrival_offset_sec": f"{analysis.arrival_offset_sec}",
    }


def learned_score_for(analysis, model: dict | None) -> tuple[float, str]:
    if model is None:
        return analysis.score, score_to_label(analysis.score)

    learned_score = predict(analysis_row(analysis), model)
    return learned_score, score_to_label(learned_score)


def send_display_score(
    ser: serial.Serial, score: float, label: str, accel_ms2: float
) -> None:
    command = f"DISPLAY_SCORE,{score:.1f},{label},{accel_ms2:.1f}\n"
    ser.write(command.encode("ascii"))


def latest_finalized_analyses(
    samples: list[Sample],
    args: argparse.Namespace,
    finalized_before_sec: float,
) -> list:
    finalized = [
        sample for sample in samples if sample.time_sec <= finalized_before_sec
    ]
    if len(finalized) < 3:
        return []

    peaks = detect_peaks(
        finalized,
        args.peak_field,
        args.peak_threshold,
        args.peak_min_interval_sec,
        args.max_peaks,
        args.peak_neighbor_gap_sec,
    )
    return analyze_punches(finalized, peaks, args.rotation_field)


def recent_samples(samples: list[Sample], latest_time_sec: float, window_sec: float) -> list[Sample]:
    start_sec = max(0.0, latest_time_sec - window_sec)
    return [sample for sample in samples if sample.time_sec >= start_sec]


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Read IMU serial CSV and print realtime personal punch scores."
    )
    parser.add_argument("-p", "--port", help="Serial port, for example COM10")
    parser.add_argument("-b", "--baud", type=int, default=921600)
    parser.add_argument("-o", "--output-dir", default="data")
    parser.add_argument("--model", type=Path, default=Path("data/subjective_score_config.json"))
    parser.add_argument("--summary-glob", default="*_summary.csv")
    parser.add_argument("--peak-field", choices=["dynamic_g", "mag_g"], default="dynamic_g")
    parser.add_argument("--rotation-field", choices=["gx_dps", "gy_dps", "gz_dps", "rotation_dps"], default="rotation_dps")
    parser.add_argument("--peak-threshold", type=float, default=1.5)
    parser.add_argument("--peak-min-interval-sec", type=float, default=0.6)
    parser.add_argument("--peak-neighbor-gap-sec", type=float, default=0.25)
    parser.add_argument("--max-peaks", type=int, default=100)
    parser.add_argument("--settle-sec", type=float, default=0.35)
    parser.add_argument("--analysis-window-sec", type=float, default=8.0)
    parser.add_argument("--expected-hz", type=float, default=100.0)
    parser.add_argument("--no-data-warning-sec", type=float, default=5.0)
    parser.add_argument("--save", action="store_true", help="Save CSV/PNG when stopped")
    args = parser.parse_args()

    model = load_or_build_model(args.model, args.summary_glob)
    if model is None:
        print(
            "No subjective model yet. Realtime output will use the old sensor score.\n"
            "Add labels to summary CSV files, then run calibrate_subjective_score.py."
        )
    else:
        print(f"Using subjective model: {args.model}")

    port = args.port or auto_detect_port()
    if not port:
        print("No serial port found. Specify one with --port COMx.", file=sys.stderr)
        return 2

    output_dir = Path(args.output_dir)
    samples: list[Sample] = []
    first_device_ms: int | None = None
    printed_peak_times: set[float] = set()
    last_status_time = 0.0
    last_sample_wall_time = time.monotonic()

    print(f"Opening {port} at {args.baud} bps.")
    print("Realtime scoring. Press Ctrl+C to stop.")
    print(f"Analysis window: latest {args.analysis_window_sec:g}s")
    print(
        "No.  time[s]  learned  label  old  dynamic[g]  rise[g]  "
        "rotation[dps]  peak_offset[s]  decel_offset[s]  arrival_offset[s]"
    )

    try:
        with serial.Serial(port, args.baud, timeout=1) as ser:
            while True:
                raw_line = ser.readline()
                if not raw_line:
                    now = time.monotonic()
                    if now - last_sample_wall_time >= args.no_data_warning_sec:
                        print(
                            "\nNo valid samples recently. Check the M5Stack connection, "
                            "Serial Monitor, and firmware.",
                            flush=True,
                        )
                        last_sample_wall_time = now
                    continue

                line = raw_line.decode("utf-8", errors="replace")
                sample, first_device_ms = parse_sample(line, first_device_ms)
                if sample is None:
                    text = line.strip()
                    if text.startswith("#"):
                        print(text)
                    continue

                samples.append(sample)
                last_sample_wall_time = time.monotonic()
                latest_time = sample.time_sec
                finalized_before = latest_time - args.settle_sec
                analysis_samples = recent_samples(
                    samples, latest_time, args.analysis_window_sec
                )
                analyses = latest_finalized_analyses(
                    analysis_samples, args, finalized_before
                )

                for analysis in analyses:
                    peak_time = round(analysis.peak.sample.time_sec, 3)
                    if peak_time in printed_peak_times:
                        continue
                    printed_peak_times.add(peak_time)
                    learned_score, learned_label = learned_score_for(analysis, model)
                    peak_accel_ms2 = analysis.peak.value * G_TO_MS2
                    send_display_score(ser, learned_score, learned_label, peak_accel_ms2)
                    print(
                        f"{len(printed_peak_times):>2}  "
                        f"{analysis.peak.sample.time_sec:>7.3f}  "
                        f"{learned_score:>7.1f}  "
                        f"{learned_label:>5}  "
                        f"{analysis.score:>5.1f}  "
                        f"{analysis.peak.value:>10.3f}  "
                        f"{analysis.accel_rise_g:>7.3f}  "
                        f"{analysis.rotation_peak_dps:>13.1f}  "
                        f"{analysis.timing_offset_sec:>14.3f}  "
                        f"{analysis.decel_offset_sec:>15.3f}  "
                        f"{analysis.arrival_offset_sec:>17.3f}"
                    )

                now = time.monotonic()
                if now - last_status_time >= 1.0:
                    print(
                        f"\rSamples: {len(samples)}  t={latest_time:7.3f}s",
                        end="",
                        flush=True,
                    )
                    last_status_time = now
    except KeyboardInterrupt:
        print()

    if not samples:
        print("No samples were recorded.")
        return 1

    print_sample_rate_warning(samples, args.expected_hz)
    if args.save:
        stamp = time.strftime("%Y%m%d_%H%M%S")
        csv_path = output_dir / f"realtime_{stamp}.csv"
        image_path = output_dir / f"realtime_{stamp}.png"
        save_csv(samples, csv_path)
        save_plot(
            samples,
            image_path,
            args.peak_field,
            args.peak_threshold,
            args.peak_min_interval_sec,
            args.max_peaks,
            args.rotation_field,
            args.peak_neighbor_gap_sec,
        )
        print(f"Saved {csv_path}")
        print(f"Saved {image_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

PlatformIO Configuration

INI
PlatformIO build configuration and required M5Stack libraries.
; PlatformIO Project Configuration File
;
;   Build options: build flags, source filter
;   Upload options: custom upload port, speed and extra flags
;   Library options: dependencies, extra library storages
;   Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html

[platformio]
data_dir = spiffs_data

[env:m5stack-core2]
platform = espressif32
board = m5stack-core2
framework = arduino
monitor_speed = 921600
lib_deps =
  m5stack/M5Unified
  https://github.com/m5stack/M5Unit-IMU-Pro-Mini.git
  https://github.com/adafruit/Adafruit_BMP280_Library.git

Credits

Kei Handa
1 project • 0 followers

Comments