{ "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 \", \" 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 }