Calibration supercharged

This commit is contained in:
2026-04-11 21:15:01 -05:00
parent 6b78fd278a
commit 7c54fe38e3
10 changed files with 1202 additions and 173 deletions

View 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 020 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 020 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
}