Files
guadaloop_lev_control/A0Calibration/calibrate_a0.ipynb
2026-04-11 21:15:01 -05:00

484 lines
18 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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
}