From 20fb7d7a4365b57781276a717a3988da9ff868ad Mon Sep 17 00:00:00 2001 From: Gustav Date: Mon, 8 Jun 2026 16:10:29 +0200 Subject: [PATCH 1/5] Lots of trouble still --- scripts/Arduino/Arduino.ino | 398 ++++++++++++++++++---- scripts/stream_signatures.py | 638 +++++++++++++++++++++-------------- 2 files changed, 707 insertions(+), 329 deletions(-) diff --git a/scripts/Arduino/Arduino.ino b/scripts/Arduino/Arduino.ino index d5095cd..a1c92d5 100644 --- a/scripts/Arduino/Arduino.ino +++ b/scripts/Arduino/Arduino.ino @@ -1,61 +1,329 @@ /* Arduino.ino ----------- - - Receives CAP signatures from PC (via stream_signatures.py) for training and inference. - + + Receives CAP signatures from PC (via stream_signatures.py) for training and + inference, then runs the EscapeNet SMALL CNN on-device. + Protocol (from signatures_protocol.md): - TRAIN packet: [0xAA] [label] [11200 bytes of int16 signature data] - INFER packet: [0xBB] [11200 bytes of int16 signature data] - DONE packet: [0xCC] - + LED behaviour: - GREEN : ready and waiting for data - BLUE : packet being received (filling buffer) - CYAN : packet received, processing (training/inference) - RED : error or timeout - OFF : training complete, ready for inference - - The Arduino accumulates signature bytes into a 56x100 buffer, then: - - If TRAIN: runs one training step on the received signature - - If INFER: runs inference and sends back the predicted class (0/1/2) - - If DONE: finalizes training - - See scripts/signatures_protocol.md for complete protocol specification. + + The Arduino accumulates signature bytes into a 56x100 buffer, downsamples + to 28x50 floats, then passes the image to EscapeNet for training or inference. + + See scripts/CNN/README.md and scripts/signatures_protocol.md for details. */ #include +#include +#include +#include + +// ═══════════════════════════════════════════════════════════════════════════ +// ESCAPENET SMALL — on-device CNN (from EscapeNetSmall.ino) +// Input: 28×50×1 float image. 3 conv layers + 2 FC layers. ~5.7 k weights. +// RAM budget: ~175 KB (fits in the Nano 33 BLE's 256 KB). +// ═══════════════════════════════════════════════════════════════════════════ + +#define INC 1 +#define INH 28 +#define INW 50 +#define C1K 5 +#define F1 8 +#define C2K 3 +#define F2 8 +#define C3K 2 +#define F3 8 +#define FC1U 16 + +#define NCLS 3 +#define LR 0.001f + +#define H1 (INH - C1K + 1) +#define W1 (INW - C1K + 1) +#define H1P (H1 / 2) +#define W1P (W1 / 2) +#define H2 (H1P - C2K + 1) +#define W2 (W1P - C2K + 1) +#define H2P (H2 / 2) +#define W2P (W2 / 2) +#define H3 (H2P - C3K + 1) +#define W3 (W2P - C3K + 1) +#define FLAT (H3 * W3 * F3) +#define FC1IN FLAT + +#define IMAX(a, b) ((a) > (b) ? (a) : (b)) +#define MAX_ACT IMAX(INH*INW*INC, IMAX(H1*W1*F1, IMAX(H1P*W1P*F1, \ + IMAX(H2*W2*F2, IMAX(H2P*W2P*F2, H3*W3*F3))))) +#define MAX_W IMAX(C1K*C1K*INC*F1, IMAX(C2K*C2K*F1*F2, \ + IMAX(C3K*C3K*F2*F3, IMAX(FC1IN*FC1U, FC1U*NCLS)))) +#define MAX_UNIT IMAX(F1, IMAX(F2, IMAX(F3, FC1U))) + +static float conv1_w[C1K*C1K*INC*F1], conv1_b[F1]; +static float conv2_w[C2K*C2K*F1*F2], conv2_b[F2]; +static float conv3_w[C3K*C3K*F2*F3], conv3_b[F3]; +static float fc1_w[FC1IN*FC1U], fc1_b[FC1U]; +static float fc2_w[FC1U*NCLS], fc2_b[NCLS]; + +static float img_buf[INH*INW*INC]; +static float a1[H1*W1*F1]; +static float pool1_out[H1P*W1P*F1]; static uint8_t pool1_arg[H1P*W1P*F1]; +static float a2[H2*W2*F2]; +static float pool2_out[H2P*W2P*F2]; static uint8_t pool2_arg[H2P*W2P*F2]; +static float a3[H3*W3*F3]; +static float f1[FC1U]; +static float logits[NCLS], probs[NCLS]; + +static float gA[MAX_ACT], gB[MAX_ACT]; +static float dWbuf[MAX_W]; +static float dBbuf[MAX_UNIT]; + +static inline int idx3(int h, int w, int c, int W, int C) { return (h*W + w)*C + c; } +static inline float reluf(float x) { return x > 0.0f ? x : 0.0f; } +static inline void zero(float* p, int n) { memset(p, 0, n * sizeof(float)); } + +static uint32_t rng_state = 12345u; +static inline float randu() { + rng_state = rng_state * 1664525u + 1013904223u; + return ((rng_state >> 8) / 16777216.0f) * 2.0f - 1.0f; +} + +static void init_layer(float* W, int n, float* b, int nb, int fan_in) { + float bound = sqrtf(6.0f / (float)fan_in); + for (int i = 0; i < n; i++) W[i] = randu() * bound; + for (int i = 0; i < nb; i++) b[i] = 0.0f; +} + +void net_init(uint32_t seed) { + rng_state = seed; + init_layer(conv1_w, C1K*C1K*INC*F1, conv1_b, F1, C1K*C1K*INC); + init_layer(conv2_w, C2K*C2K*F1*F2, conv2_b, F2, C2K*C2K*F1); + init_layer(conv3_w, C3K*C3K*F2*F3, conv3_b, F3, C3K*C3K*F2); + init_layer(fc1_w, FC1IN*FC1U, fc1_b, FC1U, FC1IN); + init_layer(fc2_w, FC1U*NCLS, fc2_b, NCLS, FC1U); +} + +static void conv_fwd(const float* in, int inH, int inW, int inC, + const float* W, const float* b, int k, int cout, + float* out) { + const int outH = inH - k + 1, outW = inW - k + 1; + for (int oh = 0; oh < outH; oh++) + for (int ow = 0; ow < outW; ow++) + for (int oc = 0; oc < cout; oc++) { + float acc = b[oc]; + for (int ki = 0; ki < k; ki++) + for (int kj = 0; kj < k; kj++) + for (int ic = 0; ic < inC; ic++) + acc += in[idx3(oh+ki, ow+kj, ic, inW, inC)] + * W[((ki*k + kj)*inC + ic)*cout + oc]; + out[idx3(oh, ow, oc, outW, cout)] = reluf(acc); + } +} + +static void pool_fwd(const float* in, int inH, int inW, int C, + float* out, uint8_t* arg) { + const int outH = inH/2, outW = inW/2; + for (int oh = 0; oh < outH; oh++) + for (int ow = 0; ow < outW; ow++) + for (int c = 0; c < C; c++) { + float best = in[idx3(2*oh, 2*ow, c, inW, C)]; uint8_t bi = 0; + float v; + v = in[idx3(2*oh, 2*ow+1, c, inW, C)]; if (v > best) { best = v; bi = 1; } + v = in[idx3(2*oh+1, 2*ow, c, inW, C)]; if (v > best) { best = v; bi = 2; } + v = in[idx3(2*oh+1, 2*ow+1, c, inW, C)]; if (v > best) { best = v; bi = 3; } + out[idx3(oh, ow, c, outW, C)] = best; + arg[idx3(oh, ow, c, outW, C)] = bi; + } +} + +static void dense_fwd(const float* in, const float* W, const float* b, + int nin, int nout, bool applyRelu, float* out) { + for (int o = 0; o < nout; o++) { + float acc = b[o]; + for (int i = 0; i < nin; i++) acc += in[i] * W[i*nout + o]; + out[o] = applyRelu ? reluf(acc) : acc; + } +} + +static void softmax(const float* in, float* out, int n) { + float mx = in[0]; + for (int i = 1; i < n; i++) if (in[i] > mx) mx = in[i]; + float sum = 0.0f; + for (int i = 0; i < n; i++) { out[i] = expf(in[i] - mx); sum += out[i]; } + for (int i = 0; i < n; i++) out[i] /= sum; +} + +void net_forward(const float* image) { + memcpy(img_buf, image, sizeof(img_buf)); + conv_fwd(img_buf, INH, INW, INC, conv1_w, conv1_b, C1K, F1, a1); + pool_fwd(a1, H1, W1, F1, pool1_out, pool1_arg); + conv_fwd(pool1_out, H1P, W1P, F1, conv2_w, conv2_b, C2K, F2, a2); + pool_fwd(a2, H2, W2, F2, pool2_out, pool2_arg); + conv_fwd(pool2_out, H2P, W2P, F2, conv3_w, conv3_b, C3K, F3, a3); + dense_fwd(a3, fc1_w, fc1_b, FC1IN, FC1U, true, f1); + dense_fwd(f1, fc2_w, fc2_b, FC1U, NCLS, false, logits); + softmax(logits, probs, NCLS); +} + +static void dense_bwd(const float* in, const float* out, float* W, float* b, + int nin, int nout, float* dOut, float* dIn, + bool had_relu, float lr) { + if (had_relu) for (int o = 0; o < nout; o++) if (out[o] <= 0.0f) dOut[o] = 0.0f; + for (int i = 0; i < nin; i++) { + float s = 0.0f; + for (int o = 0; o < nout; o++) s += W[i*nout + o] * dOut[o]; + dIn[i] = s; + } + for (int o = 0; o < nout; o++) { + b[o] -= lr * dOut[o]; + for (int i = 0; i < nin; i++) W[i*nout + o] -= lr * in[i] * dOut[o]; + } +} + +static void conv_bwd(const float* in, int inH, int inW, int inC, + const float* out, int outH, int outW, int cout, + float* W, float* b, int k, + float* dOut, float* dIn, bool compute_dIn, + bool had_relu, float lr) { + const int wcount = k*k*inC*cout; + if (had_relu) + for (int i = 0; i < outH*outW*cout; i++) if (out[i] <= 0.0f) dOut[i] = 0.0f; + zero(dWbuf, wcount); + for (int oc = 0; oc < cout; oc++) dBbuf[oc] = 0.0f; + if (compute_dIn) zero(dIn, inH*inW*inC); + for (int oh = 0; oh < outH; oh++) + for (int ow = 0; ow < outW; ow++) + for (int oc = 0; oc < cout; oc++) { + float g = dOut[idx3(oh, ow, oc, outW, cout)]; + dBbuf[oc] += g; + for (int ki = 0; ki < k; ki++) + for (int kj = 0; kj < k; kj++) + for (int ic = 0; ic < inC; ic++) { + int ii = idx3(oh+ki, ow+kj, ic, inW, inC); + int wi = ((ki*k + kj)*inC + ic)*cout + oc; + dWbuf[wi] += in[ii] * g; + if (compute_dIn) dIn[ii] += W[wi] * g; + } + } + for (int i = 0; i < wcount; i++) W[i] -= lr * dWbuf[i]; + for (int oc = 0; oc < cout; oc++) b[oc] -= lr * dBbuf[oc]; +} + +static void pool_bwd(const float* dOut, const uint8_t* arg, + int inH, int inW, int C, float* dIn) { + const int outH = inH/2, outW = inW/2; + zero(dIn, inH*inW*C); + for (int oh = 0; oh < outH; oh++) + for (int ow = 0; ow < outW; ow++) + for (int c = 0; c < C; c++) { + uint8_t a = arg[idx3(oh, ow, c, outW, C)]; + int ih = 2*oh + (a >> 1), iw = 2*ow + (a & 1); + dIn[idx3(ih, iw, c, inW, C)] += dOut[idx3(oh, ow, c, outW, C)]; + } +} + +float net_train_step(const float* image, int label, float lr) { + net_forward(image); + float loss = -logf(probs[label] + 1e-9f); + for (int i = 0; i < NCLS; i++) gA[i] = probs[i]; + gA[label] -= 1.0f; + dense_bwd(f1, logits, fc2_w, fc2_b, FC1U, NCLS, gA, gB, false, lr); + dense_bwd(a3, f1, fc1_w, fc1_b, FC1IN, FC1U, gB, gA, true, lr); + conv_bwd(pool2_out, H2P, W2P, F2, a3, H3, W3, F3, conv3_w, conv3_b, C3K, gA, gB, true, true, lr); + pool_bwd(gB, pool2_arg, H2, W2, F2, gA); + conv_bwd(pool1_out, H1P, W1P, F1, a2, H2, W2, F2, conv2_w, conv2_b, C2K, gA, gB, true, true, lr); + pool_bwd(gB, pool1_arg, H1, W1, F1, gA); + conv_bwd(img_buf, INH, INW, INC, a1, H1, W1, F1, conv1_w, conv1_b, C1K, gA, gB, + false, true, lr); + return loss; +} + +int net_argmax() { + int m = 0; + for (int i = 1; i < NCLS; i++) if (probs[i] > probs[m]) m = i; + return m; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// SIGNATURE → CNN INPUT CONVERSION +// +// sig_buffer is int16_t[56][100] in the wire layout: +// sig_buffer[ch][sample], row-major (ch is the slow axis). +// The CNN expects float[28*50] (INH=28, INW=50), one channel. +// +// We 2×2 average-pool the 56-channel × 100-sample grid down to 28×50, then +// map the int16 values to [0, 1]. +// +// Unit note: preprocess_and_extract.py stores (µV × 100) as int16, so a +// value of 1500 in the buffer = 15 µV. Artifact rejection in that script +// already guarantees all values are within ±15 µV (= ±1500 in int16 units). +// SIG_SCALE is therefore 1500: the full signal range maps cleanly to [0, 1] +// with no clamping under normal conditions. +// ═══════════════════════════════════════════════════════════════════════════ + +#define NUM_CHANNELS 56 +#define NUM_SAMPLES 100 +#define SIG_SCALE 1500.0f // half-range of int16 buffer values (= 15 µV × 100) + +static float cnn_input[INH * INW]; // 28*50 = 1400 floats + +// Fills cnn_input from sig_buffer via 2x2 average-pooling + normalisation. +// sig_buffer layout: sig_buffer[ch][sample], ch in [0,55], sample in [0,99]. +// Output layout: cnn_input[row*INW + col], row=ch/2, col=sample/2. +static void sig_to_cnn_input(const int16_t sig[NUM_CHANNELS][NUM_SAMPLES]) { + for (int row = 0; row < INH; row++) { + for (int col = 0; col < INW; col++) { + // Average the four int16 samples in the 2x2 block. + float sum = (float)sig[2*row ][2*col ] + + (float)sig[2*row ][2*col + 1] + + (float)sig[2*row + 1][2*col ] + + (float)sig[2*row + 1][2*col + 1]; + float uv = sum * 0.25f; + // Map [-SIG_SCALE, +SIG_SCALE] µV → [0, 1], then clamp. + float norm = (uv + SIG_SCALE) / (2.0f * SIG_SCALE); + if (norm < 0.0f) norm = 0.0f; + if (norm > 1.0f) norm = 1.0f; + cnn_input[row * INW + col] = norm; + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// SERIAL PROTOCOL + STATE MACHINE +// ═══════════════════════════════════════════════════════════════════════════ -// Configuration #define BAUD_RATE 115200 // Short timeout so a glitched/partial packet resets the board to STATE_WAITING_CMD // almost immediately, letting the host resync and retry. During normal streaming // bytes arrive every few ms, so this never fires mid-packet. #define SIGNATURE_TIMEOUT_MS 400 #define SIGNATURE_SIZE 11200 -#define NUM_CHANNELS 56 -#define NUM_SAMPLES 100 // Flow control: the host sends the signature in CHUNK_SIZE-byte chunks and waits // for an ACK (0xFF) after each one before sending the next. This caps in-flight // data so the USB-CDC receive buffer can never overflow. 11200 / 64 = 175 chunks. #define CHUNK_SIZE 64 -// Protocol command bytes #define CMD_TRAIN 0xAA #define CMD_INFER 0xBB #define CMD_DONE 0xCC -#define ACK_BYTE 0xFF // 0xFF: unambiguous, never appears in ASCII debug output +#define ACK_BYTE 0xFF -// LED PIN DEFINITIONS (NANO 33 BLE Sense REV2) #define LED_RED LEDR #define LED_GREEN LEDG #define LED_BLUE LEDB -// Signature buffer int16_t sig_buffer[NUM_CHANNELS][NUM_SAMPLES]; -// State machine enum PacketState { STATE_WAITING_CMD, STATE_READING_LABEL, @@ -63,7 +331,6 @@ enum PacketState { STATE_PROCESSING }; -// Global state variables PacketState packet_state = STATE_WAITING_CMD; uint32_t bytes_received = 0; uint8_t current_label = 0; @@ -72,64 +339,64 @@ uint32_t last_byte_time = 0; bool training_complete = false; uint32_t packet_count = 0; -// LED Control +// Running accuracy counters (printed every 20 training steps). +static long train_seen = 0, train_correct = 0; + void setLED(bool r, bool g, bool b) { digitalWrite(LED_RED, r ? LOW : HIGH); digitalWrite(LED_GREEN, g ? LOW : HIGH); digitalWrite(LED_BLUE, b ? LOW : HIGH); } - void setLED_RED() { setLED(true, false, false); } void setLED_GREEN() { setLED(false, true, false); } void setLED_BLUE() { setLED(false, false, true ); } void setLED_CYAN() { setLED(false, true, true ); } void setLED_OFF() { setLED(false, false, false); } -// Process training packet void process_train_packet(uint8_t label) { - String label_name = (label == 0) ? "Dorsiflexion" : - (label == 1) ? "Plantarflexion" : "Pricking"; - - Serial.print("[TRAIN] Packet "); - Serial.print(packet_count); - Serial.print(" ("); - Serial.print(label_name); - Serial.print(") - Buffer loaded\n"); + sig_to_cnn_input(sig_buffer); + float loss = net_train_step(cnn_input, label, LR); + train_seen++; + if (net_argmax() == label) train_correct++; + + if (train_seen % 20 == 0) { + Serial.print("[TRAIN] n="); Serial.print(train_seen); + Serial.print(" loss="); Serial.print(loss, 4); + Serial.print(" run_acc="); Serial.println((float)train_correct / train_seen, 3); + } } -// Process inference packet uint8_t process_infer_packet() { - Serial.print("[INFER] Packet "); - Serial.print(packet_count); - Serial.print(" - Classifying...\n"); - - uint8_t pred = 0; - return pred; + sig_to_cnn_input(sig_buffer); + net_forward(cnn_input); + return (uint8_t)net_argmax(); } -// Setup void setup() { pinMode(LED_RED, OUTPUT); pinMode(LED_GREEN, OUTPUT); pinMode(LED_BLUE, OUTPUT); setLED_GREEN(); - + Serial.begin(BAUD_RATE); - + + net_init(42); + delay(1000); - Serial.print("\n=== Arduino CAP Signature Receiver ===\n"); - Serial.print("Baud: "); - Serial.print(BAUD_RATE); + Serial.print("\n=== Arduino CAP Signature Receiver + EscapeNet SMALL ===\n"); + Serial.print("CNN input: "); Serial.print(INH); Serial.print("x"); Serial.print(INW); + Serial.print(" weights: "); + Serial.print((long)(C1K*C1K*INC*F1 + C2K*C2K*F1*F2 + C3K*C3K*F2*F3 + FC1IN*FC1U + FC1U*NCLS)); + Serial.print("\nBaud: "); Serial.print(BAUD_RATE); Serial.print(" | Waiting for data...\n\n"); - + packet_state = STATE_WAITING_CMD; bytes_received = 0; last_byte_time = millis(); } -// Main loop void loop() { - // Check for timeout during data reception (BEFORE reading new bytes) + // Check for timeout during data reception (BEFORE reading new bytes). if ((packet_state == STATE_READING_DATA || packet_state == STATE_READING_LABEL) && (millis() - last_byte_time > SIGNATURE_TIMEOUT_MS)) { Serial.print("[ERROR] Timeout: incomplete packet after "); @@ -141,17 +408,16 @@ void loop() { bytes_received = 0; setLED_GREEN(); } - + // Read incoming data: drain the ENTIRE RX buffer each loop so we keep up with // the host (115200 baud ~= 11.5 kB/s). Reading one byte per loop with delay(1) // caps us at ~1 kB/s, which overflows the USB RX buffer and wedges the port. while (Serial.available() > 0) { uint8_t byte = Serial.read(); - last_byte_time = millis(); // Update timestamp for EVERY byte received - - // State machine + last_byte_time = millis(); + switch (packet_state) { - + case STATE_WAITING_CMD: if (byte == CMD_TRAIN) { current_cmd = CMD_TRAIN; @@ -174,19 +440,17 @@ void loop() { setLED_OFF(); Serial.write(CMD_DONE); } - else { - // Silently ignore unexpected bytes. Printing + delay(500) here was fatal: - // on any desync, every junk byte froze the board for 0.5 s while the RX - // buffer overflowed, wedging the port. Ignoring lets us resync cleanly. - } + // Silently ignore unexpected bytes. Printing + delay(500) here was fatal: + // on any desync, every junk byte froze the board for 0.5 s while the RX + // buffer overflowed, wedging the port. Ignoring lets us resync cleanly. break; - + case STATE_READING_LABEL: current_label = byte; packet_state = STATE_READING_DATA; bytes_received = 0; break; - + case STATE_READING_DATA: ((uint8_t *)sig_buffer)[bytes_received] = byte; bytes_received++; @@ -202,28 +466,26 @@ void loop() { setLED_CYAN(); } break; - + default: packet_state = STATE_WAITING_CMD; break; } } - - // Process complete packet + + // Process a complete packet (runs once per packet, outside the byte-draining loop). if (packet_state == STATE_PROCESSING) { packet_count++; - + if (current_cmd == CMD_TRAIN) { - // Nothing to send here: the final chunk-ACK (at byte SIGNATURE_SIZE) already - // told the host the packet is complete. This is where real on-device training - // on sig_buffer would run. + process_train_packet(current_label); + // ACK already sent by the final chunk boundary above — no extra write needed. } else if (current_cmd == CMD_INFER) { uint8_t prediction = process_infer_packet(); - // Send prediction Serial.write(prediction); } - + packet_state = STATE_WAITING_CMD; bytes_received = 0; setLED_GREEN(); diff --git a/scripts/stream_signatures.py b/scripts/stream_signatures.py index 105c295..198b3c1 100644 --- a/scripts/stream_signatures.py +++ b/scripts/stream_signatures.py @@ -4,15 +4,22 @@ Streams extracted CAP signatures to the Arduino for training and inference. Process: - 1. Load shuffled signatures from training_signatures.npz + 1. Load signatures from training_signatures.npz, shuffle, split train/test 2. Open serial connection to Arduino - 3. For each signature: - - Send TRAIN packet (command 0xAA + label + signature data) - - Wait for ACK from Arduino - 4. Send DONE packet (0xCC) to finalize training - 5. (Optional) Perform inference on test signatures - -Protocol is defined in signatures_protocol.md + 3. Stream training signatures with chunk/ACK handshake, printing loss/accuracy + lines live as the Arduino sends them + 4. Send DONE packet to finalize training + 5. Optionally stream test signatures and report inference accuracy + +Protocol is defined in signatures_protocol.md. + +Usage examples: + python stream_signatures.py # train only (80/20 split) + python stream_signatures.py --infer # train then test + python stream_signatures.py --infer-only # test only (no training) + python stream_signatures.py --split 0.7 --infer # 70% train, 30% test + python stream_signatures.py --train-count 80 --test-count 20 --infer + python stream_signatures.py --list-ports """ import struct @@ -26,19 +33,21 @@ # CONFIGURATION # ────────────────────────────────────────────── -# Path to the pre-extracted signatures (created by preprocess_and_extract.py) -# Resolved relative to this script so it works regardless of current directory. SIGNATURES_FILE = pathlib.Path(__file__).resolve().parent / "training_signatures.npz" -# Serial port configuration -# On macOS: typically /dev/cu.usbmodem14101 (run with LIST_PORTS=True to find yours) -# Change this to match YOUR board's port (run with --list-ports flag to find it) -SERIAL_PORT = "/dev/cu.usbmodem1101" -BAUD_RATE = 115200 +SERIAL_PORT = "/dev/cu.usbmodem14301" +BAUD_RATE = 115200 # Timeout for waiting for Arduino acknowledgement (seconds) ACK_TIMEOUT = 5.0 +# Default train/test split fraction (fraction used for training). +# Override with --split F, --train-count N, or --test-count N on command line. +TRAIN_SPLIT = 0.8 # 80% train, 20% test by default + +# Number of passes over the training data. Override with --epochs N. +EPOCHS = 5 + # Set to True to print all available serial ports and exit LIST_PORTS = False @@ -48,72 +57,9 @@ CMD_TRAIN = 0xAA # Send signature for training: [0xAA][label][11200 bytes] CMD_INFER = 0xBB # Send signature for inference: [0xBB][11200 bytes] -CMD_DONE = 0xCC # Training complete: [0xCC] - -ACK_RESPONSE = 0xFF # Arduino responds with 0xFF to acknowledge (0xAA reserved for CMD_TRAIN) - -# ────────────────────────────────────────────── -# HELPER: PACKET CREATION -# ────────────────────────────────────────────── - -def create_train_packet(label, signature_56x100): - """ - Create a TRAIN packet for streaming to Arduino. - - Args: - label: int (0/1/2) = Dorsiflexion / Plantarflexion / Pricking - signature_56x100: numpy array of shape (56, 100) and dtype int16 - - Returns: - bytes: Complete packet ready to send over serial - Format: [0xAA] [label] [11200 bytes of int16 data] - """ - packet = bytearray() - - # Command byte - packet.append(CMD_TRAIN) - - # Label byte - packet.append(label & 0xFF) - - # Signature data (56x100 int16 in little-endian) - # tobytes() preserves the memory layout, which matches little-endian for int16 - packet.extend(signature_56x100.astype(np.int16).tobytes()) - - return bytes(packet) - - -def create_infer_packet(signature_56x100): - """ - Create an INFER packet for streaming to Arduino. - - Args: - signature_56x100: numpy array of shape (56, 100) and dtype int16 - - Returns: - bytes: Complete packet ready to send over serial - Format: [0xBB] [11200 bytes of int16 data] - """ - packet = bytearray() - - # Command byte - packet.append(CMD_INFER) - - # Signature data - packet.extend(signature_56x100.astype(np.int16).tobytes()) - - return bytes(packet) - - -def create_done_packet(): - """ - Create a DONE packet to signal end of training. - - Returns: - bytes: [0xCC] - """ - return bytes([CMD_DONE]) +CMD_DONE = 0xCC # Training complete: [0xCC] +ACK_RESPONSE = 0xFF # Arduino ACKs (0xFF) every CHUNK_SIZE bytes # ────────────────────────────────────────────── # CHUNKED TRANSFER (USB-CDC flow control) @@ -124,9 +70,51 @@ def create_done_packet(): # that ACK before sending more. Each chunk is delivered in WRITE_SIZE-byte # sub-writes, since writes <=16 bytes are 100% reliable (measured). -SIG_SIZE = 11200 # 56 * 100 * 2 bytes -ACK_EVERY = 64 # MUST match CHUNK_SIZE in Arduino.ino (SIG_SIZE % ACK_EVERY == 0) -WRITE_SIZE = 16 # largest serial write the board accepts without dropping bytes +SIG_SIZE = 11200 # 56 * 100 * 2 bytes +ACK_EVERY = 64 # MUST match CHUNK_SIZE in Arduino.ino (SIG_SIZE % ACK_EVERY == 0) +WRITE_SIZE = 16 # largest serial write the board accepts without dropping bytes + +_text_line_buf = bytearray() # accumulates partial text lines across wait_for_ack calls + + +def _emit_text(b): + """Accumulate a byte into the text line buffer; print complete lines.""" + global _text_line_buf + _text_line_buf.append(b) + if b == ord('\n'): + line = _text_line_buf.decode('ascii', errors='replace').strip() + if line: + print(f" [Arduino] {line}") + _text_line_buf = bytearray() + + +def wait_for_ack(ser): + """ + Read bytes until a 0xFF ACK arrives or ser.timeout elapses. + + Any non-ACK printable bytes are treated as Arduino text output (loss/ + accuracy lines) and forwarded to stdout via _emit_text. This is necessary + because the Arduino sends the training-progress line *before* the chunk ACK + when train_seen % 20 == 0, so both end up in the TX buffer in that order. + + Returns True if 0xFF was received, False on timeout. + """ + deadline = time.monotonic() + ser.timeout + old_timeout = ser.timeout + ser.timeout = 0.05 # short per-read timeout inside the loop + try: + while time.monotonic() < deadline: + b = ser.read(1) + if not b: + continue # short read; keep waiting within the deadline + if b[0] == ACK_RESPONSE: + return True + # Printable / newline byte → part of a text line from the Arduino + if b[0] == ord('\n') or b[0] == ord('\r') or 32 <= b[0] < 127: + _emit_text(b[0]) + finally: + ser.timeout = old_timeout + return False def send_signature(ser, cmd, label, signature_56x100): @@ -135,7 +123,7 @@ def send_signature(ser, cmd, label, signature_56x100): Sends the header ([cmd] or [cmd][label]), then the 11200 data bytes in ACK_EVERY-byte chunks — each chunk written as WRITE_SIZE-byte sub-writes — - waiting for a 0xFF ACK after every chunk. + waiting for a 0xFF ACK after every chunk via wait_for_ack. Returns True only if every chunk was acknowledged. """ @@ -153,221 +141,276 @@ def send_signature(ser, cmd, label, signature_56x100): for j in range(0, len(chunk), WRITE_SIZE): ser.write(chunk[j:j + WRITE_SIZE]) ser.flush() - ack = ser.read(1) # blocks up to ser.timeout - if not (len(ack) == 1 and ack[0] == ACK_RESPONSE): + if not wait_for_ack(ser): return False return True -# Number of times to retry a signature that fails mid-transfer. -MAX_RETRIES = 4 -# Must exceed the firmware's SIGNATURE_TIMEOUT_MS so the board has reset to -# STATE_WAITING_CMD before we resend. -RESYNC_WAIT = 0.6 +MAX_RETRIES = 4 +RESYNC_WAIT = 0.6 # must exceed Arduino SIGNATURE_TIMEOUT_MS (400 ms) def send_signature_with_retry(ser, cmd, label, signature_56x100): """ Send a signature, retrying after a resync if a chunk goes unacknowledged. - - A mid-packet failure leaves the board in STATE_READING_DATA; after - SIGNATURE_TIMEOUT_MS it resets itself to STATE_WAITING_CMD. We wait that out, - flush stale bytes, then resend. Returns (ok, attempts_used). + Returns (ok, attempts_used). """ for attempt in range(1, MAX_RETRIES + 1): if send_signature(ser, cmd, label, signature_56x100): return True, attempt - # Resync: let the board time out back to STATE_WAITING_CMD, then drain. time.sleep(RESYNC_WAIT) ser.reset_input_buffer() return False, MAX_RETRIES # ────────────────────────────────────────────── -# MAIN TRAINING LOOP +# REAL-TIME TEXT DRAIN # ────────────────────────────────────────────── -def train_on_arduino(): +def drain_text(ser, timeout=0.08): """ - Main function: loads signatures and streams them to Arduino for training. + Read any text lines the Arduino has printed (loss/accuracy updates etc.) + and print them to stdout. ACK bytes (0xFF) are silently discarded. + Called after every packet so progress appears live rather than in bursts. + """ + old = ser.timeout + ser.timeout = timeout + raw = ser.read(512) + ser.timeout = old + if not raw: + return + text = bytes( + b for b in raw + if b != ACK_RESPONSE and (b in (ord('\n'), ord('\r')) or 32 <= b < 127) + ) + if text: + for line in text.decode('ascii', errors='replace').splitlines(): + if line.strip(): + print(f" [Arduino] {line.strip()}") + + +# ────────────────────────────────────────────── +# DATA LOADING + SPLIT +# ────────────────────────────────────────────── + +CLASS_NAMES = ["Dorsiflexion", "Plantarflexion", "Pricking"] + + +def load_and_split(train_count=None, test_count=None, split=None, seed=0): + """ + Load signatures, shuffle with a fixed seed, then split into train/test sets. + + Priority: + 1. If train_count and test_count are both given, use them directly. + 2. If only one is given, derive the other from total - given. + 3. If split fraction is given, apply it. + 4. Default: TRAIN_SPLIT fraction. + + Returns (train_sigs, train_labels, test_sigs, test_labels). """ - print("=" * 70) - print("CAP Signature Streaming for Arduino Training") - print("=" * 70) - - # ──── Load signatures ──── if not SIGNATURES_FILE.exists(): print(f"ERROR: {SIGNATURES_FILE} not found!") print("Run preprocess_and_extract.py first to generate signatures.") - return - - print(f"\nLoading signatures from {SIGNATURES_FILE}...") - data = np.load(SIGNATURES_FILE) - labels = data['labels'] # Shape: (N,) dtype uint8 - signatures = data['signatures'] # Shape: (N, 56, 100) dtype int16 - - print(f" Loaded {len(signatures)} signatures") - print(f" Labels shape: {labels.shape}") - print(f" Signatures shape: {signatures.shape}") - - # Print class distribution + return None + + print(f"Loading signatures from {SIGNATURES_FILE}...") + data = np.load(SIGNATURES_FILE) + labels = data['labels'] # (N,) uint8 + signatures = data['signatures'] # (N, 56, 100) int16 + N = len(signatures) + + print(f" Loaded {N} signatures (shape {signatures.shape})") unique, counts = np.unique(labels, return_counts=True) - print(f"\n Class distribution:") - for label_id, count in zip(unique, counts): - label_name = ["Dorsiflexion", "Plantarflexion", "Pricking"][label_id] - print(f" {label_name} (label {label_id}): {count} signatures") - - # ──── Open serial connection ──── + print(" Class distribution:") + for lid, cnt in zip(unique, counts): + print(f" {CLASS_NAMES[lid]:15s} (label {lid}): {cnt}") + + # Shuffle reproducibly + rng = np.random.default_rng(seed) + perm = rng.permutation(N) + labels = labels[perm] + signatures = signatures[perm] + + # Determine split sizes + if train_count is not None and test_count is not None: + n_train, n_test = int(train_count), int(test_count) + elif train_count is not None: + n_train = int(train_count) + n_test = N - n_train + elif test_count is not None: + n_test = int(test_count) + n_train = N - n_test + else: + frac = split if split is not None else TRAIN_SPLIT + n_train = int(N * frac) + n_test = N - n_train + + n_train = max(0, min(n_train, N)) + n_test = max(0, min(n_test, N - n_train)) + + train_sigs = signatures[:n_train] + train_labels = labels[:n_train] + test_sigs = signatures[n_train:n_train + n_test] + test_labels = labels[n_train:n_train + n_test] + + print(f"\n Train set: {n_train} signatures") + print(f" Test set: {n_test} signatures") + + return train_sigs, train_labels, test_sigs, test_labels + + +# ────────────────────────────────────────────── +# SERIAL SETUP HELPER +# ────────────────────────────────────────────── + +def open_serial(boot_wait=True): + """Open the serial port and optionally wait for the Arduino to boot.""" print(f"\nOpening serial connection to {SERIAL_PORT} @ {BAUD_RATE} baud...") - try: ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1) except serial.SerialException as e: - print(f"ERROR: Could not open serial port {SERIAL_PORT}") - print(f" Details: {e}") + print(f"ERROR: Could not open {SERIAL_PORT}\n {e}") print("\nAvailable ports:") - for port in serial.tools.list_ports.comports(): - print(f" {port.device} – {port.description}") - return - - # Give Arduino time to boot after serial connection opens. - # Do NOT call reset_input_buffer() here — on macOS USB CDC it can trigger - # a brief USB reconnect that drops the first bytes of packet 1. - # Instead just drain stale bytes by reading with a short timeout. - print("Waiting for Arduino to boot (3 s)...") - time.sleep(3) - ser.timeout = 0.1 - while ser.read(64): # drain any stale boot-message bytes - pass - ser.timeout = 0.5 # per-chunk ACK wait; well above normal ~2 ms latency - - # ──── Stream training signatures ──── - print(f"\nStreaming {len(signatures)} training signatures...\n") - - successful = 0 - failed = 0 - retries = 0 - - for i, (label, sig) in enumerate(zip(labels, signatures)): - label_name = ["Dorsiflexion", "Plantarflexion", "Pricking"][label] - - # Stream the signature with the chunk/ACK handshake + retry-on-resync. - ack_received, attempts = send_signature_with_retry(ser, CMD_TRAIN, label, sig) - retries += attempts - 1 - - if ack_received: - successful += 1 - status = "✓" if attempts == 1 else f"✓ (retry x{attempts - 1})" - else: - failed += 1 - status = "✗ FAILED" + for p in serial.tools.list_ports.comports(): + print(f" {p.device} – {p.description}") + return None + + if boot_wait: + print("Waiting for Arduino to boot (3 s)...") + time.sleep(3) + # Drain boot messages without reset_input_buffer (can trigger USB reconnect) + ser.timeout = 0.1 + while ser.read(64): + pass + + ser.timeout = 0.5 # per-chunk ACK wait + return ser - # Print progress - print(f" [{i+1:3d}/{len(signatures)}] {label_name:15s} {status}") - - # ──── Send DONE packet ──── + +# ────────────────────────────────────────────── +# TRAINING +# ────────────────────────────────────────────── + +def train(ser, train_sigs, train_labels, epochs=None): + """ + Stream training signatures to the Arduino over multiple epochs, printing + live loss/accuracy updates as the board sends them. + Returns (successful, failed, retries). + """ + if epochs is None: + epochs = EPOCHS + n = len(train_sigs) + total = n * epochs + print(f"\nStreaming {n} training signatures × {epochs} epochs ({total} total)...\n") + + successful = failed = retries = 0 + rng = np.random.default_rng(0) + + for epoch in range(epochs): + perm = rng.permutation(n) + print(f" ── Epoch {epoch + 1}/{epochs} ──") + + for step, idx in enumerate(perm): + label = int(train_labels[idx]) + sig = train_sigs[idx] + label_name = CLASS_NAMES[label] + global_step = epoch * n + step + 1 + + ok, attempts = send_signature_with_retry(ser, CMD_TRAIN, label, sig) + retries += attempts - 1 + + if ok: + successful += 1 + status = "✓" if attempts == 1 else f"✓ (retry x{attempts - 1})" + else: + failed += 1 + status = "✗ FAILED" + + print(f" [{global_step:4d}/{total}] {label_name:15s} {status}") + + # Catch the last backprop's text output (arrives ~400 ms after final ACK). + time.sleep(1.5) + drain_text(ser, timeout=0.5) + + # Send DONE print(f"\nSending DONE command...") ser.reset_input_buffer() - ser.write(create_done_packet()) + ser.write(bytes([CMD_DONE])) ser.flush() - # Wait for the 0xCC echo (firmware also prints "[DONE] ..." text first). - start_time = time.time() - while time.time() - start_time < ACK_TIMEOUT: - response = ser.read(1) # blocking up to ser.timeout - if len(response) > 0 and response[0] == CMD_DONE: - print(" Training complete! Arduino is ready for inference.") + start = time.time() + while time.time() - start < ACK_TIMEOUT: + resp = ser.read(1) + if len(resp) == 1 and resp[0] == CMD_DONE: + print(" Training complete! Arduino ready for inference.") break - - # ──── Summary ──── - print(f"\n" + "=" * 70) + + print(f"\n{'='*70}") print(f"Training Transfer Summary") - print(f"=" * 70) - print(f" Successful: {successful}/{len(signatures)}") - print(f" Failed: {failed}/{len(signatures)}") - print(f" Retries: {retries}") - print(f" Accuracy: {100*successful/len(signatures):.1f}%") - - ser.close() - print(f"\nSerial port closed.") + print(f"{'='*70}") + print(f" Signatures sent: {n}") + print(f" Successful: {successful} ({100*successful/n:.1f}%)") + print(f" Failed: {failed}") + print(f" Retries: {retries}") + + return successful, failed, retries # ────────────────────────────────────────────── -# INFERENCE LOOP (Optional) +# INFERENCE / TESTING # ────────────────────────────────────────────── -def infer_on_arduino(test_indices=None): +def test(ser, test_sigs, test_labels, fresh_connection=False): """ - (Optional) Stream test signatures to Arduino for inference and collect predictions. - - Args: - test_indices: List of indices from signatures file to use as test set. - If None, uses last 10 signatures. + Stream test signatures to the Arduino for inference and report accuracy. + + If fresh_connection is True, waits briefly to drain stale serial bytes + (used when the serial port was just opened without a full boot wait). """ - print("\n" + "=" * 70) - print("CAP Signature Inference on Arduino") - print("=" * 70) - - # Load signatures - if not SIGNATURES_FILE.exists(): - print(f"ERROR: {SIGNATURES_FILE} not found!") + n = len(test_sigs) + if n == 0: + print("No test signatures — skipping inference.") return - - data = np.load(SIGNATURES_FILE) - labels = data['labels'] - signatures = data['signatures'] - - # Use last 10 signatures as test set if not specified - if test_indices is None: - test_indices = range(len(signatures) - 10, len(signatures)) - - print(f"\nTesting on {len(test_indices)} signatures...") - - # Open serial connection - try: - ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1) - except serial.SerialException as e: - print(f"ERROR: Could not open serial port {SERIAL_PORT}") - return - - time.sleep(2) - - # Send test signatures and collect predictions + + print(f"\n{'='*70}") + print(f"Inference on {n} test signatures") + print(f"{'='*70}\n") + + if fresh_connection: + time.sleep(2) + ser.timeout = 0.1 + while ser.read(64): + pass + ser.timeout = ACK_TIMEOUT + predictions = [] correct = 0 - - for idx in test_indices: - label = labels[idx] - sig = signatures[idx] - label_name = ["Dorsiflexion", "Plantarflexion", "Pricking"][label] - - # Create and send INFER packet - packet = create_infer_packet(sig) - ser.write(packet) - - # Wait for prediction - start_time = time.time() - prediction = None - - while time.time() - start_time < ACK_TIMEOUT: - if ser.in_waiting > 0: - response = ser.read(1) - prediction = response[0] - break - - if prediction is not None: - pred_name = ["Dorsiflexion", "Plantarflexion", "Pricking"][prediction] - match = "✓" if prediction == label else "✗" - correct += (prediction == label) - predictions.append(prediction) - print(f" True: {label_name:15s} | Pred: {pred_name:15s} {match}") + + for i, (label, sig) in enumerate(zip(test_labels, test_sigs)): + label = int(label) + label_name = CLASS_NAMES[label] + + ok, attempts = send_signature_with_retry(ser, CMD_INFER, 0, sig) + if not ok: + print(f" [{i+1:2d}/{n}] ERROR: signature transfer failed") + continue + + resp = ser.read(1) + pred = resp[0] if len(resp) == 1 else None + + if pred is not None and pred < 3: + pred_name = CLASS_NAMES[pred] + match = "✓" if pred == label else "✗" + correct += int(pred == label) + predictions.append(pred) + retry_str = f" (retry x{attempts-1})" if attempts > 1 else "" + print(f" [{i+1:2d}/{n}] True: {label_name:15s} | Pred: {pred_name:15s} {match}{retry_str}") else: - print(f" ERROR: No response from Arduino") - - accuracy = 100 * correct / len(predictions) if predictions else 0 - print(f"\nInference Accuracy: {correct}/{len(predictions)} ({accuracy:.1f}%)") - - ser.close() + print(f" [{i+1:2d}/{n}] ERROR: unexpected response {pred!r}") + + total = len(predictions) + acc = 100 * correct / total if total else 0.0 + print(f"\nTest Accuracy: {correct}/{total} ({acc:.1f}%)") # ────────────────────────────────────────────── @@ -376,19 +419,92 @@ def infer_on_arduino(test_indices=None): if __name__ == "__main__": import sys - - # Check for --list-ports flag - if "--list-ports" in sys.argv: + + train_count = None + test_count = None + split = None + epochs = None + + args = sys.argv[1:] + i = 0 + while i < len(args): + a = args[i] + if a == "--train-count" and i + 1 < len(args): + try: + train_count = int(args[i + 1]); i += 2 + except ValueError: + print(f"ERROR: --train-count requires an integer"); sys.exit(1) + elif a == "--test-count" and i + 1 < len(args): + try: + test_count = int(args[i + 1]); i += 2 + except ValueError: + print(f"ERROR: --test-count requires an integer"); sys.exit(1) + elif a == "--split" and i + 1 < len(args): + try: + split = float(args[i + 1]) + if not 0 < split < 1: + raise ValueError + i += 2 + except ValueError: + print(f"ERROR: --split requires a float in (0, 1)"); sys.exit(1) + elif a == "--epochs" and i + 1 < len(args): + try: + epochs = int(args[i + 1]); i += 2 + except ValueError: + print(f"ERROR: --epochs requires an integer"); sys.exit(1) + else: + i += 1 + + if "--list-ports" in args: print("Available serial ports:") for port in serial.tools.list_ports.comports(): print(f" {port.device} – {port.description}") sys.exit(0) - - # Check for --infer flag (optional inference after training) - if "--infer" in sys.argv: - train_on_arduino() - input("\nPress Enter to start inference...") - infer_on_arduino() - else: - # Default: just training - train_on_arduino() + + # ── Load and split data ── + result = load_and_split( + train_count=train_count, + test_count=test_count, + split=split, + ) + if result is None: + sys.exit(1) + train_sigs, train_labels, test_sigs, test_labels = result + + # ── Decide what to run ── + do_train = "--infer-only" not in args + do_test = "--infer" in args or "--infer-only" in args + + ser = open_serial(boot_wait=True) + if ser is None: + sys.exit(1) + + try: + if do_train: + train(ser, train_sigs, train_labels, epochs=epochs) + + if do_test: + if do_train: + # Already connected; brief pause then drain after training. + time.sleep(1) + ser.timeout = 0.1 + while ser.read(64): + pass + ser.timeout = ACK_TIMEOUT + else: + # Opened fresh for inference only; drain boot bytes. + time.sleep(2) + ser.timeout = 0.1 + while ser.read(64): + pass + ser.timeout = ACK_TIMEOUT + + test(ser, test_sigs, test_labels) + + if not do_train and not do_test: + # Default (no flags): train only + train(ser, train_sigs, train_labels) + + finally: + ser.close() + print("\nSerial port closed.") From 2f78ac73bcb854b26ffa9fa6e3747a5772125c57 Mon Sep 17 00:00:00 2001 From: Gustav Date: Mon, 8 Jun 2026 16:14:37 +0200 Subject: [PATCH 2/5] optional ensemble trial --- scripts/Arduino/Arduino.ino | 13 ++-- scripts/stream_signatures.py | 120 ++++++++++++++++++++++++++--------- 2 files changed, 99 insertions(+), 34 deletions(-) diff --git a/scripts/Arduino/Arduino.ino b/scripts/Arduino/Arduino.ino index a1c92d5..5ae02a8 100644 --- a/scripts/Arduino/Arduino.ino +++ b/scripts/Arduino/Arduino.ino @@ -366,10 +366,16 @@ void process_train_packet(uint8_t label) { } } -uint8_t process_infer_packet() { +void process_infer_packet() { sig_to_cnn_input(sig_buffer); net_forward(cnn_input); - return (uint8_t)net_argmax(); + // Send 3 probability bytes (prob * 255, rounded) so the host can either + // use the CNN argmax directly or soft-vote with an ensemble partner on the PC. + for (int i = 0; i < NCLS; i++) { + float p = probs[i] * 255.0f + 0.5f; + if (p > 255.0f) p = 255.0f; + Serial.write((uint8_t)p); + } } void setup() { @@ -482,8 +488,7 @@ void loop() { // ACK already sent by the final chunk boundary above — no extra write needed. } else if (current_cmd == CMD_INFER) { - uint8_t prediction = process_infer_packet(); - Serial.write(prediction); + process_infer_packet(); } packet_state = STATE_WAITING_CMD; diff --git a/scripts/stream_signatures.py b/scripts/stream_signatures.py index 198b3c1..80a4d95 100644 --- a/scripts/stream_signatures.py +++ b/scripts/stream_signatures.py @@ -28,6 +28,7 @@ import serial import serial.tools.list_ports import time +from sklearn.ensemble import RandomForestClassifier # ────────────────────────────────────────────── # CONFIGURATION @@ -356,38 +357,60 @@ def train(ser, train_sigs, train_labels, epochs=None): return successful, failed, retries +# ────────────────────────────────────────────── +# RANDOM FOREST (PC-SIDE ENSEMBLE PARTNER) +# ────────────────────────────────────────────── + +def _sig_to_rf_features(sig_56x100): + """ + Mirror the Arduino's sig_to_cnn_input preprocessing so RF and CNN see the + same representation: 2×2 average-pool 56×100 → 28×50, normalise to [0, 1]. + Returns a flat 1400-element float32 vector. + """ + s = sig_56x100.astype(np.float32) + pooled = (s[0::2, 0::2] + s[0::2, 1::2] + s[1::2, 0::2] + s[1::2, 1::2]) * 0.25 + norm = (pooled + 1500.0) / 3000.0 # SIG_SCALE = 1500 matches Arduino.ino + return np.clip(norm, 0.0, 1.0).ravel() + + +def train_random_forest(train_sigs, train_labels): + """Train a Random Forest on the same features the CNN sees. Returns the fitted model.""" + print("\nTraining Random Forest on PC...") + X = np.array([_sig_to_rf_features(s) for s in train_sigs]) + rf = RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=-1) + rf.fit(X, train_labels) + print(f" RF in-sample accuracy: {rf.score(X, train_labels):.3f}") + return rf + + # ────────────────────────────────────────────── # INFERENCE / TESTING # ────────────────────────────────────────────── -def test(ser, test_sigs, test_labels, fresh_connection=False): +def test(ser, test_sigs, test_labels, rf=None): """ Stream test signatures to the Arduino for inference and report accuracy. - If fresh_connection is True, waits briefly to drain stale serial bytes - (used when the serial port was just opened without a full boot wait). + The Arduino always sends 3 probability bytes (one per class, scaled 0-255). + If rf is None: CNN-only mode — take argmax of CNN probs. + If rf is given: ensemble mode — soft-vote (average CNN + RF probs), and + also report CNN-only and RF-only accuracies for comparison. """ n = len(test_sigs) if n == 0: print("No test signatures — skipping inference.") return + mode = "CNN + Random Forest ensemble" if rf is not None else "CNN only" print(f"\n{'='*70}") - print(f"Inference on {n} test signatures") + print(f"Inference on {n} test signatures [{mode}]") print(f"{'='*70}\n") - if fresh_connection: - time.sleep(2) - ser.timeout = 0.1 - while ser.read(64): - pass - ser.timeout = ACK_TIMEOUT - - predictions = [] - correct = 0 + cnn_correct = rf_correct = ens_correct = 0 + valid = 0 for i, (label, sig) in enumerate(zip(test_labels, test_sigs)): - label = int(label) + label = int(label) label_name = CLASS_NAMES[label] ok, attempts = send_signature_with_retry(ser, CMD_INFER, 0, sig) @@ -395,22 +418,55 @@ def test(ser, test_sigs, test_labels, fresh_connection=False): print(f" [{i+1:2d}/{n}] ERROR: signature transfer failed") continue - resp = ser.read(1) - pred = resp[0] if len(resp) == 1 else None + # Arduino sends 3 probability bytes + resp = ser.read(3) + if len(resp) != 3: + print(f" [{i+1:2d}/{n}] ERROR: expected 3 prob bytes, got {len(resp)}") + continue + + cnn_probs = np.array(resp, dtype=np.float32) + cnn_probs /= cnn_probs.sum() if cnn_probs.sum() > 0 else 1.0 + cnn_pred = int(np.argmax(cnn_probs)) - if pred is not None and pred < 3: + retry_str = f" (retry x{attempts-1})" if attempts > 1 else "" + + if rf is None: + # ── CNN-only ── + pred = cnn_pred pred_name = CLASS_NAMES[pred] - match = "✓" if pred == label else "✗" - correct += int(pred == label) - predictions.append(pred) - retry_str = f" (retry x{attempts-1})" if attempts > 1 else "" - print(f" [{i+1:2d}/{n}] True: {label_name:15s} | Pred: {pred_name:15s} {match}{retry_str}") + match = "✓" if pred == label else "✗" + cnn_correct += int(pred == label) + print(f" [{i+1:2d}/{n}] True: {label_name:15s} | CNN: {pred_name:15s} {match}{retry_str}") else: - print(f" [{i+1:2d}/{n}] ERROR: unexpected response {pred!r}") + # ── Ensemble ── + rf_probs = rf.predict_proba([_sig_to_rf_features(sig)])[0] + rf_pred = int(np.argmax(rf_probs)) + + ens_probs = (cnn_probs + rf_probs) / 2.0 + ens_pred = int(np.argmax(ens_probs)) + + cnn_match = "✓" if cnn_pred == label else "✗" + rf_match = "✓" if rf_pred == label else "✗" + ens_match = "✓" if ens_pred == label else "✗" - total = len(predictions) - acc = 100 * correct / total if total else 0.0 - print(f"\nTest Accuracy: {correct}/{total} ({acc:.1f}%)") + cnn_correct += int(cnn_pred == label) + rf_correct += int(rf_pred == label) + ens_correct += int(ens_pred == label) + + print(f" [{i+1:2d}/{n}] True: {label_name:15s} | " + f"CNN: {CLASS_NAMES[cnn_pred]:15s}{cnn_match} " + f"RF: {CLASS_NAMES[rf_pred]:15s}{rf_match} " + f"Ens: {CLASS_NAMES[ens_pred]:15s}{ens_match}{retry_str}") + + valid += 1 + + print(f"\n{'─'*70}") + if rf is None: + print(f" CNN accuracy: {cnn_correct}/{valid} ({100*cnn_correct/valid:.1f}%)" if valid else " No valid predictions.") + else: + print(f" CNN accuracy: {cnn_correct}/{valid} ({100*cnn_correct/valid:.1f}%)" if valid else "") + print(f" RF accuracy: {rf_correct}/{valid} ({100*rf_correct/valid:.1f}%)" if valid else "") + print(f" Ensemble accuracy: {ens_correct}/{valid} ({100*ens_correct/valid:.1f}%)" if valid else "") # ────────────────────────────────────────────── @@ -472,8 +528,12 @@ def test(ser, test_sigs, test_labels, fresh_connection=False): train_sigs, train_labels, test_sigs, test_labels = result # ── Decide what to run ── - do_train = "--infer-only" not in args - do_test = "--infer" in args or "--infer-only" in args + do_train = "--infer-only" not in args + do_test = "--infer" in args or "--infer-only" in args + use_ensemble = "--ensemble" in args + + # Train RF on PC before opening serial (no Arduino needed for this step) + rf = train_random_forest(train_sigs, train_labels) if use_ensemble else None ser = open_serial(boot_wait=True) if ser is None: @@ -499,11 +559,11 @@ def test(ser, test_sigs, test_labels, fresh_connection=False): pass ser.timeout = ACK_TIMEOUT - test(ser, test_sigs, test_labels) + test(ser, test_sigs, test_labels, rf=rf) if not do_train and not do_test: # Default (no flags): train only - train(ser, train_sigs, train_labels) + train(ser, train_sigs, train_labels, epochs=epochs) finally: ser.close() From f4913953aeabf5f8d68baf7ddbe35c7065578879 Mon Sep 17 00:00:00 2001 From: Trexz14 <55001975+Trexz14@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:42:56 +0200 Subject: [PATCH 3/5] integrate CNN into Arduino.ino, swap to sync-byte protocol --- scripts/Arduino/Arduino.ino | 288 ++++----------- scripts/CNN/EscapeNetSmall/EscapeNetSmall.ino | 331 ------------------ 2 files changed, 65 insertions(+), 554 deletions(-) delete mode 100644 scripts/CNN/EscapeNetSmall/EscapeNetSmall.ino diff --git a/scripts/Arduino/Arduino.ino b/scripts/Arduino/Arduino.ino index 5ae02a8..a53505c 100644 --- a/scripts/Arduino/Arduino.ino +++ b/scripts/Arduino/Arduino.ino @@ -5,22 +5,23 @@ Receives CAP signatures from PC (via stream_signatures.py) for training and inference, then runs the EscapeNet SMALL CNN on-device. - Protocol (from signatures_protocol.md): - - TRAIN packet: [0xAA] [label] [11200 bytes of int16 signature data] - - INFER packet: [0xBB] [11200 bytes of int16 signature data] - - DONE packet: [0xCC] + Protocol: + Python sends per sample: + [0xFE] sync byte — Arduino discards all bytes up to this one + [label] 0/1/2 = Dorsiflexion / Plantarflexion / Pricking + 255 = inference only (no weight update) + [1400 bytes] pixels uint8, 28×50, row-major (2×2 block average of 56×100, + per-signature min-max normalised to [0, 255] on the PC) + + Arduino replies: + Training: optional "[TRAIN] n=X loss=Y run_acc=Z\r\n", then [0xFF] ACK + Inference: 3 probability bytes (p0, p1, p2), each scaled to [0, 255] LED behaviour: - - GREEN : ready and waiting for data - - BLUE : packet being received (filling buffer) - - CYAN : packet received, processing (training/inference) - - RED : error or timeout - - OFF : training complete, ready for inference + - GREEN : ready and waiting for data + - CYAN : processing (training or inference) - The Arduino accumulates signature bytes into a 56x100 buffer, downsamples - to 28x50 floats, then passes the image to EscapeNet for training or inference. - - See scripts/CNN/README.md and scripts/signatures_protocol.md for details. + See scripts/CNN/README.md for CNN architecture details. */ #include @@ -46,7 +47,7 @@ #define FC1U 16 #define NCLS 3 -#define LR 0.001f +#define LR 0.01f #define H1 (INH - C1K + 1) #define W1 (INW - C1K + 1) @@ -254,245 +255,86 @@ int net_argmax() { } // ═══════════════════════════════════════════════════════════════════════════ -// SIGNATURE → CNN INPUT CONVERSION -// -// sig_buffer is int16_t[56][100] in the wire layout: -// sig_buffer[ch][sample], row-major (ch is the slow axis). -// The CNN expects float[28*50] (INH=28, INW=50), one channel. -// -// We 2×2 average-pool the 56-channel × 100-sample grid down to 28×50, then -// map the int16 values to [0, 1]. -// -// Unit note: preprocess_and_extract.py stores (µV × 100) as int16, so a -// value of 1500 in the buffer = 15 µV. Artifact rejection in that script -// already guarantees all values are within ±15 µV (= ±1500 in int16 units). -// SIG_SCALE is therefore 1500: the full signal range maps cleanly to [0, 1] -// with no clamping under normal conditions. -// ═══════════════════════════════════════════════════════════════════════════ - -#define NUM_CHANNELS 56 -#define NUM_SAMPLES 100 -#define SIG_SCALE 1500.0f // half-range of int16 buffer values (= 15 µV × 100) - -static float cnn_input[INH * INW]; // 28*50 = 1400 floats - -// Fills cnn_input from sig_buffer via 2x2 average-pooling + normalisation. -// sig_buffer layout: sig_buffer[ch][sample], ch in [0,55], sample in [0,99]. -// Output layout: cnn_input[row*INW + col], row=ch/2, col=sample/2. -static void sig_to_cnn_input(const int16_t sig[NUM_CHANNELS][NUM_SAMPLES]) { - for (int row = 0; row < INH; row++) { - for (int col = 0; col < INW; col++) { - // Average the four int16 samples in the 2x2 block. - float sum = (float)sig[2*row ][2*col ] - + (float)sig[2*row ][2*col + 1] - + (float)sig[2*row + 1][2*col ] - + (float)sig[2*row + 1][2*col + 1]; - float uv = sum * 0.25f; - // Map [-SIG_SCALE, +SIG_SCALE] µV → [0, 1], then clamp. - float norm = (uv + SIG_SCALE) / (2.0f * SIG_SCALE); - if (norm < 0.0f) norm = 0.0f; - if (norm > 1.0f) norm = 1.0f; - cnn_input[row * INW + col] = norm; - } - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// SERIAL PROTOCOL + STATE MACHINE +// ARDUINO ENTRY POINTS // ═══════════════════════════════════════════════════════════════════════════ #define BAUD_RATE 115200 -// Short timeout so a glitched/partial packet resets the board to STATE_WAITING_CMD -// almost immediately, letting the host resync and retry. During normal streaming -// bytes arrive every few ms, so this never fires mid-packet. -#define SIGNATURE_TIMEOUT_MS 400 -#define SIGNATURE_SIZE 11200 - -// Flow control: the host sends the signature in CHUNK_SIZE-byte chunks and waits -// for an ACK (0xFF) after each one before sending the next. This caps in-flight -// data so the USB-CDC receive buffer can never overflow. 11200 / 64 = 175 chunks. -#define CHUNK_SIZE 64 - -#define CMD_TRAIN 0xAA -#define CMD_INFER 0xBB -#define CMD_DONE 0xCC -#define ACK_BYTE 0xFF #define LED_RED LEDR #define LED_GREEN LEDG #define LED_BLUE LEDB -int16_t sig_buffer[NUM_CHANNELS][NUM_SAMPLES]; - -enum PacketState { - STATE_WAITING_CMD, - STATE_READING_LABEL, - STATE_READING_DATA, - STATE_PROCESSING -}; - -PacketState packet_state = STATE_WAITING_CMD; -uint32_t bytes_received = 0; -uint8_t current_label = 0; -uint8_t current_cmd = 0; -uint32_t last_byte_time = 0; -bool training_complete = false; -uint32_t packet_count = 0; +static float in_image[INH*INW]; +static long seen = 0, correct = 0; -// Running accuracy counters (printed every 20 training steps). -static long train_seen = 0, train_correct = 0; +static bool read_exact(uint8_t* dst, int n) { + int got = 0; + unsigned long t0 = millis(); + while (got < n) { + if (Serial.available()) dst[got++] = (uint8_t)Serial.read(); + else if (millis() - t0 > 5000) return false; + } + return true; +} void setLED(bool r, bool g, bool b) { digitalWrite(LED_RED, r ? LOW : HIGH); digitalWrite(LED_GREEN, g ? LOW : HIGH); digitalWrite(LED_BLUE, b ? LOW : HIGH); } -void setLED_RED() { setLED(true, false, false); } -void setLED_GREEN() { setLED(false, true, false); } -void setLED_BLUE() { setLED(false, false, true ); } -void setLED_CYAN() { setLED(false, true, true ); } -void setLED_OFF() { setLED(false, false, false); } - -void process_train_packet(uint8_t label) { - sig_to_cnn_input(sig_buffer); - float loss = net_train_step(cnn_input, label, LR); - train_seen++; - if (net_argmax() == label) train_correct++; - - if (train_seen % 20 == 0) { - Serial.print("[TRAIN] n="); Serial.print(train_seen); - Serial.print(" loss="); Serial.print(loss, 4); - Serial.print(" run_acc="); Serial.println((float)train_correct / train_seen, 3); - } -} - -void process_infer_packet() { - sig_to_cnn_input(sig_buffer); - net_forward(cnn_input); - // Send 3 probability bytes (prob * 255, rounded) so the host can either - // use the CNN argmax directly or soft-vote with an ensemble partner on the PC. - for (int i = 0; i < NCLS; i++) { - float p = probs[i] * 255.0f + 0.5f; - if (p > 255.0f) p = 255.0f; - Serial.write((uint8_t)p); - } -} void setup() { pinMode(LED_RED, OUTPUT); pinMode(LED_GREEN, OUTPUT); pinMode(LED_BLUE, OUTPUT); - setLED_GREEN(); + setLED(false, true, false); // GREEN: ready Serial.begin(BAUD_RATE); - + while (!Serial) {} net_init(42); - - delay(1000); - Serial.print("\n=== Arduino CAP Signature Receiver + EscapeNet SMALL ===\n"); - Serial.print("CNN input: "); Serial.print(INH); Serial.print("x"); Serial.print(INW); - Serial.print(" weights: "); - Serial.print((long)(C1K*C1K*INC*F1 + C2K*C2K*F1*F2 + C3K*C3K*F2*F3 + FC1IN*FC1U + FC1U*NCLS)); - Serial.print("\nBaud: "); Serial.print(BAUD_RATE); - Serial.print(" | Waiting for data...\n\n"); - - packet_state = STATE_WAITING_CMD; - bytes_received = 0; - last_byte_time = millis(); + Serial.print("EscapeNet ready. SMALL "); + Serial.print(INH); Serial.print("x"); Serial.print(INW); + Serial.print(" weights="); + Serial.println((long)(C1K*C1K*INC*F1 + C2K*C2K*F1*F2 + C3K*C3K*F2*F3 + FC1IN*FC1U + FC1U*NCLS)); } void loop() { - // Check for timeout during data reception (BEFORE reading new bytes). - if ((packet_state == STATE_READING_DATA || packet_state == STATE_READING_LABEL) && - (millis() - last_byte_time > SIGNATURE_TIMEOUT_MS)) { - Serial.print("[ERROR] Timeout: incomplete packet after "); - Serial.print(bytes_received); - Serial.print(" bytes\n"); - // No delay() here: any pause in the receive path lets the RX buffer overflow - // and wedges the USB-CDC port. Just reset and keep reading. - packet_state = STATE_WAITING_CMD; - bytes_received = 0; - setLED_GREEN(); - } + static uint8_t pix[INH*INW]; + if (!Serial.available()) return; - // Read incoming data: drain the ENTIRE RX buffer each loop so we keep up with - // the host (115200 baud ~= 11.5 kB/s). Reading one byte per loop with delay(1) - // caps us at ~1 kB/s, which overflows the USB RX buffer and wedges the port. - while (Serial.available() > 0) { - uint8_t byte = Serial.read(); - last_byte_time = millis(); - - switch (packet_state) { - - case STATE_WAITING_CMD: - if (byte == CMD_TRAIN) { - current_cmd = CMD_TRAIN; - packet_state = STATE_READING_LABEL; - bytes_received = 0; - setLED_BLUE(); - } - else if (byte == CMD_INFER) { - current_cmd = CMD_INFER; - packet_state = STATE_READING_DATA; - bytes_received = 0; - setLED_BLUE(); - } - else if (byte == CMD_DONE) { - current_cmd = CMD_DONE; - packet_state = STATE_PROCESSING; - setLED_CYAN(); - Serial.print("[DONE] Training complete!\n"); - training_complete = true; - setLED_OFF(); - Serial.write(CMD_DONE); - } - // Silently ignore unexpected bytes. Printing + delay(500) here was fatal: - // on any desync, every junk byte froze the board for 0.5 s while the RX - // buffer overflowed, wedging the port. Ignoring lets us resync cleanly. - break; - - case STATE_READING_LABEL: - current_label = byte; - packet_state = STATE_READING_DATA; - bytes_received = 0; - break; - - case STATE_READING_DATA: - ((uint8_t *)sig_buffer)[bytes_received] = byte; - bytes_received++; - // Flow control: after every full chunk (and at the very end), tell the host - // we've consumed it so it may send the next chunk. SIGNATURE_SIZE is a - // multiple of CHUNK_SIZE, so the final byte also triggers an ACK here. - if (bytes_received % CHUNK_SIZE == 0) { - Serial.write(ACK_BYTE); - Serial.flush(); - } - if (bytes_received == SIGNATURE_SIZE) { - packet_state = STATE_PROCESSING; - setLED_CYAN(); - } - break; - - default: - packet_state = STATE_WAITING_CMD; - break; - } - } + // Discard bytes until sync byte 0xFE — makes the stream self-synchronising. + if (Serial.read() != 0xFE) return; + + unsigned long t0 = millis(); + while (!Serial.available()) { if (millis() - t0 > 1000) return; } + int label = Serial.read(); // 0..NCLS-1 train, 255 inference - // Process a complete packet (runs once per packet, outside the byte-draining loop). - if (packet_state == STATE_PROCESSING) { - packet_count++; + if (!read_exact(pix, INH*INW)) return; + for (int i = 0; i < INH*INW; i++) in_image[i] = pix[i] / 255.0f; - if (current_cmd == CMD_TRAIN) { - process_train_packet(current_label); - // ACK already sent by the final chunk boundary above — no extra write needed. + setLED(false, true, true); // CYAN: processing + + if (label >= 0 && label < NCLS) { + float loss = net_train_step(in_image, label, LR); + seen++; + if (net_argmax() == label) correct++; + if (seen % 20 == 0) { + Serial.print("[TRAIN] n="); Serial.print(seen); + Serial.print(" loss="); Serial.print(loss, 4); + Serial.print(" run_acc="); Serial.println((float)correct / seen, 3); } - else if (current_cmd == CMD_INFER) { - process_infer_packet(); + Serial.write((uint8_t)0xFF); + Serial.flush(); + } else { + // Inference: send 3 probability bytes (each scaled to 0-255). + net_forward(in_image); + for (int i = 0; i < NCLS; i++) { + float p = probs[i] * 255.0f + 0.5f; + if (p > 255.0f) p = 255.0f; + Serial.write((uint8_t)p); } - - packet_state = STATE_WAITING_CMD; - bytes_received = 0; - setLED_GREEN(); + Serial.flush(); } + + setLED(false, true, false); // GREEN: ready } diff --git a/scripts/CNN/EscapeNetSmall/EscapeNetSmall.ino b/scripts/CNN/EscapeNetSmall/EscapeNetSmall.ino deleted file mode 100644 index 5c9d089..0000000 --- a/scripts/CNN/EscapeNetSmall/EscapeNetSmall.ino +++ /dev/null @@ -1,331 +0,0 @@ -/* - * EscapeNetSmall.ino - * ------------------ - * ESCAPE-NET CNN (SMALL config) with ON-DEVICE TRAINING, from scratch in plain - * C++. No TensorFlow Lite, no CMSIS-NN, no library — just float arrays and math. - * Target board: Arduino NANO 33 BLE Sense REV2 (nRF52840, 256 KB RAM / 1 MB flash). - * - * The network trains itself sample-by-sample as 28x50 images arrive over the - * serial stream: every image runs a full forward pass, a full backward pass - * (back-prop), and a plain-SGD weight update — all on the microcontroller. - * - * SMALL config: 28x50 input, 8 filters/layer, FC 16. ~5.7k weights. - * RAM budget (float32, full back-prop): - * weights+biases ........ ~23 KB - * activations (cached) .. ~59 KB - * grad ping-pong (2x) ... ~70 KB - * dW scratch ............ ~18 KB - * ------------------------------ - * total ................ ~175 KB -> FITS in 256 KB. Trains on device. - * - * For the full paper architecture see ../EscapeNetBig/EscapeNetBig.ino - * (identical code, larger arrays — does NOT fit this board). - * - * Stream protocol (loop()): - * 1 byte label : 0..NCLS-1 -> train on this image; 255 -> inference only - * INH*INW bytes : pixels, uint8 0..255 (converted to float /255) - */ - -#include -#include -#include - -// ── SMALL architecture ────────────────────────────────────────────────────── -#define INC 1 -#define INH 28 -#define INW 50 -#define C1K 5 -#define F1 8 -#define C2K 3 -#define F2 8 -#define C3K 2 -#define F3 8 -#define FC1U 16 - -#define NCLS 3 -#define LR 0.01f // SGD learning rate - -// ── Derived layer sizes (valid padding, stride 1; pools 2x2 stride 2) ─────── -#define H1 (INH - C1K + 1) -#define W1 (INW - C1K + 1) -#define H1P (H1 / 2) -#define W1P (W1 / 2) -#define H2 (H1P - C2K + 1) -#define W2 (W1P - C2K + 1) -#define H2P (H2 / 2) -#define W2P (W2 / 2) -#define H3 (H2P - C3K + 1) -#define W3 (W2P - C3K + 1) -#define FLAT (H3 * W3 * F3) -#define FC1IN FLAT - -#define IMAX(a, b) ((a) > (b) ? (a) : (b)) - -// Largest single activation map, weight array, and unit count — for scratch. -#define MAX_ACT IMAX(INH*INW*INC, IMAX(H1*W1*F1, IMAX(H1P*W1P*F1, \ - IMAX(H2*W2*F2, IMAX(H2P*W2P*F2, H3*W3*F3))))) -#define MAX_W IMAX(C1K*C1K*INC*F1, IMAX(C2K*C2K*F1*F2, \ - IMAX(C3K*C3K*F2*F3, IMAX(FC1IN*FC1U, FC1U*NCLS)))) -#define MAX_UNIT IMAX(F1, IMAX(F2, IMAX(F3, FC1U))) - -// ── Weights (mutable — trained in place, so they live in RAM, not flash) ──── -static float conv1_w[C1K*C1K*INC*F1], conv1_b[F1]; -static float conv2_w[C2K*C2K*F1*F2], conv2_b[F2]; -static float conv3_w[C3K*C3K*F2*F3], conv3_b[F3]; -static float fc1_w[FC1IN*FC1U], fc1_b[FC1U]; -static float fc2_w[FC1U*NCLS], fc2_b[NCLS]; - -// ── Forward activations (kept for the backward pass) ──────────────────────── -static float img[INH*INW*INC]; -static float a1[H1*W1*F1]; -static float p1[H1P*W1P*F1]; static uint8_t p1arg[H1P*W1P*F1]; -static float a2[H2*W2*F2]; -static float p2[H2P*W2P*F2]; static uint8_t p2arg[H2P*W2P*F2]; -static float a3[H3*W3*F3]; -static float f1[FC1U]; -static float logits[NCLS], probs[NCLS]; - -// ── Backward scratch ──────────────────────────────────────────────────────── -static float gA[MAX_ACT], gB[MAX_ACT]; // ping-pong activation gradients -static float dWbuf[MAX_W]; // per-layer weight gradient -static float dBbuf[MAX_UNIT]; // per-layer bias gradient - -// ── Small helpers ─────────────────────────────────────────────────────────── -static inline int idx3(int h, int w, int c, int W, int C) { return (h*W + w)*C + c; } -static inline float reluf(float x) { return x > 0.0f ? x : 0.0f; } -static inline void zero(float* p, int n) { memset(p, 0, n * sizeof(float)); } - -// Tiny LCG RNG -> uniform in [-1, 1). -static uint32_t rng_state = 12345u; -static inline float randu() { - rng_state = rng_state * 1664525u + 1013904223u; - return ((rng_state >> 8) / 16777216.0f) * 2.0f - 1.0f; -} - -// Kaiming-uniform init for one weight array; biases set to 0. -static void init_layer(float* W, int n, float* b, int nb, int fan_in) { - float bound = sqrtf(6.0f / (float)fan_in); - for (int i = 0; i < n; i++) W[i] = randu() * bound; - for (int i = 0; i < nb; i++) b[i] = 0.0f; -} - -void net_init(uint32_t seed) { - rng_state = seed; - init_layer(conv1_w, C1K*C1K*INC*F1, conv1_b, F1, C1K*C1K*INC); - init_layer(conv2_w, C2K*C2K*F1*F2, conv2_b, F2, C2K*C2K*F1); - init_layer(conv3_w, C3K*C3K*F2*F3, conv3_b, F3, C3K*C3K*F2); - init_layer(fc1_w, FC1IN*FC1U, fc1_b, FC1U, FC1IN); - init_layer(fc2_w, FC1U*NCLS, fc2_b, NCLS, FC1U); -} - -// ── Forward kernels ───────────────────────────────────────────────────────── - -// Valid conv + ReLU. W laid out [kh][kw][cin][cout]. -static void conv_fwd(const float* in, int inH, int inW, int inC, - const float* W, const float* b, int k, int cout, - float* out) { - const int outH = inH - k + 1, outW = inW - k + 1; - for (int oh = 0; oh < outH; oh++) - for (int ow = 0; ow < outW; ow++) - for (int oc = 0; oc < cout; oc++) { - float acc = b[oc]; - for (int ki = 0; ki < k; ki++) - for (int kj = 0; kj < k; kj++) - for (int ic = 0; ic < inC; ic++) - acc += in[idx3(oh+ki, ow+kj, ic, inW, inC)] - * W[((ki*k + kj)*inC + ic)*cout + oc]; - out[idx3(oh, ow, oc, outW, cout)] = reluf(acc); - } -} - -static void pool_fwd(const float* in, int inH, int inW, int C, - float* out, uint8_t* arg) { - const int outH = inH/2, outW = inW/2; - for (int oh = 0; oh < outH; oh++) - for (int ow = 0; ow < outW; ow++) - for (int c = 0; c < C; c++) { - float best = in[idx3(2*oh, 2*ow, c, inW, C)]; uint8_t bi = 0; - float v; - v = in[idx3(2*oh, 2*ow+1, c, inW, C)]; if (v > best) { best = v; bi = 1; } - v = in[idx3(2*oh+1, 2*ow, c, inW, C)]; if (v > best) { best = v; bi = 2; } - v = in[idx3(2*oh+1, 2*ow+1, c, inW, C)]; if (v > best) { best = v; bi = 3; } - out[idx3(oh, ow, c, outW, C)] = best; - arg[idx3(oh, ow, c, outW, C)] = bi; - } -} - -static void dense_fwd(const float* in, const float* W, const float* b, - int nin, int nout, bool applyRelu, float* out) { - for (int o = 0; o < nout; o++) { - float acc = b[o]; - for (int i = 0; i < nin; i++) acc += in[i] * W[i*nout + o]; - out[o] = applyRelu ? reluf(acc) : acc; - } -} - -static void softmax(const float* in, float* out, int n) { - float mx = in[0]; - for (int i = 1; i < n; i++) if (in[i] > mx) mx = in[i]; - float sum = 0.0f; - for (int i = 0; i < n; i++) { out[i] = expf(in[i] - mx); sum += out[i]; } - for (int i = 0; i < n; i++) out[i] /= sum; -} - -void net_forward(const float* image) { - memcpy(img, image, sizeof(img)); - conv_fwd(img, INH, INW, INC, conv1_w, conv1_b, C1K, F1, a1); - pool_fwd(a1, H1, W1, F1, p1, p1arg); - conv_fwd(p1, H1P, W1P, F1, conv2_w, conv2_b, C2K, F2, a2); - pool_fwd(a2, H2, W2, F2, p2, p2arg); - conv_fwd(p2, H2P, W2P, F2, conv3_w, conv3_b, C3K, F3, a3); - dense_fwd(a3, fc1_w, fc1_b, FC1IN, FC1U, true, f1); // flatten == a3 read linearly - dense_fwd(f1, fc2_w, fc2_b, FC1U, NCLS, false, logits); - softmax(logits, probs, NCLS); -} - -// ── Backward kernels (compute grads, then SGD-update weights in place) ────── - -// dOut is masked by ReLU' (using the layer's own output) when had_relu is set. -static void dense_bwd(const float* in, const float* out, float* W, float* b, - int nin, int nout, float* dOut, float* dIn, - bool had_relu, float lr) { - if (had_relu) for (int o = 0; o < nout; o++) if (out[o] <= 0.0f) dOut[o] = 0.0f; - // grad w.r.t. input (uses current/old W) - for (int i = 0; i < nin; i++) { - float s = 0.0f; - for (int o = 0; o < nout; o++) s += W[i*nout + o] * dOut[o]; - dIn[i] = s; - } - // update W, b - for (int o = 0; o < nout; o++) { - b[o] -= lr * dOut[o]; - for (int i = 0; i < nin; i++) W[i*nout + o] -= lr * in[i] * dOut[o]; - } -} - -static void conv_bwd(const float* in, int inH, int inW, int inC, - const float* out, int outH, int outW, int cout, - float* W, float* b, int k, - float* dOut, float* dIn, bool compute_dIn, - bool had_relu, float lr) { - const int wcount = k*k*inC*cout; - if (had_relu) - for (int i = 0; i < outH*outW*cout; i++) if (out[i] <= 0.0f) dOut[i] = 0.0f; - - zero(dWbuf, wcount); - for (int oc = 0; oc < cout; oc++) dBbuf[oc] = 0.0f; - if (compute_dIn) zero(dIn, inH*inW*inC); - - for (int oh = 0; oh < outH; oh++) - for (int ow = 0; ow < outW; ow++) - for (int oc = 0; oc < cout; oc++) { - float g = dOut[idx3(oh, ow, oc, outW, cout)]; - dBbuf[oc] += g; - for (int ki = 0; ki < k; ki++) - for (int kj = 0; kj < k; kj++) - for (int ic = 0; ic < inC; ic++) { - int ii = idx3(oh+ki, ow+kj, ic, inW, inC); - int wi = ((ki*k + kj)*inC + ic)*cout + oc; - dWbuf[wi] += in[ii] * g; - if (compute_dIn) dIn[ii] += W[wi] * g; // old W (update is after) - } - } - - for (int i = 0; i < wcount; i++) W[i] -= lr * dWbuf[i]; - for (int oc = 0; oc < cout; oc++) b[oc] -= lr * dBbuf[oc]; -} - -static void pool_bwd(const float* dOut, const uint8_t* arg, - int inH, int inW, int C, float* dIn) { - const int outH = inH/2, outW = inW/2; - zero(dIn, inH*inW*C); - for (int oh = 0; oh < outH; oh++) - for (int ow = 0; ow < outW; ow++) - for (int c = 0; c < C; c++) { - uint8_t a = arg[idx3(oh, ow, c, outW, C)]; - int ih = 2*oh + (a >> 1), iw = 2*ow + (a & 1); - dIn[idx3(ih, iw, c, inW, C)] += dOut[idx3(oh, ow, c, outW, C)]; - } -} - -// One full online training step. Returns cross-entropy loss for this sample. -float net_train_step(const float* image, int label, float lr) { - net_forward(image); - float loss = -logf(probs[label] + 1e-9f); - - // d(loss)/d(logits) = softmax - onehot -> gA - for (int i = 0; i < NCLS; i++) gA[i] = probs[i]; - gA[label] -= 1.0f; - - // FC2: logits (linear) ; FC1: f1 (relu) - dense_bwd(f1, logits, fc2_w, fc2_b, FC1U, NCLS, gA, gB, false, lr); // gB = dL/df1 - dense_bwd(a3, f1, fc1_w, fc1_b, FC1IN, FC1U, gB, gA, true, lr); // gA = dL/d(a3 flat) - - // Conv3 -> Pool2 -> Conv2 -> Pool1 -> Conv1 - conv_bwd(p2, H2P, W2P, F2, a3, H3, W3, F3, conv3_w, conv3_b, C3K, gA, gB, true, true, lr); - pool_bwd(gB, p2arg, H2, W2, F2, gA); - conv_bwd(p1, H1P, W1P, F1, a2, H2, W2, F2, conv2_w, conv2_b, C2K, gA, gB, true, true, lr); - pool_bwd(gB, p1arg, H1, W1, F1, gA); - conv_bwd(img, INH, INW, INC, a1, H1, W1, F1, conv1_w, conv1_b, C1K, gA, gB, - /*compute_dIn=*/false, true, lr); // input grad unused -> skip for speed - - return loss; -} - -int net_argmax() { - int m = 0; - for (int i = 1; i < NCLS; i++) if (probs[i] > probs[m]) m = i; - return m; -} - -// ── Arduino entry points ──────────────────────────────────────────────────── -#ifndef HOST_TEST - -static float in_image[INH*INW*INC]; -static long seen = 0, correct = 0; - -static bool read_exact(uint8_t* dst, int n) { - int got = 0; - unsigned long t0 = millis(); - while (got < n) { - if (Serial.available()) dst[got++] = (uint8_t)Serial.read(); - else if (millis() - t0 > 5000) return false; // stream stalled - } - return true; -} - -void setup() { - Serial.begin(115200); - while (!Serial) {} - net_init(42); - Serial.print("EscapeNet ready. config=SMALL "); - Serial.print(INH); Serial.print("x"); Serial.print(INW); - Serial.print(" input, "); Serial.print((long)( - C1K*C1K*INC*F1 + C2K*C2K*F1*F2 + C3K*C3K*F2*F3 + FC1IN*FC1U + FC1U*NCLS)); - Serial.println(" weights. Streaming..."); -} - -void loop() { - static uint8_t pix[INH*INW*INC]; - if (!Serial.available()) return; - - int label = Serial.read(); // 0..NCLS-1 train, 255 inference - if (!read_exact(pix, INH*INW*INC)) return; - for (int i = 0; i < INH*INW*INC; i++) in_image[i] = pix[i] / 255.0f; - - if (label >= 0 && label < NCLS) { - float loss = net_train_step(in_image, label, LR); - seen++; - if (net_argmax() == label) correct++; - if (seen % 20 == 0) { - Serial.print("n="); Serial.print(seen); - Serial.print(" loss="); Serial.print(loss, 4); - Serial.print(" run_acc="); Serial.println((float)correct / seen, 3); - } - } else { - net_forward(in_image); - Serial.print("pred="); Serial.println(net_argmax()); - } -} - -#endif // !HOST_TEST From b7ad464eeab9517d96246f776833d655297480c8 Mon Sep 17 00:00:00 2001 From: Trexz14 <55001975+Trexz14@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:43:02 +0200 Subject: [PATCH 4/5] update stream_signatures for new protocol, fix numpy and summary bugs --- scripts/stream_signatures.py | 240 ++++++++++++++++------------------- 1 file changed, 111 insertions(+), 129 deletions(-) diff --git a/scripts/stream_signatures.py b/scripts/stream_signatures.py index 80a4d95..3df9db3 100644 --- a/scripts/stream_signatures.py +++ b/scripts/stream_signatures.py @@ -6,12 +6,21 @@ Process: 1. Load signatures from training_signatures.npz, shuffle, split train/test 2. Open serial connection to Arduino - 3. Stream training signatures with chunk/ACK handshake, printing loss/accuracy + 3. Stream training signatures over multiple epochs, printing loss/accuracy lines live as the Arduino sends them - 4. Send DONE packet to finalize training - 5. Optionally stream test signatures and report inference accuracy + 4. Optionally stream test signatures and report inference accuracy -Protocol is defined in signatures_protocol.md. +Protocol: + Python sends per sample: + [0xFE] sync byte — Arduino discards all bytes up to this one + [label] 0/1/2 = Dorsiflexion / Plantarflexion / Pricking + 255 = inference only (no weight update) + [1400 bytes] pixels uint8, 28×50, row-major + (2×2 block average of 56×100, per-signature min-max to [0,255]) + + Arduino replies: + Training: optional "n=X loss=Y run_acc=Z\r\n", then [0xFF] ACK + Inference: 3 probability bytes (p0, p1, p2), each scaled to [0, 255] Usage examples: python stream_signatures.py # train only (80/20 split) @@ -22,7 +31,6 @@ python stream_signatures.py --list-ports """ -import struct import pathlib import numpy as np import serial @@ -36,7 +44,7 @@ SIGNATURES_FILE = pathlib.Path(__file__).resolve().parent / "training_signatures.npz" -SERIAL_PORT = "/dev/cu.usbmodem14301" +SERIAL_PORT = "/dev/cu.usbmodem3101" BAUD_RATE = 115200 # Timeout for waiting for Arduino acknowledgement (seconds) @@ -53,117 +61,107 @@ LIST_PORTS = False # ────────────────────────────────────────────── -# PROTOCOL COMMANDS (from signatures_protocol.md) +# PROTOCOL CONSTANTS # ────────────────────────────────────────────── -CMD_TRAIN = 0xAA # Send signature for training: [0xAA][label][11200 bytes] -CMD_INFER = 0xBB # Send signature for inference: [0xBB][11200 bytes] -CMD_DONE = 0xCC # Training complete: [0xCC] - -ACK_RESPONSE = 0xFF # Arduino ACKs (0xFF) every CHUNK_SIZE bytes - -# ────────────────────────────────────────────── -# CHUNKED TRANSFER (USB-CDC flow control) -# ────────────────────────────────────────────── -# The Nano 33 BLE's USB-CDC silently drops bytes on single writes larger than -# ~32 bytes, and overflows entirely on one big 11 KB write. So we use a handshake: -# the firmware ACKs (0xFF) after every ACK_EVERY bytes, and the host waits for -# that ACK before sending more. Each chunk is delivered in WRITE_SIZE-byte -# sub-writes, since writes <=16 bytes are 100% reliable (measured). +# Arduino image dimensions — must match #define INH / INW in Arduino.ino +INH, INW = 28, 50 -SIG_SIZE = 11200 # 56 * 100 * 2 bytes -ACK_EVERY = 64 # MUST match CHUNK_SIZE in Arduino.ino (SIG_SIZE % ACK_EVERY == 0) -WRITE_SIZE = 16 # largest serial write the board accepts without dropping bytes - -_text_line_buf = bytearray() # accumulates partial text lines across wait_for_ack calls +# Largest serial write the board accepts without silently dropping bytes (measured). +# Each chunk is delivered in WRITE_SIZE-byte sub-writes with a flush after each. +WRITE_SIZE = 16 +MAX_RETRIES = 4 +RESYNC_WAIT = 0.6 # must exceed Arduino SIGNATURE_TIMEOUT_MS (400 ms) -def _emit_text(b): - """Accumulate a byte into the text line buffer; print complete lines.""" - global _text_line_buf - _text_line_buf.append(b) - if b == ord('\n'): - line = _text_line_buf.decode('ascii', errors='replace').strip() - if line: - print(f" [Arduino] {line}") - _text_line_buf = bytearray() +# ────────────────────────────────────────────── +# PREPROCESSING +# ────────────────────────────────────────────── -def wait_for_ack(ser): +def downsample_normalise(sig_56x100): """ - Read bytes until a 0xFF ACK arrives or ser.timeout elapses. - - Any non-ACK printable bytes are treated as Arduino text output (loss/ - accuracy lines) and forwarded to stdout via _emit_text. This is necessary - because the Arduino sends the training-progress line *before* the chunk ACK - when train_seen % 20 == 0, so both end up in the TX buffer in that order. - - Returns True if 0xFF was received, False on timeout. + 56×100 int16 → 28×50 uint8 via 2×2 block average + per-signature min-max + normalisation. Matches the representation the CNN receives on-device. """ - deadline = time.monotonic() + ser.timeout - old_timeout = ser.timeout - ser.timeout = 0.05 # short per-read timeout inside the loop - try: - while time.monotonic() < deadline: - b = ser.read(1) - if not b: - continue # short read; keep waiting within the deadline - if b[0] == ACK_RESPONSE: - return True - # Printable / newline byte → part of a text line from the Arduino - if b[0] == ord('\n') or b[0] == ord('\r') or 32 <= b[0] < 127: - _emit_text(b[0]) - finally: - ser.timeout = old_timeout - return False + f = sig_56x100.astype(np.float32) + down = f.reshape(INH, 2, INW, 2).mean(axis=(1, 3)) + lo, hi = down.min(), down.max() + if hi > lo: + down = (down - lo) / (hi - lo) * 255.0 + else: + down[:] = 127.0 + return down.astype(np.uint8) -def send_signature(ser, cmd, label, signature_56x100): - """ - Stream one signature to the Arduino using the chunk/ACK handshake. +# ────────────────────────────────────────────── +# SERIAL TRANSFER +# ────────────────────────────────────────────── - Sends the header ([cmd] or [cmd][label]), then the 11200 data bytes in - ACK_EVERY-byte chunks — each chunk written as WRITE_SIZE-byte sub-writes — - waiting for a 0xFF ACK after every chunk via wait_for_ack. +def send_train_sample(ser, label, pixels_28x50): + """ + Send [0xFE][label][1400 bytes], then wait for a 0xFF ACK. - Returns True only if every chunk was acknowledged. + Any text lines printed by the Arduino (loss/accuracy updates) are forwarded + to stdout as they arrive. Returns True on ACK, False on timeout. """ - data = signature_56x100.astype(' lo: + return ((down - lo) / (hi - lo)).ravel().astype(np.float32) + return np.full(INH * INW, 0.5, dtype=np.float32) def train_random_forest(train_sigs, train_labels): @@ -412,19 +399,14 @@ def test(ser, test_sigs, test_labels, rf=None): for i, (label, sig) in enumerate(zip(test_labels, test_sigs)): label = int(label) label_name = CLASS_NAMES[label] + pixels = downsample_normalise(sig) - ok, attempts = send_signature_with_retry(ser, CMD_INFER, 0, sig) - if not ok: + resp, attempts = send_infer_with_retry(ser, pixels) + if resp is None: print(f" [{i+1:2d}/{n}] ERROR: signature transfer failed") continue - # Arduino sends 3 probability bytes - resp = ser.read(3) - if len(resp) != 3: - print(f" [{i+1:2d}/{n}] ERROR: expected 3 prob bytes, got {len(resp)}") - continue - - cnn_probs = np.array(resp, dtype=np.float32) + cnn_probs = np.frombuffer(resp, dtype=np.uint8).astype(np.float32) cnn_probs /= cnn_probs.sum() if cnn_probs.sum() > 0 else 1.0 cnn_pred = int(np.argmax(cnn_probs)) From fa787450b38a0a17c2823482df33a24860bec91f Mon Sep 17 00:00:00 2001 From: Trexz14 <55001975+Trexz14@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:43:08 +0200 Subject: [PATCH 5/5] add scikit-learn dep --- pyproject.toml | 1 + uv.lock | 113 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index f566f2b..a6612d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,4 +8,5 @@ dependencies = [ "jupyter>=1.1.1", "numpy>=2.4.3", "pyserial>=3.5", + "scikit-learn>=1.9.0", ] diff --git a/uv.lock b/uv.lock index 158eefe..e105988 100644 --- a/uv.lock +++ b/uv.lock @@ -35,6 +35,7 @@ dependencies = [ { name = "jupyter" }, { name = "numpy" }, { name = "pyserial" }, + { name = "scikit-learn" }, ] [package.metadata] @@ -42,6 +43,7 @@ requires-dist = [ { name = "jupyter", specifier = ">=1.1.1" }, { name = "numpy", specifier = ">=2.4.3" }, { name = "pyserial", specifier = ">=3.5" }, + { name = "scikit-learn", specifier = ">=1.9.0" }, ] [[package]] @@ -512,6 +514,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + [[package]] name = "json5" version = "0.13.0" @@ -850,6 +861,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, ] +[[package]] +name = "narwhals" +version = "2.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, +] + [[package]] name = "nbclient" version = "0.10.4" @@ -1377,6 +1397,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + [[package]] name = "send2trash" version = "2.1.0" @@ -1441,6 +1545,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, ] +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + [[package]] name = "tinycss2" version = "1.4.0"