Calibration supercharged
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ sim_results/
|
||||
sim_results_multi/
|
||||
tuningTrials/
|
||||
# RL_Trials/
|
||||
venv/
|
||||
152
A0Calibration/A0CalibrationSketch/A0CalibrationSketch.ino
Normal file
152
A0Calibration/A0CalibrationSketch/A0CalibrationSketch.ino
Normal file
@@ -0,0 +1,152 @@
|
||||
// A0CalibrationSketch
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Trimmed-down calibration streamer. Continuously outputs
|
||||
// "<mm>, <a0_raw>" lines over serial, where <mm> is the
|
||||
// position measured by one of the two known sensors (A2, A3)
|
||||
// and <a0_raw> is the raw ADC count from the unknown sensor
|
||||
// on A0. A companion Python notebook buckets these points
|
||||
// into 0.05mm intervals and exports an Excel calibration.
|
||||
//
|
||||
// Handshake protocol (restartable without re-uploading):
|
||||
// Wake byte : 'S' Python -> Arduino (start/restart streaming)
|
||||
// Stop byte : 'X' Python -> Arduino (stop streaming, return to idle)
|
||||
//
|
||||
// Idle state : Arduino waits for 'S', ignores all other bytes.
|
||||
// Stream state: Arduino emits data lines, watches for 'X'.
|
||||
// On 'X' it returns to idle state.
|
||||
//
|
||||
// Python connect sequence (works regardless of current Arduino state):
|
||||
// 1. Send 'X' (stops streaming if running; no-op if idle)
|
||||
// 2. sleep 200 ms + flush (drain any in-flight data lines)
|
||||
// 3. Send 'S' (Arduino: 1 s settle, then prints #READY)
|
||||
// 4. Wait for '#READY'
|
||||
//
|
||||
// Data lines: <float_mm>, <int_a0_raw>\n — no other output ever.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <util/atomic.h>
|
||||
|
||||
// ── ADC Interrupt-driven 3-channel read (A2, A3, A0) ─────────
|
||||
// Channel index: 0 → A2 (sensor 0), 1 → A3 (sensor 1), 2 → A0 (unknown)
|
||||
static const uint8_t adc_mux[3] = {2, 3, 1};
|
||||
|
||||
volatile uint16_t adc_result[3] = {0, 0, 0};
|
||||
volatile bool adc_ready[3] = {false, false, false};
|
||||
volatile uint8_t adc_channel = 0;
|
||||
|
||||
void setupADC() {
|
||||
ADMUX = (1 << REFS0) | adc_mux[0]; // AVCC ref, start on A2
|
||||
ADCSRA = (1 << ADEN) | (1 << ADIE) | (1 << ADPS2); // /16 prescaler
|
||||
ADCSRA |= (1 << ADSC);
|
||||
}
|
||||
|
||||
// ── OOR digital inputs ───────────────────────────────────────
|
||||
#define OOR_PIN_0 12 // HIGH = out of range, sensor 0 (A2)
|
||||
#define OOR_PIN_1 13 // HIGH = out of range, sensor 1 (A3)
|
||||
|
||||
volatile bool OOR[2];
|
||||
|
||||
ISR(ADC_vect) {
|
||||
uint16_t sample = ADC;
|
||||
uint8_t ch = adc_channel;
|
||||
uint8_t next = (ch + 1) % 3;
|
||||
|
||||
if (ch < 2) {
|
||||
OOR[ch] = digitalRead(ch == 0 ? OOR_PIN_0 : OOR_PIN_1);
|
||||
if (!OOR[ch]) {
|
||||
adc_result[ch] = sample;
|
||||
adc_ready[ch] = true;
|
||||
}
|
||||
} else {
|
||||
// A0: no OOR, always store
|
||||
adc_result[2] = sample;
|
||||
adc_ready[2] = true;
|
||||
}
|
||||
|
||||
ADMUX = (ADMUX & 0xF0) | adc_mux[next];
|
||||
adc_channel = next;
|
||||
ADCSRA |= (1 << ADSC);
|
||||
}
|
||||
|
||||
// ── ADC → mm linear mappings (raw range: 16–26 mm) ──────────
|
||||
// Kept identical to AltSensorTesting.ino so calibration is
|
||||
// performed in the same position frame.
|
||||
#define adcToMM0(adc) ((float)map(adc, 178, 895, 1600, 2600) / 100.0f)
|
||||
#define adcToMM1(adc) ((float)map(adc, 176, 885, 1600, 2600) / 100.0f)
|
||||
|
||||
// Mounting offsets so sensor 0 → 0–10 mm, sensor 1 → 10–20 mm
|
||||
#define OFFSET_MM0 15.6f
|
||||
#define OFFSET_MM1 6.2f
|
||||
|
||||
// ── Streaming state ──────────────────────────────────────────
|
||||
bool streaming = false;
|
||||
|
||||
// Enter idle: wait for the 'S' wake byte (all other bytes ignored).
|
||||
// Then settle, clear stale ADC flags, announce #READY, and set streaming.
|
||||
// Can be called from both setup() and loop() for restartable sessions.
|
||||
void waitForWake() {
|
||||
streaming = false;
|
||||
while (true) {
|
||||
if (Serial.available()) {
|
||||
char c = Serial.read();
|
||||
if (c == 'S') break;
|
||||
// Any other byte (e.g. stray 'X') is silently discarded.
|
||||
}
|
||||
}
|
||||
delay(1000); // ADC reference settle
|
||||
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
|
||||
adc_ready[0] = adc_ready[1] = adc_ready[2] = false;
|
||||
}
|
||||
Serial.println(F("#READY"));
|
||||
streaming = true;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
void setup() {
|
||||
Serial.begin(2000000);
|
||||
pinMode(OOR_PIN_0, INPUT);
|
||||
pinMode(OOR_PIN_1, INPUT);
|
||||
setupADC(); // ADC runs continuously; emission is gated by streaming flag
|
||||
waitForWake();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Check for stop byte before doing any work.
|
||||
if (Serial.available()) {
|
||||
char c = Serial.read();
|
||||
if (c == 'X') {
|
||||
waitForWake(); // returns only after next 'S' + #READY
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!streaming) return;
|
||||
|
||||
uint16_t val[3];
|
||||
bool ready[3];
|
||||
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
|
||||
for (uint8_t i = 0; i < 3; i++) {
|
||||
ready[i] = adc_ready[i];
|
||||
val[i] = adc_result[i];
|
||||
adc_ready[i] = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ready[0] && !ready[1]) return;
|
||||
|
||||
// Emit one line per in-range sensor sample, paired with the
|
||||
// most recent A0 raw ADC count (val[2] is always fresh).
|
||||
if (ready[0]) {
|
||||
float mm = adcToMM0(val[0]) - OFFSET_MM0;
|
||||
Serial.print(mm, 3);
|
||||
Serial.print(F(", "));
|
||||
Serial.println(val[2]);
|
||||
}
|
||||
if (ready[1]) {
|
||||
float mm = adcToMM1(val[1]) - OFFSET_MM1;
|
||||
Serial.print(mm, 3);
|
||||
Serial.print(F(", "));
|
||||
Serial.println(val[2]);
|
||||
}
|
||||
}
|
||||
483
A0Calibration/calibrate_a0.ipynb
Normal file
483
A0Calibration/calibrate_a0.ipynb
Normal file
@@ -0,0 +1,483 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# A0 Sensor Calibration\n",
|
||||
"\n",
|
||||
"Streams `mm, a0_raw` lines from the `A0CalibrationSketch` Arduino sketch, buckets them into 0.05 mm intervals (±0.02 mm acceptance window), and exports a calibration table to `data/` as a timestamped Excel file.\n",
|
||||
"\n",
|
||||
"**Workflow**\n",
|
||||
"1. Upload `A0CalibrationSketch/A0CalibrationSketch.ino` to the Arduino.\n",
|
||||
"2. Run the **Config** and **Connect** cells below.\n",
|
||||
"3. Run the **Collect** cell and sweep the target slowly through the full 0–20 mm range. The cell terminates automatically once every bucket has ≥50 samples, or press **Stop Collection** to halt early.\n",
|
||||
"4. Run the **Export** cell.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "83a5e59e",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"401 buckets from 0.0 to 20.0 mm (step 0.05 mm)\n",
|
||||
"Acceptance window: +/-0.02 mm from each bucket center\n",
|
||||
"Target: >= 50 samples per bucket\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"# === Config ===\n",
|
||||
"SERIAL_PORT = None # e.g. '/dev/cu.usbmodem14101'; None -> auto-detect\n",
|
||||
"BAUD_RATE = 115200\n",
|
||||
"\n",
|
||||
"MM_MIN = 0.0\n",
|
||||
"MM_MAX = 20.0\n",
|
||||
"BUCKET_STEP = 0.05 # bucket spacing (mm)\n",
|
||||
"BUCKET_WINDOW = 0.02 # accept samples within +/- WINDOW of bucket center\n",
|
||||
"MIN_SAMPLES = 50 # each bucket must reach this count to auto-stop\n",
|
||||
"\n",
|
||||
"from pathlib import Path\n",
|
||||
"DATA_DIR = Path('data')\n",
|
||||
"DATA_DIR.mkdir(exist_ok=True)\n",
|
||||
"\n",
|
||||
"import numpy as np\n",
|
||||
"\n",
|
||||
"N_BUCKETS = int(round((MM_MAX - MM_MIN) / BUCKET_STEP)) + 1\n",
|
||||
"bucket_centers = np.round(MM_MIN + np.arange(N_BUCKETS) * BUCKET_STEP, 4)\n",
|
||||
"\n",
|
||||
"print(f\"{N_BUCKETS} buckets from {MM_MIN} to {MM_MAX} mm (step {BUCKET_STEP} mm)\")\n",
|
||||
"print(f\"Acceptance window: +/-{BUCKET_WINDOW} mm from each bucket center\")\n",
|
||||
"print(f\"Target: >= {MIN_SAMPLES} samples per bucket\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "7b88a837",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Connect to Arduino"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "bee575ca",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Opening /dev/cu.usbmodem11301 @ 115200...\n",
|
||||
"Sending wake signal...\n",
|
||||
"Waiting for #READY marker...\n",
|
||||
"Arduino ready - streaming.\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import serial\n",
|
||||
"import serial.tools.list_ports\n",
|
||||
"import time\n",
|
||||
"\n",
|
||||
"def find_port():\n",
|
||||
" if SERIAL_PORT is not None:\n",
|
||||
" return SERIAL_PORT\n",
|
||||
" ports = list(serial.tools.list_ports.comports())\n",
|
||||
" for p in ports:\n",
|
||||
" dev = p.device\n",
|
||||
" desc = (p.description or '').lower()\n",
|
||||
" if ('usbmodem' in dev or 'usbserial' in dev.lower()\n",
|
||||
" or 'arduino' in desc or 'wch' in desc):\n",
|
||||
" return dev\n",
|
||||
" if ports:\n",
|
||||
" return ports[0].device\n",
|
||||
" raise RuntimeError(\"No serial ports found. Set SERIAL_PORT manually.\")\n",
|
||||
"\n",
|
||||
"port = find_port()\n",
|
||||
"print(f\"Opening {port} @ {BAUD_RATE}...\")\n",
|
||||
"\n",
|
||||
"# Close any previously-held handle if this cell is re-run.\n",
|
||||
"try:\n",
|
||||
" if ser.is_open:\n",
|
||||
" ser.close()\n",
|
||||
"except NameError:\n",
|
||||
" pass\n",
|
||||
"\n",
|
||||
"ser = serial.Serial(port, BAUD_RATE, timeout=0.1)\n",
|
||||
"\n",
|
||||
"# Opening the port may or may not reset the board.\n",
|
||||
"# Either way, the Arduino is in idle state (waiting for 'S') after boot.\n",
|
||||
"# Wait for reset/boot to finish, then flush anything printed before we opened.\n",
|
||||
"time.sleep(3.0)\n",
|
||||
"ser.reset_input_buffer()\n",
|
||||
"\n",
|
||||
"# Send 'X' first: stops any ongoing stream if the Arduino was already running.\n",
|
||||
"# If it's in idle state, 'X' is silently ignored.\n",
|
||||
"ser.write(b'X')\n",
|
||||
"time.sleep(0.2)\n",
|
||||
"ser.reset_input_buffer()\n",
|
||||
"\n",
|
||||
"# Send the wake byte. Arduino will settle for 1 s then print #READY.\n",
|
||||
"print(\"Sending wake signal...\")\n",
|
||||
"ser.write(b'S')\n",
|
||||
"\n",
|
||||
"print(\"Waiting for #READY marker...\")\n",
|
||||
"deadline = time.time() + 15.0\n",
|
||||
"seen_ready = False\n",
|
||||
"while time.time() < deadline:\n",
|
||||
" raw = ser.readline()\n",
|
||||
" if not raw:\n",
|
||||
" continue\n",
|
||||
" if raw.strip() == b'#READY':\n",
|
||||
" seen_ready = True\n",
|
||||
" break\n",
|
||||
"\n",
|
||||
"if not seen_ready:\n",
|
||||
" raise RuntimeError(\n",
|
||||
" \"Did not receive #READY within 15s. \"\n",
|
||||
" \"Check sketch is uploaded and SERIAL_PORT is correct.\"\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
"# Discard one line to ensure we start reading on a clean boundary.\n",
|
||||
"_ = ser.readline()\n",
|
||||
"print(\"Arduino ready - streaming.\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "f4955b1c",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. Collect calibration data\n",
|
||||
"\n",
|
||||
"Run this cell and slowly sweep the target through the full 0–20 mm range. The cell drains the serial stream in a background thread, buckets each valid sample, and updates the plot every ~0.3 s.\n",
|
||||
"\n",
|
||||
"Press **Stop Collection** to halt early, or let it run until every bucket has reached the target.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"id": "1207881e",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"application/vnd.jupyter.widget-view+json": {
|
||||
"model_id": "0aa7a77335254ed68cf8461be35468c7",
|
||||
"version_major": 2,
|
||||
"version_minor": 0
|
||||
},
|
||||
"text/plain": [
|
||||
"VBox(children=(HBox(children=(Button(button_style='danger', description='Stop Collection', icon='stop', style=…"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"ename": "CancelledError",
|
||||
"evalue": "",
|
||||
"output_type": "error",
|
||||
"traceback": [
|
||||
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
|
||||
"\u001b[31mCancelledError\u001b[39m Traceback (most recent call last)",
|
||||
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[6]\u001b[39m\u001b[32m, line 174\u001b[39m\n\u001b[32m 170\u001b[39m \u001b[38;5;28;01mbreak\u001b[39;00m\n\u001b[32m 171\u001b[39m \n\u001b[32m 172\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m asyncio.sleep(\u001b[32m0.05\u001b[39m)\n\u001b[32m 173\u001b[39m \n\u001b[32m--> \u001b[39m\u001b[32m174\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m collect()\n\u001b[32m 175\u001b[39m \n\u001b[32m 176\u001b[39m \u001b[38;5;66;03m# Tell the Arduino to stop streaming and return to idle,\u001b[39;00m\n\u001b[32m 177\u001b[39m \u001b[38;5;66;03m# so the connect cell can restart without re-uploading the sketch.\u001b[39;00m\n",
|
||||
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[6]\u001b[39m\u001b[32m, line 172\u001b[39m, in \u001b[36mcollect\u001b[39m\u001b[34m()\u001b[39m\n\u001b[32m 168\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m filled == N_BUCKETS:\n\u001b[32m 169\u001b[39m status_label.value += \u001b[33m' -- target reached'\u001b[39m\n\u001b[32m 170\u001b[39m \u001b[38;5;28;01mbreak\u001b[39;00m\n\u001b[32m 171\u001b[39m \n\u001b[32m--> \u001b[39m\u001b[32m172\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m asyncio.sleep(\u001b[32m0.05\u001b[39m)\n",
|
||||
"\u001b[36mFile \u001b[39m\u001b[32m/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/tasks.py:718\u001b[39m, in \u001b[36msleep\u001b[39m\u001b[34m(delay, result)\u001b[39m\n\u001b[32m 714\u001b[39m h = loop.call_later(delay,\n\u001b[32m 715\u001b[39m futures._set_result_unless_cancelled,\n\u001b[32m 716\u001b[39m future, result)\n\u001b[32m 717\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m718\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m future\n\u001b[32m 719\u001b[39m \u001b[38;5;28;01mfinally\u001b[39;00m:\n\u001b[32m 720\u001b[39m h.cancel()\n",
|
||||
"\u001b[31mCancelledError\u001b[39m: "
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import re\n",
|
||||
"import queue\n",
|
||||
"import threading\n",
|
||||
"import asyncio\n",
|
||||
"import time\n",
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"import ipywidgets as widgets\n",
|
||||
"from IPython.display import display, clear_output\n",
|
||||
"\n",
|
||||
"# === Bucket state (reset on each run of this cell) ===\n",
|
||||
"counts = np.zeros(N_BUCKETS, dtype=np.int64)\n",
|
||||
"means = np.zeros(N_BUCKETS, dtype=np.float64)\n",
|
||||
"\n",
|
||||
"# Matches \"<float>, <int>\" with optional leading sign and whitespace.\n",
|
||||
"LINE_RE = re.compile(r'^\\s*(-?\\d+\\.?\\d*)\\s*,\\s*(\\d+)\\s*$')\n",
|
||||
"\n",
|
||||
"def update_bucket(mm, adc):\n",
|
||||
" idx = int(round((mm - MM_MIN) / BUCKET_STEP))\n",
|
||||
" if idx < 0 or idx >= N_BUCKETS:\n",
|
||||
" return False\n",
|
||||
" if abs(mm - bucket_centers[idx]) > BUCKET_WINDOW + 1e-9:\n",
|
||||
" return False # falls into the gap between bucket windows\n",
|
||||
" counts[idx] += 1\n",
|
||||
" # Welford-style incremental mean (numerically stable, no accumulator overflow)\n",
|
||||
" means[idx] += (adc - means[idx]) / counts[idx]\n",
|
||||
" return True\n",
|
||||
"\n",
|
||||
"# === Serial reader thread ===\n",
|
||||
"# If this cell is re-run, stop the previous reader first so we don't end up\n",
|
||||
"# with two threads racing on ser.readline().\n",
|
||||
"try:\n",
|
||||
" _reader_stop.set()\n",
|
||||
" _reader_thread.join(timeout=1.0)\n",
|
||||
"except NameError:\n",
|
||||
" pass\n",
|
||||
"\n",
|
||||
"_reader_stop = threading.Event()\n",
|
||||
"line_queue = queue.Queue()\n",
|
||||
"\n",
|
||||
"def _reader_loop(stop_ev, q, s):\n",
|
||||
" while not stop_ev.is_set():\n",
|
||||
" try:\n",
|
||||
" raw = s.readline() # returns after timeout=0.1s if no data\n",
|
||||
" except Exception:\n",
|
||||
" break\n",
|
||||
" if raw:\n",
|
||||
" q.put(raw)\n",
|
||||
"\n",
|
||||
"_reader_thread = threading.Thread(\n",
|
||||
" target=_reader_loop, args=(_reader_stop, line_queue, ser), daemon=True,\n",
|
||||
")\n",
|
||||
"_reader_thread.start()\n",
|
||||
"\n",
|
||||
"# === UI widgets ===\n",
|
||||
"stop_button = widgets.Button(\n",
|
||||
" description='Stop Collection', button_style='danger', icon='stop',\n",
|
||||
")\n",
|
||||
"progress = widgets.IntProgress(\n",
|
||||
" min=0, max=N_BUCKETS, value=0,\n",
|
||||
" description='Buckets @target:', bar_style='info',\n",
|
||||
" style={'description_width': 'initial'},\n",
|
||||
" layout=widgets.Layout(width='500px'),\n",
|
||||
")\n",
|
||||
"status_label = widgets.Label(value='Starting...')\n",
|
||||
"plot_out = widgets.Output()\n",
|
||||
"\n",
|
||||
"stop_requested = False\n",
|
||||
"def _on_stop(_b):\n",
|
||||
" global stop_requested\n",
|
||||
" stop_requested = True\n",
|
||||
" stop_button.description = 'Stopping...'\n",
|
||||
" stop_button.disabled = True\n",
|
||||
"stop_button.on_click(_on_stop)\n",
|
||||
"\n",
|
||||
"display(widgets.VBox([\n",
|
||||
" widgets.HBox([stop_button, progress]),\n",
|
||||
" status_label,\n",
|
||||
" plot_out,\n",
|
||||
"]))\n",
|
||||
"\n",
|
||||
"# === Live plot ===\n",
|
||||
"plt.ioff()\n",
|
||||
"fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 6), sharex=True)\n",
|
||||
"plt.close(fig) # prevent implicit display; we manage display manually\n",
|
||||
"\n",
|
||||
"def _draw():\n",
|
||||
" ax1.clear(); ax2.clear()\n",
|
||||
" filled = counts > 0\n",
|
||||
" if filled.any():\n",
|
||||
" ax1.plot(bucket_centers[filled], means[filled], 'b.-', markersize=3)\n",
|
||||
" ax1.set_ylabel('Avg A0 ADC')\n",
|
||||
" ax1.set_title('Calibration curve (filled buckets)')\n",
|
||||
" ax1.grid(True, alpha=0.3)\n",
|
||||
"\n",
|
||||
" colors = [\n",
|
||||
" '#2ecc71' if c >= MIN_SAMPLES\n",
|
||||
" else '#f39c12' if c > 0\n",
|
||||
" else '#e74c3c'\n",
|
||||
" for c in counts\n",
|
||||
" ]\n",
|
||||
" ax2.bar(bucket_centers, counts, width=BUCKET_STEP * 0.9, color=colors)\n",
|
||||
" ax2.axhline(\n",
|
||||
" MIN_SAMPLES, color='red', linestyle='--', linewidth=1,\n",
|
||||
" label=f'target = {MIN_SAMPLES}',\n",
|
||||
" )\n",
|
||||
" ax2.set_xlabel('Position (mm)')\n",
|
||||
" ax2.set_ylabel('Sample count')\n",
|
||||
" ax2.set_title('Bucket fill progress (green = done, orange = partial, red = empty)')\n",
|
||||
" ax2.legend(loc='upper right', fontsize=8)\n",
|
||||
" ax2.grid(True, alpha=0.3)\n",
|
||||
"\n",
|
||||
" fig.tight_layout()\n",
|
||||
" with plot_out:\n",
|
||||
" clear_output(wait=True)\n",
|
||||
" display(fig)\n",
|
||||
"\n",
|
||||
"_draw()\n",
|
||||
"\n",
|
||||
"# === Collection loop ===\n",
|
||||
"total_parsed = 0\n",
|
||||
"total_bucketed = 0\n",
|
||||
"total_malformed = 0\n",
|
||||
"last_draw = time.monotonic()\n",
|
||||
"DRAW_INTERVAL = 0.3 # seconds\n",
|
||||
"\n",
|
||||
"async def collect():\n",
|
||||
" global total_parsed, total_bucketed, total_malformed, last_draw\n",
|
||||
"\n",
|
||||
" while not stop_requested:\n",
|
||||
" # Drain whatever came in since last tick.\n",
|
||||
" try:\n",
|
||||
" while True:\n",
|
||||
" raw = line_queue.get_nowait()\n",
|
||||
" try:\n",
|
||||
" txt = raw.decode('ascii', errors='replace').strip()\n",
|
||||
" except Exception:\n",
|
||||
" total_malformed += 1\n",
|
||||
" continue\n",
|
||||
" if not txt or txt.startswith('#'):\n",
|
||||
" continue # stray marker or blank line\n",
|
||||
" m = LINE_RE.match(txt)\n",
|
||||
" if not m:\n",
|
||||
" total_malformed += 1\n",
|
||||
" continue\n",
|
||||
" total_parsed += 1\n",
|
||||
" mm = float(m.group(1))\n",
|
||||
" adc = int(m.group(2))\n",
|
||||
" if update_bucket(mm, adc):\n",
|
||||
" total_bucketed += 1\n",
|
||||
" except queue.Empty:\n",
|
||||
" pass\n",
|
||||
"\n",
|
||||
" filled = int(np.sum(counts >= MIN_SAMPLES))\n",
|
||||
"\n",
|
||||
" now = time.monotonic()\n",
|
||||
" if now - last_draw >= DRAW_INTERVAL:\n",
|
||||
" last_draw = now\n",
|
||||
" progress.value = filled\n",
|
||||
" status_label.value = (\n",
|
||||
" f\"filled={filled}/{N_BUCKETS} \"\n",
|
||||
" f\"min_count={int(counts.min())} \"\n",
|
||||
" f\"parsed={total_parsed} \"\n",
|
||||
" f\"bucketed={total_bucketed} \"\n",
|
||||
" f\"malformed={total_malformed}\"\n",
|
||||
" )\n",
|
||||
" _draw()\n",
|
||||
"\n",
|
||||
" if filled == N_BUCKETS:\n",
|
||||
" status_label.value += ' -- target reached'\n",
|
||||
" break\n",
|
||||
"\n",
|
||||
" await asyncio.sleep(0.05)\n",
|
||||
"\n",
|
||||
"await collect()\n",
|
||||
"\n",
|
||||
"# Tell the Arduino to stop streaming and return to idle,\n",
|
||||
"# so the connect cell can restart without re-uploading the sketch.\n",
|
||||
"try:\n",
|
||||
" ser.write(b'X')\n",
|
||||
" time.sleep(0.1)\n",
|
||||
" ser.reset_input_buffer()\n",
|
||||
"except Exception:\n",
|
||||
" pass\n",
|
||||
"\n",
|
||||
"# Clean shutdown of the reader thread.\n",
|
||||
"_reader_stop.set()\n",
|
||||
"\n",
|
||||
"_draw()\n",
|
||||
"filled_final = int(np.sum(counts >= MIN_SAMPLES))\n",
|
||||
"status_label.value = (\n",
|
||||
" f\"DONE. filled={filled_final}/{N_BUCKETS} \"\n",
|
||||
" f\"parsded={total_parsed} bucketed={total_bucketed} malformed={total_malformed}\"\n",
|
||||
")\n",
|
||||
"stop_button.description = 'Stopped'\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3. Export to Excel"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from datetime import datetime\n",
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"ts = datetime.now().strftime('%Y%m%d_%H%M%S')\n",
|
||||
"out_path = DATA_DIR / f'a0_calibration_{ts}.xlsx'\n",
|
||||
"\n",
|
||||
"has_data = counts > 0\n",
|
||||
"df = pd.DataFrame({\n",
|
||||
" 'mm_val': bucket_centers[has_data],\n",
|
||||
" 'avg_adc': np.round(means[has_data], 3),\n",
|
||||
"})\n",
|
||||
"\n",
|
||||
"df.to_excel(out_path, index=False)\n",
|
||||
"print(f\"Exported {len(df)} rows -> {out_path.resolve()}\")\n",
|
||||
"\n",
|
||||
"short = (counts > 0) & (counts < MIN_SAMPLES)\n",
|
||||
"empty = counts == 0\n",
|
||||
"if short.any():\n",
|
||||
" print(f\" Note: {int(short.sum())} buckets have data but < {MIN_SAMPLES} samples.\")\n",
|
||||
"if empty.any():\n",
|
||||
" print(f\" Note: {int(empty.sum())} buckets had no data and are omitted from the export.\")\n",
|
||||
"\n",
|
||||
"df.head()\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4. Cleanup (optional — run when done)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"try:\n",
|
||||
" _reader_stop.set()\n",
|
||||
" _reader_thread.join(timeout=1.0)\n",
|
||||
"except NameError:\n",
|
||||
" pass\n",
|
||||
"\n",
|
||||
"try:\n",
|
||||
" if ser.is_open:\n",
|
||||
" ser.close()\n",
|
||||
" print(\"Serial port closed.\")\n",
|
||||
"except NameError:\n",
|
||||
" pass\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "venv",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.7"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
447
A0Calibration/calibrate_a0.py
Normal file
447
A0Calibration/calibrate_a0.py
Normal file
@@ -0,0 +1,447 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
A0 Calibration — standalone tkinter GUI
|
||||
Usage: python calibrate_a0.py
|
||||
Deps: pyserial numpy pandas openpyxl matplotlib
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
import matplotlib
|
||||
matplotlib.use('TkAgg')
|
||||
from matplotlib.figure import Figure
|
||||
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
import pandas as pd
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────
|
||||
BAUD_RATE = 2_000_000
|
||||
MM_MIN = 0.0
|
||||
MM_MAX = 20.0
|
||||
BUCKET_STEP = 0.05
|
||||
BUCKET_WINDOW = 0.02
|
||||
MIN_SAMPLES = 50
|
||||
DATA_DIR = Path(__file__).parent / 'data'
|
||||
DRAW_INTERVAL_MS = 300 # GUI redraw period — worker is never throttled by this
|
||||
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
N_BUCKETS = int(round((MM_MAX - MM_MIN) / BUCKET_STEP)) + 1
|
||||
bucket_centers = np.round(MM_MIN + np.arange(N_BUCKETS) * BUCKET_STEP, 4)
|
||||
|
||||
# Bytes regex: avoids decode overhead on every line in the hot path
|
||||
LINE_RE = re.compile(rb'^\s*(-?\d+\.?\d*)\s*,\s*(\d+)\s*$')
|
||||
|
||||
|
||||
class CalibrationApp:
|
||||
|
||||
def __init__(self, root: tk.Tk):
|
||||
self.root = root
|
||||
self.root.title('A0 Sensor Calibration')
|
||||
self.root.minsize(900, 650)
|
||||
|
||||
# ── Shared bucket state (worker writes, GUI reads snapshots) ──
|
||||
self.counts = np.zeros(N_BUCKETS, dtype=np.int64)
|
||||
self.means = np.zeros(N_BUCKETS, dtype=np.float64)
|
||||
self.data_lock = threading.Lock()
|
||||
|
||||
# ── Display stats: written by worker, read by GUI ──
|
||||
self._stat_parsed = 0
|
||||
self._stat_bucketed = 0
|
||||
self._stat_malformed = 0
|
||||
self._stats_lock = threading.Lock()
|
||||
|
||||
# ── Thread / connection state ──
|
||||
self.ser = None
|
||||
self.worker_thread = None
|
||||
self.worker_stop = threading.Event()
|
||||
self.collecting = False
|
||||
|
||||
self._build_ui()
|
||||
self._refresh_ports()
|
||||
self._schedule_draw()
|
||||
|
||||
# ── UI construction ───────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
# Connection bar
|
||||
top = ttk.Frame(self.root, padding=(6, 6, 6, 2))
|
||||
top.pack(fill='x')
|
||||
|
||||
ttk.Label(top, text='Port:').pack(side='left')
|
||||
self.port_var = tk.StringVar()
|
||||
self.port_cb = ttk.Combobox(top, textvariable=self.port_var,
|
||||
width=30, state='readonly')
|
||||
self.port_cb.pack(side='left', padx=(4, 6))
|
||||
ttk.Button(top, text='↺', width=3,
|
||||
command=self._refresh_ports).pack(side='left', padx=2)
|
||||
self.conn_btn = ttk.Button(top, text='Connect',
|
||||
command=self._toggle_connect)
|
||||
self.conn_btn.pack(side='left', padx=2)
|
||||
self.conn_label = ttk.Label(top, text='● Disconnected',
|
||||
foreground='#888')
|
||||
self.conn_label.pack(side='left', padx=8)
|
||||
|
||||
# Control bar
|
||||
ctrl = ttk.Frame(self.root, padding=(6, 2, 6, 2))
|
||||
ctrl.pack(fill='x')
|
||||
|
||||
self.start_btn = ttk.Button(ctrl, text='Start Collection',
|
||||
command=self._start, state='disabled')
|
||||
self.start_btn.pack(side='left', padx=2)
|
||||
self.stop_btn = ttk.Button(ctrl, text='Stop Collection',
|
||||
command=self._stop, state='disabled')
|
||||
self.stop_btn.pack(side='left', padx=2)
|
||||
ttk.Button(ctrl, text='Export Excel',
|
||||
command=self._export).pack(side='left', padx=(14, 2))
|
||||
|
||||
ttk.Separator(ctrl, orient='vertical').pack(side='left',
|
||||
fill='y', padx=8)
|
||||
|
||||
self.progress_var = tk.IntVar(value=0)
|
||||
self.progress_bar = ttk.Progressbar(ctrl, variable=self.progress_var,
|
||||
maximum=N_BUCKETS, length=260)
|
||||
self.progress_bar.pack(side='left')
|
||||
self.progress_lbl = ttk.Label(ctrl, text=f' 0 / {N_BUCKETS} buckets')
|
||||
self.progress_lbl.pack(side='left')
|
||||
|
||||
# Stats bar
|
||||
sbar = ttk.Frame(self.root, padding=(6, 0, 6, 2))
|
||||
sbar.pack(fill='x')
|
||||
self.stats_lbl = ttk.Label(sbar,
|
||||
text='parsed=0 bucketed=0 malformed=0',
|
||||
foreground='#555')
|
||||
self.stats_lbl.pack(side='left')
|
||||
|
||||
# Matplotlib canvas
|
||||
self.fig = Figure(tight_layout=True)
|
||||
self.ax1 = self.fig.add_subplot(2, 1, 1)
|
||||
self.ax2 = self.fig.add_subplot(2, 1, 2, sharex=self.ax1)
|
||||
|
||||
self.ax1.set_ylabel('Avg A0 ADC')
|
||||
self.ax1.set_title('Calibration curve')
|
||||
self.ax1.grid(True, alpha=0.3)
|
||||
|
||||
self.ax2.set_xlabel('Position (mm)')
|
||||
self.ax2.set_ylabel('Sample count')
|
||||
self.ax2.set_title(
|
||||
'Bucket fill progress (green = done, orange = partial, red = empty)')
|
||||
self.ax2.axhline(MIN_SAMPLES, color='red', linestyle='--',
|
||||
linewidth=1, label=f'target = {MIN_SAMPLES}')
|
||||
self.ax2.legend(fontsize=8, loc='upper right')
|
||||
self.ax2.grid(True, alpha=0.3)
|
||||
|
||||
# Create empty line and bar artists to update later
|
||||
self.line, = self.ax1.plot([], [], 'b.-', markersize=3)
|
||||
self.bars = self.ax2.bar(bucket_centers, np.zeros(N_BUCKETS), width=BUCKET_STEP * 0.9)
|
||||
|
||||
self.canvas = FigureCanvasTkAgg(self.fig, master=self.root)
|
||||
self.canvas.get_tk_widget().pack(fill='both', expand=True,
|
||||
padx=6, pady=(2, 6))
|
||||
|
||||
# ── Port management ───────────────────────────────────────
|
||||
|
||||
def _refresh_ports(self):
|
||||
ports = list(serial.tools.list_ports.comports())
|
||||
self.port_cb['values'] = [p.device for p in ports]
|
||||
if not ports:
|
||||
return
|
||||
for p in ports:
|
||||
d = (p.description or '').lower()
|
||||
if ('usbmodem' in p.device or 'usbserial' in p.device.lower()
|
||||
or 'arduino' in d or 'wch' in d):
|
||||
self.port_var.set(p.device)
|
||||
return
|
||||
if not self.port_var.get():
|
||||
self.port_var.set(ports[0].device)
|
||||
|
||||
# ── Connection ────────────────────────────────────────────
|
||||
|
||||
def _toggle_connect(self):
|
||||
if self.ser and self.ser.is_open:
|
||||
self._disconnect()
|
||||
else:
|
||||
self._connect()
|
||||
|
||||
def _connect(self):
|
||||
port = self.port_var.get()
|
||||
if not port:
|
||||
messagebox.showerror('No port', 'Select a serial port first.')
|
||||
return
|
||||
try:
|
||||
# timeout=0.05: readline() returns within 50 ms when no data,
|
||||
# so the worker's stop-event check is always responsive.
|
||||
self.ser = serial.Serial(port, BAUD_RATE, timeout=0.05)
|
||||
except serial.SerialException as e:
|
||||
messagebox.showerror('Connect failed', str(e))
|
||||
return
|
||||
self.conn_btn.config(state='disabled')
|
||||
self.conn_label.config(text='⏳ Connecting…', foreground='orange')
|
||||
threading.Thread(target=self._handshake, daemon=True).start()
|
||||
|
||||
def _handshake(self):
|
||||
"""Runs in a daemon thread so the GUI stays responsive during waits."""
|
||||
try:
|
||||
time.sleep(3.0) # cover Arduino reset/boot
|
||||
self.ser.reset_input_buffer()
|
||||
self.ser.write(b'X') # stop stream if already running
|
||||
time.sleep(0.2)
|
||||
self.ser.reset_input_buffer()
|
||||
self.ser.write(b'S') # send wake byte
|
||||
|
||||
deadline = time.time() + 15.0
|
||||
while time.time() < deadline:
|
||||
raw = self.ser.readline()
|
||||
if raw.strip() == b'#READY':
|
||||
self.ser.readline() # discard potential partial first line
|
||||
self.root.after(0, self._on_connected)
|
||||
return
|
||||
self.root.after(0, lambda: self._on_connect_failed(
|
||||
'Did not receive #READY within 15 s.\n'
|
||||
'Check sketch upload and baud rate.'))
|
||||
except Exception as e:
|
||||
self.root.after(0, lambda err=str(e): self._on_connect_failed(err))
|
||||
|
||||
def _on_connected(self):
|
||||
self.conn_label.config(text='● Connected', foreground='green')
|
||||
self.conn_btn.config(text='Disconnect', state='normal')
|
||||
self.start_btn.config(state='normal')
|
||||
|
||||
def _on_connect_failed(self, msg):
|
||||
messagebox.showerror('Connection failed', msg)
|
||||
try:
|
||||
self.ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ser = None
|
||||
self.conn_label.config(text='● Disconnected', foreground='#888')
|
||||
self.conn_btn.config(text='Connect', state='normal')
|
||||
|
||||
def _disconnect(self):
|
||||
self._stop()
|
||||
try:
|
||||
if self.ser and self.ser.is_open:
|
||||
self.ser.write(b'X')
|
||||
self.ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ser = None
|
||||
self.conn_label.config(text='● Disconnected', foreground='#888')
|
||||
self.conn_btn.config(text='Connect', state='normal')
|
||||
self.start_btn.config(state='disabled')
|
||||
self.stop_btn.config(state='disabled')
|
||||
|
||||
# ── Collection control ────────────────────────────────────
|
||||
|
||||
def _start(self):
|
||||
if self.collecting:
|
||||
return
|
||||
with self.data_lock:
|
||||
self.counts[:] = 0
|
||||
self.means[:] = 0.0
|
||||
with self._stats_lock:
|
||||
self._stat_parsed = self._stat_bucketed = self._stat_malformed = 0
|
||||
|
||||
self.collecting = True
|
||||
self.worker_stop.clear()
|
||||
self.worker_thread = threading.Thread(
|
||||
target=self._worker, daemon=True, name='serial-worker')
|
||||
self.worker_thread.start()
|
||||
|
||||
self.start_btn.config(state='disabled')
|
||||
self.stop_btn.config(state='normal')
|
||||
|
||||
def _stop(self):
|
||||
if not self.collecting:
|
||||
return
|
||||
self.collecting = False
|
||||
self.worker_stop.set()
|
||||
if self.worker_thread:
|
||||
self.worker_thread.join(timeout=1.0)
|
||||
self.start_btn.config(
|
||||
state='normal' if (self.ser and self.ser.is_open) else 'disabled')
|
||||
self.stop_btn.config(state='disabled')
|
||||
|
||||
def _auto_complete(self):
|
||||
"""Called from worker via root.after — runs on the GUI thread."""
|
||||
self._stop()
|
||||
messagebox.showinfo(
|
||||
'Collection complete',
|
||||
f'All {N_BUCKETS} buckets reached ≥{MIN_SAMPLES} samples.\n'
|
||||
'Click Export Excel to save.')
|
||||
|
||||
# ── Serial worker ─────────────────────────────────────────
|
||||
#
|
||||
# Design priorities:
|
||||
# 1. Never miss a byte — reads everything in the OS buffer each wake
|
||||
# 2. Never wait on the GUI — lock hold-time is a single array update
|
||||
# 3. Auto-complete in O(1) — tracks filled count incrementally
|
||||
#
|
||||
def _worker(self):
|
||||
ser = self.ser
|
||||
stop = self.worker_stop
|
||||
counts = self.counts
|
||||
means = self.means
|
||||
lock = self.data_lock
|
||||
centers = bucket_centers
|
||||
|
||||
parsed = bucketed = malformed = filled_count = 0
|
||||
buf = bytearray()
|
||||
STAT_EVERY = 500 # flush display stats every N parsed lines
|
||||
|
||||
while not stop.is_set():
|
||||
waiting = ser.in_waiting
|
||||
if waiting:
|
||||
try:
|
||||
buf += ser.read(waiting) # single syscall for all bytes
|
||||
except serial.SerialException:
|
||||
break
|
||||
|
||||
# Process every complete line accumulated in the buffer
|
||||
while b'\n' in buf:
|
||||
line, buf = buf.split(b'\n', 1)
|
||||
line = line.strip()
|
||||
if not line or line[0:1] == b'#':
|
||||
continue
|
||||
|
||||
m = LINE_RE.match(line)
|
||||
if not m:
|
||||
malformed += 1
|
||||
continue
|
||||
|
||||
parsed += 1
|
||||
mm = float(m.group(1))
|
||||
adc = int(m.group(2))
|
||||
idx = int(round((mm - MM_MIN) / BUCKET_STEP))
|
||||
|
||||
if (0 <= idx < N_BUCKETS
|
||||
and abs(mm - centers[idx]) <= BUCKET_WINDOW + 1e-9):
|
||||
with lock:
|
||||
counts[idx] += 1
|
||||
means[idx] += (adc - means[idx]) / counts[idx]
|
||||
just_filled = (counts[idx] == MIN_SAMPLES)
|
||||
|
||||
if just_filled:
|
||||
filled_count += 1
|
||||
if filled_count == N_BUCKETS:
|
||||
# Flush final stats then signal the GUI
|
||||
with self._stats_lock:
|
||||
self._stat_parsed = parsed
|
||||
self._stat_bucketed = bucketed + 1
|
||||
self._stat_malformed = malformed
|
||||
self.root.after(0, self._auto_complete)
|
||||
return
|
||||
bucketed += 1
|
||||
|
||||
if parsed % STAT_EVERY == 0:
|
||||
with self._stats_lock:
|
||||
self._stat_parsed = parsed
|
||||
self._stat_bucketed = bucketed
|
||||
self._stat_malformed = malformed
|
||||
else:
|
||||
# Serial quiet — yield briefly so stop_event can be noticed
|
||||
time.sleep(0.0005)
|
||||
|
||||
# Final stats flush on manual stop
|
||||
with self._stats_lock:
|
||||
self._stat_parsed = parsed
|
||||
self._stat_bucketed = bucketed
|
||||
self._stat_malformed = malformed
|
||||
|
||||
# ── GUI draw (runs on main thread via root.after) ─────────
|
||||
|
||||
def _schedule_draw(self):
|
||||
self._draw()
|
||||
self.root.after(DRAW_INTERVAL_MS, self._schedule_draw)
|
||||
|
||||
def _draw(self):
|
||||
with self.data_lock:
|
||||
counts = self.counts.copy()
|
||||
means = self.means.copy()
|
||||
with self._stats_lock:
|
||||
parsed = self._stat_parsed
|
||||
bucketed = self._stat_bucketed
|
||||
malformed = self._stat_malformed
|
||||
|
||||
filled = int(np.sum(counts >= MIN_SAMPLES))
|
||||
self.progress_var.set(filled)
|
||||
self.progress_lbl.config(text=f' {filled} / {N_BUCKETS} buckets')
|
||||
self.stats_lbl.config(
|
||||
text=f'parsed={parsed} bucketed={bucketed} malformed={malformed}')
|
||||
|
||||
# --- NEW: Update existing artists instead of clearing axes ---
|
||||
has = counts > 0
|
||||
if has.any():
|
||||
self.line.set_data(bucket_centers[has], means[has])
|
||||
self.ax1.relim() # Recalculate limits
|
||||
self.ax1.autoscale_view() # Rescale axes
|
||||
|
||||
colors = [
|
||||
'#2ecc71' if c >= MIN_SAMPLES
|
||||
else '#f39c12' if c > 0
|
||||
else '#e74c3c'
|
||||
for c in counts
|
||||
]
|
||||
|
||||
# Update each bar's height and color
|
||||
for bar, count, color in zip(self.bars, counts, colors):
|
||||
bar.set_height(count)
|
||||
bar.set_facecolor(color)
|
||||
|
||||
self.ax2.relim()
|
||||
self.ax2.autoscale_view()
|
||||
|
||||
self.canvas.draw_idle()
|
||||
|
||||
# ── Export ────────────────────────────────────────────────
|
||||
|
||||
def _export(self):
|
||||
with self.data_lock:
|
||||
counts = self.counts.copy()
|
||||
means = self.means.copy()
|
||||
if not counts.any():
|
||||
messagebox.showwarning('No data', 'No data collected yet.')
|
||||
return
|
||||
|
||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
out_path = DATA_DIR / f'a0_calibration_{ts}.xlsx'
|
||||
has = counts > 0
|
||||
pd.DataFrame({
|
||||
'mm_val': bucket_centers[has],
|
||||
'avg_adc': np.round(means[has], 3),
|
||||
}).to_excel(out_path, index=False)
|
||||
|
||||
short = int(np.sum((counts > 0) & (counts < MIN_SAMPLES)))
|
||||
empty = int(np.sum(counts == 0))
|
||||
msg = f'Exported {int(has.sum())} rows to:\n{out_path}'
|
||||
if short:
|
||||
msg += f'\n\n{short} bucket(s) have data but < {MIN_SAMPLES} samples.'
|
||||
if empty:
|
||||
msg += f'\n{empty} empty bucket(s) omitted.'
|
||||
messagebox.showinfo('Exported', msg)
|
||||
|
||||
# ── Cleanup ───────────────────────────────────────────────
|
||||
|
||||
def on_close(self):
|
||||
self._stop()
|
||||
try:
|
||||
if self.ser and self.ser.is_open:
|
||||
self.ser.write(b'X')
|
||||
self.ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.root.destroy()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
root = tk.Tk()
|
||||
app = CalibrationApp(root)
|
||||
root.protocol('WM_DELETE_WINDOW', app.on_close)
|
||||
root.mainloop()
|
||||
BIN
A0Calibration/data/Sensor0.xlsx
Normal file
BIN
A0Calibration/data/Sensor0.xlsx
Normal file
Binary file not shown.
BIN
A0Calibration/data/Sensor1.xlsx
Normal file
BIN
A0Calibration/data/Sensor1.xlsx
Normal file
Binary file not shown.
BIN
A0Calibration/data/Sensor2.xlsx
Normal file
BIN
A0Calibration/data/Sensor2.xlsx
Normal file
Binary file not shown.
BIN
A0Calibration/data/Sensor5.xlsx
Normal file
BIN
A0Calibration/data/Sensor5.xlsx
Normal file
Binary file not shown.
@@ -1,111 +1,111 @@
|
||||
#include <Arduino.h>
|
||||
#include <util/atomic.h>
|
||||
|
||||
// ── ADC Interrupt-driven single-channel read (A0) ────────────
|
||||
volatile uint16_t adc_result = 0;
|
||||
volatile bool adc_ready = false;
|
||||
// ── ADC Interrupt-driven 3-channel read (A2, A3, A0) ─────────
|
||||
// Channel index: 0 → A2 (sensor 0), 1 → A3 (sensor 1), 2 → A0 (raw ref)
|
||||
static const uint8_t adc_mux[3] = {2, 3, 0};
|
||||
|
||||
volatile uint16_t adc_result[3] = {0, 0, 0};
|
||||
volatile bool adc_ready[3] = {false, false, false};
|
||||
volatile uint8_t adc_channel = 0;
|
||||
|
||||
void setupADC() {
|
||||
// Reference = AVCC (5 V)
|
||||
ADMUX = (1 << REFS0);
|
||||
|
||||
// Channel = A0 (MUX[3:0] = 0000)
|
||||
ADMUX &= 0xF0;
|
||||
|
||||
// Prescaler = 16 → 16 MHz / 16 = 1 MHz ADC clock
|
||||
// Each conversion ≈ 13 ADC clocks → ~76.9 kHz sample rate
|
||||
ADCSRA = (1 << ADEN) | (1 << ADIE)
|
||||
| (1 << ADPS2); // ADPS = 100 → /16
|
||||
|
||||
// Start first conversion
|
||||
ADMUX = (1 << REFS0) | adc_mux[0]; // AVCC ref, start on A2
|
||||
ADCSRA = (1 << ADEN) | (1 << ADIE) | (1 << ADPS2); // /16 prescaler
|
||||
ADCSRA |= (1 << ADSC);
|
||||
}
|
||||
|
||||
// ── OOR digital input ────────────────────────────────────────
|
||||
#define OOR_PIN 2 // digital pin 2 — HIGH = out of range
|
||||
// ── OOR digital inputs ───────────────────────────────────────
|
||||
#define OOR_PIN_0 12 // HIGH = out of range, sensor 0 (A2)
|
||||
#define OOR_PIN_1 13 // HIGH = out of range, sensor 1 (A3)
|
||||
|
||||
volatile bool OOR;
|
||||
volatile bool OOR[2];
|
||||
|
||||
ISR(ADC_vect) {
|
||||
uint16_t sample = ADC;
|
||||
// Discard if OOR pin (PD2) is HIGH
|
||||
OOR = (digitalRead(OOR_PIN));
|
||||
if (!OOR) {
|
||||
adc_result = sample;
|
||||
adc_ready = true;
|
||||
uint8_t ch = adc_channel;
|
||||
uint8_t next = (ch + 1) % 3;
|
||||
|
||||
if (ch < 2) {
|
||||
// Sensor channels: filter by OOR
|
||||
OOR[ch] = digitalRead(ch == 0 ? OOR_PIN_0 : OOR_PIN_1);
|
||||
if (!OOR[ch]) {
|
||||
adc_result[ch] = sample;
|
||||
adc_ready[ch] = true;
|
||||
}
|
||||
} else {
|
||||
// A0: no OOR, always store
|
||||
adc_result[2] = sample;
|
||||
adc_ready[2] = true;
|
||||
}
|
||||
ADCSRA |= (1 << ADSC); // kick off next conversion immediately
|
||||
|
||||
ADMUX = (ADMUX & 0xF0) | adc_mux[next];
|
||||
adc_channel = next;
|
||||
ADCSRA |= (1 << ADSC);
|
||||
}
|
||||
|
||||
// ── ADC → mm linear mapping ─────────────────────────────────
|
||||
// ADC 185 → 16 mm, ADC 900 → 26 mm
|
||||
// #define adcToMM(adc) (16.0f + (float)((adc) - 185) * (10.0f / 715.0f))
|
||||
#define adcToMM(adc) (0.0f + (float)((adc) - 0) * (10.0f / 1024.0f))
|
||||
// ── ADC → mm linear mappings (raw range: 16–26 mm) ──────────
|
||||
#define adcToMM0(adc) ((float)map(adc, 178, 895, 1600, 2600) / 100.0f)
|
||||
#define adcToMM1(adc) ((float)map(adc, 176, 885, 1600, 2600) / 100.0f)
|
||||
|
||||
// ── Boundary tracking (in-range only) ────────────────────────
|
||||
// Subtract mounting offsets so both sensors share the same position frame:
|
||||
// Sensor 0 raw 16–26 mm − 16 → 0–10 mm
|
||||
// Sensor 1 raw 16–26 mm − 6 → 10–20 mm
|
||||
#define OFFSET_MM0 15.6f
|
||||
#define OFFSET_MM1 6.2f
|
||||
|
||||
// ── Boundary tracking (in-range only, per sensor) ────────────
|
||||
#define TRACK_N 10
|
||||
uint16_t lowestVals[TRACK_N];
|
||||
uint16_t highestVals[TRACK_N];
|
||||
uint8_t lowestCount = 0;
|
||||
uint8_t highestCount = 0;
|
||||
uint16_t lowestVals[2][TRACK_N];
|
||||
uint16_t highestVals[2][TRACK_N];
|
||||
uint8_t lowestCount[2] = {0, 0};
|
||||
uint8_t highestCount[2] = {0, 0};
|
||||
|
||||
// Insert val into a sorted-ascending array of up to TRACK_N entries
|
||||
// keeping only the N smallest values seen so far.
|
||||
static void trackLowest(uint16_t val) {
|
||||
if (lowestCount < TRACK_N) {
|
||||
// Array not full — insert in sorted position
|
||||
uint8_t i = lowestCount;
|
||||
while (i > 0 && lowestVals[i - 1] > val) {
|
||||
lowestVals[i] = lowestVals[i - 1];
|
||||
i--;
|
||||
}
|
||||
lowestVals[i] = val;
|
||||
lowestCount++;
|
||||
} else if (val < lowestVals[TRACK_N - 1]) {
|
||||
// Replace the current largest of the "lowest" set
|
||||
static void trackLowest(uint8_t s, uint16_t val) {
|
||||
uint16_t *lv = lowestVals[s];
|
||||
uint8_t &lc = lowestCount[s];
|
||||
if (lc < TRACK_N) {
|
||||
uint8_t i = lc;
|
||||
while (i > 0 && lv[i - 1] > val) { lv[i] = lv[i - 1]; i--; }
|
||||
lv[i] = val;
|
||||
lc++;
|
||||
} else if (val < lv[TRACK_N - 1]) {
|
||||
uint8_t i = TRACK_N - 1;
|
||||
while (i > 0 && lowestVals[i - 1] > val) {
|
||||
lowestVals[i] = lowestVals[i - 1];
|
||||
i--;
|
||||
}
|
||||
lowestVals[i] = val;
|
||||
while (i > 0 && lv[i - 1] > val) { lv[i] = lv[i - 1]; i--; }
|
||||
lv[i] = val;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert val into a sorted-descending array of up to TRACK_N entries
|
||||
// keeping only the N largest values seen so far.
|
||||
static void trackHighest(uint16_t val) {
|
||||
if (highestCount < TRACK_N) {
|
||||
uint8_t i = highestCount;
|
||||
while (i > 0 && highestVals[i - 1] < val) {
|
||||
highestVals[i] = highestVals[i - 1];
|
||||
i--;
|
||||
}
|
||||
highestVals[i] = val;
|
||||
highestCount++;
|
||||
} else if (val > highestVals[TRACK_N - 1]) {
|
||||
static void trackHighest(uint8_t s, uint16_t val) {
|
||||
uint16_t *hv = highestVals[s];
|
||||
uint8_t &hc = highestCount[s];
|
||||
if (hc < TRACK_N) {
|
||||
uint8_t i = hc;
|
||||
while (i > 0 && hv[i - 1] < val) { hv[i] = hv[i - 1]; i--; }
|
||||
hv[i] = val;
|
||||
hc++;
|
||||
} else if (val > hv[TRACK_N - 1]) {
|
||||
uint8_t i = TRACK_N - 1;
|
||||
while (i > 0 && highestVals[i - 1] < val) {
|
||||
highestVals[i] = highestVals[i - 1];
|
||||
i--;
|
||||
}
|
||||
highestVals[i] = val;
|
||||
while (i > 0 && hv[i - 1] < val) { hv[i] = hv[i - 1]; i--; }
|
||||
hv[i] = val;
|
||||
}
|
||||
}
|
||||
|
||||
static void resetTracking() {
|
||||
lowestCount = 0;
|
||||
highestCount = 0;
|
||||
lowestCount[0] = highestCount[0] = 0;
|
||||
lowestCount[1] = highestCount[1] = 0;
|
||||
}
|
||||
|
||||
static void printBoundaries() {
|
||||
Serial.println(F("--- 10 Lowest In-Range ADC Values ---"));
|
||||
for (uint8_t i = 0; i < lowestCount; i++) {
|
||||
Serial.println(lowestVals[i]);
|
||||
}
|
||||
Serial.println(F("--- 10 Highest In-Range ADC Values ---"));
|
||||
for (uint8_t i = 0; i < highestCount; i++) {
|
||||
Serial.println(highestVals[i]);
|
||||
for (uint8_t s = 0; s < 2; s++) {
|
||||
Serial.print(F("--- Sensor "));
|
||||
Serial.print(s);
|
||||
Serial.println(F(": 10 Lowest In-Range ADC Values ---"));
|
||||
for (uint8_t i = 0; i < lowestCount[s]; i++) Serial.println(lowestVals[s][i]);
|
||||
Serial.print(F("--- Sensor "));
|
||||
Serial.print(s);
|
||||
Serial.println(F(": 10 Highest In-Range ADC Values ---"));
|
||||
for (uint8_t i = 0; i < highestCount[s]; i++) Serial.println(highestVals[s][i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,8 +115,9 @@ bool rawMode = false;
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
void setup() {
|
||||
Serial.begin(2000000);
|
||||
pinMode(OOR_PIN, INPUT);
|
||||
Serial.begin(115200);
|
||||
pinMode(OOR_PIN_0, INPUT);
|
||||
pinMode(OOR_PIN_1, INPUT);
|
||||
setupADC();
|
||||
Serial.println(F("Send '1' to start sampling, '0' to stop and print bounds, '2' for raw ADC output."));
|
||||
}
|
||||
@@ -147,37 +148,49 @@ void loop() {
|
||||
// ── Main sample path ────────────────────────────────────
|
||||
if (!sampling) return;
|
||||
|
||||
// Grab the latest ADC value atomically
|
||||
uint16_t val;
|
||||
bool ready;
|
||||
bool newOOR;
|
||||
uint16_t val[3];
|
||||
bool ready[3];
|
||||
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
|
||||
ready = adc_ready;
|
||||
val = adc_result;
|
||||
adc_ready = false;
|
||||
newOOR = OOR;
|
||||
for (uint8_t i = 0; i < 3; i++) {
|
||||
ready[i] = adc_ready[i];
|
||||
val[i] = adc_result[i];
|
||||
adc_ready[i] = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ready) return; // nothing new — come back next iteration
|
||||
if (!ready[0] && !ready[1]) return;
|
||||
|
||||
if (rawMode) {
|
||||
long mm_x100 = map(val, 178, 895, 1600, 2600);
|
||||
Serial.print(mm_x100 / 100);
|
||||
Serial.print('.');
|
||||
long frac = mm_x100 % 100;
|
||||
if (frac < 10) Serial.print('0');
|
||||
Serial.println(frac);
|
||||
if (ready[0]) {
|
||||
Serial.print(adcToMM0(val[0]) - OFFSET_MM0);
|
||||
Serial.print(F(", "));
|
||||
Serial.println(val[2]);
|
||||
}
|
||||
if (ready[1]) {
|
||||
Serial.print(adcToMM1(val[1]) - OFFSET_MM1);
|
||||
Serial.print(F(", "));
|
||||
Serial.println(val[2]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// All values here are in-range (OOR filtered in ISR)
|
||||
trackLowest(val);
|
||||
trackHighest(val);
|
||||
|
||||
float mm = adcToMM(val);
|
||||
Serial.print(val);
|
||||
Serial.print(", ");
|
||||
Serial.print(mm, 2);
|
||||
Serial.print(" mm, ");
|
||||
Serial.println(newOOR ? "out of range" : "in range");
|
||||
// Apply offset for whichever sensor(s) are in range
|
||||
if (ready[0]) {
|
||||
float mm = adcToMM0(val[0]) - OFFSET_MM0;
|
||||
trackLowest(0, val[0]);
|
||||
trackHighest(0, val[0]);
|
||||
Serial.print(val[0]);
|
||||
Serial.print(F(", "));
|
||||
Serial.print(mm, 2);
|
||||
Serial.println(F(" mm (s0)"));
|
||||
}
|
||||
if (ready[1]) {
|
||||
float mm = adcToMM1(val[1]) - OFFSET_MM1;
|
||||
trackLowest(1, val[1]);
|
||||
trackHighest(1, val[1]);
|
||||
Serial.print(val[1]);
|
||||
Serial.print(F(", "));
|
||||
Serial.print(mm, 2);
|
||||
Serial.println(F(" mm (s1)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
alembic==1.18.4
|
||||
appnope==0.1.4
|
||||
asttokens==3.0.1
|
||||
cloudpickle==3.1.2
|
||||
colorlog==6.10.1
|
||||
comm==0.2.3
|
||||
contourpy==1.3.3
|
||||
cycler==0.12.1
|
||||
debugpy==1.8.20
|
||||
decorator==5.2.1
|
||||
executing==2.2.1
|
||||
Farama-Notifications==0.0.4
|
||||
filelock==3.25.2
|
||||
fonttools==4.61.1
|
||||
fsspec==2026.2.0
|
||||
gymnasium==1.2.3
|
||||
ImageIO==2.37.2
|
||||
imageio-ffmpeg==0.6.0
|
||||
ipykernel==7.2.0
|
||||
ipython==9.10.0
|
||||
ipython_pygments_lexers==1.1.1
|
||||
jedi==0.19.2
|
||||
Jinja2==3.1.6
|
||||
joblib==1.5.3
|
||||
jupyter_client==8.8.0
|
||||
jupyter_core==5.9.1
|
||||
kiwisolver==1.4.9
|
||||
Mako==1.3.10
|
||||
MarkupSafe==3.0.3
|
||||
matplotlib==3.10.8
|
||||
matplotlib-inline==0.2.1
|
||||
mpmath==1.3.0
|
||||
nest-asyncio==1.6.0
|
||||
networkx==3.6.1
|
||||
numpy==2.4.2
|
||||
optuna==4.7.0
|
||||
packaging==26.0
|
||||
pandas==3.0.1
|
||||
parso==0.8.6
|
||||
pexpect==4.9.0
|
||||
pillow==12.1.1
|
||||
platformdirs==4.9.2
|
||||
prompt_toolkit==3.0.52
|
||||
psutil==7.2.2
|
||||
ptyprocess==0.7.0
|
||||
pure_eval==0.2.3
|
||||
pybullet==3.2.7
|
||||
Pygments==2.19.2
|
||||
pyparsing==3.3.2
|
||||
python-dateutil==2.9.0.post0
|
||||
PyYAML==6.0.3
|
||||
pyzmq==27.1.0
|
||||
scikit-learn==1.8.0
|
||||
scipy==1.17.0
|
||||
seaborn==0.13.2
|
||||
setuptools==82.0.1
|
||||
six==1.17.0
|
||||
SQLAlchemy==2.0.46
|
||||
stack-data==0.6.3
|
||||
sympy==1.14.0
|
||||
threadpoolctl==3.6.0
|
||||
torch==2.10.0
|
||||
tornado==6.5.4
|
||||
tqdm==4.67.3
|
||||
traitlets==5.14.3
|
||||
typing_extensions==4.15.0
|
||||
wcwidth==0.6.0
|
||||
Reference in New Issue
Block a user