Compare commits
12 Commits
48961ef0b8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c54fe38e3 | |||
| 6b78fd278a | |||
| e8b4dca6db | |||
| a098ec5462 | |||
| a7ff7ae0ca | |||
| 2f8e38f339 | |||
| 6d36cb4bab | |||
| 010d997eef | |||
| 725f227943 | |||
| 799ee6c956 | |||
| e800041603 | |||
| 2d80505006 |
1
.gitignore
vendored
@@ -5,3 +5,4 @@ sim_results/
|
|||||||
sim_results_multi/
|
sim_results_multi/
|
||||||
tuningTrials/
|
tuningTrials/
|
||||||
# RL_Trials/
|
# RL_Trials/
|
||||||
|
venv/
|
||||||
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
@@ -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
@@ -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/Sensor1.xlsx
Normal file
BIN
A0Calibration/data/Sensor2.xlsx
Normal file
BIN
A0Calibration/data/Sensor5.xlsx
Normal file
@@ -1,176 +0,0 @@
|
|||||||
#include <Arduino.h>
|
|
||||||
#include "IndSensorMap.hpp"
|
|
||||||
#include "Controller.hpp"
|
|
||||||
#include "ADC.hpp"
|
|
||||||
#include "FastPWM.hpp"
|
|
||||||
|
|
||||||
// K, Ki, Kd Constants
|
|
||||||
Constants repelling = {250, 0, 1000};
|
|
||||||
Constants attracting = {250, 0, 1000};
|
|
||||||
|
|
||||||
Constants RollLeftUp = {0, 0, 100};
|
|
||||||
Constants RollLeftDown = {0, 0, 100};
|
|
||||||
|
|
||||||
Constants RollFrontUp = {0, 0, 500};
|
|
||||||
Constants RollFrontDown = {0, 0, 500};
|
|
||||||
|
|
||||||
// Reference values for average dist,
|
|
||||||
float avgRef = 11.0; // TBD: what is our equilibrium height with this testrig?
|
|
||||||
float LRDiffRef = -2.0; // TBD: what is our left-right balance equilibrium? Positive -> left is above right
|
|
||||||
float FBDiffRef = 0.0; // TBD: what is front-back balance equilibrium? Positive -> front above back.
|
|
||||||
|
|
||||||
// Might be useful for things like jitter or lag.
|
|
||||||
#define sampling_rate 1000 // Hz
|
|
||||||
|
|
||||||
// EMA filter alpha value (all sensors use same alpha)
|
|
||||||
#define alphaVal 0.3f
|
|
||||||
|
|
||||||
// ABOVE THIS LINE IS TUNING VALUES ONLY, BELOW IS ACTUAL CODE.
|
|
||||||
|
|
||||||
unsigned long tprior;
|
|
||||||
unsigned int tDiffMicros;
|
|
||||||
|
|
||||||
FullConsts fullConsts = {
|
|
||||||
{repelling, attracting},
|
|
||||||
{RollLeftDown, RollLeftUp},
|
|
||||||
{RollFrontDown, RollFrontUp}
|
|
||||||
};
|
|
||||||
|
|
||||||
FullController controller(indL, indR, indF, indB, fullConsts, avgRef, LRDiffRef, FBDiffRef);
|
|
||||||
|
|
||||||
const int dt_micros = 1e6/sampling_rate;
|
|
||||||
|
|
||||||
#define LEV_ON
|
|
||||||
|
|
||||||
int ON = 0;
|
|
||||||
|
|
||||||
void setup() {
|
|
||||||
Serial.begin(2000000);
|
|
||||||
setupADC();
|
|
||||||
setupFastPWM();
|
|
||||||
|
|
||||||
indL.alpha = alphaVal;
|
|
||||||
indR.alpha = alphaVal;
|
|
||||||
indF.alpha = alphaVal;
|
|
||||||
indB.alpha = alphaVal;
|
|
||||||
|
|
||||||
tprior = micros();
|
|
||||||
|
|
||||||
pinMode(dirFL, OUTPUT);
|
|
||||||
pinMode(pwmFL, OUTPUT);
|
|
||||||
pinMode(dirBL, OUTPUT);
|
|
||||||
pinMode(pwmBL, OUTPUT);
|
|
||||||
pinMode(dirFR, OUTPUT);
|
|
||||||
pinMode(pwmFR, OUTPUT);
|
|
||||||
pinMode(dirBR, OUTPUT);
|
|
||||||
pinMode(pwmBR, OUTPUT);
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
if (Serial.available() > 0) {
|
|
||||||
String cmd = Serial.readStringUntil('\n');
|
|
||||||
cmd.trim();
|
|
||||||
|
|
||||||
// Check if it's a reference update command (format: REF,avgRef,lrDiffRef,fbDiffRef)
|
|
||||||
if (cmd.startsWith("REF,")) {
|
|
||||||
int firstComma = cmd.indexOf(',');
|
|
||||||
int secondComma = cmd.indexOf(',', firstComma + 1);
|
|
||||||
int thirdComma = cmd.indexOf(',', secondComma + 1);
|
|
||||||
|
|
||||||
if (firstComma > 0 && secondComma > 0 && thirdComma > 0) {
|
|
||||||
float newAvgRef = cmd.substring(firstComma + 1, secondComma).toFloat();
|
|
||||||
float newLRDiffRef = cmd.substring(secondComma + 1, thirdComma).toFloat();
|
|
||||||
float newFBDiffRef = cmd.substring(thirdComma + 1).toFloat();
|
|
||||||
|
|
||||||
avgRef = newAvgRef;
|
|
||||||
LRDiffRef = newLRDiffRef;
|
|
||||||
FBDiffRef = newFBDiffRef;
|
|
||||||
|
|
||||||
controller.updateReferences(avgRef, LRDiffRef, FBDiffRef);
|
|
||||||
Serial.print("Updated References: Avg=");
|
|
||||||
Serial.print(avgRef);
|
|
||||||
Serial.print(", LR=");
|
|
||||||
Serial.print(LRDiffRef);
|
|
||||||
Serial.print(", FB=");
|
|
||||||
Serial.println(FBDiffRef);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check if it's a PID tuning command (format: PID,mode,kp,ki,kd)
|
|
||||||
else if (cmd.startsWith("PID,")) {
|
|
||||||
int firstComma = cmd.indexOf(',');
|
|
||||||
int secondComma = cmd.indexOf(',', firstComma + 1);
|
|
||||||
int thirdComma = cmd.indexOf(',', secondComma + 1);
|
|
||||||
int fourthComma = cmd.indexOf(',', thirdComma + 1);
|
|
||||||
|
|
||||||
if (firstComma > 0 && secondComma > 0 && thirdComma > 0 && fourthComma > 0) {
|
|
||||||
int mode = cmd.substring(firstComma + 1, secondComma).toInt();
|
|
||||||
float kp = cmd.substring(secondComma + 1, thirdComma).toFloat();
|
|
||||||
float ki = cmd.substring(thirdComma + 1, fourthComma).toFloat();
|
|
||||||
float kd = cmd.substring(fourthComma + 1).toFloat();
|
|
||||||
|
|
||||||
Constants newConst = {kp, ki, kd};
|
|
||||||
|
|
||||||
// Mode mapping:
|
|
||||||
// 0: Repelling
|
|
||||||
// 1: Attracting
|
|
||||||
// 2: RollLeftDown
|
|
||||||
// 3: RollLeftUp
|
|
||||||
// 4: RollFrontDown
|
|
||||||
// 5: RollFrontUp
|
|
||||||
|
|
||||||
switch(mode) {
|
|
||||||
case 0: // Repelling
|
|
||||||
repelling = newConst;
|
|
||||||
controller.updateAvgPID(repelling, attracting);
|
|
||||||
Serial.println("Updated Repelling PID");
|
|
||||||
break;
|
|
||||||
case 1: // Attracting
|
|
||||||
attracting = newConst;
|
|
||||||
controller.updateAvgPID(repelling, attracting);
|
|
||||||
Serial.println("Updated Attracting PID");
|
|
||||||
break;
|
|
||||||
case 2: // RollLeftDown
|
|
||||||
RollLeftDown = newConst;
|
|
||||||
controller.updateLRPID(RollLeftDown, RollLeftUp);
|
|
||||||
Serial.println("Updated RollLeftDown PID");
|
|
||||||
break;
|
|
||||||
case 3: // RollLeftUp
|
|
||||||
RollLeftUp = newConst;
|
|
||||||
controller.updateLRPID(RollLeftDown, RollLeftUp);
|
|
||||||
Serial.println("Updated RollLeftUp PID");
|
|
||||||
break;
|
|
||||||
case 4: // RollFrontDown
|
|
||||||
RollFrontDown = newConst;
|
|
||||||
controller.updateFBPID(RollFrontDown, RollFrontUp);
|
|
||||||
Serial.println("Updated RollFrontDown PID");
|
|
||||||
break;
|
|
||||||
case 5: // RollFrontUp
|
|
||||||
RollFrontUp = newConst;
|
|
||||||
controller.updateFBPID(RollFrontDown, RollFrontUp);
|
|
||||||
Serial.println("Updated RollFrontUp PID");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
Serial.println("Invalid mode");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Original control on/off command
|
|
||||||
controller.outputOn = (cmd.charAt(0) != '0');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tDiffMicros = micros() - tprior;
|
|
||||||
|
|
||||||
if (tDiffMicros >= dt_micros){
|
|
||||||
controller.update();
|
|
||||||
controller.report();
|
|
||||||
controller.sendOutputs();
|
|
||||||
// this and the previous line can be switched if you want the PWMs to display 0 when controller off.
|
|
||||||
|
|
||||||
tprior = micros(); // maybe we have to move this line to before the update commands?
|
|
||||||
// since the floating point arithmetic may take a while...
|
|
||||||
}
|
|
||||||
|
|
||||||
//Serial.println(telapsed);
|
|
||||||
}
|
|
||||||
196
AltSensorTesting/AltSensorTesting.ino
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
#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 (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() {
|
||||||
|
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) {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
ADMUX = (ADMUX & 0xF0) | adc_mux[next];
|
||||||
|
adc_channel = next;
|
||||||
|
ADCSRA |= (1 << ADSC);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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)
|
||||||
|
|
||||||
|
// 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[2][TRACK_N];
|
||||||
|
uint16_t highestVals[2][TRACK_N];
|
||||||
|
uint8_t lowestCount[2] = {0, 0};
|
||||||
|
uint8_t highestCount[2] = {0, 0};
|
||||||
|
|
||||||
|
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 && lv[i - 1] > val) { lv[i] = lv[i - 1]; i--; }
|
||||||
|
lv[i] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && hv[i - 1] < val) { hv[i] = hv[i - 1]; i--; }
|
||||||
|
hv[i] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void resetTracking() {
|
||||||
|
lowestCount[0] = highestCount[0] = 0;
|
||||||
|
lowestCount[1] = highestCount[1] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void printBoundaries() {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────────
|
||||||
|
bool sampling = false;
|
||||||
|
bool rawMode = false;
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════
|
||||||
|
void setup() {
|
||||||
|
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."));
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// ── Serial command handling ──────────────────────────────
|
||||||
|
if (Serial.available() > 0) {
|
||||||
|
String cmd = Serial.readStringUntil('\n');
|
||||||
|
cmd.trim();
|
||||||
|
|
||||||
|
if (cmd.charAt(0) == '1') {
|
||||||
|
sampling = true;
|
||||||
|
rawMode = false;
|
||||||
|
resetTracking();
|
||||||
|
Serial.println(F("Sampling started."));
|
||||||
|
} else if (cmd.charAt(0) == '2') {
|
||||||
|
sampling = true;
|
||||||
|
rawMode = true;
|
||||||
|
Serial.println(F("Raw ADC output started."));
|
||||||
|
} else if (cmd.charAt(0) == '0') {
|
||||||
|
sampling = false;
|
||||||
|
rawMode = false;
|
||||||
|
Serial.println(F("Sampling stopped."));
|
||||||
|
printBoundaries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main sample path ────────────────────────────────────
|
||||||
|
if (!sampling) 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;
|
||||||
|
|
||||||
|
if (rawMode) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,47 +0,0 @@
|
|||||||
import numpy as np
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
from scipy.signal import TransferFunction, lsim
|
|
||||||
|
|
||||||
# ---- Parameters (edit these) ----
|
|
||||||
R = 1.5 # ohms
|
|
||||||
L = 0.0025 # henries
|
|
||||||
V = 12.0 # volts
|
|
||||||
|
|
||||||
# Time base
|
|
||||||
t_end = 0.2
|
|
||||||
dt = 1e-4
|
|
||||||
t = np.arange(0, t_end, dt)
|
|
||||||
|
|
||||||
# ---- Define a duty command D(t) ----
|
|
||||||
# Example: start at 0, step to 0.2 at 20 ms, then to 0.6 at 80 ms, then to 1.0 at 140 ms (DC full on)
|
|
||||||
D = np.zeros_like(t)
|
|
||||||
D[t >= 0.020] = 0.2
|
|
||||||
D[t >= 0.080] = 0.6
|
|
||||||
D[t >= 0.140] = 1.0 # "straight DC" case (100% duty)
|
|
||||||
|
|
||||||
# Clamp just in case
|
|
||||||
D = np.clip(D, 0.0, 1.0)
|
|
||||||
|
|
||||||
# ---- Transfer function I(s)/D(s) = V / (L s + R) ----
|
|
||||||
# In scipy.signal.TransferFunction, numerator/denominator are polynomials in s
|
|
||||||
G = TransferFunction([V], [L, R])
|
|
||||||
|
|
||||||
# Simulate i(t) response to input D(t)
|
|
||||||
tout, i, _ = lsim(G, U=D, T=t)
|
|
||||||
|
|
||||||
# ---- Print steady-state expectations ----
|
|
||||||
# For constant duty D0, steady-state i_ss = (V/R)*D0
|
|
||||||
print("Expected steady-state currents (V/R * D):")
|
|
||||||
for D0 in [0.0, 0.2, 0.6, 1.0]:
|
|
||||||
print(f" D={D0:.1f} -> i_ss ~ {(V/R)*D0:.3f} A")
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Plot ----
|
|
||||||
plt.figure()
|
|
||||||
plt.plot(tout, D, label="Duty D(t)")
|
|
||||||
plt.plot(tout, i, label="Current i(t) [A]")
|
|
||||||
plt.xlabel("Time [s]")
|
|
||||||
plt.grid(True)
|
|
||||||
plt.legend()
|
|
||||||
plt.show()
|
|
||||||
|
|
||||||
@@ -1,787 +0,0 @@
|
|||||||
{
|
|
||||||
"cells": [
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"# PID Control Testing for Maglev Pod\n",
|
|
||||||
"\n",
|
|
||||||
"This notebook provides a PID control interface for the `LevPodEnv` simulation environment.\n",
|
|
||||||
"\n",
|
|
||||||
"## Control Architecture\n",
|
|
||||||
"- **Height PID**: Controls average gap height → base PWM for all coils\n",
|
|
||||||
"- **Roll PID**: Corrects roll angle → differential left/right adjustment\n",
|
|
||||||
"- **Pitch PID**: Corrects pitch angle → differential front/back adjustment\n",
|
|
||||||
"\n",
|
|
||||||
"The outputs combine: `coil_pwm = height_output ± roll_adjustment ± pitch_adjustment`"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"import numpy as np\n",
|
|
||||||
"import matplotlib.pyplot as plt\n",
|
|
||||||
"from lev_pod_env import LevPodEnv, TARGET_GAP\n",
|
|
||||||
"\n",
|
|
||||||
"# Set plot style for better aesthetics\n",
|
|
||||||
"plt.style.use('seaborn-v0_8-whitegrid')\n",
|
|
||||||
"plt.rcParams['figure.facecolor'] = 'white'\n",
|
|
||||||
"plt.rcParams['axes.facecolor'] = 'white'\n",
|
|
||||||
"plt.rcParams['font.size'] = 11\n",
|
|
||||||
"plt.rcParams['axes.titlesize'] = 14\n",
|
|
||||||
"plt.rcParams['axes.labelsize'] = 12\n",
|
|
||||||
"\n",
|
|
||||||
"print(f\"Target Gap Height: {TARGET_GAP * 1000:.2f} mm\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"---\n",
|
|
||||||
"## PID Controller Class"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"class PIDController:\n",
|
|
||||||
" \"\"\"Simple PID controller with anti-windup.\"\"\"\n",
|
|
||||||
" \n",
|
|
||||||
" def __init__(self, kp: float, ki: float, kd: float, \n",
|
|
||||||
" output_min: float = -1.0, output_max: float = 1.0,\n",
|
|
||||||
" integral_limit: float = np.inf):\n",
|
|
||||||
" self.kp = kp\n",
|
|
||||||
" self.ki = ki\n",
|
|
||||||
" self.kd = kd\n",
|
|
||||||
" self.output_min = output_min\n",
|
|
||||||
" self.output_max = output_max\n",
|
|
||||||
" self.integral_limit = integral_limit if integral_limit else abs(output_max) * 2\n",
|
|
||||||
" \n",
|
|
||||||
" self.integral = 0.0\n",
|
|
||||||
" self.prev_error = 0.0\n",
|
|
||||||
" self.first_update = True\n",
|
|
||||||
" \n",
|
|
||||||
" def reset(self):\n",
|
|
||||||
" \"\"\"Reset controller state.\"\"\"\n",
|
|
||||||
" self.integral = 0.0\n",
|
|
||||||
" self.prev_error = 0.0\n",
|
|
||||||
" self.first_update = True\n",
|
|
||||||
" \n",
|
|
||||||
" def update(self, error: float, dt: float) -> float:\n",
|
|
||||||
" \"\"\"Compute PID output.\n",
|
|
||||||
" \n",
|
|
||||||
" Args:\n",
|
|
||||||
" error: Current error (setpoint - measurement)\n",
|
|
||||||
" dt: Time step in seconds\n",
|
|
||||||
" \n",
|
|
||||||
" Returns:\n",
|
|
||||||
" Control output (clamped to output limits)\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" # Proportional term\n",
|
|
||||||
" p_term = self.kp * error\n",
|
|
||||||
" \n",
|
|
||||||
" # Integral term with anti-windup\n",
|
|
||||||
" self.integral += error * dt\n",
|
|
||||||
" self.integral = np.clip(self.integral, -self.integral_limit, self.integral_limit)\n",
|
|
||||||
" i_term = self.ki * self.integral\n",
|
|
||||||
" \n",
|
|
||||||
" # Derivative term (skip on first update to avoid spike)\n",
|
|
||||||
" if self.first_update:\n",
|
|
||||||
" d_term = 0.0\n",
|
|
||||||
" self.first_update = False\n",
|
|
||||||
" else:\n",
|
|
||||||
" d_term = self.kd * (error - self.prev_error) / dt\n",
|
|
||||||
" \n",
|
|
||||||
" self.prev_error = error\n",
|
|
||||||
" \n",
|
|
||||||
" # Compute and clamp output\n",
|
|
||||||
" output = p_term + i_term + d_term\n",
|
|
||||||
" return np.clip(output, self.output_min, self.output_max)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"---\n",
|
|
||||||
"## PID Gains Configuration\n",
|
|
||||||
"\n",
|
|
||||||
"**Modify these values to tune the controller.**\n",
|
|
||||||
"\n",
|
|
||||||
"- **Height PID**: Controls vertical position (gap height)\n",
|
|
||||||
"- **Roll PID**: Corrects side-to-side tilt\n",
|
|
||||||
"- **Pitch PID**: Corrects front-to-back tilt"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# ============================================================\n",
|
|
||||||
"# HEIGHT PID GAINS\n",
|
|
||||||
"# ============================================================\n",
|
|
||||||
"# Controls average gap height. Positive error = pod too low.\n",
|
|
||||||
"# Output: base PWM applied to all coils (positive = more lift)\n",
|
|
||||||
"\n",
|
|
||||||
"HEIGHT_KP = 50.0 # Proportional gain\n",
|
|
||||||
"HEIGHT_KI = 5.0 # Integral gain \n",
|
|
||||||
"HEIGHT_KD = 10.0 # Derivative gain\n",
|
|
||||||
"\n",
|
|
||||||
"# ============================================================\n",
|
|
||||||
"# ROLL PID GAINS\n",
|
|
||||||
"# ============================================================\n",
|
|
||||||
"# Corrects roll angle (rotation about X-axis).\n",
|
|
||||||
"# Positive roll = right side lower → increase right coils\n",
|
|
||||||
"# Output: differential adjustment to left/right coils\n",
|
|
||||||
"\n",
|
|
||||||
"ROLL_KP = 2.0 # Proportional gain\n",
|
|
||||||
"ROLL_KI = 0.5 # Integral gain\n",
|
|
||||||
"ROLL_KD = 0.5 # Derivative gain\n",
|
|
||||||
"\n",
|
|
||||||
"# ============================================================\n",
|
|
||||||
"# PITCH PID GAINS \n",
|
|
||||||
"# ============================================================\n",
|
|
||||||
"# Corrects pitch angle (rotation about Y-axis).\n",
|
|
||||||
"# Positive pitch = back lower → increase back coils\n",
|
|
||||||
"# Output: differential adjustment to front/back coils\n",
|
|
||||||
"\n",
|
|
||||||
"PITCH_KP = 2.0 # Proportional gain\n",
|
|
||||||
"PITCH_KI = 0.5 # Integral gain\n",
|
|
||||||
"PITCH_KD = 0.5 # Derivative gain\n",
|
|
||||||
"\n",
|
|
||||||
"print(\"PID Gains Configured:\")\n",
|
|
||||||
"print(f\" Height: Kp={HEIGHT_KP}, Ki={HEIGHT_KI}, Kd={HEIGHT_KD}\")\n",
|
|
||||||
"print(f\" Roll: Kp={ROLL_KP}, Ki={ROLL_KI}, Kd={ROLL_KD}\")\n",
|
|
||||||
"print(f\" Pitch: Kp={PITCH_KP}, Ki={PITCH_KI}, Kd={PITCH_KD}\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"---\n",
|
|
||||||
"## Simulation Runner"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"def run_pid_simulation(\n",
|
|
||||||
" initial_gap_mm: float = 14.0,\n",
|
|
||||||
" max_steps: int = 2000,\n",
|
|
||||||
" use_gui: bool = False,\n",
|
|
||||||
" disturbance_step: int = None,\n",
|
|
||||||
" disturbance_force: float = 0.0,\n",
|
|
||||||
" disturbance_force_std: float = 0.0,\n",
|
|
||||||
" verbose: bool = True\n",
|
|
||||||
") -> dict:\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" Run a PID-controlled simulation of the maglev pod.\n",
|
|
||||||
"\n",
|
|
||||||
" Args:\n",
|
|
||||||
" initial_gap_mm: Starting gap height in millimeters\n",
|
|
||||||
" max_steps: Maximum simulation steps (each step is 1/240 second)\n",
|
|
||||||
" use_gui: Whether to show PyBullet GUI visualization\n",
|
|
||||||
" disturbance_step: Step at which to apply impulse disturbance (None = no impulse)\n",
|
|
||||||
" disturbance_force: Impulse force in Newtons at disturbance_step (positive = upward)\n",
|
|
||||||
" disturbance_force_std: Standard deviation of continuous stochastic noise (Newtons)\n",
|
|
||||||
" verbose: Print progress updates\n",
|
|
||||||
"\n",
|
|
||||||
" Returns:\n",
|
|
||||||
" Dictionary containing time series data for plotting\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" # Create environment with stochastic disturbance if specified\n",
|
|
||||||
" env = LevPodEnv(\n",
|
|
||||||
" use_gui=use_gui,\n",
|
|
||||||
" initial_gap_mm=initial_gap_mm,\n",
|
|
||||||
" max_steps=max_steps,\n",
|
|
||||||
" disturbance_force_std=disturbance_force_std\n",
|
|
||||||
" )\n",
|
|
||||||
" dt = env.dt # 1/240 seconds\n",
|
|
||||||
"\n",
|
|
||||||
" # Initialize PID controllers\n",
|
|
||||||
" height_pid = PIDController(HEIGHT_KP, HEIGHT_KI, HEIGHT_KD, output_min=-1.0, output_max=1.0)\n",
|
|
||||||
" roll_pid = PIDController(ROLL_KP, ROLL_KI, ROLL_KD, output_min=-0.5, output_max=0.5)\n",
|
|
||||||
" pitch_pid = PIDController(PITCH_KP, PITCH_KI, PITCH_KD, output_min=-0.5, output_max=0.5)\n",
|
|
||||||
"\n",
|
|
||||||
" # Data storage\n",
|
|
||||||
" data = {\n",
|
|
||||||
" 'time': [],\n",
|
|
||||||
" 'gap_front': [],\n",
|
|
||||||
" 'gap_back': [],\n",
|
|
||||||
" 'gap_avg': [],\n",
|
|
||||||
" 'roll_deg': [],\n",
|
|
||||||
" 'pitch_deg': [],\n",
|
|
||||||
" 'current_FL': [],\n",
|
|
||||||
" 'current_FR': [],\n",
|
|
||||||
" 'current_BL': [],\n",
|
|
||||||
" 'current_BR': [],\n",
|
|
||||||
" 'current_total': [],\n",
|
|
||||||
" 'pwm_FL': [],\n",
|
|
||||||
" 'pwm_FR': [],\n",
|
|
||||||
" 'pwm_BL': [],\n",
|
|
||||||
" 'pwm_BR': [],\n",
|
|
||||||
" }\n",
|
|
||||||
"\n",
|
|
||||||
" # Reset environment\n",
|
|
||||||
" obs, _ = env.reset()\n",
|
|
||||||
"\n",
|
|
||||||
" target_gap = TARGET_GAP # meters\n",
|
|
||||||
"\n",
|
|
||||||
" if verbose:\n",
|
|
||||||
" print(f\"Starting simulation: initial_gap={initial_gap_mm}mm, target={target_gap*1000:.2f}mm\")\n",
|
|
||||||
" if disturbance_step:\n",
|
|
||||||
" print(f\" Impulse disturbance: {disturbance_force}N at step {disturbance_step}\")\n",
|
|
||||||
" if disturbance_force_std > 0:\n",
|
|
||||||
" print(f\" Stochastic noise: std={disturbance_force_std}N\")\n",
|
|
||||||
"\n",
|
|
||||||
" for step in range(max_steps):\n",
|
|
||||||
" t = step * dt\n",
|
|
||||||
"\n",
|
|
||||||
" # Extract gap heights from observation (first 4 values are normalized gaps)\n",
|
|
||||||
" # These are sensor readings (may include noise in future)\n",
|
|
||||||
" gaps_normalized = obs[:4] # [center_right, center_left, front, back]\n",
|
|
||||||
" gaps = gaps_normalized * env.gap_scale + TARGET_GAP\n",
|
|
||||||
"\n",
|
|
||||||
" gap_front = gaps[2] # Front sensor\n",
|
|
||||||
" gap_back = gaps[3] # Back sensor\n",
|
|
||||||
" gap_left = gaps[1] # Center_Left sensor\n",
|
|
||||||
" gap_right = gaps[0] # Center_Right sensor\n",
|
|
||||||
"\n",
|
|
||||||
" gap_avg = (gap_front + gap_back + gap_left + gap_right) / 4\n",
|
|
||||||
"\n",
|
|
||||||
" # Calculate roll and pitch from sensor gaps (for PID control)\n",
|
|
||||||
" y_distance = 0.1016 # Left to right distance (m)\n",
|
|
||||||
" x_distance = 0.2518 # Front to back distance (m)\n",
|
|
||||||
"\n",
|
|
||||||
" roll_angle = np.arcsin(np.clip((gap_left - gap_right) / y_distance, -1, 1))\n",
|
|
||||||
" pitch_angle = np.arcsin(np.clip((gap_back - gap_front) / x_distance, -1, 1))\n",
|
|
||||||
"\n",
|
|
||||||
" # Compute PID outputs\n",
|
|
||||||
" height_error = target_gap - gap_avg # Positive = too low, need more lift\n",
|
|
||||||
" roll_error = -roll_angle # Target is 0, negative feedback\n",
|
|
||||||
" pitch_error = -pitch_angle # Target is 0, negative feedback\n",
|
|
||||||
"\n",
|
|
||||||
" height_output = height_pid.update(height_error, dt)\n",
|
|
||||||
" roll_output = roll_pid.update(roll_error, dt)\n",
|
|
||||||
" pitch_output = pitch_pid.update(pitch_error, dt)\n",
|
|
||||||
"\n",
|
|
||||||
" # Combine outputs into 4 coil PWM commands\n",
|
|
||||||
" # Coil layout: front_left (+x,+y), front_right (+x,-y), back_left (-x,+y), back_right (-x,-y)\n",
|
|
||||||
" # Roll correction: positive roll_output → increase right side (FR, BR)\n",
|
|
||||||
" # Pitch correction: positive pitch_output → increase back side (BL, BR)\n",
|
|
||||||
"\n",
|
|
||||||
" pwm_FL = np.clip(height_output - roll_output - pitch_output, -1, 1)\n",
|
|
||||||
" pwm_FR = np.clip(height_output + roll_output - pitch_output, -1, 1)\n",
|
|
||||||
" pwm_BL = np.clip(height_output - roll_output + pitch_output, -1, 1)\n",
|
|
||||||
" pwm_BR = np.clip(height_output + roll_output + pitch_output, -1, 1)\n",
|
|
||||||
"\n",
|
|
||||||
" action = np.array([pwm_FL, pwm_FR, pwm_BL, pwm_BR], dtype=np.float32)\n",
|
|
||||||
"\n",
|
|
||||||
" # Apply impulse disturbance if specified (uses environment method)\n",
|
|
||||||
" if disturbance_step and step == disturbance_step:\n",
|
|
||||||
" env.apply_impulse(disturbance_force)\n",
|
|
||||||
" if verbose:\n",
|
|
||||||
" print(f\" Applied {disturbance_force}N impulse at step {step}\")\n",
|
|
||||||
"\n",
|
|
||||||
" # Step environment (stochastic disturbance applied internally if enabled)\n",
|
|
||||||
" obs, _, terminated, truncated, info = env.step(action)\n",
|
|
||||||
"\n",
|
|
||||||
" # Store ground truth data from environment info (not sensor observations)\n",
|
|
||||||
" data['time'].append(t)\n",
|
|
||||||
" data['gap_front'].append(info['gap_front_yoke'] * 1000) # Convert to mm\n",
|
|
||||||
" data['gap_back'].append(info['gap_back_yoke'] * 1000)\n",
|
|
||||||
" data['gap_avg'].append(info['gap_avg'] * 1000) # Use ground truth from info\n",
|
|
||||||
" data['roll_deg'].append(np.degrees(info['roll']))\n",
|
|
||||||
" data['pitch_deg'].append(np.degrees(info['pitch'])) # Use ground truth from info\n",
|
|
||||||
" data['current_FL'].append(info['curr_front_L'])\n",
|
|
||||||
" data['current_FR'].append(info['curr_front_R'])\n",
|
|
||||||
" data['current_BL'].append(info['curr_back_L'])\n",
|
|
||||||
" data['current_BR'].append(info['curr_back_R'])\n",
|
|
||||||
" data['current_total'].append(\n",
|
|
||||||
" abs(info['curr_front_L']) + abs(info['curr_front_R']) +\n",
|
|
||||||
" abs(info['curr_back_L']) + abs(info['curr_back_R'])\n",
|
|
||||||
" )\n",
|
|
||||||
" data['pwm_FL'].append(pwm_FL)\n",
|
|
||||||
" data['pwm_FR'].append(pwm_FR)\n",
|
|
||||||
" data['pwm_BL'].append(pwm_BL)\n",
|
|
||||||
" data['pwm_BR'].append(pwm_BR)\n",
|
|
||||||
"\n",
|
|
||||||
" if terminated or truncated:\n",
|
|
||||||
" if verbose:\n",
|
|
||||||
" print(f\" Simulation ended at step {step} (terminated={terminated})\")\n",
|
|
||||||
" break\n",
|
|
||||||
"\n",
|
|
||||||
" env.close()\n",
|
|
||||||
"\n",
|
|
||||||
" # Convert to numpy arrays\n",
|
|
||||||
" for key in data:\n",
|
|
||||||
" data[key] = np.array(data[key])\n",
|
|
||||||
"\n",
|
|
||||||
" if verbose:\n",
|
|
||||||
" print(f\"Simulation complete: {len(data['time'])} steps, {data['time'][-1]:.2f}s\")\n",
|
|
||||||
" print(f\" Final gap: {data['gap_avg'][-1]:.2f}mm (target: {target_gap*1000:.2f}mm)\")\n",
|
|
||||||
" print(f\" Final roll: {data['roll_deg'][-1]:.3f}°, pitch: {data['pitch_deg'][-1]:.3f}°\")\n",
|
|
||||||
"\n",
|
|
||||||
" return data"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"---\n",
|
|
||||||
"## Plotting Functions"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"def plot_results(data: dict, title_suffix: str = \"\"):\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" Create aesthetic plots of simulation results.\n",
|
|
||||||
" \n",
|
|
||||||
" Args:\n",
|
|
||||||
" data: Dictionary from run_pid_simulation()\n",
|
|
||||||
" title_suffix: Optional suffix for plot titles\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" fig, axes = plt.subplots(2, 2, figsize=(14, 10))\n",
|
|
||||||
" fig.suptitle(f'PID Control Performance{title_suffix}', fontsize=16, fontweight='bold', y=1.02)\n",
|
|
||||||
" \n",
|
|
||||||
" target_gap_mm = TARGET_GAP * 1000\n",
|
|
||||||
" time = data['time']\n",
|
|
||||||
" \n",
|
|
||||||
" # Color palette\n",
|
|
||||||
" colors = {\n",
|
|
||||||
" 'primary': '#2563eb', # Blue\n",
|
|
||||||
" 'secondary': '#7c3aed', # Purple\n",
|
|
||||||
" 'accent1': '#059669', # Green\n",
|
|
||||||
" 'accent2': '#dc2626', # Red\n",
|
|
||||||
" 'target': '#f59e0b', # Orange\n",
|
|
||||||
" 'grid': '#e5e7eb'\n",
|
|
||||||
" }\n",
|
|
||||||
" \n",
|
|
||||||
" # ========== Gap Height Plot ==========\n",
|
|
||||||
" ax1 = axes[0, 0]\n",
|
|
||||||
" ax1.plot(time, data['gap_avg'], color=colors['primary'], linewidth=2, label='Average Gap')\n",
|
|
||||||
" ax1.plot(time, data['gap_front'], color=colors['secondary'], linewidth=1, alpha=0.6, label='Front Yoke')\n",
|
|
||||||
" ax1.plot(time, data['gap_back'], color=colors['accent1'], linewidth=1, alpha=0.6, label='Back Yoke')\n",
|
|
||||||
" ax1.axhline(y=target_gap_mm, color=colors['target'], linestyle='--', linewidth=2, label=f'Target ({target_gap_mm:.1f}mm)')\n",
|
|
||||||
" \n",
|
|
||||||
" ax1.set_xlabel('Time (s)')\n",
|
|
||||||
" ax1.set_ylabel('Gap Height (mm)')\n",
|
|
||||||
" ax1.set_title('Gap Height Over Time', fontweight='bold')\n",
|
|
||||||
" ax1.legend(loc='best', framealpha=0.9)\n",
|
|
||||||
" ax1.set_ylim([0, max(35, max(data['gap_avg']) * 1.1)])\n",
|
|
||||||
" ax1.grid(True, alpha=0.3)\n",
|
|
||||||
" \n",
|
|
||||||
" # Add settling info\n",
|
|
||||||
" final_error = abs(data['gap_avg'][-1] - target_gap_mm)\n",
|
|
||||||
" ax1.text(0.98, 0.02, f'Final error: {final_error:.2f}mm', \n",
|
|
||||||
" transform=ax1.transAxes, ha='right', va='bottom',\n",
|
|
||||||
" fontsize=10, bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))\n",
|
|
||||||
" \n",
|
|
||||||
" # ========== Roll Angle Plot ==========\n",
|
|
||||||
" ax2 = axes[0, 1]\n",
|
|
||||||
" ax2.plot(time, data['roll_deg'], color=colors['primary'], linewidth=2)\n",
|
|
||||||
" ax2.axhline(y=0, color=colors['target'], linestyle='--', linewidth=1.5, alpha=0.7)\n",
|
|
||||||
" ax2.fill_between(time, data['roll_deg'], 0, alpha=0.2, color=colors['primary'])\n",
|
|
||||||
" \n",
|
|
||||||
" ax2.set_xlabel('Time (s)')\n",
|
|
||||||
" ax2.set_ylabel('Roll Angle (degrees)')\n",
|
|
||||||
" ax2.set_title('Roll Angle Over Time', fontweight='bold')\n",
|
|
||||||
" ax2.grid(True, alpha=0.3)\n",
|
|
||||||
" \n",
|
|
||||||
" max_roll = max(abs(data['roll_deg'].min()), abs(data['roll_deg'].max()))\n",
|
|
||||||
" ax2.set_ylim([-max(1, max_roll * 1.2), max(1, max_roll * 1.2)])\n",
|
|
||||||
" \n",
|
|
||||||
" # ========== Pitch Angle Plot ==========\n",
|
|
||||||
" ax3 = axes[1, 0]\n",
|
|
||||||
" ax3.plot(time, data['pitch_deg'], color=colors['secondary'], linewidth=2)\n",
|
|
||||||
" ax3.axhline(y=0, color=colors['target'], linestyle='--', linewidth=1.5, alpha=0.7)\n",
|
|
||||||
" ax3.fill_between(time, data['pitch_deg'], 0, alpha=0.2, color=colors['secondary'])\n",
|
|
||||||
" \n",
|
|
||||||
" ax3.set_xlabel('Time (s)')\n",
|
|
||||||
" ax3.set_ylabel('Pitch Angle (degrees)')\n",
|
|
||||||
" ax3.set_title('Pitch Angle Over Time', fontweight='bold')\n",
|
|
||||||
" ax3.grid(True, alpha=0.3)\n",
|
|
||||||
" \n",
|
|
||||||
" max_pitch = max(abs(data['pitch_deg'].min()), abs(data['pitch_deg'].max()))\n",
|
|
||||||
" ax3.set_ylim([-max(1, max_pitch * 1.2), max(1, max_pitch * 1.2)])\n",
|
|
||||||
" \n",
|
|
||||||
" # ========== Current Draw Plot ==========\n",
|
|
||||||
" ax4 = axes[1, 1]\n",
|
|
||||||
" ax4.plot(time, data['current_FL'], linewidth=1.5, label='Front Left', alpha=0.8)\n",
|
|
||||||
" ax4.plot(time, data['current_FR'], linewidth=1.5, label='Front Right', alpha=0.8)\n",
|
|
||||||
" ax4.plot(time, data['current_BL'], linewidth=1.5, label='Back Left', alpha=0.8)\n",
|
|
||||||
" ax4.plot(time, data['current_BR'], linewidth=1.5, label='Back Right', alpha=0.8)\n",
|
|
||||||
" ax4.plot(time, data['current_total'], color='black', linewidth=2, label='Total', linestyle='--')\n",
|
|
||||||
" \n",
|
|
||||||
" ax4.set_xlabel('Time (s)')\n",
|
|
||||||
" ax4.set_ylabel('Current (A)')\n",
|
|
||||||
" ax4.set_title('Coil Current Draw Over Time', fontweight='bold')\n",
|
|
||||||
" ax4.legend(loc='best', framealpha=0.9, ncol=2)\n",
|
|
||||||
" ax4.grid(True, alpha=0.3)\n",
|
|
||||||
" \n",
|
|
||||||
" # Add average power info\n",
|
|
||||||
" avg_total_current = np.mean(data['current_total'])\n",
|
|
||||||
" ax4.text(0.98, 0.98, f'Avg total: {avg_total_current:.2f}A', \n",
|
|
||||||
" transform=ax4.transAxes, ha='right', va='top',\n",
|
|
||||||
" fontsize=10, bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))\n",
|
|
||||||
" \n",
|
|
||||||
" plt.tight_layout()\n",
|
|
||||||
" plt.show()\n",
|
|
||||||
" \n",
|
|
||||||
" return fig"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"def plot_control_signals(data: dict):\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" Plot the PWM control signals sent to each coil.\n",
|
|
||||||
" \n",
|
|
||||||
" Args:\n",
|
|
||||||
" data: Dictionary from run_pid_simulation()\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" fig, ax = plt.subplots(figsize=(12, 5))\n",
|
|
||||||
" \n",
|
|
||||||
" time = data['time']\n",
|
|
||||||
" \n",
|
|
||||||
" ax.plot(time, data['pwm_FL'], label='Front Left', linewidth=1.5, alpha=0.8)\n",
|
|
||||||
" ax.plot(time, data['pwm_FR'], label='Front Right', linewidth=1.5, alpha=0.8)\n",
|
|
||||||
" ax.plot(time, data['pwm_BL'], label='Back Left', linewidth=1.5, alpha=0.8)\n",
|
|
||||||
" ax.plot(time, data['pwm_BR'], label='Back Right', linewidth=1.5, alpha=0.8)\n",
|
|
||||||
" \n",
|
|
||||||
" ax.axhline(y=0, color='gray', linestyle='-', linewidth=0.5)\n",
|
|
||||||
" ax.axhline(y=1, color='red', linestyle='--', linewidth=1, alpha=0.5, label='Saturation')\n",
|
|
||||||
" ax.axhline(y=-1, color='red', linestyle='--', linewidth=1, alpha=0.5)\n",
|
|
||||||
" \n",
|
|
||||||
" ax.set_xlabel('Time (s)')\n",
|
|
||||||
" ax.set_ylabel('PWM Duty Cycle')\n",
|
|
||||||
" ax.set_title('Control Signals (PWM)', fontsize=14, fontweight='bold')\n",
|
|
||||||
" ax.legend(loc='best', framealpha=0.9)\n",
|
|
||||||
" ax.set_ylim([-1.2, 1.2])\n",
|
|
||||||
" ax.grid(True, alpha=0.3)\n",
|
|
||||||
" \n",
|
|
||||||
" plt.tight_layout()\n",
|
|
||||||
" plt.show()\n",
|
|
||||||
" \n",
|
|
||||||
" return fig"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"---\n",
|
|
||||||
"## Run Simulation\n",
|
|
||||||
"\n",
|
|
||||||
"**Configure the simulation parameters below and run the cell.**"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# ============================================================\n",
|
|
||||||
"# SIMULATION PARAMETERS\n",
|
|
||||||
"# ============================================================\n",
|
|
||||||
"\n",
|
|
||||||
"INITIAL_GAP_MM = 14.0 # Starting gap height (mm). Target is ~16.49mm\n",
|
|
||||||
"MAX_STEPS = 2000 # Simulation steps (240 steps = 1 second)\n",
|
|
||||||
"USE_GUI = False # Set to True to see PyBullet visualization\n",
|
|
||||||
"\n",
|
|
||||||
"# Impulse disturbance (one-time force at a specific step)\n",
|
|
||||||
"DISTURBANCE_STEP = None # Step at which to apply impulse (e.g., 500). None = disabled\n",
|
|
||||||
"DISTURBANCE_FORCE = 0.0 # Impulse force in Newtons (positive = upward push)\n",
|
|
||||||
"\n",
|
|
||||||
"# Stochastic disturbance (continuous random noise each step)\n",
|
|
||||||
"DISTURBANCE_FORCE_STD = 0.0 # Std dev of random force noise (Newtons). 0 = disabled\n",
|
|
||||||
"\n",
|
|
||||||
"# ============================================================\n",
|
|
||||||
"\n",
|
|
||||||
"# Run simulation\n",
|
|
||||||
"results = run_pid_simulation(\n",
|
|
||||||
" initial_gap_mm=INITIAL_GAP_MM,\n",
|
|
||||||
" max_steps=MAX_STEPS,\n",
|
|
||||||
" use_gui=USE_GUI,\n",
|
|
||||||
" disturbance_step=DISTURBANCE_STEP,\n",
|
|
||||||
" disturbance_force=DISTURBANCE_FORCE,\n",
|
|
||||||
" disturbance_force_std=DISTURBANCE_FORCE_STD,\n",
|
|
||||||
" verbose=True\n",
|
|
||||||
")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"---\n",
|
|
||||||
"## View Results"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# Plot main performance metrics\n",
|
|
||||||
"plot_results(results)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# Plot control signals (optional)\n",
|
|
||||||
"plot_control_signals(results)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"---\n",
|
|
||||||
"## Compare Multiple Initial Conditions\n",
|
|
||||||
"\n",
|
|
||||||
"Run this cell to test the controller with different starting heights."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"def compare_initial_conditions(initial_gaps_mm: list, max_steps: int = 2000):\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" Compare PID performance across different initial conditions.\n",
|
|
||||||
" \n",
|
|
||||||
" Args:\n",
|
|
||||||
" initial_gaps_mm: List of starting gap heights to test\n",
|
|
||||||
" max_steps: Maximum steps per simulation\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" fig, axes = plt.subplots(2, 2, figsize=(14, 10))\n",
|
|
||||||
" fig.suptitle('PID Performance Comparison: Different Initial Conditions', \n",
|
|
||||||
" fontsize=16, fontweight='bold', y=1.02)\n",
|
|
||||||
" \n",
|
|
||||||
" target_gap_mm = TARGET_GAP * 1000\n",
|
|
||||||
" colors = plt.cm.viridis(np.linspace(0, 0.8, len(initial_gaps_mm)))\n",
|
|
||||||
" \n",
|
|
||||||
" all_results = []\n",
|
|
||||||
" \n",
|
|
||||||
" for i, gap in enumerate(initial_gaps_mm):\n",
|
|
||||||
" print(f\"Running simulation {i+1}/{len(initial_gaps_mm)}: initial_gap={gap}mm\")\n",
|
|
||||||
" data = run_pid_simulation(initial_gap_mm=gap, max_steps=max_steps, verbose=False)\n",
|
|
||||||
" all_results.append((gap, data))\n",
|
|
||||||
" \n",
|
|
||||||
" label = f'{gap}mm'\n",
|
|
||||||
" \n",
|
|
||||||
" # Gap height\n",
|
|
||||||
" axes[0, 0].plot(data['time'], data['gap_avg'], color=colors[i], \n",
|
|
||||||
" linewidth=2, label=label)\n",
|
|
||||||
" \n",
|
|
||||||
" # Roll\n",
|
|
||||||
" axes[0, 1].plot(data['time'], data['roll_deg'], color=colors[i], \n",
|
|
||||||
" linewidth=2, label=label)\n",
|
|
||||||
" \n",
|
|
||||||
" # Pitch\n",
|
|
||||||
" axes[1, 0].plot(data['time'], data['pitch_deg'], color=colors[i], \n",
|
|
||||||
" linewidth=2, label=label)\n",
|
|
||||||
" \n",
|
|
||||||
" # Total current\n",
|
|
||||||
" axes[1, 1].plot(data['time'], data['current_total'], color=colors[i], \n",
|
|
||||||
" linewidth=2, label=label)\n",
|
|
||||||
" \n",
|
|
||||||
" # Add target line to gap plot\n",
|
|
||||||
" axes[0, 0].axhline(y=target_gap_mm, color='red', linestyle='--', \n",
|
|
||||||
" linewidth=2, label=f'Target ({target_gap_mm:.1f}mm)')\n",
|
|
||||||
" \n",
|
|
||||||
" # Configure axes\n",
|
|
||||||
" axes[0, 0].set_xlabel('Time (s)')\n",
|
|
||||||
" axes[0, 0].set_ylabel('Gap Height (mm)')\n",
|
|
||||||
" axes[0, 0].set_title('Average Gap Height', fontweight='bold')\n",
|
|
||||||
" axes[0, 0].legend(loc='best')\n",
|
|
||||||
" axes[0, 0].grid(True, alpha=0.3)\n",
|
|
||||||
" \n",
|
|
||||||
" axes[0, 1].axhline(y=0, color='gray', linestyle='--', linewidth=1)\n",
|
|
||||||
" axes[0, 1].set_xlabel('Time (s)')\n",
|
|
||||||
" axes[0, 1].set_ylabel('Roll Angle (degrees)')\n",
|
|
||||||
" axes[0, 1].set_title('Roll Angle', fontweight='bold')\n",
|
|
||||||
" axes[0, 1].legend(loc='best')\n",
|
|
||||||
" axes[0, 1].grid(True, alpha=0.3)\n",
|
|
||||||
" \n",
|
|
||||||
" axes[1, 0].axhline(y=0, color='gray', linestyle='--', linewidth=1)\n",
|
|
||||||
" axes[1, 0].set_xlabel('Time (s)')\n",
|
|
||||||
" axes[1, 0].set_ylabel('Pitch Angle (degrees)')\n",
|
|
||||||
" axes[1, 0].set_title('Pitch Angle', fontweight='bold')\n",
|
|
||||||
" axes[1, 0].legend(loc='best')\n",
|
|
||||||
" axes[1, 0].grid(True, alpha=0.3)\n",
|
|
||||||
" \n",
|
|
||||||
" axes[1, 1].set_xlabel('Time (s)')\n",
|
|
||||||
" axes[1, 1].set_ylabel('Total Current (A)')\n",
|
|
||||||
" axes[1, 1].set_title('Total Current Draw', fontweight='bold')\n",
|
|
||||||
" axes[1, 1].legend(loc='best')\n",
|
|
||||||
" axes[1, 1].grid(True, alpha=0.3)\n",
|
|
||||||
" \n",
|
|
||||||
" plt.tight_layout()\n",
|
|
||||||
" plt.show()\n",
|
|
||||||
" \n",
|
|
||||||
" return all_results"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# Compare different starting heights\n",
|
|
||||||
"# Uncomment and run to test multiple initial conditions\n",
|
|
||||||
"\n",
|
|
||||||
"# comparison_results = compare_initial_conditions(\n",
|
|
||||||
"# initial_gaps_mm=[10.0, 14.0, 18.0, 22.0],\n",
|
|
||||||
"# max_steps=2000\n",
|
|
||||||
"# )"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"---\n",
|
|
||||||
"## Test Disturbance Rejection\n",
|
|
||||||
"\n",
|
|
||||||
"Apply a sudden force disturbance and observe recovery."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# Test disturbance rejection\n",
|
|
||||||
"# Uncomment and run to test disturbance response\n",
|
|
||||||
"\n",
|
|
||||||
"# Example 1: Impulse disturbance (one-time force)\n",
|
|
||||||
"# disturbance_results = run_pid_simulation(\n",
|
|
||||||
"# initial_gap_mm=16.5, # Start near target\n",
|
|
||||||
"# max_steps=3000,\n",
|
|
||||||
"# use_gui=False,\n",
|
|
||||||
"# disturbance_step=720, # Apply at 3 seconds\n",
|
|
||||||
"# disturbance_force=-20.0, # 20N downward push\n",
|
|
||||||
"# verbose=True\n",
|
|
||||||
"# )\n",
|
|
||||||
"# plot_results(disturbance_results, title_suffix=' (with 20N impulse at t=3s)')\n",
|
|
||||||
"\n",
|
|
||||||
"# Example 2: Continuous stochastic noise\n",
|
|
||||||
"# noisy_results = run_pid_simulation(\n",
|
|
||||||
"# initial_gap_mm=16.5,\n",
|
|
||||||
"# max_steps=3000,\n",
|
|
||||||
"# use_gui=False,\n",
|
|
||||||
"# disturbance_force_std=2.0, # 2N standard deviation continuous noise\n",
|
|
||||||
"# verbose=True\n",
|
|
||||||
"# )\n",
|
|
||||||
"# plot_results(noisy_results, title_suffix=' (with 2N std continuous noise)')"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"---\n",
|
|
||||||
"## Summary Statistics"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"def print_summary(data: dict):\n",
|
|
||||||
" \"\"\"Print summary statistics for a simulation run.\"\"\"\n",
|
|
||||||
" target_gap_mm = TARGET_GAP * 1000\n",
|
|
||||||
" \n",
|
|
||||||
" # Calculate settling time (within 2% of target)\n",
|
|
||||||
" tolerance = 0.02 * target_gap_mm\n",
|
|
||||||
" settled_idx = None\n",
|
|
||||||
" for i in range(len(data['gap_avg'])):\n",
|
|
||||||
" if abs(data['gap_avg'][i] - target_gap_mm) < tolerance:\n",
|
|
||||||
" # Check if it stays settled\n",
|
|
||||||
" if all(abs(data['gap_avg'][j] - target_gap_mm) < tolerance \n",
|
|
||||||
" for j in range(i, min(i+100, len(data['gap_avg'])))):\n",
|
|
||||||
" settled_idx = i\n",
|
|
||||||
" break\n",
|
|
||||||
" \n",
|
|
||||||
" print(\"=\" * 50)\n",
|
|
||||||
" print(\"SIMULATION SUMMARY\")\n",
|
|
||||||
" print(\"=\" * 50)\n",
|
|
||||||
" print(f\"Duration: {data['time'][-1]:.2f} seconds ({len(data['time'])} steps)\")\n",
|
|
||||||
" print(f\"Target gap: {target_gap_mm:.2f} mm\")\n",
|
|
||||||
" print()\n",
|
|
||||||
" print(\"Gap Height:\")\n",
|
|
||||||
" print(f\" Initial: {data['gap_avg'][0]:.2f} mm\")\n",
|
|
||||||
" print(f\" Final: {data['gap_avg'][-1]:.2f} mm\")\n",
|
|
||||||
" print(f\" Error: {abs(data['gap_avg'][-1] - target_gap_mm):.3f} mm\")\n",
|
|
||||||
" print(f\" Max: {max(data['gap_avg']):.2f} mm\")\n",
|
|
||||||
" print(f\" Min: {min(data['gap_avg']):.2f} mm\")\n",
|
|
||||||
" if settled_idx:\n",
|
|
||||||
" print(f\" Settling time (2%): {data['time'][settled_idx]:.3f} s\")\n",
|
|
||||||
" else:\n",
|
|
||||||
" print(f\" Settling time: Not settled within tolerance\")\n",
|
|
||||||
" print()\n",
|
|
||||||
" print(\"Orientation (final):\")\n",
|
|
||||||
" print(f\" Roll: {data['roll_deg'][-1]:+.3f} degrees\")\n",
|
|
||||||
" print(f\" Pitch: {data['pitch_deg'][-1]:+.3f} degrees\")\n",
|
|
||||||
" print()\n",
|
|
||||||
" print(\"Current Draw:\")\n",
|
|
||||||
" print(f\" Average total: {np.mean(data['current_total']):.2f} A\")\n",
|
|
||||||
" print(f\" Peak total: {max(data['current_total']):.2f} A\")\n",
|
|
||||||
" print(\"=\" * 50)\n",
|
|
||||||
"\n",
|
|
||||||
"# Print summary for last simulation\n",
|
|
||||||
"print_summary(results)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"metadata": {
|
|
||||||
"kernelspec": {
|
|
||||||
"display_name": "Python 3",
|
|
||||||
"language": "python",
|
|
||||||
"name": "python3"
|
|
||||||
},
|
|
||||||
"language_info": {
|
|
||||||
"name": "python",
|
|
||||||
"version": "3.11.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nbformat": 4,
|
|
||||||
"nbformat_minor": 4
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
annotated-types==0.7.0
|
|
||||||
anyio==4.10.0
|
|
||||||
appnope==0.1.4
|
|
||||||
argon2-cffi==25.1.0
|
|
||||||
argon2-cffi-bindings==25.1.0
|
|
||||||
arrow==1.3.0
|
|
||||||
asttokens==3.0.0
|
|
||||||
async-lru==2.0.5
|
|
||||||
attrs==25.3.0
|
|
||||||
babel==2.17.0
|
|
||||||
beautifulsoup4==4.13.5
|
|
||||||
bleach==6.2.0
|
|
||||||
cachetools==6.2.1
|
|
||||||
certifi==2025.8.3
|
|
||||||
cffi==1.17.1
|
|
||||||
charset-normalizer==3.4.3
|
|
||||||
click==8.3.0
|
|
||||||
comm==0.2.3
|
|
||||||
contourpy==1.3.3
|
|
||||||
cycler==0.12.1
|
|
||||||
debugpy==1.8.16
|
|
||||||
decorator==5.2.1
|
|
||||||
defusedxml==0.7.1
|
|
||||||
executing==2.2.1
|
|
||||||
fastapi==0.115.0
|
|
||||||
fastjsonschema==2.21.2
|
|
||||||
fonttools==4.59.2
|
|
||||||
fqdn==1.5.1
|
|
||||||
future==1.0.0
|
|
||||||
google-ai-generativelanguage==0.6.10
|
|
||||||
google-api-core==2.26.0
|
|
||||||
google-api-python-client==2.185.0
|
|
||||||
google-auth==2.41.1
|
|
||||||
google-auth-httplib2==0.2.0
|
|
||||||
google-generativeai==0.8.3
|
|
||||||
googleapis-common-protos==1.70.0
|
|
||||||
grpcio==1.75.1
|
|
||||||
grpcio-status==1.71.2
|
|
||||||
h11==0.16.0
|
|
||||||
httpcore==1.0.9
|
|
||||||
httplib2==0.31.0
|
|
||||||
httpx==0.27.2
|
|
||||||
idna==3.10
|
|
||||||
ipykernel==6.30.1
|
|
||||||
ipython==9.5.0
|
|
||||||
ipython_pygments_lexers==1.1.1
|
|
||||||
ipywidgets==8.1.7
|
|
||||||
iso8601==2.1.0
|
|
||||||
isoduration==20.11.0
|
|
||||||
jedi==0.19.2
|
|
||||||
Jinja2==3.1.6
|
|
||||||
json5==0.12.1
|
|
||||||
jsonpointer==3.0.0
|
|
||||||
jsonschema==4.25.1
|
|
||||||
jsonschema-specifications==2025.4.1
|
|
||||||
jupyter==1.1.1
|
|
||||||
jupyter-console==6.6.3
|
|
||||||
jupyter-events==0.12.0
|
|
||||||
jupyter-lsp==2.3.0
|
|
||||||
jupyter_client==8.6.3
|
|
||||||
jupyter_core==5.8.1
|
|
||||||
jupyter_server==2.17.0
|
|
||||||
jupyter_server_terminals==0.5.3
|
|
||||||
jupyterlab==4.4.7
|
|
||||||
jupyterlab_pygments==0.3.0
|
|
||||||
jupyterlab_server==2.27.3
|
|
||||||
jupyterlab_widgets==3.0.15
|
|
||||||
kiwisolver==1.4.9
|
|
||||||
lark==1.2.2
|
|
||||||
lazy_loader==0.4
|
|
||||||
MarkupSafe==3.0.2
|
|
||||||
matplotlib==3.10.6
|
|
||||||
matplotlib-inline==0.1.7
|
|
||||||
mistune==3.1.4
|
|
||||||
mne==1.10.2
|
|
||||||
mpmath==1.3.0
|
|
||||||
nbclient==0.10.2
|
|
||||||
nbconvert==7.16.6
|
|
||||||
nbformat==5.10.4
|
|
||||||
nest-asyncio==1.6.0
|
|
||||||
notebook==7.4.5
|
|
||||||
notebook_shim==0.2.4
|
|
||||||
numpy==2.3.2
|
|
||||||
packaging==25.0
|
|
||||||
pandas==2.3.3
|
|
||||||
pandocfilters==1.5.1
|
|
||||||
parso==0.8.5
|
|
||||||
pexpect==4.9.0
|
|
||||||
pillow==11.3.0
|
|
||||||
platformdirs==4.4.0
|
|
||||||
pooch==1.8.2
|
|
||||||
prometheus_client==0.22.1
|
|
||||||
prompt_toolkit==3.0.52
|
|
||||||
proto-plus==1.26.1
|
|
||||||
protobuf==5.29.5
|
|
||||||
psutil==7.0.0
|
|
||||||
ptyprocess==0.7.0
|
|
||||||
pure_eval==0.2.3
|
|
||||||
pyasn1==0.6.1
|
|
||||||
pyasn1_modules==0.4.2
|
|
||||||
pycparser==2.22
|
|
||||||
pydantic==2.9.2
|
|
||||||
pydantic-settings==2.6.0
|
|
||||||
pydantic_core==2.23.4
|
|
||||||
Pygments==2.19.2
|
|
||||||
pyparsing==3.2.3
|
|
||||||
PyQt6==6.10.0
|
|
||||||
PyQt6-Qt6==6.10.0
|
|
||||||
PyQt6_sip==13.10.2
|
|
||||||
pyqtgraph==0.13.7
|
|
||||||
pyserial==3.5
|
|
||||||
PySide6==6.10.0
|
|
||||||
PySide6_Addons==6.10.0
|
|
||||||
PySide6_Essentials==6.10.0
|
|
||||||
python-dateutil==2.9.0.post0
|
|
||||||
python-dotenv==1.0.1
|
|
||||||
python-json-logger==3.3.0
|
|
||||||
pytz==2025.2
|
|
||||||
PyYAML==6.0.2
|
|
||||||
pyzmq==27.0.2
|
|
||||||
referencing==0.36.2
|
|
||||||
requests==2.32.5
|
|
||||||
rfc3339-validator==0.1.4
|
|
||||||
rfc3986-validator==0.1.1
|
|
||||||
rfc3987-syntax==1.1.0
|
|
||||||
rpds-py==0.27.1
|
|
||||||
rsa==4.9.1
|
|
||||||
scipy==1.16.3
|
|
||||||
Send2Trash==1.8.3
|
|
||||||
serial==0.0.97
|
|
||||||
setuptools==80.9.0
|
|
||||||
shiboken6==6.10.0
|
|
||||||
six==1.17.0
|
|
||||||
sniffio==1.3.1
|
|
||||||
soupsieve==2.8
|
|
||||||
stack-data==0.6.3
|
|
||||||
starlette==0.38.6
|
|
||||||
sympy==1.14.0
|
|
||||||
terminado==0.18.1
|
|
||||||
tinycss2==1.4.0
|
|
||||||
tornado==6.5.2
|
|
||||||
tqdm==4.67.1
|
|
||||||
traitlets==5.14.3
|
|
||||||
types-python-dateutil==2.9.0.20250822
|
|
||||||
typing_extensions==4.15.0
|
|
||||||
tzdata==2025.2
|
|
||||||
uri-template==1.3.0
|
|
||||||
uritemplate==4.2.0
|
|
||||||
urllib3==2.5.0
|
|
||||||
uvicorn==0.32.0
|
|
||||||
wcwidth==0.2.13
|
|
||||||
webcolors==24.11.1
|
|
||||||
webencodings==0.5.1
|
|
||||||
websocket-client==1.8.0
|
|
||||||
websockets==15.0.1
|
|
||||||
widgetsnbextension==4.0.14
|
|
||||||
115
ReadMe.md
@@ -1,6 +1,115 @@
|
|||||||
# Levitation Control Repository
|
# Levitation Control Repository
|
||||||
This Repo contains the code and data from sensor calibration as well as arduino code for 4-point control on the 2-yoke test rig in the ```PIDTesting-2yoke4coil``` directory.
|
|
||||||
|
|
||||||
This code is property of Texas Guadaloop, a student-led hyperloop team from the University of Texas at Austin.
|
This repository contains embedded firmware, sensor characterization tooling, and a physics-based simulation stack as well as control algorithm trials for the Texas Guadaloop maglev system.
|
||||||
|
|
||||||
To be able to run all python files in the repo, run ```pip install -r requirements.txt```
|
This code is property of **Texas Guadaloop**, a student-led hyperloop team from the University of Texas at Austin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── AltSensorTesting/ # Arduino firmware for Baumer inductive sensor characterization
|
||||||
|
├── sensor/ # Python scripts to collect and fit sensor calibration data
|
||||||
|
├── loadCellCode/ # Arduino firmware for HX711 load-cell calibration
|
||||||
|
├── TwoCellMagChar/ # Two-cell magnetic characterization rig (validates Ansys data)
|
||||||
|
├── lev_sim/ # PyBullet simulation driven by Ansys sweep data
|
||||||
|
├── MAGLEV_DIGITALTWIN_PYTHON/ # Earlier single-axis analytical digital twin (reference)
|
||||||
|
├── serial_plotter.py # Live serial plotter utility
|
||||||
|
├── equilibrium.py # Equilibrium gap/current solver
|
||||||
|
└── requirements.txt # Python dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Embedded Stack
|
||||||
|
|
||||||
|
### `AltSensorTesting/`
|
||||||
|
|
||||||
|
Arduino firmware for reading Baumer inductive analog distance sensors. Uses interrupt-driven ADC sampling at ~77 kHz (16 MHz / prescaler 16) for low-latency gap measurement. Tracks the 10 lowest and 10 highest in-range ADC values over a sampling window to help establish calibration bounds. An out-of-range (OOR) digital pin is monitored in the ISR to discard invalid readings.
|
||||||
|
|
||||||
|
Key details:
|
||||||
|
- ADC ISR at ~77 kHz; readings discarded automatically when OOR pin is HIGH
|
||||||
|
- Serial commands: `1` to start sampling, `0` to stop and print boundary statistics
|
||||||
|
- Baud rate: 2,000,000
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sensor Characterization
|
||||||
|
|
||||||
|
### `sensor/`
|
||||||
|
|
||||||
|
Python pipeline for converting raw ADC readings from the inductive gap sensors into calibrated millimeter distances.
|
||||||
|
|
||||||
|
- **`sensorCollector.py`** — Serial interface that reads live ADC values from Arduino and applies the calibration model in real time.
|
||||||
|
- **`analogFitter-*.py`** — Curve-fitting scripts (polynomial, exponential, 3/4/5-parameter logistic) that fit calibration sweep data (`data*.csv`) to find the best sensor model. The 5-parameter generalized logistic form was found to give the best fit.
|
||||||
|
- **`Sensor*Averages.csv` / `data*.csv`** — Raw and averaged calibration data for sensors 0–3. Sensor 3 required a different voltage divider (20 kΩ / 50 kΩ) because the induction sensor output exceeds 6 V.
|
||||||
|
|
||||||
|
Calibration constants (A, K, B, C, v) for the generalized logistic model are embedded directly in `sensorCollector.py` and `sensor_simplerNew.py` for deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Magnetic Characterization (`TwoCellMagChar/`)
|
||||||
|
|
||||||
|
A minimal bench rig used to validate Ansys Maxwell FEA force predictions experimentally.
|
||||||
|
|
||||||
|
- **`TwoCellMagChar.ino`** — Drives two H-bridge coil channels across a sweep of PWM values (−250 to +250 in steps of 50) while reading two HX711 load cells simultaneously. Averages 10 measurements per PWM step and reports gram-force readings over serial.
|
||||||
|
- **`MagCharTrial.xlsx`** — Recorded force-vs-PWM data from physical trials.
|
||||||
|
- **`CalibConsts.hpp`** — Load-cell offset and scale constants shared with `loadCellCode/`.
|
||||||
|
|
||||||
|
The measured force curves confirmed that the Ansys sweep data is in reasonable agreement with physical hardware, providing confidence for using the FEA model inside the simulation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Levitation Simulation (`lev_sim/`)
|
||||||
|
|
||||||
|
A PyBullet-based simulation environment for 4-point active magnetic levitation of the pod. The simulation is driven by an Ansys Maxwell parametric sweep (coil currents × gap height × roll angle → force & torque), fitted to a polynomial regression model for fast inference.
|
||||||
|
|
||||||
|
### Data pipeline
|
||||||
|
|
||||||
|
1. **Ansys sweep** — `Ansys Results 12-9.csv / .xlsx` contains FEA results sweeping left/right coil currents, roll angle, and gap height.
|
||||||
|
2. **Function Fitting** — `Function Fitting.ipynb` fits the Ansys data to a `PolynomialFeatures + LinearRegression` model (inputs: `currL`, `currR`, `roll`, `1/gap`). The trained model is saved to `maglev_model.pkl`.
|
||||||
|
3. **Fast inference** — `maglev_predictor.py` (`MaglevPredictor`) loads the pickle and bypasses sklearn overhead by extracting raw weight matrices, running pure-NumPy polynomial expansion for ~100× faster per-sample prediction.
|
||||||
|
|
||||||
|
### Simulation environment
|
||||||
|
|
||||||
|
`lev_pod_env.py` implements a [Gymnasium](https://gymnasium.farama.org/) `Env` wrapping PyBullet:
|
||||||
|
|
||||||
|
- **State**: 4 gap heights (normalized) + 4 gap-height velocities
|
||||||
|
- **Action**: 4 PWM duty cycles ∈ [−1, 1] (front-left, front-right, back-left, back-right coils)
|
||||||
|
- **Physics**: first-order RL circuit model per coil (`mag_lev_coil.py`); coil parameters: R = 1.1 Ω, L = 2.5 mH, V_supply = 12 V, I_max = 10.2 A
|
||||||
|
- **Forces**: `MaglevPredictor.predict()` maps coil currents + gap + roll → force and torque applied to the pod body in PyBullet
|
||||||
|
- **Noise**: configurable Gaussian sensor noise (default σ = 0.1 mm) and stochastic disturbance forces
|
||||||
|
- Target equilibrium gap: 11.86 mm (9.4 kg pod)
|
||||||
|
- Simulation timestep: 1/240 s
|
||||||
|
|
||||||
|
The environment is controller-agnostic — any control algorithm can be plugged in:
|
||||||
|
|
||||||
|
| File | Controller |
|
||||||
|
|------|-----------|
|
||||||
|
| `lev_PID.ipynb` | Interactive PID tuning notebook |
|
||||||
|
| `lev_PPO.ipynb` | PPO reinforcement learning via Stable-Baselines3 |
|
||||||
|
|
||||||
|
### Supplementary Files for PID:
|
||||||
|
|
||||||
|
`pid_simulation.py`: Feedforward LUT + PID
|
||||||
|
`optuna_pid_tune.py`: Optuna-based automated PID hyperparameter search
|
||||||
|
|
||||||
|
Pre-tuned PID gain sets are saved in `pid_best_params*.json` (variants for 1500, 3000, and 6000 Optuna trials).
|
||||||
|
|
||||||
|
### PWM circuit model
|
||||||
|
|
||||||
|
`PWM_Circuit_Model.py` is a standalone electrical model of the H-bridge PWM drive circuit used for offline verification of the coil current dynamics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Key dependencies: `pybullet`, `gymnasium`, `stable-baselines3`, `scikit-learn`, `optuna`, `pyserial`, `numpy`, `scipy`, `pandas`, `matplotlib`.
|
||||||
|
|||||||
139
embedded/AdditiveControlCode/AdditiveControlCode.ino
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include "IndSensorMap.hpp"
|
||||||
|
#include "Controller.hpp"
|
||||||
|
#include "ADC.hpp"
|
||||||
|
#include "FastPWM.hpp"
|
||||||
|
|
||||||
|
// ── PID Gains (Kp, Ki, Kd) ──────────────────────────────────
|
||||||
|
// Height loop: controls average gap → additive PWM on all coils
|
||||||
|
PIDGains heightGains = { 100.0f, 0.0f, 8.0f };
|
||||||
|
|
||||||
|
// Roll loop: corrects left/right tilt → differential L/R
|
||||||
|
PIDGains rollGains = { 0.6f, 0.0f, -0.1f };
|
||||||
|
|
||||||
|
// Pitch loop: corrects front/back tilt → differential F/B
|
||||||
|
PIDGains pitchGains = { 50.0f, 0.0f, 1.9f };
|
||||||
|
|
||||||
|
// ── Reference ────────────────────────────────────────────────
|
||||||
|
float avgRef = 12.36f; // Target gap height (mm) — 9.4 kg equilibrium
|
||||||
|
|
||||||
|
// ── Feedforward ──────────────────────────────────────────────
|
||||||
|
bool useFeedforward = true; // Set false to disable feedforward LUT
|
||||||
|
|
||||||
|
// ── Sampling ─────────────────────────────────────────────────
|
||||||
|
#define SAMPLING_RATE 200 // Hz (controller tick rate)
|
||||||
|
|
||||||
|
// ── EMA filter alpha (all sensors) ───────────────────────────
|
||||||
|
#define ALPHA_VAL 1.0f
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// ABOVE THIS LINE IS TUNING VALUES ONLY, BELOW IS ACTUAL CODE.
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
unsigned long tprior;
|
||||||
|
unsigned int tDiffMicros;
|
||||||
|
|
||||||
|
FullController controller(indL, indR, indF, indB,
|
||||||
|
heightGains, rollGains, pitchGains,
|
||||||
|
avgRef, useFeedforward);
|
||||||
|
|
||||||
|
const int dt_micros = 1000000 / SAMPLING_RATE;
|
||||||
|
|
||||||
|
int ON = 0;
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(2000000);
|
||||||
|
setupADC();
|
||||||
|
setupFastPWM();
|
||||||
|
|
||||||
|
indL.alpha = ALPHA_VAL;
|
||||||
|
indR.alpha = ALPHA_VAL;
|
||||||
|
indF.alpha = ALPHA_VAL;
|
||||||
|
indB.alpha = ALPHA_VAL;
|
||||||
|
|
||||||
|
tprior = micros();
|
||||||
|
|
||||||
|
pinMode(dirFL, OUTPUT);
|
||||||
|
pinMode(pwmFL, OUTPUT);
|
||||||
|
pinMode(dirBL, OUTPUT);
|
||||||
|
pinMode(pwmBL, OUTPUT);
|
||||||
|
pinMode(dirFR, OUTPUT);
|
||||||
|
pinMode(pwmFR, OUTPUT);
|
||||||
|
pinMode(dirBR, OUTPUT);
|
||||||
|
pinMode(pwmBR, OUTPUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
if (Serial.available() > 0) {
|
||||||
|
String cmd = Serial.readStringUntil('\n');
|
||||||
|
cmd.trim();
|
||||||
|
|
||||||
|
// REF,avgRef — update target gap height
|
||||||
|
if (cmd.startsWith("REF,")) {
|
||||||
|
float newRef = cmd.substring(4).toFloat();
|
||||||
|
avgRef = newRef;
|
||||||
|
controller.updateReference(avgRef);
|
||||||
|
Serial.print("Updated Ref: ");
|
||||||
|
Serial.println(avgRef);
|
||||||
|
}
|
||||||
|
// PID,loop,kp,ki,kd — update gains (loop: 0=height, 1=roll, 2=pitch)
|
||||||
|
else if (cmd.startsWith("PID,")) {
|
||||||
|
int c1 = cmd.indexOf(',');
|
||||||
|
int c2 = cmd.indexOf(',', c1 + 1);
|
||||||
|
int c3 = cmd.indexOf(',', c2 + 1);
|
||||||
|
int c4 = cmd.indexOf(',', c3 + 1);
|
||||||
|
|
||||||
|
if (c1 > 0 && c2 > 0 && c3 > 0 && c4 > 0) {
|
||||||
|
int loop = cmd.substring(c1 + 1, c2).toInt();
|
||||||
|
float kp = cmd.substring(c2 + 1, c3).toFloat();
|
||||||
|
float ki = cmd.substring(c3 + 1, c4).toFloat();
|
||||||
|
float kd = cmd.substring(c4 + 1).toFloat();
|
||||||
|
|
||||||
|
PIDGains g = { kp, ki, kd };
|
||||||
|
|
||||||
|
switch (loop) {
|
||||||
|
case 0:
|
||||||
|
heightGains = g;
|
||||||
|
controller.updateHeightPID(g);
|
||||||
|
Serial.println("Updated Height PID");
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
rollGains = g;
|
||||||
|
controller.updateRollPID(g);
|
||||||
|
Serial.println("Updated Roll PID");
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
pitchGains = g;
|
||||||
|
controller.updatePitchPID(g);
|
||||||
|
Serial.println("Updated Pitch PID");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Serial.println("Invalid loop (0=height, 1=roll, 2=pitch)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// FF,0 or FF,1 — toggle feedforward
|
||||||
|
else if (cmd.startsWith("FF,")) {
|
||||||
|
bool en = (cmd.charAt(3) != '0');
|
||||||
|
useFeedforward = en;
|
||||||
|
controller.setFeedforward(en);
|
||||||
|
Serial.print("Feedforward: ");
|
||||||
|
Serial.println(en ? "ON" : "OFF");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Original on/off command (any char except '0' turns on)
|
||||||
|
controller.outputOn = (cmd.charAt(0) != '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tDiffMicros = micros() - tprior;
|
||||||
|
|
||||||
|
if (tDiffMicros >= dt_micros) {
|
||||||
|
controller.update();
|
||||||
|
controller.report();
|
||||||
|
controller.sendOutputs();
|
||||||
|
|
||||||
|
tprior = micros();
|
||||||
|
}
|
||||||
|
}
|
||||||
181
embedded/lib/Controller.cpp
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
#include "Controller.hpp"
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
// ── Integral windup limit ────────────────────────────────────
|
||||||
|
static const float MAX_INTEGRAL = 1e4f;
|
||||||
|
|
||||||
|
// ── Feedforward LUT in PROGMEM ───────────────────────────────
|
||||||
|
// Generated by gen_ff_lut.py (pod 9.4 kg, R=1.1Ω, V=12V, 3–20 mm)
|
||||||
|
// Positive = repelling, Negative = attracting
|
||||||
|
static const int16_t FF_PWM_LUT[FF_LUT_SIZE] PROGMEM = {
|
||||||
|
238, 238, 238, 238, 238, 238, 238, 238,
|
||||||
|
238, 238, 238, 238, 238, 238, 238, 238,
|
||||||
|
238, 238, 234, 219, 204, 188, 172, 157,
|
||||||
|
141, 125, 109, 93, 77, 61, 45, 29,
|
||||||
|
13, -3, -19, -35, -51, -67, -84, -100,
|
||||||
|
-116, -133, -150, -166, -183, -200, -217, -234,
|
||||||
|
-250, -250, -250, -250, -250, -250, -250, -250,
|
||||||
|
-250, -250, -250, -250, -250, -250, -250, -250
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Constructor ──────────────────────────────────────────────
|
||||||
|
FullController::FullController(
|
||||||
|
IndSensor& l, IndSensor& r, IndSensor& f, IndSensor& b,
|
||||||
|
PIDGains hGains, PIDGains rGains, PIDGains pGains,
|
||||||
|
float avgRef, bool useFeedforward)
|
||||||
|
: Left(l), Right(r), Front(f), Back(b),
|
||||||
|
heightGains(hGains), rollGains(rGains), pitchGains(pGains),
|
||||||
|
heightErr({0,0,0}), rollErr({0,0,0}), pitchErr({0,0,0}),
|
||||||
|
AvgRef(avgRef), avg(0), ffEnabled(useFeedforward),
|
||||||
|
oor(false), outputOn(false),
|
||||||
|
FLPWM(0), BLPWM(0), FRPWM(0), BRPWM(0)
|
||||||
|
{}
|
||||||
|
|
||||||
|
// ── Main update (called each control tick) ───────────────────
|
||||||
|
void FullController::update() {
|
||||||
|
// 1. Read all sensors (updates mmVal, oor)
|
||||||
|
Left.readMM();
|
||||||
|
Right.readMM();
|
||||||
|
Front.readMM();
|
||||||
|
Back.readMM();
|
||||||
|
|
||||||
|
oor = Left.oor || Right.oor || Front.oor || Back.oor;
|
||||||
|
|
||||||
|
// 2. Compute average gap (mm)
|
||||||
|
avg = (Left.mmVal + Right.mmVal + Front.mmVal + Back.mmVal) * 0.25f;
|
||||||
|
|
||||||
|
// 3. Feedforward: base PWM from equilibrium LUT
|
||||||
|
int16_t ffPWM = ffEnabled ? feedforward(avg) : 0;
|
||||||
|
|
||||||
|
// 4. Height PID: error = reference - average gap
|
||||||
|
float heightE = AvgRef - avg;
|
||||||
|
heightErr.eDiff = heightE - heightErr.e;
|
||||||
|
if (!oor) {
|
||||||
|
heightErr.eInt += heightE;
|
||||||
|
heightErr.eInt = constrain(heightErr.eInt, -MAX_INTEGRAL, MAX_INTEGRAL);
|
||||||
|
}
|
||||||
|
heightErr.e = heightE;
|
||||||
|
int16_t heightPWM = pidCompute(heightGains, heightErr, CAP);
|
||||||
|
|
||||||
|
// 5. Roll PID: angle-based error (matches simulation)
|
||||||
|
// roll_angle = asin((left - right) / y_distance)
|
||||||
|
// error = -roll_angle (drive roll toward zero)
|
||||||
|
float rollRatio = (Left.mmVal - Right.mmVal) / Y_DISTANCE_MM;
|
||||||
|
rollRatio = constrain(rollRatio, -1.0f, 1.0f);
|
||||||
|
float rollAngle = asinf(rollRatio);
|
||||||
|
float rollE = -rollAngle;
|
||||||
|
|
||||||
|
rollErr.eDiff = rollE - rollErr.e;
|
||||||
|
if (!oor) {
|
||||||
|
rollErr.eInt += rollE;
|
||||||
|
rollErr.eInt = constrain(rollErr.eInt, -MAX_INTEGRAL, MAX_INTEGRAL);
|
||||||
|
}
|
||||||
|
rollErr.e = rollE;
|
||||||
|
int16_t rollPWM = pidCompute(rollGains, rollErr, CAP / 2);
|
||||||
|
|
||||||
|
// 6. Pitch PID: angle-based error (matches simulation)
|
||||||
|
// pitch_angle = asin((back - front) / x_distance)
|
||||||
|
// error = -pitch_angle (drive pitch toward zero)
|
||||||
|
float pitchRatio = (Back.mmVal - Front.mmVal) / X_DISTANCE_MM;
|
||||||
|
pitchRatio = constrain(pitchRatio, -1.0f, 1.0f);
|
||||||
|
float pitchAngle = asinf(pitchRatio);
|
||||||
|
float pitchE = -pitchAngle;
|
||||||
|
|
||||||
|
pitchErr.eDiff = pitchE - pitchErr.e;
|
||||||
|
if (!oor) {
|
||||||
|
pitchErr.eInt += pitchE;
|
||||||
|
pitchErr.eInt = constrain(pitchErr.eInt, -MAX_INTEGRAL, MAX_INTEGRAL);
|
||||||
|
}
|
||||||
|
pitchErr.e = pitchE;
|
||||||
|
int16_t pitchPWM = pidCompute(pitchGains, pitchErr, CAP / 2);
|
||||||
|
|
||||||
|
// 7. Mix outputs (same sign convention as simulation)
|
||||||
|
// FL: ff + height - roll - pitch
|
||||||
|
// FR: ff + height + roll - pitch
|
||||||
|
// BL: ff + height - roll + pitch
|
||||||
|
// BR: ff + height + roll + pitch
|
||||||
|
FLPWM = constrain(ffPWM + heightPWM - rollPWM - pitchPWM, -CAP, CAP);
|
||||||
|
FRPWM = constrain(ffPWM + heightPWM + rollPWM - pitchPWM, -CAP, CAP);
|
||||||
|
BLPWM = constrain(ffPWM + heightPWM - rollPWM + pitchPWM, -CAP, CAP);
|
||||||
|
BRPWM = constrain(ffPWM + heightPWM + rollPWM + pitchPWM, -CAP, CAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PID compute (single symmetric set of gains) ─────────────
|
||||||
|
int16_t FullController::pidCompute(PIDGains& gains, PIDState& state, float maxOutput) {
|
||||||
|
if (oor) return 0;
|
||||||
|
float out = gains.kp * state.e
|
||||||
|
+ gains.ki * state.eInt
|
||||||
|
+ gains.kd * state.eDiff;
|
||||||
|
return (int16_t)constrain(out, -maxOutput, maxOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feedforward: linearly interpolate PROGMEM LUT ────────────
|
||||||
|
int16_t FullController::feedforward(float gapMM) {
|
||||||
|
if (gapMM <= FF_GAP_MIN) return (int16_t)pgm_read_word(&FF_PWM_LUT[0]);
|
||||||
|
if (gapMM >= FF_GAP_MAX) return (int16_t)pgm_read_word(&FF_PWM_LUT[FF_LUT_SIZE - 1]);
|
||||||
|
|
||||||
|
float idx_f = (gapMM - 1.0 - FF_GAP_MIN) / FF_GAP_STEP;
|
||||||
|
uint8_t idx = (uint8_t)idx_f;
|
||||||
|
if (idx >= FF_LUT_SIZE - 1) idx = FF_LUT_SIZE - 2;
|
||||||
|
|
||||||
|
int16_t v0 = (int16_t)pgm_read_word(&FF_PWM_LUT[idx]);
|
||||||
|
int16_t v1 = (int16_t)pgm_read_word(&FF_PWM_LUT[idx + 1]);
|
||||||
|
float frac = idx_f - (float)idx;
|
||||||
|
|
||||||
|
return (int16_t)(v0 + frac * (v1 - v0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zero all PWMs ────────────────────────────────────────────
|
||||||
|
void FullController::zeroPWMs() {
|
||||||
|
FLPWM = 0;
|
||||||
|
BLPWM = 0;
|
||||||
|
FRPWM = 0;
|
||||||
|
BRPWM = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Send PWM values to hardware ──────────────────────────────
|
||||||
|
void FullController::sendOutputs() {
|
||||||
|
if (!outputOn) {
|
||||||
|
zeroPWMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direction: LOW = repelling (positive PWM), HIGH = attracting (negative PWM)
|
||||||
|
// Using direct register writes for fast PWM mode set by setupFastPWM()
|
||||||
|
digitalWrite(dirFL, FLPWM < 0);
|
||||||
|
OCR2A = abs(FLPWM); // Pin 11 → Timer 2A
|
||||||
|
digitalWrite(dirBL, BLPWM < 0);
|
||||||
|
OCR1A = abs(BLPWM); // Pin 9 → Timer 1A
|
||||||
|
digitalWrite(dirFR, FRPWM < 0);
|
||||||
|
OCR2B = abs(FRPWM); // Pin 3 → Timer 2B
|
||||||
|
digitalWrite(dirBR, BRPWM < 0);
|
||||||
|
OCR1B = abs(BRPWM); // Pin 10 → Timer 1B
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Serial report ────────────────────────────────────────────
|
||||||
|
void FullController::report() {
|
||||||
|
// CSV: Left,Right,Front,Back,Avg,FLPWM,BLPWM,FRPWM,BRPWM,ControlOn
|
||||||
|
Serial.print(Left.mmVal); Serial.print(',');
|
||||||
|
Serial.print(Right.mmVal); Serial.print(',');
|
||||||
|
Serial.print(Front.mmVal); Serial.print(',');
|
||||||
|
Serial.print(Back.mmVal); Serial.print(',');
|
||||||
|
Serial.print(avg); Serial.print(',');
|
||||||
|
Serial.print(FLPWM); Serial.print(',');
|
||||||
|
Serial.print(BLPWM); Serial.print(',');
|
||||||
|
Serial.print(FRPWM); Serial.print(',');
|
||||||
|
Serial.print(BRPWM); Serial.print(',');
|
||||||
|
Serial.println(outputOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Runtime tuning methods ───────────────────────────────────
|
||||||
|
void FullController::updateHeightPID(PIDGains gains) { heightGains = gains; }
|
||||||
|
void FullController::updateRollPID(PIDGains gains) { rollGains = gains; }
|
||||||
|
void FullController::updatePitchPID(PIDGains gains) { pitchGains = gains; }
|
||||||
|
|
||||||
|
void FullController::updateReference(float avgReference) {
|
||||||
|
AvgRef = avgReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FullController::setFeedforward(bool enabled) {
|
||||||
|
ffEnabled = enabled;
|
||||||
|
}
|
||||||
99
embedded/lib/Controller.hpp
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#ifndef CONTROLLER_HPP
|
||||||
|
#define CONTROLLER_HPP
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <avr/pgmspace.h>
|
||||||
|
#include "IndSensorMap.hpp"
|
||||||
|
|
||||||
|
// ── Pin Mapping ──────────────────────────────────────────────
|
||||||
|
#define dirBL 2
|
||||||
|
#define pwmBL 3
|
||||||
|
#define dirBR 4
|
||||||
|
#define pwmBR 10
|
||||||
|
#define pwmFL 11
|
||||||
|
#define dirFL 7
|
||||||
|
#define dirFR 8
|
||||||
|
#define pwmFR 9
|
||||||
|
|
||||||
|
// ── Output Cap ───────────────────────────────────────────────
|
||||||
|
#define CAP 250 // Max PWM magnitude (0-255 Arduino range)
|
||||||
|
|
||||||
|
// ── PID Gains (single set per loop — matches simulation) ────
|
||||||
|
typedef struct PIDGains {
|
||||||
|
float kp;
|
||||||
|
float ki;
|
||||||
|
float kd;
|
||||||
|
} PIDGains;
|
||||||
|
|
||||||
|
// ── PID Error State ──────────────────────────────────────────
|
||||||
|
typedef struct PIDState {
|
||||||
|
float e; // Current error
|
||||||
|
float eDiff; // Derivative of error (e[k] - e[k-1])
|
||||||
|
float eInt; // Integral of error (accumulated)
|
||||||
|
} PIDState;
|
||||||
|
|
||||||
|
// ── Feedforward LUT (PROGMEM) ────────────────────────────────
|
||||||
|
// Generated by gen_ff_lut.py from MaglevPredictor model
|
||||||
|
// Pod mass: 9.4 kg, Coil R: 1.1Ω, V_supply: 12V
|
||||||
|
// Positive = repelling, Negative = attracting
|
||||||
|
// Beyond ~16mm: clamped to -CAP (no equilibrium exists)
|
||||||
|
#define FF_LUT_SIZE 64
|
||||||
|
#define FF_GAP_MIN 3.0f
|
||||||
|
#define FF_GAP_MAX 20.0f
|
||||||
|
#define FF_GAP_STEP 0.269841f
|
||||||
|
|
||||||
|
// ── Geometry (mm, matching simulation) ───────────────────────
|
||||||
|
// Sensor-to-sensor distances for angle computation
|
||||||
|
#define Y_DISTANCE_MM 101.6f // Left↔Right sensor spacing (mm)
|
||||||
|
#define X_DISTANCE_MM 251.8f // Front↔Back sensor spacing (mm)
|
||||||
|
|
||||||
|
// ── Controller Class ─────────────────────────────────────────
|
||||||
|
class FullController {
|
||||||
|
public:
|
||||||
|
bool oor; // Any sensor out-of-range
|
||||||
|
bool outputOn; // Enable/disable output
|
||||||
|
|
||||||
|
FullController(IndSensor& l, IndSensor& r, IndSensor& f, IndSensor& b,
|
||||||
|
PIDGains heightGains, PIDGains rollGains, PIDGains pitchGains,
|
||||||
|
float avgRef, bool useFeedforward);
|
||||||
|
|
||||||
|
void update();
|
||||||
|
void zeroPWMs();
|
||||||
|
void sendOutputs();
|
||||||
|
void report();
|
||||||
|
|
||||||
|
// Runtime tuning
|
||||||
|
void updateHeightPID(PIDGains gains);
|
||||||
|
void updateRollPID(PIDGains gains);
|
||||||
|
void updatePitchPID(PIDGains gains);
|
||||||
|
void updateReference(float avgReference);
|
||||||
|
void setFeedforward(bool enabled);
|
||||||
|
|
||||||
|
private:
|
||||||
|
int16_t pidCompute(PIDGains& gains, PIDState& state, float maxOutput);
|
||||||
|
int16_t feedforward(float gapMM);
|
||||||
|
|
||||||
|
IndSensor& Left;
|
||||||
|
IndSensor& Right;
|
||||||
|
IndSensor& Front;
|
||||||
|
IndSensor& Back;
|
||||||
|
|
||||||
|
PIDGains heightGains;
|
||||||
|
PIDGains rollGains;
|
||||||
|
PIDGains pitchGains;
|
||||||
|
|
||||||
|
PIDState heightErr;
|
||||||
|
PIDState rollErr;
|
||||||
|
PIDState pitchErr;
|
||||||
|
|
||||||
|
float AvgRef; // Target gap height (mm)
|
||||||
|
float avg; // Current average gap (mm)
|
||||||
|
bool ffEnabled; // Feedforward on/off
|
||||||
|
|
||||||
|
int16_t FLPWM;
|
||||||
|
int16_t BLPWM;
|
||||||
|
int16_t FRPWM;
|
||||||
|
int16_t BRPWM;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // CONTROLLER_HPP
|
||||||
@@ -58,7 +58,7 @@ float IndSensor::readMM() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Predefined sensor instances
|
// Predefined sensor instances
|
||||||
IndSensor indL(ind1Map, A0);
|
IndSensor indF(ind1Map, A0);
|
||||||
IndSensor indR(ind0Map, A1);
|
IndSensor indB(ind0Map, A1);
|
||||||
IndSensor indF(ind3Map, A5);
|
IndSensor indR(ind3Map, A5);
|
||||||
IndSensor indB(ind2Map, A4);
|
IndSensor indL(ind2Map, A4);
|
||||||
4472
lev_sim/Ansys Results 12-9.csv
Normal file
164
lev_sim/PWM_Circuit_Model.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from scipy.signal import TransferFunction, lsim
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Circuit Parameters
|
||||||
|
# ============================================================
|
||||||
|
R = 1.5 # Resistance [Ω]
|
||||||
|
L = 0.0025 # Inductance [H] (2.5 mH)
|
||||||
|
V_SUPPLY = 12.0 # Supply rail [V]
|
||||||
|
tau = L / R # RL time constant ≈ 1.667 ms
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PWM Parameters
|
||||||
|
# ============================================================
|
||||||
|
F_PWM = 16e3 # PWM frequency [Hz]
|
||||||
|
T_PWM = 1.0 / F_PWM # PWM period [s] (62.5 µs)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Simulation Window
|
||||||
|
# ============================================================
|
||||||
|
T_END = 1e-3 # 1 ms → 16 full PWM cycles
|
||||||
|
DT = 1e-7 # 100 ns resolution (625 pts / PWM cycle)
|
||||||
|
t = np.arange(0, T_END + DT / 2, DT)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Duty-Cycle Command D(t)
|
||||||
|
# ============================================================
|
||||||
|
# Ramp from 20 % → 80 % over the window so every PWM cycle
|
||||||
|
# has a visibly different pulse width.
|
||||||
|
def duty_command(t_val):
|
||||||
|
"""Continuous duty-cycle setpoint (from a controller)."""
|
||||||
|
return np.clip(0.2 + 0.6 * (np.asarray(t_val) / T_END), 0.0, 1.0)
|
||||||
|
|
||||||
|
D_continuous = duty_command(t)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# MODEL 1 — Abstracted (Average-Voltage) Approximation
|
||||||
|
# ============================================================
|
||||||
|
# Treats the coil voltage as the smooth signal D(t)·V.
|
||||||
|
# Transfer function: I(s)/D(s) = V / (Ls + R)
|
||||||
|
G = TransferFunction([V_SUPPLY], [L, R])
|
||||||
|
_, i_avg, _ = lsim(G, U=D_continuous, T=t)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# MODEL 2 — True PWM Waveform (exact analytical solution)
|
||||||
|
# ============================================================
|
||||||
|
# Between every switching edge the RL circuit obeys:
|
||||||
|
#
|
||||||
|
# di/dt = (V_seg − R·i) / L (V_seg = V_SUPPLY or 0)
|
||||||
|
#
|
||||||
|
# Closed-form from initial condition i₀ at time t₀:
|
||||||
|
#
|
||||||
|
# i(t) = V_seg/R + (i₀ − V_seg/R) · exp(−R·(t − t₀) / L)
|
||||||
|
#
|
||||||
|
# We propagate i analytically from edge to edge — zero
|
||||||
|
# numerical-integration error. The only error source is
|
||||||
|
# IEEE-754 floating-point arithmetic (~1e-15 relative).
|
||||||
|
|
||||||
|
# --- Step 1: build segment table and propagate boundary currents ---
|
||||||
|
seg_t_start = [] # start time of each constant-V segment
|
||||||
|
seg_V = [] # voltage applied during segment
|
||||||
|
seg_i0 = [] # current at segment start
|
||||||
|
|
||||||
|
i_boundary = 0.0 # coil starts de-energised
|
||||||
|
|
||||||
|
cycle = 0
|
||||||
|
while cycle * T_PWM < T_END:
|
||||||
|
t_cycle = cycle * T_PWM
|
||||||
|
D = float(duty_command(t_cycle))
|
||||||
|
|
||||||
|
# ---- ON phase (V_SUPPLY applied) ----
|
||||||
|
t_on_start = t_cycle
|
||||||
|
t_on_end = min(t_cycle + D * T_PWM, T_END)
|
||||||
|
if t_on_end > t_on_start:
|
||||||
|
seg_t_start.append(t_on_start)
|
||||||
|
seg_V.append(V_SUPPLY)
|
||||||
|
seg_i0.append(i_boundary)
|
||||||
|
dt_on = t_on_end - t_on_start
|
||||||
|
i_boundary = (V_SUPPLY / R) + (i_boundary - V_SUPPLY / R) * np.exp(-R * dt_on / L)
|
||||||
|
|
||||||
|
# ---- OFF phase (0 V applied, free-wheeling through diode) ----
|
||||||
|
t_off_start = t_on_end
|
||||||
|
t_off_end = min((cycle + 1) * T_PWM, T_END)
|
||||||
|
if t_off_end > t_off_start:
|
||||||
|
seg_t_start.append(t_off_start)
|
||||||
|
seg_V.append(0.0)
|
||||||
|
seg_i0.append(i_boundary)
|
||||||
|
dt_off = t_off_end - t_off_start
|
||||||
|
i_boundary = i_boundary * np.exp(-R * dt_off / L)
|
||||||
|
|
||||||
|
cycle += 1
|
||||||
|
|
||||||
|
seg_t_start = np.array(seg_t_start)
|
||||||
|
seg_V = np.array(seg_V)
|
||||||
|
seg_i0 = np.array(seg_i0)
|
||||||
|
|
||||||
|
# --- Step 2: evaluate on the dense time array (vectorised) ---
|
||||||
|
idx = np.searchsorted(seg_t_start, t, side='right') - 1
|
||||||
|
idx = np.clip(idx, 0, len(seg_t_start) - 1)
|
||||||
|
|
||||||
|
dt_in_seg = t - seg_t_start[idx]
|
||||||
|
V_at_t = seg_V[idx]
|
||||||
|
i0_at_t = seg_i0[idx]
|
||||||
|
|
||||||
|
i_pwm = (V_at_t / R) + (i0_at_t - V_at_t / R) * np.exp(-R * dt_in_seg / L)
|
||||||
|
v_pwm = V_at_t # switching waveform for plotting
|
||||||
|
v_avg = D_continuous * V_SUPPLY # average-model voltage
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Console Output — sanity-check steady-state values
|
||||||
|
# ============================================================
|
||||||
|
print(f"RL time constant τ = L/R = {tau*1e3:.3f} ms")
|
||||||
|
print(f"PWM period T = 1/f = {T_PWM*1e6:.1f} µs")
|
||||||
|
print(f"Sim resolution Δt = {DT*1e9:.0f} ns ({int(T_PWM/DT)} pts/cycle)")
|
||||||
|
print()
|
||||||
|
print("Expected steady-state currents i_ss = (V/R)·D :")
|
||||||
|
for d in [0.2, 0.4, 0.6, 0.8]:
|
||||||
|
print(f" D = {d:.1f} → i_ss = {V_SUPPLY / R * d:.3f} A")
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Plotting — 4-panel comparison
|
||||||
|
# ============================================================
|
||||||
|
t_us = t * 1e6 # time axis in µs
|
||||||
|
|
||||||
|
fig, axes = plt.subplots(4, 1, figsize=(14, 10), sharex=True)
|
||||||
|
fig.suptitle("PWM RL-Circuit Model Comparison (16 kHz, 1 ms window)",
|
||||||
|
fontsize=13, fontweight='bold')
|
||||||
|
|
||||||
|
# --- 1. Duty-cycle command ---
|
||||||
|
ax = axes[0]
|
||||||
|
ax.plot(t_us, D_continuous * 100, color='tab:purple', linewidth=1.2)
|
||||||
|
ax.set_ylabel("Duty Cycle [%]")
|
||||||
|
ax.set_ylim(0, 100)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
# --- 2. Voltage waveforms ---
|
||||||
|
ax = axes[1]
|
||||||
|
ax.plot(t_us, v_pwm, color='tab:orange', linewidth=0.6, label="True PWM voltage")
|
||||||
|
ax.plot(t_us, v_avg, color='tab:blue', linewidth=1.4, label="Average voltage D·V",
|
||||||
|
linestyle='--')
|
||||||
|
ax.set_ylabel("Voltage [V]")
|
||||||
|
ax.set_ylim(-0.5, V_SUPPLY + 1)
|
||||||
|
ax.legend(loc='upper left', fontsize=9)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
# --- 3. Current comparison ---
|
||||||
|
ax = axes[2]
|
||||||
|
ax.plot(t_us, i_pwm, color='tab:red', linewidth=0.7, label="True PWM current (exact)")
|
||||||
|
ax.plot(t_us, i_avg, color='tab:blue', linewidth=1.4, label="Averaged-model current",
|
||||||
|
linestyle='--')
|
||||||
|
ax.set_ylabel("Current [A]")
|
||||||
|
ax.legend(loc='upper left', fontsize=9)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
# --- 4. Difference / ripple ---
|
||||||
|
ax = axes[3]
|
||||||
|
ax.plot(t_us, (i_pwm - i_avg) * 1e3, color='tab:green', linewidth=0.7)
|
||||||
|
ax.set_ylabel("Δi (PWM − avg) [mA]")
|
||||||
|
ax.set_xlabel("Time [µs]")
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.show()
|
||||||
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
362
lev_sim/archive_not_used/maglev_linearizer.py
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
"""
|
||||||
|
Magnetic Levitation Jacobian Linearizer
|
||||||
|
|
||||||
|
Computes the local linear (Jacobian) approximation of the degree-6 polynomial
|
||||||
|
force/torque model at any operating point. The result is an LTI gain matrix
|
||||||
|
that relates small perturbations in (currL, currR, roll, gap_height) to
|
||||||
|
perturbations in (Force, Torque):
|
||||||
|
|
||||||
|
[ΔF ] [∂F/∂currL ∂F/∂currR ∂F/∂roll ∂F/∂gap] [ΔcurrL ]
|
||||||
|
[ΔTau] ≈ J [∂T/∂currL ∂T/∂currR ∂T/∂roll ∂T/∂gap] [ΔcurrR ]
|
||||||
|
[Δroll ]
|
||||||
|
[Δgap ]
|
||||||
|
|
||||||
|
Since the polynomial is analytic, all derivatives are computed exactly
|
||||||
|
(symbolic differentiation of the power-product terms), NOT by finite
|
||||||
|
differences.
|
||||||
|
|
||||||
|
The chain rule is applied automatically to convert the internal invGap
|
||||||
|
(= 1/gap_height) variable back to physical gap_height [mm].
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
lin = MaglevLinearizer("maglev_model.pkl")
|
||||||
|
plant = lin.linearize(currL=-15, currR=-15, roll=0.0, gap_height=10.0)
|
||||||
|
print(plant)
|
||||||
|
print(plant.dF_dcurrL) # single gain
|
||||||
|
print(plant.control_jacobian) # 2×2 matrix mapping ΔcurrL/R → ΔF/ΔT
|
||||||
|
f, t = plant.predict(delta_currL=0.5) # quick what-if
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import joblib
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class LinearizedPlant:
|
||||||
|
"""
|
||||||
|
Holds the Jacobian linearization of the force/torque model at one
|
||||||
|
operating point.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
operating_point : dict
|
||||||
|
The (currL, currR, roll, gap_height) where linearization was computed.
|
||||||
|
f0 : float
|
||||||
|
Force [N] at the operating point.
|
||||||
|
tau0 : float
|
||||||
|
Torque [mN·m] at the operating point.
|
||||||
|
jacobian : ndarray, shape (2, 4)
|
||||||
|
Full Jacobian:
|
||||||
|
Row 0 = Force derivatives, Row 1 = Torque derivatives.
|
||||||
|
Columns = [currL [A], currR [A], rollDeg [deg], gap_height [mm]]
|
||||||
|
"""
|
||||||
|
|
||||||
|
INPUT_LABELS = ['currL [A]', 'currR [A]', 'rollDeg [deg]', 'gap_height [mm]']
|
||||||
|
|
||||||
|
def __init__(self, operating_point, f0, tau0, jacobian):
|
||||||
|
self.operating_point = operating_point
|
||||||
|
self.f0 = f0
|
||||||
|
self.tau0 = tau0
|
||||||
|
self.jacobian = jacobian
|
||||||
|
|
||||||
|
# ---- Individual gain accessors ----
|
||||||
|
@property
|
||||||
|
def dF_dcurrL(self):
|
||||||
|
"""∂Force/∂currL [N/A] at operating point."""
|
||||||
|
return self.jacobian[0, 0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dF_dcurrR(self):
|
||||||
|
"""∂Force/∂currR [N/A] at operating point."""
|
||||||
|
return self.jacobian[0, 1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dF_droll(self):
|
||||||
|
"""∂Force/∂roll [N/deg] at operating point."""
|
||||||
|
return self.jacobian[0, 2]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dF_dgap(self):
|
||||||
|
"""∂Force/∂gap_height [N/mm] at operating point.
|
||||||
|
Typically positive (unstable): force increases as gap shrinks.
|
||||||
|
"""
|
||||||
|
return self.jacobian[0, 3]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dT_dcurrL(self):
|
||||||
|
"""∂Torque/∂currL [mN·m/A] at operating point."""
|
||||||
|
return self.jacobian[1, 0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dT_dcurrR(self):
|
||||||
|
"""∂Torque/∂currR [mN·m/A] at operating point."""
|
||||||
|
return self.jacobian[1, 1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dT_droll(self):
|
||||||
|
"""∂Torque/∂roll [mN·m/deg] at operating point."""
|
||||||
|
return self.jacobian[1, 2]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dT_dgap(self):
|
||||||
|
"""∂Torque/∂gap_height [mN·m/mm] at operating point."""
|
||||||
|
return self.jacobian[1, 3]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def control_jacobian(self):
|
||||||
|
"""2×2 sub-matrix mapping control inputs [ΔcurrL, ΔcurrR] → [ΔF, ΔT].
|
||||||
|
|
||||||
|
This is the "B" portion of the linearized plant that the PID
|
||||||
|
controller acts through.
|
||||||
|
"""
|
||||||
|
return self.jacobian[:, :2]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_jacobian(self):
|
||||||
|
"""2×2 sub-matrix mapping state perturbations [Δroll, Δgap] → [ΔF, ΔT].
|
||||||
|
|
||||||
|
Contains the magnetic stiffness (∂F/∂gap) and roll coupling.
|
||||||
|
This feeds into the "A" matrix of the full mechanical state-space.
|
||||||
|
"""
|
||||||
|
return self.jacobian[:, 2:]
|
||||||
|
|
||||||
|
def predict(self, delta_currL=0.0, delta_currR=0.0,
|
||||||
|
delta_roll=0.0, delta_gap=0.0):
|
||||||
|
"""
|
||||||
|
Predict force and torque using the linear approximation.
|
||||||
|
|
||||||
|
Returns (force, torque) including the nominal operating-point values.
|
||||||
|
"""
|
||||||
|
delta = np.array([delta_currL, delta_currR, delta_roll, delta_gap])
|
||||||
|
perturbation = self.jacobian @ delta
|
||||||
|
return self.f0 + perturbation[0], self.tau0 + perturbation[1]
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
op = self.operating_point
|
||||||
|
lines = [
|
||||||
|
f"LinearizedPlant @ currL={op['currL']:.1f}A, "
|
||||||
|
f"currR={op['currR']:.1f}A, "
|
||||||
|
f"roll={op['roll']:.2f}°, "
|
||||||
|
f"gap={op['gap_height']:.2f}mm",
|
||||||
|
f" F₀ = {self.f0:+.4f} N τ₀ = {self.tau0:+.4f} mN·m",
|
||||||
|
f" ∂F/∂currL = {self.dF_dcurrL:+.4f} N/A "
|
||||||
|
f"∂T/∂currL = {self.dT_dcurrL:+.4f} mN·m/A",
|
||||||
|
f" ∂F/∂currR = {self.dF_dcurrR:+.4f} N/A "
|
||||||
|
f"∂T/∂currR = {self.dT_dcurrR:+.4f} mN·m/A",
|
||||||
|
f" ∂F/∂roll = {self.dF_droll:+.4f} N/deg "
|
||||||
|
f"∂T/∂roll = {self.dT_droll:+.4f} mN·m/deg",
|
||||||
|
f" ∂F/∂gap = {self.dF_dgap:+.4f} N/mm "
|
||||||
|
f"∂T/∂gap = {self.dT_dgap:+.4f} mN·m/mm",
|
||||||
|
]
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
class MaglevLinearizer:
|
||||||
|
"""
|
||||||
|
Jacobian linearizer for the polynomial maglev force/torque model.
|
||||||
|
|
||||||
|
Loads the same .pkl model as MaglevPredictor, but instead of just
|
||||||
|
evaluating the polynomial, computes exact analytical partial derivatives
|
||||||
|
at any operating point.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model_path='maglev_model.pkl'):
|
||||||
|
if not os.path.exists(model_path):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Model file '{model_path}' not found. "
|
||||||
|
"Train and save the model from Function Fitting.ipynb first."
|
||||||
|
)
|
||||||
|
|
||||||
|
data = joblib.load(model_path)
|
||||||
|
poly_transformer = data['poly_features']
|
||||||
|
linear_model = data['model']
|
||||||
|
|
||||||
|
# powers_: (n_terms, n_inputs) — exponent matrix from sklearn
|
||||||
|
# Transpose to (n_inputs, n_terms) for broadcasting with x[:, None]
|
||||||
|
self.powers = poly_transformer.powers_.T.astype(np.float64)
|
||||||
|
|
||||||
|
self.force_coef = linear_model.coef_[0] # (n_terms,)
|
||||||
|
self.torque_coef = linear_model.coef_[1] # (n_terms,)
|
||||||
|
self.force_intercept = linear_model.intercept_[0]
|
||||||
|
self.torque_intercept = linear_model.intercept_[1]
|
||||||
|
|
||||||
|
self.degree = data['degree']
|
||||||
|
self.n_terms = self.powers.shape[1]
|
||||||
|
|
||||||
|
def _to_internal(self, currL, currR, roll, gap_height):
|
||||||
|
"""Convert physical inputs to the polynomial's internal variables."""
|
||||||
|
invGap = 1.0 / max(gap_height, 1e-6)
|
||||||
|
return np.array([currL, currR, roll, invGap], dtype=np.float64)
|
||||||
|
|
||||||
|
def evaluate(self, currL, currR, roll, gap_height):
|
||||||
|
"""
|
||||||
|
Evaluate the full (nonlinear) polynomial at a single point.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
force : float [N]
|
||||||
|
torque : float [mN·m]
|
||||||
|
"""
|
||||||
|
x = self._to_internal(currL, currR, roll, gap_height)
|
||||||
|
poly_features = np.prod(x[:, None] ** self.powers, axis=0)
|
||||||
|
force = np.dot(self.force_coef, poly_features) + self.force_intercept
|
||||||
|
torque = np.dot(self.torque_coef, poly_features) + self.torque_intercept
|
||||||
|
return float(force), float(torque)
|
||||||
|
|
||||||
|
def _jacobian_internal(self, x):
|
||||||
|
"""
|
||||||
|
Compute the 2×4 Jacobian w.r.t. the internal polynomial variables
|
||||||
|
(currL, currR, rollDeg, invGap).
|
||||||
|
|
||||||
|
For each variable x_k, the partial derivative of a polynomial term
|
||||||
|
c · x₁^a₁ · x₂^a₂ · x₃^a₃ · x₄^a₄
|
||||||
|
is:
|
||||||
|
c · a_k · x_k^(a_k - 1) · ∏_{j≠k} x_j^a_j
|
||||||
|
|
||||||
|
This is computed vectorised over all terms simultaneously.
|
||||||
|
"""
|
||||||
|
jac = np.zeros((2, 4))
|
||||||
|
for k in range(4):
|
||||||
|
# a_k for every term — this becomes the multiplicative scale
|
||||||
|
scale = self.powers[k, :] # (n_terms,)
|
||||||
|
|
||||||
|
# Reduce the k-th exponent by 1 (floored at 0; the scale
|
||||||
|
# factor of 0 for constant-in-x_k terms zeros those out)
|
||||||
|
deriv_powers = self.powers.copy()
|
||||||
|
deriv_powers[k, :] = np.maximum(deriv_powers[k, :] - 1.0, 0.0)
|
||||||
|
|
||||||
|
# Evaluate the derivative polynomial
|
||||||
|
poly_terms = np.prod(x[:, None] ** deriv_powers, axis=0)
|
||||||
|
deriv_features = scale * poly_terms # (n_terms,)
|
||||||
|
|
||||||
|
jac[0, k] = np.dot(self.force_coef, deriv_features)
|
||||||
|
jac[1, k] = np.dot(self.torque_coef, deriv_features)
|
||||||
|
|
||||||
|
return jac
|
||||||
|
|
||||||
|
def linearize(self, currL, currR, roll, gap_height):
|
||||||
|
"""
|
||||||
|
Compute the Jacobian linearization at the given operating point.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
currL : float Left coil current [A]
|
||||||
|
currR : float Right coil current [A]
|
||||||
|
roll : float Roll angle [deg]
|
||||||
|
gap_height : float Air gap [mm]
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
LinearizedPlant
|
||||||
|
Contains the operating-point values (F₀, τ₀) and the 2×4
|
||||||
|
Jacobian with columns [currL, currR, roll, gap_height].
|
||||||
|
"""
|
||||||
|
x = self._to_internal(currL, currR, roll, gap_height)
|
||||||
|
f0, tau0 = self.evaluate(currL, currR, roll, gap_height)
|
||||||
|
|
||||||
|
# Jacobian in internal coordinates (w.r.t. invGap in column 3)
|
||||||
|
jac_internal = self._jacobian_internal(x)
|
||||||
|
|
||||||
|
# Chain rule: ∂f/∂gap = ∂f/∂invGap · d(invGap)/d(gap)
|
||||||
|
# = ∂f/∂invGap · (−1 / gap²)
|
||||||
|
jac = jac_internal.copy()
|
||||||
|
jac[:, 3] *= -1.0 / (gap_height ** 2)
|
||||||
|
|
||||||
|
return LinearizedPlant(
|
||||||
|
operating_point={
|
||||||
|
'currL': currL, 'currR': currR,
|
||||||
|
'roll': roll, 'gap_height': gap_height,
|
||||||
|
},
|
||||||
|
f0=f0,
|
||||||
|
tau0=tau0,
|
||||||
|
jacobian=jac,
|
||||||
|
)
|
||||||
|
|
||||||
|
def gain_schedule(self, gap_heights, currL, currR, roll=0.0):
|
||||||
|
"""
|
||||||
|
Precompute linearizations across a range of gap heights at fixed
|
||||||
|
current and roll. Useful for visualising how plant gains vary
|
||||||
|
and for designing a gain-scheduled PID.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
gap_heights : array-like of float [mm]
|
||||||
|
currL, currR : float [A]
|
||||||
|
roll : float [deg], default 0
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list of LinearizedPlant, one per gap height
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
self.linearize(currL, currR, roll, g)
|
||||||
|
for g in gap_heights
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Demo / quick validation
|
||||||
|
# ==========================================================================
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
|
||||||
|
model_path = os.path.join(os.path.dirname(__file__), 'maglev_model.pkl')
|
||||||
|
lin = MaglevLinearizer(model_path)
|
||||||
|
|
||||||
|
# --- Single-point linearization ---
|
||||||
|
print("=" * 70)
|
||||||
|
print("SINGLE-POINT LINEARIZATION")
|
||||||
|
print("=" * 70)
|
||||||
|
plant = lin.linearize(currL=-15, currR=-15, roll=0.0, gap_height=10.0)
|
||||||
|
print(plant)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Quick sanity check: compare linear prediction vs full polynomial
|
||||||
|
dc = 0.5 # small current perturbation
|
||||||
|
f_lin, t_lin = plant.predict(delta_currL=dc)
|
||||||
|
f_act, t_act = lin.evaluate(-15 + dc, -15, 0.0, 10.0)
|
||||||
|
print(f"Linearised vs Actual (ΔcurrL = {dc:+.1f} A):")
|
||||||
|
print(f" Force: {f_lin:.4f} vs {f_act:.4f} (err {abs(f_lin-f_act):.6f} N)")
|
||||||
|
print(f" Torque: {t_lin:.4f} vs {t_act:.4f} (err {abs(t_lin-t_act):.6f} mN·m)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# --- Gain schedule across gap heights ---
|
||||||
|
print("=" * 70)
|
||||||
|
print("GAIN SCHEDULE (currL = currR = -15 A, roll = 0°)")
|
||||||
|
print("=" * 70)
|
||||||
|
gaps = [6, 8, 10, 12, 15, 20, 25]
|
||||||
|
plants = lin.gain_schedule(gaps, currL=-15, currR=-15, roll=0.0)
|
||||||
|
|
||||||
|
header = f"{'Gap [mm]':>10} {'F₀ [N]':>10} {'∂F/∂iL':>10} {'∂F/∂iR':>10} {'∂F/∂gap':>10} {'∂T/∂iL':>12} {'∂T/∂iR':>12}"
|
||||||
|
print(header)
|
||||||
|
print("-" * len(header))
|
||||||
|
for p in plants:
|
||||||
|
g = p.operating_point['gap_height']
|
||||||
|
print(
|
||||||
|
f"{g:10.1f} {p.f0:10.3f} {p.dF_dcurrL:10.4f} "
|
||||||
|
f"{p.dF_dcurrR:10.4f} {p.dF_dgap:10.4f} "
|
||||||
|
f"{p.dT_dcurrL:12.4f} {p.dT_dcurrR:12.4f}"
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# --- Note on PID usage ---
|
||||||
|
print("=" * 70)
|
||||||
|
print("NOTES FOR PID DESIGN")
|
||||||
|
print("=" * 70)
|
||||||
|
print("""
|
||||||
|
At each operating point, the linearized electromagnetic plant is:
|
||||||
|
|
||||||
|
[ΔF ] [∂F/∂iL ∂F/∂iR] [ΔiL] [∂F/∂roll ∂F/∂gap] [Δroll]
|
||||||
|
[ΔTau] = [∂T/∂iL ∂T/∂iR] [ΔiR] + [∂T/∂roll ∂T/∂gap] [Δgap ]
|
||||||
|
^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^
|
||||||
|
control_jacobian state_jacobian
|
||||||
|
|
||||||
|
The full mechanical dynamics (linearized) are:
|
||||||
|
m · Δg̈ap = ΔF - m·g (vertical — note ∂F/∂gap > 0 means unstable)
|
||||||
|
Iz · Δroll̈ = ΔTau (roll)
|
||||||
|
|
||||||
|
So the PID loop sees:
|
||||||
|
control_jacobian → the gain from current commands to force/torque
|
||||||
|
state_jacobian → the coupling from state perturbations (acts like
|
||||||
|
a destabilising spring for gap, restoring for roll)
|
||||||
|
""")
|
||||||
653
lev_sim/archive_not_used/maglev_state_space.py
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
"""
|
||||||
|
Full Linearized State-Space Model for the Guadaloop Maglev Pod
|
||||||
|
==============================================================
|
||||||
|
|
||||||
|
Combines three dynamic layers into a single LTI system ẋ = Ax + Bu, y = Cx:
|
||||||
|
|
||||||
|
Layer 1 — Coil RL dynamics (electrical):
|
||||||
|
di/dt = (V·pwm − R·i) / L
|
||||||
|
This is already linear. A first-order lag from PWM command to current.
|
||||||
|
|
||||||
|
Layer 2 — Electromagnetic force/torque map (from Ansys polynomial):
|
||||||
|
(F, τ) = f(iL, iR, roll, gap)
|
||||||
|
Nonlinear, but the MaglevLinearizer gives us the Jacobian at any
|
||||||
|
operating point, making it locally linear.
|
||||||
|
|
||||||
|
Layer 3 — Rigid-body mechanics (Newton/Euler):
|
||||||
|
m·z̈ = F_front + F_back − m·g (heave)
|
||||||
|
Iy·θ̈ = L_arm·(F_front − F_back) (pitch from force differential)
|
||||||
|
Ix·φ̈ = τ_front + τ_back (roll from magnetic torque)
|
||||||
|
These are linear once the force/torque are linearized.
|
||||||
|
|
||||||
|
The key coupling: the pod is rigid, so front and back yoke gaps are NOT
|
||||||
|
independent. They are related to the average gap and pitch angle:
|
||||||
|
|
||||||
|
gap_front = gap_avg − L_arm · pitch
|
||||||
|
gap_back = gap_avg + L_arm · pitch
|
||||||
|
|
||||||
|
This means a pitch perturbation changes both yoke gaps, which changes both
|
||||||
|
yoke forces, which feeds back into the heave and pitch dynamics. The
|
||||||
|
electromagnetic Jacobian captures how force/torque respond to these gap
|
||||||
|
changes, creating the destabilizing "magnetic stiffness" that makes maglev
|
||||||
|
inherently open-loop unstable.
|
||||||
|
|
||||||
|
State vector (10 states):
|
||||||
|
x = [gap_avg, gap_vel, pitch, pitch_rate, roll, roll_rate,
|
||||||
|
i_FL, i_FR, i_BL, i_BR]
|
||||||
|
|
||||||
|
- gap_avg [m]: average air gap (track-to-yoke distance)
|
||||||
|
- gap_vel [m/s]: d(gap_avg)/dt
|
||||||
|
- pitch [rad]: rotation about Y axis (positive = back hangs lower)
|
||||||
|
- pitch_rate [rad/s]
|
||||||
|
- roll [rad]: rotation about X axis
|
||||||
|
- roll_rate [rad/s]
|
||||||
|
- i_FL..BR [A]: the four coil currents
|
||||||
|
|
||||||
|
Input vector (4 inputs):
|
||||||
|
u = [pwm_FL, pwm_FR, pwm_BL, pwm_BR] (duty cycles, dimensionless)
|
||||||
|
|
||||||
|
Output vector (3 outputs):
|
||||||
|
y = [gap_avg, pitch, roll]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import os
|
||||||
|
from maglev_linearizer import MaglevLinearizer
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Physical constants and unit conversions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
GRAVITY = 9.81 # m/s²
|
||||||
|
DEG2RAD = np.pi / 180.0
|
||||||
|
RAD2DEG = 180.0 / np.pi
|
||||||
|
|
||||||
|
# State indices (for readability)
|
||||||
|
GAP, GAPV, PITCH, PITCHV, ROLL, ROLLV, I_FL, I_FR, I_BL, I_BR = range(10)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# StateSpaceResult — the output container
|
||||||
|
# ===================================================================
|
||||||
|
class StateSpaceResult:
|
||||||
|
"""
|
||||||
|
Holds the A, B, C, D matrices of the linearized plant plus
|
||||||
|
operating-point metadata and stability analysis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATE_LABELS = [
|
||||||
|
'gap_avg [m]', 'gap_vel [m/s]',
|
||||||
|
'pitch [rad]', 'pitch_rate [rad/s]',
|
||||||
|
'roll [rad]', 'roll_rate [rad/s]',
|
||||||
|
'i_FL [A]', 'i_FR [A]', 'i_BL [A]', 'i_BR [A]',
|
||||||
|
]
|
||||||
|
INPUT_LABELS = ['pwm_FL', 'pwm_FR', 'pwm_BL', 'pwm_BR']
|
||||||
|
OUTPUT_LABELS = ['gap_avg [m]', 'pitch [rad]', 'roll [rad]']
|
||||||
|
|
||||||
|
def __init__(self, A, B, C, D, operating_point,
|
||||||
|
equilibrium_force_error, plant_front, plant_back):
|
||||||
|
self.A = A
|
||||||
|
self.B = B
|
||||||
|
self.C = C
|
||||||
|
self.D = D
|
||||||
|
self.operating_point = operating_point
|
||||||
|
self.equilibrium_force_error = equilibrium_force_error
|
||||||
|
self.plant_front = plant_front # LinearizedPlant for front yoke
|
||||||
|
self.plant_back = plant_back # LinearizedPlant for back yoke
|
||||||
|
|
||||||
|
@property
|
||||||
|
def eigenvalues(self):
|
||||||
|
"""Eigenvalues of A, sorted by decreasing real part."""
|
||||||
|
eigs = np.linalg.eigvals(self.A)
|
||||||
|
return eigs[np.argsort(-np.real(eigs))]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open_loop_stable(self):
|
||||||
|
return bool(np.all(np.real(self.eigenvalues) < 0))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unstable_eigenvalues(self):
|
||||||
|
eigs = self.eigenvalues
|
||||||
|
return eigs[np.real(eigs) > 1e-8]
|
||||||
|
|
||||||
|
def to_scipy(self):
|
||||||
|
"""Convert to scipy.signal.StateSpace for frequency-domain analysis."""
|
||||||
|
from scipy.signal import StateSpace
|
||||||
|
return StateSpace(self.A, self.B, self.C, self.D)
|
||||||
|
|
||||||
|
def print_A_structure(self):
|
||||||
|
"""Print the A matrix with row/column labels for physical insight."""
|
||||||
|
labels_short = ['gap', 'ġap', 'θ', 'θ̇', 'φ', 'φ̇',
|
||||||
|
'iFL', 'iFR', 'iBL', 'iBR']
|
||||||
|
print("\nA matrix (non-zero entries):")
|
||||||
|
print("-" * 65)
|
||||||
|
for i in range(10):
|
||||||
|
for j in range(10):
|
||||||
|
if abs(self.A[i, j]) > 1e-10:
|
||||||
|
print(f" A[{labels_short[i]:>3}, {labels_short[j]:>3}] "
|
||||||
|
f"= {self.A[i,j]:+12.4f}")
|
||||||
|
print("-" * 65)
|
||||||
|
|
||||||
|
def print_B_structure(self):
|
||||||
|
"""Print the B matrix with labels."""
|
||||||
|
labels_short = ['gap', 'ġap', 'θ', 'θ̇', 'φ', 'φ̇',
|
||||||
|
'iFL', 'iFR', 'iBL', 'iBR']
|
||||||
|
u_labels = ['uFL', 'uFR', 'uBL', 'uBR']
|
||||||
|
print("\nB matrix (non-zero entries):")
|
||||||
|
print("-" * 50)
|
||||||
|
for i in range(10):
|
||||||
|
for j in range(4):
|
||||||
|
if abs(self.B[i, j]) > 1e-10:
|
||||||
|
print(f" B[{labels_short[i]:>3}, {u_labels[j]:>3}] "
|
||||||
|
f"= {self.B[i,j]:+12.4f}")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
op = self.operating_point
|
||||||
|
eigs = self.eigenvalues
|
||||||
|
|
||||||
|
at_eq = abs(self.equilibrium_force_error) < 0.5
|
||||||
|
eq_str = ('AT EQUILIBRIUM' if at_eq
|
||||||
|
else f'NOT AT EQUILIBRIUM — {self.equilibrium_force_error:+.2f} N residual')
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"=" * 70,
|
||||||
|
"LINEARIZED MAGLEV STATE-SPACE (ẋ = Ax + Bu, y = Cx)",
|
||||||
|
"=" * 70,
|
||||||
|
f"Operating point:",
|
||||||
|
f" gap = {op['gap_height']:.2f} mm, "
|
||||||
|
f"currL = {op['currL']:.2f} A, "
|
||||||
|
f"currR = {op['currR']:.2f} A, "
|
||||||
|
f"roll = {op['roll']:.1f}°, "
|
||||||
|
f"pitch = {op['pitch']:.1f}°",
|
||||||
|
f" F_front = {self.plant_front.f0:.3f} N, "
|
||||||
|
f"F_back = {self.plant_back.f0:.3f} N, "
|
||||||
|
f"F_total = {self.plant_front.f0 + self.plant_back.f0:.3f} N, "
|
||||||
|
f"Weight = {op['mass'] * GRAVITY:.3f} N",
|
||||||
|
f" >> {eq_str}",
|
||||||
|
"",
|
||||||
|
f"System: {self.A.shape[0]} states × "
|
||||||
|
f"{self.B.shape[1]} inputs × "
|
||||||
|
f"{self.C.shape[0]} outputs",
|
||||||
|
f"Open-loop stable: {self.is_open_loop_stable}",
|
||||||
|
"",
|
||||||
|
"Eigenvalues of A:",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Group complex conjugate pairs
|
||||||
|
printed = set()
|
||||||
|
for i, ev in enumerate(eigs):
|
||||||
|
if i in printed:
|
||||||
|
continue
|
||||||
|
re_part = np.real(ev)
|
||||||
|
im_part = np.imag(ev)
|
||||||
|
stability = "UNSTABLE" if re_part > 1e-8 else "stable"
|
||||||
|
|
||||||
|
if abs(im_part) < 1e-6:
|
||||||
|
lines.append(
|
||||||
|
f" λ = {re_part:+12.4f} "
|
||||||
|
f" τ = {abs(1/re_part)*1000 if abs(re_part) > 1e-8 else float('inf'):.2f} ms"
|
||||||
|
f" ({stability})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Find conjugate pair
|
||||||
|
for j in range(i + 1, len(eigs)):
|
||||||
|
if j not in printed and abs(eigs[j] - np.conj(ev)) < 1e-6:
|
||||||
|
printed.add(j)
|
||||||
|
break
|
||||||
|
omega_n = abs(ev)
|
||||||
|
lines.append(
|
||||||
|
f" λ = {re_part:+12.4f} ± {abs(im_part):.4f}j"
|
||||||
|
f" ω_n = {omega_n:.1f} rad/s"
|
||||||
|
f" ({stability})"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.extend(["", "=" * 70])
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# MaglevStateSpace — the builder
|
||||||
|
# ===================================================================
|
||||||
|
class MaglevStateSpace:
|
||||||
|
"""
|
||||||
|
Assembles the full 10-state linearized state-space from the
|
||||||
|
electromagnetic Jacobian + rigid body dynamics + coil dynamics.
|
||||||
|
|
||||||
|
Physical parameters come from the URDF (pod.xml) and MagLevCoil.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, linearizer,
|
||||||
|
mass=9.4,
|
||||||
|
I_roll=0.0192942414, # Ixx from pod.xml [kg·m²]
|
||||||
|
I_pitch=0.130582305, # Iyy from pod.xml [kg·m²]
|
||||||
|
coil_R=1.1, # from MagLevCoil in lev_pod_env.py
|
||||||
|
coil_L=0.0025, # 2.5 mH
|
||||||
|
V_supply=12.0, # supply voltage [V]
|
||||||
|
L_arm=0.1259): # front/back yoke X-offset [m]
|
||||||
|
self.linearizer = linearizer
|
||||||
|
self.mass = mass
|
||||||
|
self.I_roll = I_roll
|
||||||
|
self.I_pitch = I_pitch
|
||||||
|
self.coil_R = coil_R
|
||||||
|
self.coil_L = coil_L
|
||||||
|
self.V_supply = V_supply
|
||||||
|
self.L_arm = L_arm
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_jacobian_to_si(jac):
|
||||||
|
"""
|
||||||
|
Convert a linearizer Jacobian from mixed units to pure SI.
|
||||||
|
|
||||||
|
The linearizer returns:
|
||||||
|
Row 0: Force [N] per [A, A, deg, mm]
|
||||||
|
Row 1: Torque [mN·m] per [A, A, deg, mm]
|
||||||
|
|
||||||
|
We need:
|
||||||
|
Row 0: Force [N] per [A, A, rad, m]
|
||||||
|
Row 1: Torque [N·m] per [A, A, rad, m]
|
||||||
|
|
||||||
|
Conversion factors:
|
||||||
|
col 0,1 (current): ×1 for force, ×(1/1000) for torque
|
||||||
|
col 2 (roll): ×(180/π) for force, ×(180/π)/1000 for torque
|
||||||
|
col 3 (gap): ×1000 for force, ×(1000/1000)=×1 for torque
|
||||||
|
"""
|
||||||
|
si = np.zeros((2, 4))
|
||||||
|
|
||||||
|
# Force row — already in N
|
||||||
|
si[0, 0] = jac[0, 0] # N/A → N/A
|
||||||
|
si[0, 1] = jac[0, 1] # N/A → N/A
|
||||||
|
si[0, 2] = jac[0, 2] * RAD2DEG # N/deg → N/rad
|
||||||
|
si[0, 3] = jac[0, 3] * 1000.0 # N/mm → N/m
|
||||||
|
|
||||||
|
# Torque row — from mN·m to N·m
|
||||||
|
si[1, 0] = jac[1, 0] / 1000.0 # mN·m/A → N·m/A
|
||||||
|
si[1, 1] = jac[1, 1] / 1000.0 # mN·m/A → N·m/A
|
||||||
|
si[1, 2] = jac[1, 2] / 1000.0 * RAD2DEG # mN·m/deg → N·m/rad
|
||||||
|
si[1, 3] = jac[1, 3] # mN·m/mm → N·m/m (factors cancel)
|
||||||
|
|
||||||
|
return si
|
||||||
|
|
||||||
|
def build(self, gap_height, currL, currR, roll=0.0, pitch=0.0):
|
||||||
|
"""
|
||||||
|
Build the A, B, C, D matrices at a given operating point.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
gap_height : float Average gap [mm]
|
||||||
|
currL : float Equilibrium left coil current [A] (same front & back)
|
||||||
|
currR : float Equilibrium right coil current [A]
|
||||||
|
roll : float Equilibrium roll angle [deg], default 0
|
||||||
|
pitch : float Equilibrium pitch angle [deg], default 0
|
||||||
|
Non-zero pitch means front/back gaps differ.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
StateSpaceResult
|
||||||
|
"""
|
||||||
|
m = self.mass
|
||||||
|
Ix = self.I_roll
|
||||||
|
Iy = self.I_pitch
|
||||||
|
R = self.coil_R
|
||||||
|
Lc = self.coil_L
|
||||||
|
V = self.V_supply
|
||||||
|
La = self.L_arm
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Step 1: Compute individual yoke gaps from average gap + pitch
|
||||||
|
#
|
||||||
|
# The pod is rigid. If it pitches, the front and back yoke ends
|
||||||
|
# are at different distances from the track:
|
||||||
|
# gap_front = gap_avg − L_arm · sin(pitch) ≈ gap_avg − L_arm · pitch
|
||||||
|
# gap_back = gap_avg + L_arm · sin(pitch) ≈ gap_avg + L_arm · pitch
|
||||||
|
#
|
||||||
|
# Sign convention (from lev_pod_env.py lines 230-232):
|
||||||
|
# positive pitch = back gap > front gap (back hangs lower)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
pitch_rad = pitch * DEG2RAD
|
||||||
|
# L_arm [m] * sin(pitch) [rad] → meters; convert to mm for linearizer
|
||||||
|
gap_front_mm = gap_height - La * np.sin(pitch_rad) * 1000.0
|
||||||
|
gap_back_mm = gap_height + La * np.sin(pitch_rad) * 1000.0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Step 2: Linearize each yoke independently
|
||||||
|
#
|
||||||
|
# Each U-yoke has its own (iL, iR) pair and sees its own gap.
|
||||||
|
# Both yokes see the same roll angle (the pod is rigid).
|
||||||
|
# The linearizer returns the Jacobian in mixed units.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
plant_f = self.linearizer.linearize(currL, currR, roll, gap_front_mm)
|
||||||
|
plant_b = self.linearizer.linearize(currL, currR, roll, gap_back_mm)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Step 3: Convert Jacobians to SI
|
||||||
|
#
|
||||||
|
# After this, all gains are in [N or N·m] per [A, A, rad, m].
|
||||||
|
# Columns: [currL, currR, roll, gap_height]
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
Jf = self._convert_jacobian_to_si(plant_f.jacobian)
|
||||||
|
Jb = self._convert_jacobian_to_si(plant_b.jacobian)
|
||||||
|
|
||||||
|
# Unpack for clarity — subscript _f = front yoke, _b = back yoke
|
||||||
|
# Force gains
|
||||||
|
kFiL_f, kFiR_f, kFr_f, kFg_f = Jf[0]
|
||||||
|
kFiL_b, kFiR_b, kFr_b, kFg_b = Jb[0]
|
||||||
|
# Torque gains
|
||||||
|
kTiL_f, kTiR_f, kTr_f, kTg_f = Jf[1]
|
||||||
|
kTiL_b, kTiR_b, kTr_b, kTg_b = Jb[1]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Step 4: Assemble the A matrix (10 × 10)
|
||||||
|
#
|
||||||
|
# The A matrix encodes three kinds of coupling:
|
||||||
|
#
|
||||||
|
# (a) Kinematic identities: gap_vel = d(gap)/dt, etc.
|
||||||
|
# These are always 1.0 on the super-diagonal of the
|
||||||
|
# position/velocity pairs.
|
||||||
|
#
|
||||||
|
# (b) Electromagnetic coupling through current states:
|
||||||
|
# Coil currents produce forces/torques. The linearized
|
||||||
|
# gains (∂F/∂i, ∂T/∂i) appear in the acceleration rows.
|
||||||
|
# This is the path from current states to mechanical
|
||||||
|
# acceleration — the "plant gain" that PID acts through.
|
||||||
|
#
|
||||||
|
# (c) Electromagnetic coupling through mechanical states:
|
||||||
|
# Gap and roll perturbations change the force/torque.
|
||||||
|
# This creates feedback loops:
|
||||||
|
#
|
||||||
|
# - ∂F/∂gap < 0 → gap perturbation changes force in a
|
||||||
|
# direction that AMPLIFIES the gap error → UNSTABLE
|
||||||
|
# (magnetic stiffness is "negative spring")
|
||||||
|
#
|
||||||
|
# - ∂T/∂roll → roll perturbation changes torque;
|
||||||
|
# sign determines whether roll is self-correcting or not
|
||||||
|
#
|
||||||
|
# - Pitch couples through gap_front/gap_back dependence
|
||||||
|
# on pitch angle, creating pitch instability too
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
A = np.zeros((10, 10))
|
||||||
|
|
||||||
|
# (a) Kinematic identities
|
||||||
|
A[GAP, GAPV] = 1.0
|
||||||
|
A[PITCH, PITCHV] = 1.0
|
||||||
|
A[ROLL, ROLLV] = 1.0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# HEAVE: m · Δgap̈ = −(ΔF_front + ΔF_back)
|
||||||
|
#
|
||||||
|
# The negative sign is because force is upward (+Z) but gap
|
||||||
|
# is measured downward (gap shrinks when pod moves up).
|
||||||
|
# At equilibrium F₀ = mg; perturbation ΔF pushes pod up → gap shrinks.
|
||||||
|
#
|
||||||
|
# Expanding ΔF using the rigid-body gap coupling:
|
||||||
|
# ΔF_front = kFg_f·(Δgap − La·Δpitch) + kFr_f·Δroll + kFiL_f·ΔiFL + kFiR_f·ΔiFR
|
||||||
|
# ΔF_back = kFg_b·(Δgap + La·Δpitch) + kFr_b·Δroll + kFiL_b·ΔiBL + kFiR_b·ΔiBR
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Gap → gap acceleration (magnetic stiffness, UNSTABLE)
|
||||||
|
A[GAPV, GAP] = -(kFg_f + kFg_b) / m
|
||||||
|
# Pitch → gap acceleration (cross-coupling through differential gap)
|
||||||
|
A[GAPV, PITCH] = -(-kFg_f + kFg_b) * La / m
|
||||||
|
# Roll → gap acceleration
|
||||||
|
A[GAPV, ROLL] = -(kFr_f + kFr_b) / m
|
||||||
|
# Current → gap acceleration (the control path!)
|
||||||
|
A[GAPV, I_FL] = -kFiL_f / m
|
||||||
|
A[GAPV, I_FR] = -kFiR_f / m
|
||||||
|
A[GAPV, I_BL] = -kFiL_b / m
|
||||||
|
A[GAPV, I_BR] = -kFiR_b / m
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PITCH: Iy · Δpitcḧ = La · (ΔF_front − ΔF_back)
|
||||||
|
#
|
||||||
|
# Pitch torque comes from DIFFERENTIAL FORCE, not from the
|
||||||
|
# electromagnetic torque (which acts on roll). This is because
|
||||||
|
# the front yoke is at x = +La and the back at x = −La:
|
||||||
|
# τ_pitch = F_front·La − F_back·La = La·(F_front − F_back)
|
||||||
|
#
|
||||||
|
# At symmetric equilibrium, F_front = F_back → zero pitch torque. ✓
|
||||||
|
# A pitch perturbation breaks this symmetry through the gap coupling.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Gap → pitch acceleration (zero at symmetric equilibrium)
|
||||||
|
A[PITCHV, GAP] = La * (kFg_f - kFg_b) / Iy
|
||||||
|
# Pitch → pitch acceleration (pitch instability — UNSTABLE)
|
||||||
|
# = −La²·(kFg_f + kFg_b)/Iy. Since kFg < 0 → positive → unstable.
|
||||||
|
A[PITCHV, PITCH] = -La**2 * (kFg_f + kFg_b) / Iy
|
||||||
|
# Roll → pitch acceleration
|
||||||
|
A[PITCHV, ROLL] = La * (kFr_f - kFr_b) / Iy
|
||||||
|
# Current → pitch acceleration
|
||||||
|
A[PITCHV, I_FL] = La * kFiL_f / Iy
|
||||||
|
A[PITCHV, I_FR] = La * kFiR_f / Iy
|
||||||
|
A[PITCHV, I_BL] = -La * kFiL_b / Iy
|
||||||
|
A[PITCHV, I_BR] = -La * kFiR_b / Iy
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# ROLL: Ix · Δroll̈ = Δτ_front + Δτ_back
|
||||||
|
#
|
||||||
|
# Unlike pitch (driven by force differential), roll is driven by
|
||||||
|
# the electromagnetic TORQUE directly. In the Ansys model, torque
|
||||||
|
# is the moment about the X axis produced by the asymmetric flux
|
||||||
|
# in the left vs right legs of each U-yoke.
|
||||||
|
#
|
||||||
|
# The torque Jacobian entries determine stability:
|
||||||
|
# - ∂T/∂roll: if this causes torque that amplifies roll → unstable
|
||||||
|
# - ∂T/∂iL, ∂T/∂iR: how current asymmetry controls roll
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Gap → roll acceleration
|
||||||
|
A[ROLLV, GAP] = (kTg_f + kTg_b) / Ix
|
||||||
|
# Pitch → roll acceleration (cross-coupling)
|
||||||
|
A[ROLLV, PITCH] = (-kTg_f + kTg_b) * La / Ix
|
||||||
|
# Roll → roll acceleration (roll stiffness)
|
||||||
|
A[ROLLV, ROLL] = (kTr_f + kTr_b) / Ix
|
||||||
|
# Current → roll acceleration
|
||||||
|
A[ROLLV, I_FL] = kTiL_f / Ix
|
||||||
|
A[ROLLV, I_FR] = kTiR_f / Ix
|
||||||
|
A[ROLLV, I_BL] = kTiL_b / Ix
|
||||||
|
A[ROLLV, I_BR] = kTiR_b / Ix
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# COIL DYNAMICS: L·di/dt = V·pwm − R·i
|
||||||
|
#
|
||||||
|
# Rearranged: di/dt = −(R/L)·i + (V/L)·pwm
|
||||||
|
#
|
||||||
|
# This is a simple first-order lag with:
|
||||||
|
# - Time constant τ_coil = L/R = 2.5ms/1.1 = 2.27 ms
|
||||||
|
# - Eigenvalue = −R/L = −440 (very fast, well-damped)
|
||||||
|
#
|
||||||
|
# The coil dynamics act as a low-pass filter between the PWM
|
||||||
|
# command and the actual current. For PID frequencies below
|
||||||
|
# ~100 Hz, this lag is small but not negligible.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
for k in range(I_FL, I_BR + 1):
|
||||||
|
A[k, k] = -R / Lc
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Step 5: B matrix (10 × 4)
|
||||||
|
#
|
||||||
|
# Only the coil states respond directly to the PWM inputs.
|
||||||
|
# The mechanical states are affected INDIRECTLY: pwm → current
|
||||||
|
# → force/torque → acceleration. This indirect path shows up
|
||||||
|
# as the product A_mech_curr × B_curr_pwm in the transfer function.
|
||||||
|
#
|
||||||
|
# B[coil_k, pwm_k] = V_supply / L_coil
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
B = np.zeros((10, 4))
|
||||||
|
for k in range(4):
|
||||||
|
B[I_FL + k, k] = V / Lc
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Step 6: C matrix (3 × 10)
|
||||||
|
#
|
||||||
|
# Default outputs are the three controlled DOFs:
|
||||||
|
# gap_avg, pitch, roll
|
||||||
|
# These are directly the position states.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
C = np.zeros((3, 10))
|
||||||
|
C[0, GAP] = 1.0 # gap_avg
|
||||||
|
C[1, PITCH] = 1.0 # pitch
|
||||||
|
C[2, ROLL] = 1.0 # roll
|
||||||
|
|
||||||
|
# D = 0 (no direct feedthrough from PWM to position)
|
||||||
|
D = np.zeros((3, 4))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Step 7: Equilibrium check
|
||||||
|
#
|
||||||
|
# At a valid operating point, the total magnetic force should
|
||||||
|
# equal the pod weight. A large residual means the linearization
|
||||||
|
# is valid mathematically but not physically meaningful (the pod
|
||||||
|
# wouldn't hover at this point without acceleration).
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
F_total = plant_f.f0 + plant_b.f0
|
||||||
|
weight = m * GRAVITY
|
||||||
|
eq_error = F_total - weight
|
||||||
|
|
||||||
|
return StateSpaceResult(
|
||||||
|
A=A, B=B, C=C, D=D,
|
||||||
|
operating_point={
|
||||||
|
'gap_height': gap_height,
|
||||||
|
'currL': currL, 'currR': currR,
|
||||||
|
'roll': roll, 'pitch': pitch,
|
||||||
|
'mass': m,
|
||||||
|
},
|
||||||
|
equilibrium_force_error=eq_error,
|
||||||
|
plant_front=plant_f,
|
||||||
|
plant_back=plant_b,
|
||||||
|
)
|
||||||
|
|
||||||
|
def find_equilibrium_current(self, gap_height, roll=0.0, tol=0.01):
|
||||||
|
"""
|
||||||
|
Find the symmetric current (currL = currR = I) that makes
|
||||||
|
total force = weight at the given gap height.
|
||||||
|
|
||||||
|
Uses bisection over the current range. The search assumes
|
||||||
|
negative currents produce attractive (upward) force, which
|
||||||
|
matches the Ansys model convention.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
gap_height : float Target gap [mm]
|
||||||
|
roll : float Roll angle [deg], default 0
|
||||||
|
tol : float Force tolerance [N]
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
float : equilibrium current [A]
|
||||||
|
"""
|
||||||
|
target_per_yoke = self.mass * GRAVITY / 2.0
|
||||||
|
|
||||||
|
def force_residual(curr):
|
||||||
|
f, _ = self.linearizer.evaluate(curr, curr, roll, gap_height)
|
||||||
|
return f - target_per_yoke
|
||||||
|
|
||||||
|
# Bisection search over negative current range
|
||||||
|
# (More negative = stronger attraction)
|
||||||
|
a, b = -20.0, 0.0
|
||||||
|
fa, fb = force_residual(a), force_residual(b)
|
||||||
|
|
||||||
|
if fa * fb > 0:
|
||||||
|
# Try positive range too
|
||||||
|
a, b = 0.0, 20.0
|
||||||
|
fa, fb = force_residual(a), force_residual(b)
|
||||||
|
if fa * fb > 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot find equilibrium current at gap={gap_height}mm. "
|
||||||
|
f"Force at I=−20A: {target_per_yoke + force_residual(-20):.1f}N, "
|
||||||
|
f"at I=0: {target_per_yoke + force_residual(0):.1f}N, "
|
||||||
|
f"at I=+20A: {target_per_yoke + force_residual(20):.1f}N, "
|
||||||
|
f"target per yoke: {target_per_yoke:.1f}N"
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(100):
|
||||||
|
mid = (a + b) / 2.0
|
||||||
|
fmid = force_residual(mid)
|
||||||
|
if abs(fmid) < tol:
|
||||||
|
return mid
|
||||||
|
if fa * fmid < 0:
|
||||||
|
b = mid
|
||||||
|
else:
|
||||||
|
a, fa = mid, fmid
|
||||||
|
|
||||||
|
return (a + b) / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Demo
|
||||||
|
# ======================================================================
|
||||||
|
if __name__ == '__main__':
|
||||||
|
model_path = os.path.join(os.path.dirname(__file__), 'maglev_model.pkl')
|
||||||
|
lin = MaglevLinearizer(model_path)
|
||||||
|
ss = MaglevStateSpace(lin)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Find the equilibrium current at the target gap
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
TARGET_GAP_MM = 16.491741 # from lev_pod_env.py
|
||||||
|
print("=" * 70)
|
||||||
|
print("FINDING EQUILIBRIUM CURRENT")
|
||||||
|
print("=" * 70)
|
||||||
|
I_eq = ss.find_equilibrium_current(TARGET_GAP_MM)
|
||||||
|
F_eq, T_eq = lin.evaluate(I_eq, I_eq, 0.0, TARGET_GAP_MM)
|
||||||
|
print(f"Target gap: {TARGET_GAP_MM:.3f} mm")
|
||||||
|
print(f"Pod weight: {ss.mass * GRAVITY:.3f} N ({ss.mass} kg)")
|
||||||
|
print(f"Required per yoke: {ss.mass * GRAVITY / 2:.3f} N")
|
||||||
|
print(f"Equilibrium current: {I_eq:.4f} A (symmetric, currL = currR)")
|
||||||
|
print(f"Force per yoke at equilibrium: {F_eq:.3f} N")
|
||||||
|
print(f"Equilibrium PWM duty cycle: {I_eq * ss.coil_R / ss.V_supply:.4f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Build the state-space at equilibrium
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
result = ss.build(
|
||||||
|
gap_height=TARGET_GAP_MM,
|
||||||
|
currL=I_eq,
|
||||||
|
currR=I_eq,
|
||||||
|
roll=0.0,
|
||||||
|
pitch=0.0,
|
||||||
|
)
|
||||||
|
print(result)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Show the coupling structure
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
result.print_A_structure()
|
||||||
|
result.print_B_structure()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Physical interpretation of key eigenvalues
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
eigs = result.eigenvalues
|
||||||
|
unstable = result.unstable_eigenvalues
|
||||||
|
print(f"\nUnstable modes: {len(unstable)}")
|
||||||
|
for ev in unstable:
|
||||||
|
# Time to double = ln(2) / real_part
|
||||||
|
t_double = np.log(2) / np.real(ev) * 1000 # ms
|
||||||
|
print(f" λ = {np.real(ev):+.4f} → amplitude doubles in {t_double:.1f} ms")
|
||||||
|
print()
|
||||||
|
print("The PID loop must have bandwidth FASTER than these unstable modes")
|
||||||
|
print("to stabilize the plant.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Gain schedule: how eigenvalues change with gap
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("GAIN SCHEDULE: unstable eigenvalues vs gap height")
|
||||||
|
print("=" * 70)
|
||||||
|
gaps = [8, 10, 12, 14, TARGET_GAP_MM, 18, 20, 25]
|
||||||
|
header = f"{'Gap [mm]':>10} {'I_eq [A]':>10} {'λ_heave':>12} {'t_dbl [ms]':>12} {'λ_pitch':>12} {'t_dbl [ms]':>12}"
|
||||||
|
print(header)
|
||||||
|
print("-" * len(header))
|
||||||
|
for g in gaps:
|
||||||
|
try:
|
||||||
|
I = ss.find_equilibrium_current(g)
|
||||||
|
r = ss.build(g, I, I, 0.0, 0.0)
|
||||||
|
ue = r.unstable_eigenvalues
|
||||||
|
real_ue = sorted(np.real(ue), reverse=True)
|
||||||
|
# Typically: largest = heave, second = pitch
|
||||||
|
lam_h = real_ue[0] if len(real_ue) > 0 else 0.0
|
||||||
|
lam_p = real_ue[1] if len(real_ue) > 1 else 0.0
|
||||||
|
t_h = np.log(2) / lam_h * 1000 if lam_h > 0 else float('inf')
|
||||||
|
t_p = np.log(2) / lam_p * 1000 if lam_p > 0 else float('inf')
|
||||||
|
print(f"{g:10.2f} {I:10.4f} {lam_h:+12.4f} {t_h:12.1f} "
|
||||||
|
f"{lam_p:+12.4f} {t_p:12.1f}")
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"{g:10.2f} (no equilibrium found)")
|
||||||
1032
lev_sim/lev_PID.ipynb
Normal file
@@ -4,13 +4,15 @@ import pybullet as p
|
|||||||
import pybullet_data
|
import pybullet_data
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime
|
||||||
from mag_lev_coil import MagLevCoil
|
from mag_lev_coil import MagLevCoil
|
||||||
from maglev_predictor import MaglevPredictor
|
from maglev_predictor import MaglevPredictor
|
||||||
|
|
||||||
TARGET_GAP = 16.491741 / 1000 # target gap height for 5.8 kg pod in meters
|
TARGET_GAP = 11.86 / 1000 # target gap height for 9.4 kg pod in meters
|
||||||
|
|
||||||
class LevPodEnv(gym.Env):
|
class LevPodEnv(gym.Env):
|
||||||
def __init__(self, use_gui=False, initial_gap_mm=10.0, max_steps=2000, disturbance_force_std=0.0):
|
def __init__(self, use_gui=False, initial_gap_mm=10.0, max_steps=2000, disturbance_force_std=0.0,
|
||||||
|
record_video=False, record_telemetry=False, record_dir="recordings"):
|
||||||
super(LevPodEnv, self).__init__()
|
super(LevPodEnv, self).__init__()
|
||||||
|
|
||||||
# Store initial gap height parameter
|
# Store initial gap height parameter
|
||||||
@@ -21,6 +23,17 @@ class LevPodEnv(gym.Env):
|
|||||||
# Stochastic disturbance parameter (standard deviation of random force in Newtons)
|
# Stochastic disturbance parameter (standard deviation of random force in Newtons)
|
||||||
self.disturbance_force_std = disturbance_force_std
|
self.disturbance_force_std = disturbance_force_std
|
||||||
|
|
||||||
|
# Recording parameters
|
||||||
|
self.record_video = record_video
|
||||||
|
self.record_telemetry = record_telemetry
|
||||||
|
self.record_dir = record_dir
|
||||||
|
self._frame_skip = 4 # Capture every 4th step → 60fps video from 240Hz sim
|
||||||
|
self._video_width = 640
|
||||||
|
self._video_height = 480
|
||||||
|
self._frames = []
|
||||||
|
self._telemetry = {}
|
||||||
|
self._recording_active = False
|
||||||
|
|
||||||
# The following was coded by AI - see [1]
|
# The following was coded by AI - see [1]
|
||||||
# --- 1. Define Action & Observation Spaces ---
|
# --- 1. Define Action & Observation Spaces ---
|
||||||
# Action: 4 PWM duty cycles between -1 and 1 (4 independent coils)
|
# Action: 4 PWM duty cycles between -1 and 1 (4 independent coils)
|
||||||
@@ -143,7 +156,8 @@ class LevPodEnv(gym.Env):
|
|||||||
if abs(local_pos[0]) < 0.06 or abs(local_pos[1]) < 0.02:
|
if abs(local_pos[0]) < 0.06 or abs(local_pos[1]) < 0.02:
|
||||||
self.sensor_indices.append(i)
|
self.sensor_indices.append(i)
|
||||||
if abs(local_pos[0]) < 0.001: # Center sensors (X ≈ 0)
|
if abs(local_pos[0]) < 0.001: # Center sensors (X ≈ 0)
|
||||||
label = "Center_Right" if local_pos[1] > 0 else "Center_Left"
|
# +Y = Left, -Y = Right (consistent with yoke labeling)
|
||||||
|
label = "Center_Left" if local_pos[1] > 0 else "Center_Right"
|
||||||
else: # Front/back sensors (Y ≈ 0)
|
else: # Front/back sensors (Y ≈ 0)
|
||||||
label = "Front" if local_pos[0] > 0 else "Back"
|
label = "Front" if local_pos[0] > 0 else "Back"
|
||||||
self.sensor_labels.append(label)
|
self.sensor_labels.append(label)
|
||||||
@@ -157,6 +171,48 @@ class LevPodEnv(gym.Env):
|
|||||||
obs = self._get_obs(initial_reset=True)
|
obs = self._get_obs(initial_reset=True)
|
||||||
self.current_step = 0
|
self.current_step = 0
|
||||||
|
|
||||||
|
# --- Recording setup ---
|
||||||
|
# Finalize any previous episode's recording before starting new one
|
||||||
|
if self._recording_active:
|
||||||
|
self._finalize_recording()
|
||||||
|
|
||||||
|
if self.record_video or self.record_telemetry:
|
||||||
|
self._recording_active = True
|
||||||
|
os.makedirs(self.record_dir, exist_ok=True)
|
||||||
|
|
||||||
|
if self.record_video:
|
||||||
|
self._frames = []
|
||||||
|
# Pod body center ≈ start_z, yoke tops at ≈ start_z + 0.091
|
||||||
|
# Track bottom at Z=0. Focus camera on the pod body, looking from the side
|
||||||
|
# so both the pod and the track bottom (with gap between) are visible.
|
||||||
|
pod_center_z = start_z + 0.045 # Approximate visual center of pod
|
||||||
|
self._view_matrix = p.computeViewMatrixFromYawPitchRoll(
|
||||||
|
cameraTargetPosition=[0, 0, pod_center_z],
|
||||||
|
distance=0.55,
|
||||||
|
yaw=50, # Front-right angle
|
||||||
|
pitch=-5, # Nearly horizontal to see gap from the side
|
||||||
|
roll=0,
|
||||||
|
upAxisIndex=2,
|
||||||
|
physicsClientId=self.client
|
||||||
|
)
|
||||||
|
self._proj_matrix = p.computeProjectionMatrixFOV(
|
||||||
|
fov=35, # Narrow FOV for less distortion at close range
|
||||||
|
aspect=self._video_width / self._video_height,
|
||||||
|
nearVal=0.01,
|
||||||
|
farVal=2.0,
|
||||||
|
physicsClientId=self.client
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.record_telemetry:
|
||||||
|
self._telemetry = {
|
||||||
|
'time': [], 'gap_FL': [], 'gap_FR': [], 'gap_BL': [], 'gap_BR': [],
|
||||||
|
'gap_front_avg': [], 'gap_back_avg': [], 'gap_avg': [],
|
||||||
|
'roll_deg': [], 'pitch_deg': [],
|
||||||
|
'curr_FL': [], 'curr_FR': [], 'curr_BL': [], 'curr_BR': [],
|
||||||
|
'force_front': [], 'force_back': [],
|
||||||
|
'torque_front': [], 'torque_back': [],
|
||||||
|
}
|
||||||
|
|
||||||
return obs, {}
|
return obs, {}
|
||||||
|
|
||||||
# The following was generated by AI - see [14]
|
# The following was generated by AI - see [14]
|
||||||
@@ -295,7 +351,7 @@ class LevPodEnv(gym.Env):
|
|||||||
physicsClientId=self.client
|
physicsClientId=self.client
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- 5b. Apply Stochastic Disturbance Force (if enabled) ---
|
# --- 5b. Apply Stochastic Disturbance Force and Torques (if enabled) ---
|
||||||
if self.disturbance_force_std > 0:
|
if self.disturbance_force_std > 0:
|
||||||
disturbance_force = np.random.normal(0, self.disturbance_force_std)
|
disturbance_force = np.random.normal(0, self.disturbance_force_std)
|
||||||
p.applyExternalForce(
|
p.applyExternalForce(
|
||||||
@@ -305,6 +361,17 @@ class LevPodEnv(gym.Env):
|
|||||||
flags=p.LINK_FRAME,
|
flags=p.LINK_FRAME,
|
||||||
physicsClientId=self.client
|
physicsClientId=self.client
|
||||||
)
|
)
|
||||||
|
# Roll and pitch disturbance torques, scaled from heave force (torque ~ force * moment_arm)
|
||||||
|
# Moment arm ~ 0.15 m so e.g. 1 N force -> 0.15 N·m torque std
|
||||||
|
disturbance_torque_std = self.disturbance_force_std * 0.15
|
||||||
|
roll_torque = np.random.normal(0, disturbance_torque_std)
|
||||||
|
pitch_torque = np.random.normal(0, disturbance_torque_std)
|
||||||
|
p.applyExternalTorque(
|
||||||
|
self.podId, -1,
|
||||||
|
torqueObj=[roll_torque, pitch_torque, 0],
|
||||||
|
flags=p.LINK_FRAME,
|
||||||
|
physicsClientId=self.client
|
||||||
|
)
|
||||||
|
|
||||||
# --- 6. Step Simulation ---
|
# --- 6. Step Simulation ---
|
||||||
p.stepSimulation(physicsClientId=self.client)
|
p.stepSimulation(physicsClientId=self.client)
|
||||||
@@ -318,8 +385,8 @@ class LevPodEnv(gym.Env):
|
|||||||
obs = self._get_obs()
|
obs = self._get_obs()
|
||||||
|
|
||||||
# --- 8. Calculate Reward ---
|
# --- 8. Calculate Reward ---
|
||||||
# Goal: Hover at target gap (16.5mm), minimize roll/pitch, minimize power
|
# Goal: Hover at target gap (11.8mm), minimize roll/pitch, minimize power
|
||||||
target_gap = TARGET_GAP # 16.5mm in meters
|
target_gap = TARGET_GAP # 11.8mm in meters
|
||||||
avg_gap = (avg_gap_front + avg_gap_back) / 2
|
avg_gap = (avg_gap_front + avg_gap_back) / 2
|
||||||
|
|
||||||
gap_error = abs(avg_gap - target_gap)
|
gap_error = abs(avg_gap - target_gap)
|
||||||
@@ -391,6 +458,42 @@ class LevPodEnv(gym.Env):
|
|||||||
'torque_back': torque_back
|
'torque_back': torque_back
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Recording ---
|
||||||
|
if self.record_video and self.current_step % self._frame_skip == 0:
|
||||||
|
_, _, rgb, _, _ = p.getCameraImage(
|
||||||
|
width=self._video_width,
|
||||||
|
height=self._video_height,
|
||||||
|
viewMatrix=self._view_matrix,
|
||||||
|
projectionMatrix=self._proj_matrix,
|
||||||
|
physicsClientId=self.client
|
||||||
|
)
|
||||||
|
self._frames.append(np.array(rgb, dtype=np.uint8).reshape(
|
||||||
|
self._video_height, self._video_width, 4)[:, :, :3]) # RGBA → RGB
|
||||||
|
|
||||||
|
if self.record_telemetry:
|
||||||
|
t = self._telemetry
|
||||||
|
t['time'].append(self.current_step * self.dt)
|
||||||
|
t['gap_FL'].append(front_left_gap * 1000)
|
||||||
|
t['gap_FR'].append(front_right_gap * 1000)
|
||||||
|
t['gap_BL'].append(back_left_gap * 1000)
|
||||||
|
t['gap_BR'].append(back_right_gap * 1000)
|
||||||
|
t['gap_front_avg'].append(avg_gap_front * 1000)
|
||||||
|
t['gap_back_avg'].append(avg_gap_back * 1000)
|
||||||
|
t['gap_avg'].append(avg_gap * 1000)
|
||||||
|
t['roll_deg'].append(np.degrees(roll_angle))
|
||||||
|
t['pitch_deg'].append(np.degrees(pitch_angle))
|
||||||
|
t['curr_FL'].append(curr_front_L)
|
||||||
|
t['curr_FR'].append(curr_front_R)
|
||||||
|
t['curr_BL'].append(curr_back_L)
|
||||||
|
t['curr_BR'].append(curr_back_R)
|
||||||
|
t['force_front'].append(force_front)
|
||||||
|
t['force_back'].append(force_back)
|
||||||
|
t['torque_front'].append(torque_front)
|
||||||
|
t['torque_back'].append(torque_back)
|
||||||
|
|
||||||
|
if terminated or truncated:
|
||||||
|
self._finalize_recording()
|
||||||
|
|
||||||
return obs, reward, terminated, truncated, info
|
return obs, reward, terminated, truncated, info
|
||||||
|
|
||||||
def apply_impulse(self, force_z: float, position: list = None):
|
def apply_impulse(self, force_z: float, position: list = None):
|
||||||
@@ -411,6 +514,20 @@ class LevPodEnv(gym.Env):
|
|||||||
physicsClientId=self.client
|
physicsClientId=self.client
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def apply_torque_impulse(self, torque_nm: list):
|
||||||
|
"""
|
||||||
|
Apply a one-time impulse torque to the pod (body frame).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
torque_nm: [Tx, Ty, Tz] in N·m (LINK_FRAME: X=roll, Y=pitch, Z=yaw)
|
||||||
|
"""
|
||||||
|
p.applyExternalTorque(
|
||||||
|
self.podId, -1,
|
||||||
|
torqueObj=torque_nm,
|
||||||
|
flags=p.LINK_FRAME,
|
||||||
|
physicsClientId=self.client
|
||||||
|
)
|
||||||
|
|
||||||
# The following was generated by AI - see [15]
|
# The following was generated by AI - see [15]
|
||||||
def _get_obs(self, initial_reset=False):
|
def _get_obs(self, initial_reset=False):
|
||||||
"""
|
"""
|
||||||
@@ -511,7 +628,98 @@ class LevPodEnv(gym.Env):
|
|||||||
|
|
||||||
return temp_urdf_path
|
return temp_urdf_path
|
||||||
|
|
||||||
|
def _finalize_recording(self):
|
||||||
|
"""Save recorded video and/or telemetry plot to disk."""
|
||||||
|
if not self._recording_active:
|
||||||
|
return
|
||||||
|
self._recording_active = False
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
# --- Save video ---
|
||||||
|
if self.record_video and len(self._frames) > 0:
|
||||||
|
import imageio.v3 as iio
|
||||||
|
video_path = os.path.join(self.record_dir, f"sim_{timestamp}.mp4")
|
||||||
|
fps = int(round(1.0 / (self.dt * self._frame_skip))) # 60fps
|
||||||
|
iio.imwrite(video_path, self._frames, fps=fps, codec="h264")
|
||||||
|
print(f"Video saved: {video_path} ({len(self._frames)} frames, {fps}fps)")
|
||||||
|
self._frames = []
|
||||||
|
|
||||||
|
# --- Save telemetry plot ---
|
||||||
|
if self.record_telemetry and len(self._telemetry.get('time', [])) > 0:
|
||||||
|
self._save_telemetry_plot(timestamp)
|
||||||
|
|
||||||
|
def _save_telemetry_plot(self, timestamp):
|
||||||
|
"""Generate and save a 4-panel telemetry figure."""
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
t = {k: np.array(v) for k, v in self._telemetry.items()}
|
||||||
|
time = t['time']
|
||||||
|
target_mm = TARGET_GAP * 1000
|
||||||
|
weight = 9.4 * 9.81 # Pod weight in N
|
||||||
|
|
||||||
|
fig, axes = plt.subplots(4, 1, figsize=(12, 14), sharex=True)
|
||||||
|
fig.suptitle(f'Simulation Telemetry | gap₀={self.initial_gap_mm}mm target={target_mm:.1f}mm',
|
||||||
|
fontsize=14, fontweight='bold')
|
||||||
|
|
||||||
|
# --- Panel 1: Gap heights ---
|
||||||
|
ax = axes[0]
|
||||||
|
ax.plot(time, t['gap_FL'], lw=1, alpha=0.6, label='FL')
|
||||||
|
ax.plot(time, t['gap_FR'], lw=1, alpha=0.6, label='FR')
|
||||||
|
ax.plot(time, t['gap_BL'], lw=1, alpha=0.6, label='BL')
|
||||||
|
ax.plot(time, t['gap_BR'], lw=1, alpha=0.6, label='BR')
|
||||||
|
ax.plot(time, t['gap_avg'], lw=2, color='black', label='Average')
|
||||||
|
ax.axhline(y=target_mm, color='orange', ls='--', lw=2, label=f'Target ({target_mm:.1f}mm)')
|
||||||
|
ax.set_ylabel('Gap Height (mm)')
|
||||||
|
ax.legend(loc='best', ncol=3, fontsize=9)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
final_err = abs(t['gap_avg'][-1] - target_mm)
|
||||||
|
ax.text(0.98, 0.02, f'Final error: {final_err:.3f}mm',
|
||||||
|
transform=ax.transAxes, ha='right', va='bottom', fontsize=10,
|
||||||
|
bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))
|
||||||
|
|
||||||
|
# --- Panel 2: Roll & Pitch ---
|
||||||
|
ax = axes[1]
|
||||||
|
ax.plot(time, t['roll_deg'], lw=1.5, label='Roll')
|
||||||
|
ax.plot(time, t['pitch_deg'], lw=1.5, label='Pitch')
|
||||||
|
ax.axhline(y=0, color='gray', ls='--', lw=1)
|
||||||
|
ax.set_ylabel('Angle (degrees)')
|
||||||
|
ax.legend(loc='best', fontsize=9)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
# --- Panel 3: Coil currents ---
|
||||||
|
ax = axes[2]
|
||||||
|
ax.plot(time, t['curr_FL'], lw=1, alpha=0.8, label='FL')
|
||||||
|
ax.plot(time, t['curr_FR'], lw=1, alpha=0.8, label='FR')
|
||||||
|
ax.plot(time, t['curr_BL'], lw=1, alpha=0.8, label='BL')
|
||||||
|
ax.plot(time, t['curr_BR'], lw=1, alpha=0.8, label='BR')
|
||||||
|
total = np.abs(t['curr_FL']) + np.abs(t['curr_FR']) + np.abs(t['curr_BL']) + np.abs(t['curr_BR'])
|
||||||
|
ax.plot(time, total, lw=2, color='black', ls='--', label='Total |I|')
|
||||||
|
ax.set_ylabel('Current (A)')
|
||||||
|
ax.legend(loc='best', ncol=3, fontsize=9)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
# --- Panel 4: Forces ---
|
||||||
|
ax = axes[3]
|
||||||
|
ax.plot(time, t['force_front'], lw=1.5, label='Front yoke')
|
||||||
|
ax.plot(time, t['force_back'], lw=1.5, label='Back yoke')
|
||||||
|
f_total = t['force_front'] + t['force_back']
|
||||||
|
ax.plot(time, f_total, lw=2, color='black', label='Total')
|
||||||
|
ax.axhline(y=weight, color='red', ls='--', lw=1.5, label=f'Weight ({weight:.1f}N)')
|
||||||
|
ax.set_ylabel('Force (N)')
|
||||||
|
ax.set_xlabel('Time (s)')
|
||||||
|
ax.legend(loc='best', ncol=2, fontsize=9)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
plot_path = os.path.join(self.record_dir, f"sim_{timestamp}_telemetry.png")
|
||||||
|
fig.savefig(plot_path, dpi=200, bbox_inches='tight')
|
||||||
|
plt.close(fig)
|
||||||
|
print(f"Telemetry plot saved: {plot_path}")
|
||||||
|
self._telemetry = {}
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
self._finalize_recording()
|
||||||
try:
|
try:
|
||||||
p.disconnect(physicsClientId=self.client)
|
p.disconnect(physicsClientId=self.client)
|
||||||
except p.error:
|
except p.error:
|
||||||
BIN
lev_sim/maglev_model.pkl
Normal file
@@ -14,17 +14,22 @@ import numpy as np
|
|||||||
import joblib
|
import joblib
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
# Default path: next to this module so it works regardless of cwd
|
||||||
|
_DEFAULT_MODEL_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "maglev_model.pkl")
|
||||||
|
|
||||||
|
|
||||||
class MaglevPredictor:
|
class MaglevPredictor:
|
||||||
def __init__(self, model_path='maglev_model.pkl'):
|
def __init__(self, model_path=None):
|
||||||
"""
|
"""
|
||||||
Initialize predictor by loading the pickle file and extracting
|
Initialize predictor by loading the pickle file and extracting
|
||||||
raw matrices for fast inference.
|
raw matrices for fast inference.
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(model_path):
|
path = model_path if model_path is not None else _DEFAULT_MODEL_PATH
|
||||||
raise FileNotFoundError(f"Model file '{model_path}' not found. Please train and save the model first.")
|
if not os.path.exists(path):
|
||||||
|
raise FileNotFoundError(f"Model file '{path}' not found. Please train and save the model first.")
|
||||||
|
|
||||||
print(f"Loading maglev model from {model_path}...")
|
print(f"Loading maglev model from {path}...")
|
||||||
data = joblib.load(model_path)
|
data = joblib.load(path)
|
||||||
|
|
||||||
# 1. Extract Scikit-Learn Objects
|
# 1. Extract Scikit-Learn Objects
|
||||||
poly_transformer = data['poly_features']
|
poly_transformer = data['poly_features']
|
||||||
337
lev_sim/optuna_pid_tune.py
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
"""
|
||||||
|
Optuna (TPE / Bayesian-style) optimization for the three-PID maglev controller.
|
||||||
|
Tunes all nine gains (height, roll, pitch × Kp, Ki, Kd) jointly so coupling is respected.
|
||||||
|
|
||||||
|
Noisy optimization: set disturbance_force_std > 0 so the env applies random force/torque
|
||||||
|
each step (system changes slightly each run). To keep Bayesian optimization effective,
|
||||||
|
use n_objective_repeats > 1: each candidate is evaluated multiple times and the mean cost
|
||||||
|
is returned, reducing variance so TPE can compare trials reliably.
|
||||||
|
|
||||||
|
Run from the command line or import and call run_optimization() from a notebook.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import optuna
|
||||||
|
from optuna.samplers import TPESampler
|
||||||
|
|
||||||
|
from lev_pod_env import TARGET_GAP
|
||||||
|
from pid_controller import DEFAULT_GAINS
|
||||||
|
from pid_simulation import run_pid_simulation, build_feedforward_lut
|
||||||
|
|
||||||
|
# Save pid_best_params.json next to this script so notebook and CLI find it regardless of cwd
|
||||||
|
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
# Default optimization config: long enough to see late instability (~8s+), not so long that optimizer goes ultra-conservative
|
||||||
|
DEFAULT_MAX_STEPS = 1500
|
||||||
|
DEFAULT_INITIAL_GAPS_MM = [8.0, 15.0] # Two conditions for robustness (bracket 11.86mm target)
|
||||||
|
DEFAULT_N_TRIALS = 200
|
||||||
|
DEFAULT_TIMEOUT_S = 3600
|
||||||
|
TARGET_GAP_MM = TARGET_GAP * 1000
|
||||||
|
|
||||||
|
|
||||||
|
def _cost_from_data(
|
||||||
|
data: dict,
|
||||||
|
target_gap_mm: float,
|
||||||
|
penalty_early: float = 500.0,
|
||||||
|
weight_late_osc: float = 3.0,
|
||||||
|
weight_steady_state: float = 2.0,
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Single scalar cost from one run. Lower is better.
|
||||||
|
|
||||||
|
- ITAE for gap error and |roll|/|pitch| (tracking over time).
|
||||||
|
- Late-oscillation penalty: std(roll) and std(pitch) over the *last 50%* of the run,
|
||||||
|
plus max |roll|/|pitch| in that window, so gains that go unstable after ~half the run are penalized.
|
||||||
|
- Steady-state term: mean absolute gap error and mean |roll|/|pitch| over the *last 20%*,
|
||||||
|
so we reward settling at target with small angles.
|
||||||
|
- Penalty if episode ended early (crash/instability), regardless of run length.
|
||||||
|
- Small penalty on mean total current (efficiency).
|
||||||
|
"""
|
||||||
|
t = np.asarray(data["time"])
|
||||||
|
dt = np.diff(t, prepend=0.0)
|
||||||
|
gap_err_mm = np.abs(np.asarray(data["gap_avg"]) - target_gap_mm)
|
||||||
|
itae_gap = np.sum(t * gap_err_mm * dt)
|
||||||
|
|
||||||
|
roll_deg = np.asarray(data["roll_deg"])
|
||||||
|
pitch_deg = np.asarray(data["pitch_deg"])
|
||||||
|
roll_abs = np.abs(roll_deg)
|
||||||
|
pitch_abs = np.abs(pitch_deg)
|
||||||
|
itae_roll = np.sum(t * roll_abs * dt)
|
||||||
|
itae_pitch = np.sum(t * pitch_abs * dt)
|
||||||
|
|
||||||
|
n = len(t)
|
||||||
|
early_penalty = penalty_early if data.get("terminated_early", False) else 0.0
|
||||||
|
|
||||||
|
# Late half: penalize oscillation (std) and large angles (max) so "good for 6s then violent roll" is expensive
|
||||||
|
half = max(1, n // 2)
|
||||||
|
roll_last = roll_deg[-half:]
|
||||||
|
pitch_last = pitch_deg[-half:]
|
||||||
|
late_osc_roll = np.std(roll_last) + np.max(np.abs(roll_last))
|
||||||
|
late_osc_pitch = np.std(pitch_last) + np.max(np.abs(pitch_last))
|
||||||
|
late_osc_penalty = weight_late_osc * (late_osc_roll + late_osc_pitch)
|
||||||
|
|
||||||
|
# Last 20%: steady-state error — want small gap error and small roll/pitch at end
|
||||||
|
tail = max(1, n // 5)
|
||||||
|
ss_gap = np.mean(np.abs(np.asarray(data["gap_avg"])[-tail:] - target_gap_mm))
|
||||||
|
ss_roll = np.mean(roll_abs[-tail:])
|
||||||
|
ss_pitch = np.mean(pitch_abs[-tail:])
|
||||||
|
steady_state_penalty = weight_steady_state * (ss_gap + ss_roll + ss_pitch)
|
||||||
|
|
||||||
|
mean_current = np.mean(data["current_total"])
|
||||||
|
current_penalty = 0.01 * mean_current
|
||||||
|
|
||||||
|
return (
|
||||||
|
itae_gap
|
||||||
|
+ 2.0 * (itae_roll + itae_pitch)
|
||||||
|
+ early_penalty
|
||||||
|
+ late_osc_penalty
|
||||||
|
+ steady_state_penalty
|
||||||
|
+ current_penalty
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def objective(
|
||||||
|
trial: optuna.Trial,
|
||||||
|
initial_gaps_mm: list,
|
||||||
|
max_steps: int,
|
||||||
|
use_feedforward: bool,
|
||||||
|
penalty_early: float,
|
||||||
|
disturbance_force_std: float = 0.0,
|
||||||
|
n_objective_repeats: int = 1,
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Optuna objective: suggest 9 gains, run simulation(s), return cost.
|
||||||
|
|
||||||
|
With disturbance_force_std > 0, the env applies random force/torque disturbances each step,
|
||||||
|
so the same gains yield different costs across runs. For Bayesian optimization under noise,
|
||||||
|
set n_objective_repeats > 1 to evaluate each candidate multiple times and return the mean cost,
|
||||||
|
reducing variance so TPE can compare trials reliably.
|
||||||
|
"""
|
||||||
|
# All three loops tuned together (recommended: they interact)
|
||||||
|
height_kp = trial.suggest_float("height_kp", 5.0, 200.0, log=True)
|
||||||
|
height_ki = trial.suggest_float("height_ki", 0.5, 50.0, log=True)
|
||||||
|
height_kd = trial.suggest_float("height_kd", 1.0, 50.0, log=True)
|
||||||
|
roll_kp = trial.suggest_float("roll_kp", 0.1, 20.0, log=True)
|
||||||
|
roll_ki = trial.suggest_float("roll_ki", 0.01, 5.0, log=True)
|
||||||
|
roll_kd = trial.suggest_float("roll_kd", 0.01, 5.0, log=True)
|
||||||
|
pitch_kp = trial.suggest_float("pitch_kp", 0.1, 20.0, log=True)
|
||||||
|
pitch_ki = trial.suggest_float("pitch_ki", 0.01, 5.0, log=True)
|
||||||
|
pitch_kd = trial.suggest_float("pitch_kd", 0.01, 5.0, log=True)
|
||||||
|
|
||||||
|
gains = {
|
||||||
|
"height_kp": height_kp, "height_ki": height_ki, "height_kd": height_kd,
|
||||||
|
"roll_kp": roll_kp, "roll_ki": roll_ki, "roll_kd": roll_kd,
|
||||||
|
"pitch_kp": pitch_kp, "pitch_ki": pitch_ki, "pitch_kd": pitch_kd,
|
||||||
|
}
|
||||||
|
|
||||||
|
cost_sum = 0.0
|
||||||
|
n_evals = 0
|
||||||
|
for _ in range(n_objective_repeats):
|
||||||
|
for initial_gap in initial_gaps_mm:
|
||||||
|
data = run_pid_simulation(
|
||||||
|
initial_gap_mm=initial_gap,
|
||||||
|
max_steps=max_steps,
|
||||||
|
use_gui=False,
|
||||||
|
disturbance_force_std=disturbance_force_std,
|
||||||
|
use_feedforward=use_feedforward,
|
||||||
|
record_video=False,
|
||||||
|
record_telemetry=False,
|
||||||
|
gains=gains,
|
||||||
|
verbose=False,
|
||||||
|
)
|
||||||
|
n = len(data["time"])
|
||||||
|
data["terminated_early"] = n < max_steps - 10
|
||||||
|
cost_sum += _cost_from_data(data, TARGET_GAP_MM, penalty_early=penalty_early)
|
||||||
|
n_evals += 1
|
||||||
|
return cost_sum / n_evals
|
||||||
|
|
||||||
|
|
||||||
|
def run_optimization(
|
||||||
|
n_trials: int = DEFAULT_N_TRIALS,
|
||||||
|
timeout: int = DEFAULT_TIMEOUT_S,
|
||||||
|
initial_gaps_mm: list = None,
|
||||||
|
max_steps: int = DEFAULT_MAX_STEPS,
|
||||||
|
use_feedforward: bool = True,
|
||||||
|
penalty_early: float = 500.0,
|
||||||
|
disturbance_force_std: float = 0.0,
|
||||||
|
n_objective_repeats: int = 1,
|
||||||
|
out_dir: str = None,
|
||||||
|
study_name: str = "pid_maglev",
|
||||||
|
) -> optuna.Study:
|
||||||
|
"""
|
||||||
|
Run Optuna study (TPE sampler) and save best params to JSON.
|
||||||
|
|
||||||
|
disturbance_force_std: passed to env so each step gets random force/torque noise (N).
|
||||||
|
n_objective_repeats: when > 1, each trial is evaluated this many times (different noise)
|
||||||
|
and the mean cost is returned, so Bayesian optimization sees a less noisy objective.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
study: Optuna Study (study.best_params, study.best_value).
|
||||||
|
"""
|
||||||
|
if initial_gaps_mm is None:
|
||||||
|
initial_gaps_mm = DEFAULT_INITIAL_GAPS_MM
|
||||||
|
if out_dir is None:
|
||||||
|
out_dir = _SCRIPT_DIR
|
||||||
|
|
||||||
|
# Build feedforward LUT once so first trial doesn't do it inside run
|
||||||
|
build_feedforward_lut()
|
||||||
|
|
||||||
|
sampler = TPESampler(
|
||||||
|
n_startup_trials=min(20, n_trials // 4),
|
||||||
|
n_ei_candidates=24,
|
||||||
|
seed=42,
|
||||||
|
)
|
||||||
|
study = optuna.create_study(
|
||||||
|
direction="minimize",
|
||||||
|
study_name=study_name,
|
||||||
|
sampler=sampler,
|
||||||
|
)
|
||||||
|
|
||||||
|
study.optimize(
|
||||||
|
lambda t: objective(
|
||||||
|
t,
|
||||||
|
initial_gaps_mm=initial_gaps_mm,
|
||||||
|
max_steps=max_steps,
|
||||||
|
use_feedforward=use_feedforward,
|
||||||
|
penalty_early=penalty_early,
|
||||||
|
disturbance_force_std=disturbance_force_std,
|
||||||
|
n_objective_repeats=n_objective_repeats,
|
||||||
|
),
|
||||||
|
n_trials=n_trials,
|
||||||
|
timeout=timeout,
|
||||||
|
show_progress_bar=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
out_path = os.path.join(out_dir, "pid_best_params.json")
|
||||||
|
with open(out_path, "w") as f:
|
||||||
|
json.dump(study.best_params, f, indent=2)
|
||||||
|
print(f"Best cost: {study.best_value:.4f}")
|
||||||
|
print(f"Best params saved to {out_path}")
|
||||||
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
def run_staged_optimization(
|
||||||
|
stage_steps: list = None,
|
||||||
|
n_trials_per_stage: int = 50,
|
||||||
|
timeout_per_stage: int = None,
|
||||||
|
initial_gaps_mm: list = None,
|
||||||
|
use_feedforward: bool = True,
|
||||||
|
penalty_early: float = 500.0,
|
||||||
|
disturbance_force_std: float = 0.0,
|
||||||
|
n_objective_repeats: int = 1,
|
||||||
|
out_dir: str = None,
|
||||||
|
) -> list:
|
||||||
|
"""
|
||||||
|
Run optimization in stages with increasing max_steps, warm-starting each stage from the previous best.
|
||||||
|
|
||||||
|
disturbance_force_std: passed to env for stochastic force/torque noise (N).
|
||||||
|
n_objective_repeats: mean over this many evaluations per trial for a less noisy objective.
|
||||||
|
|
||||||
|
Example: stage_steps=[1500, 3000, 6000]
|
||||||
|
- Stage 1: optimize with 1500 steps (finds good gap/initial roll), save best.
|
||||||
|
- Stage 2: optimize with 3000 steps; first trial is the 1500-step best (evaluated at 3000 steps), then TPE suggests improvements.
|
||||||
|
- Stage 3: same with 6000 steps starting from stage 2's best.
|
||||||
|
|
||||||
|
This keeps good lift-off and gap control from the short-horizon run while refining for late-horizon roll stability.
|
||||||
|
"""
|
||||||
|
if stage_steps is None:
|
||||||
|
stage_steps = [1500, 3000, 6000]
|
||||||
|
if initial_gaps_mm is None:
|
||||||
|
initial_gaps_mm = DEFAULT_INITIAL_GAPS_MM
|
||||||
|
if out_dir is None:
|
||||||
|
out_dir = _SCRIPT_DIR
|
||||||
|
if timeout_per_stage is None:
|
||||||
|
timeout_per_stage = DEFAULT_TIMEOUT_S
|
||||||
|
|
||||||
|
build_feedforward_lut()
|
||||||
|
best_params_path = os.path.join(out_dir, "pid_best_params.json")
|
||||||
|
studies = []
|
||||||
|
|
||||||
|
for stage_idx, max_steps in enumerate(stage_steps):
|
||||||
|
print(f"\n--- Stage {stage_idx + 1}/{len(stage_steps)}: max_steps={max_steps} ---")
|
||||||
|
study = optuna.create_study(
|
||||||
|
direction="minimize",
|
||||||
|
study_name=f"pid_maglev_stage_{max_steps}",
|
||||||
|
sampler=TPESampler(
|
||||||
|
n_startup_trials=min(20, n_trials_per_stage // 4),
|
||||||
|
n_ei_candidates=24,
|
||||||
|
seed=42 + stage_idx,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Warm start: enqueue previous stage's best so we refine from it (stage 0 has no previous)
|
||||||
|
if stage_idx > 0 and os.path.isfile(best_params_path):
|
||||||
|
with open(best_params_path) as f:
|
||||||
|
prev_best = json.load(f)
|
||||||
|
study.enqueue_trial(prev_best)
|
||||||
|
print(f"Enqueued previous best params (from stage {stage_steps[stage_idx - 1]} steps) as first trial.")
|
||||||
|
|
||||||
|
study.optimize(
|
||||||
|
lambda t: objective(
|
||||||
|
t,
|
||||||
|
initial_gaps_mm=initial_gaps_mm,
|
||||||
|
max_steps=max_steps,
|
||||||
|
use_feedforward=use_feedforward,
|
||||||
|
penalty_early=penalty_early,
|
||||||
|
disturbance_force_std=disturbance_force_std,
|
||||||
|
n_objective_repeats=n_objective_repeats,
|
||||||
|
),
|
||||||
|
n_trials=n_trials_per_stage,
|
||||||
|
timeout=timeout_per_stage,
|
||||||
|
show_progress_bar=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(best_params_path, "w") as f:
|
||||||
|
json.dump(study.best_params, f, indent=2)
|
||||||
|
stage_path = os.path.join(out_dir, f"pid_best_params_{max_steps}.json")
|
||||||
|
with open(stage_path, "w") as f:
|
||||||
|
json.dump(study.best_params, f, indent=2)
|
||||||
|
print(f"Stage best cost: {study.best_value:.4f} -> saved to {best_params_path} and {stage_path}")
|
||||||
|
studies.append(study)
|
||||||
|
|
||||||
|
print(f"\nStaged optimization done. Final best params in {best_params_path}")
|
||||||
|
return studies
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser(description="Optuna PID tuning for maglev three-loop controller")
|
||||||
|
parser.add_argument("--n_trials", type=int, default=DEFAULT_N_TRIALS)
|
||||||
|
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_S)
|
||||||
|
parser.add_argument("--max_steps", type=int, default=DEFAULT_MAX_STEPS)
|
||||||
|
parser.add_argument("--gaps", type=float, nargs="+", default=DEFAULT_INITIAL_GAPS_MM)
|
||||||
|
parser.add_argument("--out_dir", type=str, default=_SCRIPT_DIR, help="Directory for pid_best_params.json (default: same as script)")
|
||||||
|
parser.add_argument("--no_feedforward", action="store_true")
|
||||||
|
parser.add_argument("--staged", action="store_true", help="Staged optimization: 1500 -> 3000 -> 6000 steps, each stage warm-starts from previous best")
|
||||||
|
parser.add_argument("--stage_steps", type=int, nargs="+", default=[1500, 3000, 6000], help="Steps per stage when using --staged (default: 1500 3000 6000)")
|
||||||
|
parser.add_argument("--n_trials_per_stage", type=int, default=DEFAULT_N_TRIALS, help="Trials per stage when using --staged")
|
||||||
|
parser.add_argument("--disturbance_force_std", type=float, default=0.0, help="Env disturbance force std (N); roll/pitch torque scale with this. Use >0 for noisy optimization.")
|
||||||
|
parser.add_argument("--n_objective_repeats", type=int, default=1, help="Evaluate each trial this many times and report mean cost (reduces noise for Bayesian optimization)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.staged:
|
||||||
|
run_staged_optimization(
|
||||||
|
stage_steps=args.stage_steps,
|
||||||
|
n_trials_per_stage=args.n_trials_per_stage,
|
||||||
|
timeout_per_stage=args.timeout,
|
||||||
|
initial_gaps_mm=args.gaps,
|
||||||
|
use_feedforward=not args.no_feedforward,
|
||||||
|
disturbance_force_std=args.disturbance_force_std,
|
||||||
|
n_objective_repeats=args.n_objective_repeats,
|
||||||
|
out_dir=args.out_dir,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
run_optimization(
|
||||||
|
n_trials=args.n_trials,
|
||||||
|
timeout=args.timeout,
|
||||||
|
initial_gaps_mm=args.gaps,
|
||||||
|
max_steps=args.max_steps,
|
||||||
|
use_feedforward=not args.no_feedforward,
|
||||||
|
disturbance_force_std=args.disturbance_force_std,
|
||||||
|
n_objective_repeats=args.n_objective_repeats,
|
||||||
|
out_dir=args.out_dir,
|
||||||
|
)
|
||||||
11
lev_sim/pid_best_params.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"height_kp": 100.962395560374814,
|
||||||
|
"height_ki": 0.0,
|
||||||
|
"height_kd": 8.1738092637166575,
|
||||||
|
"roll_kp": 0.600856607986966,
|
||||||
|
"roll_ki": 0.0,
|
||||||
|
"roll_kd": -0.1,
|
||||||
|
"pitch_kp": 50.0114708799258271,
|
||||||
|
"pitch_ki": 0,
|
||||||
|
"pitch_kd": 1.8990306608320433
|
||||||
|
}
|
||||||
11
lev_sim/pid_best_params_1500.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"height_kp": 61.34660453658844,
|
||||||
|
"height_ki": 5.337339965349835,
|
||||||
|
"height_kd": 12.13071554123404,
|
||||||
|
"roll_kp": 5.838881924776812,
|
||||||
|
"roll_ki": 0.11040111644948386,
|
||||||
|
"roll_kd": 0.0401383180893775,
|
||||||
|
"pitch_kp": 0.10114651080341679,
|
||||||
|
"pitch_ki": 0.06948994902747452,
|
||||||
|
"pitch_kd": 0.16948986671689478
|
||||||
|
}
|
||||||
11
lev_sim/pid_best_params_3000.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"height_kp": 6.79952358593656,
|
||||||
|
"height_ki": 2.7199339229214856,
|
||||||
|
"height_kd": 12.166576111298163,
|
||||||
|
"roll_kp": 1.7073146141313746,
|
||||||
|
"roll_ki": 0.026221209129363342,
|
||||||
|
"roll_kd": 0.018353603438859525,
|
||||||
|
"pitch_kp": 1.2333241666054757,
|
||||||
|
"pitch_ki": 0.03544610305322942,
|
||||||
|
"pitch_kd": 0.3711162924888727
|
||||||
|
}
|
||||||
11
lev_sim/pid_best_params_6000.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"height_kp": 5.962395560374814,
|
||||||
|
"height_ki": 0.5381137743695537,
|
||||||
|
"height_kd": 1.1738092637166575,
|
||||||
|
"roll_kp": 0.48249575996443006,
|
||||||
|
"roll_ki": 0.016923890033141546,
|
||||||
|
"roll_kd": 0.013609429509460135,
|
||||||
|
"pitch_kp": 0.10114708799258271,
|
||||||
|
"pitch_ki": 1.906050976563914,
|
||||||
|
"pitch_kd": 1.8990306608320433
|
||||||
|
}
|
||||||
92
lev_sim/pid_controller.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
PID controller and default gains for the maglev three-loop (height, roll, pitch) control.
|
||||||
|
Used by lev_PID.ipynb and optuna_pid_tune.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class PIDController:
|
||||||
|
"""Simple PID controller with anti-windup."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
kp: float,
|
||||||
|
ki: float,
|
||||||
|
kd: float,
|
||||||
|
output_min: float = -1.0,
|
||||||
|
output_max: float = 1.0,
|
||||||
|
integral_limit: float = None,
|
||||||
|
):
|
||||||
|
self.kp = kp
|
||||||
|
self.ki = ki
|
||||||
|
self.kd = kd
|
||||||
|
self.output_min = output_min
|
||||||
|
self.output_max = output_max
|
||||||
|
self.integral_limit = (
|
||||||
|
integral_limit if integral_limit is not None else abs(output_max) * 2
|
||||||
|
)
|
||||||
|
|
||||||
|
self.integral = 0.0
|
||||||
|
self.prev_error = 0.0
|
||||||
|
self.first_update = True
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Reset controller state."""
|
||||||
|
self.integral = 0.0
|
||||||
|
self.prev_error = 0.0
|
||||||
|
self.first_update = True
|
||||||
|
|
||||||
|
def update(self, error: float, dt: float) -> float:
|
||||||
|
"""Compute PID output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error: Current error (setpoint - measurement)
|
||||||
|
dt: Time step in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Control output (clamped to output limits)
|
||||||
|
"""
|
||||||
|
p_term = self.kp * error
|
||||||
|
|
||||||
|
self.integral += error * dt
|
||||||
|
self.integral = np.clip(
|
||||||
|
self.integral, -self.integral_limit, self.integral_limit
|
||||||
|
)
|
||||||
|
i_term = self.ki * self.integral
|
||||||
|
|
||||||
|
if self.first_update:
|
||||||
|
d_term = 0.0
|
||||||
|
self.first_update = False
|
||||||
|
else:
|
||||||
|
d_term = self.kd * (error - self.prev_error) / dt
|
||||||
|
|
||||||
|
self.prev_error = error
|
||||||
|
output = p_term + i_term + d_term
|
||||||
|
return np.clip(output, self.output_min, self.output_max)
|
||||||
|
|
||||||
|
|
||||||
|
# Default gains: height (main), roll, pitch.
|
||||||
|
# Optimizer and notebook can override via gains dict passed to run_pid_simulation.
|
||||||
|
DEFAULT_GAINS = {
|
||||||
|
"height_kp": 50.0,
|
||||||
|
"height_ki": 5.0,
|
||||||
|
"height_kd": 10.0,
|
||||||
|
"roll_kp": 2.0,
|
||||||
|
"roll_ki": 0.5,
|
||||||
|
"roll_kd": 0.5,
|
||||||
|
"pitch_kp": 2.0,
|
||||||
|
"pitch_ki": 0.5,
|
||||||
|
"pitch_kd": 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backward-compat names for notebook (optional)
|
||||||
|
HEIGHT_KP = DEFAULT_GAINS["height_kp"]
|
||||||
|
HEIGHT_KI = DEFAULT_GAINS["height_ki"]
|
||||||
|
HEIGHT_KD = DEFAULT_GAINS["height_kd"]
|
||||||
|
ROLL_KP = DEFAULT_GAINS["roll_kp"]
|
||||||
|
ROLL_KI = DEFAULT_GAINS["roll_ki"]
|
||||||
|
ROLL_KD = DEFAULT_GAINS["roll_kd"]
|
||||||
|
PITCH_KP = DEFAULT_GAINS["pitch_kp"]
|
||||||
|
PITCH_KI = DEFAULT_GAINS["pitch_ki"]
|
||||||
|
PITCH_KD = DEFAULT_GAINS["pitch_kd"]
|
||||||
216
lev_sim/pid_simulation.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
Feedforward + PID simulation runner for the maglev pod.
|
||||||
|
Uses LevPodEnv, MaglevPredictor (feedforward), and PIDController with configurable gains.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from lev_pod_env import LevPodEnv, TARGET_GAP
|
||||||
|
from maglev_predictor import MaglevPredictor
|
||||||
|
from pid_controller import PIDController, DEFAULT_GAINS
|
||||||
|
|
||||||
|
# Feedforward LUT (built on first use)
|
||||||
|
_FF_GAP_LUT = None
|
||||||
|
_FF_PWM_LUT = None
|
||||||
|
|
||||||
|
|
||||||
|
def build_feedforward_lut(
|
||||||
|
pod_mass: float = 9.4,
|
||||||
|
coil_r: float = 1.1,
|
||||||
|
v_supply: float = 12.0,
|
||||||
|
gap_min: float = 3.0,
|
||||||
|
gap_max: float = 35.0,
|
||||||
|
n_points: int = 500,
|
||||||
|
):
|
||||||
|
"""Build gap [mm] -> equilibrium PWM lookup table. Returns (gap_lut, pwm_lut) for np.interp."""
|
||||||
|
global _FF_GAP_LUT, _FF_PWM_LUT
|
||||||
|
target_per_yoke = pod_mass * 9.81 / 2.0
|
||||||
|
predictor = MaglevPredictor()
|
||||||
|
|
||||||
|
def _find_eq_current(gap_mm):
|
||||||
|
a, b = -10.2, 10.2
|
||||||
|
fa, _ = predictor.predict(a, a, 0.0, gap_mm)
|
||||||
|
for _ in range(80):
|
||||||
|
mid = (a + b) / 2.0
|
||||||
|
fm, _ = predictor.predict(mid, mid, 0.0, gap_mm)
|
||||||
|
if (fa > target_per_yoke) == (fm > target_per_yoke):
|
||||||
|
a, fa = mid, fm
|
||||||
|
else:
|
||||||
|
b = mid
|
||||||
|
return (a + b) / 2.0
|
||||||
|
|
||||||
|
_FF_GAP_LUT = np.linspace(gap_min, gap_max, n_points)
|
||||||
|
curr_lut = np.array([_find_eq_current(g) for g in _FF_GAP_LUT])
|
||||||
|
_FF_PWM_LUT = np.clip(curr_lut * coil_r / v_supply, -1.0, 1.0)
|
||||||
|
return _FF_GAP_LUT, _FF_PWM_LUT
|
||||||
|
|
||||||
|
|
||||||
|
def feedforward_pwm(gap_mm: float) -> float:
|
||||||
|
"""Interpolate equilibrium PWM for gap height [mm]. Builds LUT on first call."""
|
||||||
|
global _FF_GAP_LUT, _FF_PWM_LUT
|
||||||
|
if _FF_GAP_LUT is None:
|
||||||
|
build_feedforward_lut()
|
||||||
|
return float(np.interp(gap_mm, _FF_GAP_LUT, _FF_PWM_LUT))
|
||||||
|
|
||||||
|
|
||||||
|
def run_pid_simulation(
|
||||||
|
initial_gap_mm: float = 14.0,
|
||||||
|
max_steps: int = 2000,
|
||||||
|
use_gui: bool = False,
|
||||||
|
disturbance_step: int = None,
|
||||||
|
disturbance_force: float = 0.0,
|
||||||
|
disturbance_force_std: float = 0.0,
|
||||||
|
use_feedforward: bool = True,
|
||||||
|
record_video: bool = False,
|
||||||
|
record_telemetry: bool = False,
|
||||||
|
record_dir: str = "recordings",
|
||||||
|
gains: dict = None,
|
||||||
|
verbose: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Run one feedforward + PID simulation. Gains can be passed for tuning (e.g. Optuna).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
initial_gap_mm: Starting gap height (mm).
|
||||||
|
max_steps: Max simulation steps.
|
||||||
|
use_gui: PyBullet GUI (avoid in notebooks).
|
||||||
|
disturbance_step: Step for impulse (None = none).
|
||||||
|
disturbance_force: Impulse force (N).
|
||||||
|
disturbance_force_std: Noise std (N).
|
||||||
|
use_feedforward: Use MaglevPredictor feedforward.
|
||||||
|
record_video: Save MP4.
|
||||||
|
record_telemetry: Save telemetry PNG.
|
||||||
|
record_dir: Output directory.
|
||||||
|
gains: Dict with keys height_kp, height_ki, height_kd, roll_kp, roll_ki, roll_kd,
|
||||||
|
pitch_kp, pitch_ki, pitch_kd. If None, uses DEFAULT_GAINS.
|
||||||
|
verbose: Print progress.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
data: dict of arrays (time, gap_avg, roll_deg, pitch_deg, currents, pwms, ...).
|
||||||
|
"""
|
||||||
|
g = gains if gains is not None else DEFAULT_GAINS
|
||||||
|
env = LevPodEnv(
|
||||||
|
use_gui=use_gui,
|
||||||
|
initial_gap_mm=initial_gap_mm,
|
||||||
|
max_steps=max_steps,
|
||||||
|
disturbance_force_std=disturbance_force_std,
|
||||||
|
record_video=record_video,
|
||||||
|
record_telemetry=record_telemetry,
|
||||||
|
record_dir=record_dir,
|
||||||
|
)
|
||||||
|
dt = env.dt
|
||||||
|
|
||||||
|
height_pid = PIDController(
|
||||||
|
g["height_kp"], g["height_ki"], g["height_kd"],
|
||||||
|
output_min=-1.0, output_max=1.0,
|
||||||
|
)
|
||||||
|
roll_pid = PIDController(
|
||||||
|
g["roll_kp"], g["roll_ki"], g["roll_kd"],
|
||||||
|
output_min=-0.5, output_max=0.5,
|
||||||
|
)
|
||||||
|
pitch_pid = PIDController(
|
||||||
|
g["pitch_kp"], g["pitch_ki"], g["pitch_kd"],
|
||||||
|
output_min=-0.5, output_max=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"time": [], "gap_front": [], "gap_back": [], "gap_avg": [],
|
||||||
|
"roll_deg": [], "pitch_deg": [],
|
||||||
|
"current_FL": [], "current_FR": [], "current_BL": [], "current_BR": [],
|
||||||
|
"current_total": [], "pwm_FL": [], "pwm_FR": [], "pwm_BL": [], "pwm_BR": [],
|
||||||
|
"ff_pwm": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
obs, _ = env.reset()
|
||||||
|
target_gap = TARGET_GAP
|
||||||
|
y_distance = 0.1016
|
||||||
|
x_distance = 0.2518
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f"Starting simulation: initial_gap={initial_gap_mm}mm, target={target_gap*1000:.2f}mm")
|
||||||
|
if disturbance_step is not None:
|
||||||
|
print(f" Impulse disturbance: {disturbance_force}N at step {disturbance_step}")
|
||||||
|
if disturbance_force_std > 0:
|
||||||
|
print(f" Stochastic noise: std={disturbance_force_std}N")
|
||||||
|
print(f" Feedforward: {'enabled' if use_feedforward else 'disabled'}")
|
||||||
|
if record_video or record_telemetry:
|
||||||
|
print(f" Recording: video={record_video}, telemetry={record_telemetry} → {record_dir}/")
|
||||||
|
|
||||||
|
for step in range(max_steps):
|
||||||
|
t = step * dt
|
||||||
|
gaps_normalized = obs[:4]
|
||||||
|
gaps = gaps_normalized * env.gap_scale + TARGET_GAP
|
||||||
|
gap_front = gaps[2]
|
||||||
|
gap_back = gaps[3]
|
||||||
|
gap_left = gaps[1]
|
||||||
|
gap_right = gaps[0]
|
||||||
|
gap_avg = (gap_front + gap_back + gap_left + gap_right) / 4
|
||||||
|
|
||||||
|
roll_angle = np.arcsin(np.clip((gap_left - gap_right) / y_distance, -1, 1))
|
||||||
|
pitch_angle = np.arcsin(np.clip((gap_back - gap_front) / x_distance, -1, 1))
|
||||||
|
|
||||||
|
ff_base = feedforward_pwm(gap_avg * 1000) if use_feedforward else 0.0
|
||||||
|
|
||||||
|
height_error = target_gap - gap_avg
|
||||||
|
roll_error = -roll_angle
|
||||||
|
pitch_error = -pitch_angle
|
||||||
|
|
||||||
|
height_correction = height_pid.update(height_error, dt)
|
||||||
|
roll_correction = roll_pid.update(roll_error, dt)
|
||||||
|
pitch_correction = pitch_pid.update(pitch_error, dt)
|
||||||
|
|
||||||
|
pwm_FL = np.clip(ff_base + height_correction - roll_correction - pitch_correction, -1, 1)
|
||||||
|
pwm_FR = np.clip(ff_base + height_correction + roll_correction - pitch_correction, -1, 1)
|
||||||
|
pwm_BL = np.clip(ff_base + height_correction - roll_correction + pitch_correction, -1, 1)
|
||||||
|
pwm_BR = np.clip(ff_base + height_correction + roll_correction + pitch_correction, -1, 1)
|
||||||
|
|
||||||
|
action = np.array([pwm_FL, pwm_FR, pwm_BL, pwm_BR], dtype=np.float32)
|
||||||
|
|
||||||
|
if disturbance_step is not None and step == disturbance_step:
|
||||||
|
env.apply_impulse(disturbance_force)
|
||||||
|
# Coupled torque: random direction, magnitude = 0.5 * |impulse force| (N·m)
|
||||||
|
torque_mag = 0.5 * abs(disturbance_force)
|
||||||
|
direction = np.random.randn(3)
|
||||||
|
direction = direction / (np.linalg.norm(direction) + 1e-12)
|
||||||
|
torque_nm = (torque_mag * direction).tolist()
|
||||||
|
env.apply_torque_impulse(torque_nm)
|
||||||
|
if verbose:
|
||||||
|
print(f" Applied {disturbance_force}N impulse and {torque_mag:.2f} N·m torque at step {step}")
|
||||||
|
|
||||||
|
obs, _, terminated, truncated, info = env.step(action)
|
||||||
|
|
||||||
|
data["time"].append(t)
|
||||||
|
data["gap_front"].append(info["gap_front_yoke"] * 1000)
|
||||||
|
data["gap_back"].append(info["gap_back_yoke"] * 1000)
|
||||||
|
data["gap_avg"].append(info["gap_avg"] * 1000)
|
||||||
|
data["roll_deg"].append(np.degrees(info["roll"]))
|
||||||
|
data["pitch_deg"].append(np.degrees(info["pitch"]))
|
||||||
|
data["current_FL"].append(info["curr_front_L"])
|
||||||
|
data["current_FR"].append(info["curr_front_R"])
|
||||||
|
data["current_BL"].append(info["curr_back_L"])
|
||||||
|
data["current_BR"].append(info["curr_back_R"])
|
||||||
|
data["current_total"].append(
|
||||||
|
abs(info["curr_front_L"]) + abs(info["curr_front_R"])
|
||||||
|
+ abs(info["curr_back_L"]) + abs(info["curr_back_R"])
|
||||||
|
)
|
||||||
|
data["ff_pwm"].append(ff_base)
|
||||||
|
data["pwm_FL"].append(pwm_FL)
|
||||||
|
data["pwm_FR"].append(pwm_FR)
|
||||||
|
data["pwm_BL"].append(pwm_BL)
|
||||||
|
data["pwm_BR"].append(pwm_BR)
|
||||||
|
|
||||||
|
if terminated or truncated:
|
||||||
|
if verbose:
|
||||||
|
print(f" Simulation ended at step {step} (terminated={terminated})")
|
||||||
|
break
|
||||||
|
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
for key in data:
|
||||||
|
data[key] = np.array(data[key])
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f"Simulation complete: {len(data['time'])} steps, {data['time'][-1]:.2f}s")
|
||||||
|
print(f" Final gap: {data['gap_avg'][-1]:.2f}mm (target: {target_gap*1000:.2f}mm)")
|
||||||
|
print(f" Final roll: {data['roll_deg'][-1]:.3f}°, pitch: {data['pitch_deg'][-1]:.3f}°")
|
||||||
|
|
||||||
|
return data
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
<robot name="lev_pod">
|
<robot name="lev_pod">
|
||||||
<link name="base_link">
|
<link name="base_link">
|
||||||
<inertial>
|
<inertial>
|
||||||
<mass value="5.8"/>
|
<mass value="9.4"/>
|
||||||
<inertia ixx="0.0192942414" ixy="0.0" ixz="0.0" iyy="0.130582305" iyz="0.0" izz="0.13760599326"/>
|
<inertia ixx="0.03162537654" ixy="0.0" ixz="0.0" iyy="0.21929017831" iyz="0.0" izz="0.21430205089"/>
|
||||||
</inertial>
|
</inertial>
|
||||||
|
|
||||||
<collision>
|
<collision>
|
||||||
@@ -13,19 +13,19 @@
|
|||||||
|
|
||||||
Bolts
|
Bolts
|
||||||
<collision>
|
<collision>
|
||||||
<origin rpy="0 0 0" xyz="0.285 0.03 0.09585"/>
|
<origin rpy="0 0 0" xyz="0.285 0.03 0.09085"/>
|
||||||
<geometry><box size="0.01 0.01 0.01"/></geometry>
|
<geometry><box size="0.01 0.01 0.01"/></geometry>
|
||||||
</collision>
|
</collision>
|
||||||
<collision>
|
<collision>
|
||||||
<origin rpy="0 0 0" xyz="0.285 -0.03 0.09585"/>
|
<origin rpy="0 0 0" xyz="0.285 -0.03 0.09085"/>
|
||||||
<geometry><box size="0.01 0.01 0.01"/></geometry>
|
<geometry><box size="0.01 0.01 0.01"/></geometry>
|
||||||
</collision>
|
</collision>
|
||||||
<collision>
|
<collision>
|
||||||
<origin rpy="0 0 0" xyz="-0.285 0.03 0.09585"/>
|
<origin rpy="0 0 0" xyz="-0.285 0.03 0.09085"/>
|
||||||
<geometry><box size="0.01 0.01 0.01"/></geometry>
|
<geometry><box size="0.01 0.01 0.01"/></geometry>
|
||||||
</collision>
|
</collision>
|
||||||
<collision>
|
<collision>
|
||||||
<origin rpy="0 0 0" xyz="-0.285 -0.03 0.09585"/>
|
<origin rpy="0 0 0" xyz="-0.285 -0.03 0.09085"/>
|
||||||
<geometry><box size="0.01 0.01 0.01"/></geometry>
|
<geometry><box size="0.01 0.01 0.01"/></geometry>
|
||||||
</collision>
|
</collision>
|
||||||
|
|
||||||
430
lev_sim/polynomial_equations.txt
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
Magnetic Levitation Force & Torque — Explicit Polynomial Equations
|
||||||
|
Polynomial Degree: 6
|
||||||
|
Variables:
|
||||||
|
currL [A] = Left coil current
|
||||||
|
currR [A] = Right coil current
|
||||||
|
rollDeg [deg] = Roll angle
|
||||||
|
invGap = 1 / GapHeight [1/mm]
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
FORCE [N] =
|
||||||
|
-1.3837763236e+01
|
||||||
|
+ (+3.4900958018e-01) * currL [A]
|
||||||
|
+ (+3.4444782070e-01) * currR [A]
|
||||||
|
+ (+1.3948921341e-07) * rollDeg [deg]
|
||||||
|
+ (+6.3404397040e+02) * invGap
|
||||||
|
+ (+2.0808153074e-02) * currL [A]^2
|
||||||
|
+ (-8.2940557511e-04) * currL [A] * currR [A]
|
||||||
|
+ (+2.2629904125e-02) * currL [A] * rollDeg [deg]
|
||||||
|
+ (-1.4941727622e+01) * currL [A] * invGap
|
||||||
|
+ (+2.1137560003e-02) * currR [A]^2
|
||||||
|
+ (-2.2629886279e-02) * currR [A] * rollDeg [deg]
|
||||||
|
+ (-1.4834895226e+01) * currR [A] * invGap
|
||||||
|
+ (-7.3648244412e-01) * rollDeg [deg]^2
|
||||||
|
+ (-7.6740357314e-06) * rollDeg [deg] * invGap
|
||||||
|
+ (+1.3767217831e+03) * invGap^2
|
||||||
|
+ (-1.1531758728e-04) * currL [A]^3
|
||||||
|
+ (+7.2971576377e-05) * currL [A]^2 * currR [A]
|
||||||
|
+ (+5.4868393021e-06) * currL [A]^2 * rollDeg [deg]
|
||||||
|
+ (-1.0521912466e+00) * currL [A]^2 * invGap
|
||||||
|
+ (+5.2348894922e-05) * currL [A] * currR [A]^2
|
||||||
|
+ (+2.1794350221e-09) * currL [A] * currR [A] * rollDeg [deg]
|
||||||
|
+ (+3.2678067078e-02) * currL [A] * currR [A] * invGap
|
||||||
|
+ (-6.3017520966e-03) * currL [A] * rollDeg [deg]^2
|
||||||
|
+ (-2.6462505434e-01) * currL [A] * rollDeg [deg] * invGap
|
||||||
|
+ (-2.9510804409e+01) * currL [A] * invGap^2
|
||||||
|
+ (-7.2152022082e-05) * currR [A]^3
|
||||||
|
+ (-5.4823932308e-06) * currR [A]^2 * rollDeg [deg]
|
||||||
|
+ (-1.0708822638e+00) * currR [A]^2 * invGap
|
||||||
|
+ (-6.3914882711e-03) * currR [A] * rollDeg [deg]^2
|
||||||
|
+ (+2.6462554272e-01) * currR [A] * rollDeg [deg] * invGap
|
||||||
|
+ (-3.0234998506e+01) * currR [A] * invGap^2
|
||||||
|
+ (+4.5881294085e-09) * rollDeg [deg]^3
|
||||||
|
+ (+2.5797308313e+01) * rollDeg [deg]^2 * invGap
|
||||||
|
+ (+4.6682794236e-05) * rollDeg [deg] * invGap^2
|
||||||
|
+ (-5.3401486713e+03) * invGap^3
|
||||||
|
+ (+1.3320765542e-06) * currL [A]^4
|
||||||
|
+ (+3.2152411427e-06) * currL [A]^3 * currR [A]
|
||||||
|
+ (+1.2629467079e-05) * currL [A]^3 * rollDeg [deg]
|
||||||
|
+ (+1.7892402808e-03) * currL [A]^3 * invGap
|
||||||
|
+ (+3.0619624267e-06) * currL [A]^2 * currR [A]^2
|
||||||
|
+ (-1.5568499556e-07) * currL [A]^2 * currR [A] * rollDeg [deg]
|
||||||
|
+ (-1.0714912904e-03) * currL [A]^2 * currR [A] * invGap
|
||||||
|
+ (+8.7713928261e-05) * currL [A]^2 * rollDeg [deg]^2
|
||||||
|
+ (+4.5854056157e-03) * currL [A]^2 * rollDeg [deg] * invGap
|
||||||
|
+ (+2.0746027602e+01) * currL [A]^2 * invGap^2
|
||||||
|
+ (+3.8655549044e-06) * currL [A] * currR [A]^3
|
||||||
|
+ (+1.5545726439e-07) * currL [A] * currR [A]^2 * rollDeg [deg]
|
||||||
|
+ (-7.0411774478e-04) * currL [A] * currR [A]^2 * invGap
|
||||||
|
+ (-3.8261231547e-05) * currL [A] * currR [A] * rollDeg [deg]^2
|
||||||
|
+ (-9.5017239129e-09) * currL [A] * currR [A] * rollDeg [deg] * invGap
|
||||||
|
+ (+7.0646804762e-01) * currL [A] * currR [A] * invGap^2
|
||||||
|
+ (-2.5574383917e-03) * currL [A] * rollDeg [deg]^3
|
||||||
|
+ (-3.3355499360e-02) * currL [A] * rollDeg [deg]^2 * invGap
|
||||||
|
+ (-2.1337761184e+01) * currL [A] * rollDeg [deg] * invGap^2
|
||||||
|
+ (+1.2343471808e+02) * currL [A] * invGap^3
|
||||||
|
+ (+7.7592074199e-07) * currR [A]^4
|
||||||
|
+ (-1.2629426979e-05) * currR [A]^3 * rollDeg [deg]
|
||||||
|
+ (+8.6147973530e-04) * currR [A]^3 * invGap
|
||||||
|
+ (+8.0130692138e-05) * currR [A]^2 * rollDeg [deg]^2
|
||||||
|
+ (-4.5854241649e-03) * currR [A]^2 * rollDeg [deg] * invGap
|
||||||
|
+ (+2.1139446060e+01) * currR [A]^2 * invGap^2
|
||||||
|
+ (+2.5574380197e-03) * currR [A] * rollDeg [deg]^3
|
||||||
|
+ (-2.9857453053e-02) * currR [A] * rollDeg [deg]^2 * invGap
|
||||||
|
+ (+2.1337762097e+01) * currR [A] * rollDeg [deg] * invGap^2
|
||||||
|
+ (+1.2514911864e+02) * currR [A] * invGap^3
|
||||||
|
+ (+2.9443521553e-02) * rollDeg [deg]^4
|
||||||
|
+ (-7.9265644452e-09) * rollDeg [deg]^3 * invGap
|
||||||
|
+ (-2.3563990269e+02) * rollDeg [deg]^2 * invGap^2
|
||||||
|
+ (+1.6353835994e-04) * rollDeg [deg] * invGap^3
|
||||||
|
+ (-2.2354105572e+03) * invGap^4
|
||||||
|
+ (+3.1117360777e-07) * currL [A]^5
|
||||||
|
+ (-1.8750259301e-07) * currL [A]^4 * currR [A]
|
||||||
|
+ (-8.5198564648e-08) * currL [A]^4 * rollDeg [deg]
|
||||||
|
+ (-6.3869937977e-05) * currL [A]^4 * invGap
|
||||||
|
+ (-1.4911217505e-07) * currL [A]^3 * currR [A]^2
|
||||||
|
+ (+1.7061909929e-07) * currL [A]^3 * currR [A] * rollDeg [deg]
|
||||||
|
+ (-7.4262537249e-06) * currL [A]^3 * currR [A] * invGap
|
||||||
|
+ (+7.2402232831e-07) * currL [A]^3 * rollDeg [deg]^2
|
||||||
|
+ (+2.1841125430e-05) * currL [A]^3 * rollDeg [deg] * invGap
|
||||||
|
+ (-3.4357925518e-03) * currL [A]^3 * invGap^2
|
||||||
|
+ (-1.6416880300e-07) * currL [A]^2 * currR [A]^3
|
||||||
|
+ (+3.6385798723e-05) * currL [A]^2 * currR [A]^2 * invGap
|
||||||
|
+ (+5.2924336558e-07) * currL [A]^2 * currR [A] * rollDeg [deg]^2
|
||||||
|
+ (+8.5467922645e-05) * currL [A]^2 * currR [A] * rollDeg [deg] * invGap
|
||||||
|
+ (+4.5573594510e-03) * currL [A]^2 * currR [A] * invGap^2
|
||||||
|
+ (-2.9680843072e-05) * currL [A]^2 * rollDeg [deg]^3
|
||||||
|
+ (-3.7951808035e-03) * currL [A]^2 * rollDeg [deg]^2 * invGap
|
||||||
|
+ (+4.7717250306e-02) * currL [A]^2 * rollDeg [deg] * invGap^2
|
||||||
|
+ (-1.4608679078e+02) * currL [A]^2 * invGap^3
|
||||||
|
+ (-1.2370955460e-07) * currL [A] * currR [A]^4
|
||||||
|
+ (-1.7060417434e-07) * currL [A] * currR [A]^3 * rollDeg [deg]
|
||||||
|
+ (-1.3936700967e-05) * currL [A] * currR [A]^3 * invGap
|
||||||
|
+ (+4.5908154078e-07) * currL [A] * currR [A]^2 * rollDeg [deg]^2
|
||||||
|
+ (-8.5469212391e-05) * currL [A] * currR [A]^2 * rollDeg [deg] * invGap
|
||||||
|
+ (+1.6991917750e-03) * currL [A] * currR [A]^2 * invGap^2
|
||||||
|
+ (-3.7373312737e-04) * currL [A] * currR [A] * rollDeg [deg]^2 * invGap
|
||||||
|
+ (+1.5086926031e-07) * currL [A] * currR [A] * rollDeg [deg] * invGap^2
|
||||||
|
+ (-7.5987537316e+00) * currL [A] * currR [A] * invGap^3
|
||||||
|
+ (+6.6508849418e-04) * currL [A] * rollDeg [deg]^4
|
||||||
|
+ (+9.6378055653e-02) * currL [A] * rollDeg [deg]^3 * invGap
|
||||||
|
+ (+2.4435308561e+00) * currL [A] * rollDeg [deg]^2 * invGap^2
|
||||||
|
+ (+1.2664933542e+02) * currL [A] * rollDeg [deg] * invGap^3
|
||||||
|
+ (+5.1887450366e+01) * currL [A] * invGap^4
|
||||||
|
+ (+2.3180087538e-07) * currR [A]^5
|
||||||
|
+ (+8.5217195078e-08) * currR [A]^4 * rollDeg [deg]
|
||||||
|
+ (-6.9233585420e-05) * currR [A]^4 * invGap
|
||||||
|
+ (+7.5608402383e-07) * currR [A]^3 * rollDeg [deg]^2
|
||||||
|
+ (-2.1841873013e-05) * currR [A]^3 * rollDeg [deg] * invGap
|
||||||
|
+ (+3.1131797033e-03) * currR [A]^3 * invGap^2
|
||||||
|
+ (+2.9680840171e-05) * currR [A]^2 * rollDeg [deg]^3
|
||||||
|
+ (-3.7292231655e-03) * currR [A]^2 * rollDeg [deg]^2 * invGap
|
||||||
|
+ (-4.7717023731e-02) * currR [A]^2 * rollDeg [deg] * invGap^2
|
||||||
|
+ (-1.4923564858e+02) * currR [A]^2 * invGap^3
|
||||||
|
+ (+6.6540367362e-04) * currR [A] * rollDeg [deg]^4
|
||||||
|
+ (-9.6378069105e-02) * currR [A] * rollDeg [deg]^3 * invGap
|
||||||
|
+ (+2.3836959223e+00) * currR [A] * rollDeg [deg]^2 * invGap^2
|
||||||
|
+ (-1.2664937015e+02) * currR [A] * rollDeg [deg] * invGap^3
|
||||||
|
+ (+5.2627468646e+01) * currR [A] * invGap^4
|
||||||
|
+ (+1.4646059365e-10) * rollDeg [deg]^5
|
||||||
|
+ (-1.0417153181e+00) * rollDeg [deg]^4 * invGap
|
||||||
|
+ (+1.1431957446e-06) * rollDeg [deg]^3 * invGap^2
|
||||||
|
+ (+9.2056028147e+02) * rollDeg [deg]^2 * invGap^3
|
||||||
|
+ (-3.1228040188e-04) * rollDeg [deg] * invGap^4
|
||||||
|
+ (-5.9623186232e+02) * invGap^5
|
||||||
|
+ (+2.9763214116e-09) * currL [A]^6
|
||||||
|
+ (-6.2261733547e-09) * currL [A]^5 * currR [A]
|
||||||
|
+ (-3.7175269085e-08) * currL [A]^5 * rollDeg [deg]
|
||||||
|
+ (-3.1125615720e-06) * currL [A]^5 * invGap
|
||||||
|
+ (-9.9876729109e-09) * currL [A]^4 * currR [A]^2
|
||||||
|
+ (-1.3294041423e-08) * currL [A]^4 * currR [A] * rollDeg [deg]
|
||||||
|
+ (+2.2610334440e-06) * currL [A]^4 * currR [A] * invGap
|
||||||
|
+ (+2.4101829865e-08) * currL [A]^4 * rollDeg [deg]^2
|
||||||
|
+ (+2.5320289563e-06) * currL [A]^4 * rollDeg [deg] * invGap
|
||||||
|
+ (+2.0532678993e-04) * currL [A]^4 * invGap^2
|
||||||
|
+ (-7.0411871889e-09) * currL [A]^3 * currR [A]^3
|
||||||
|
+ (+1.1956544199e-08) * currL [A]^3 * currR [A]^2 * rollDeg [deg]
|
||||||
|
+ (+1.9188820701e-06) * currL [A]^3 * currR [A]^2 * invGap
|
||||||
|
+ (+6.3643952330e-08) * currL [A]^3 * currR [A] * rollDeg [deg]^2
|
||||||
|
+ (-3.3660324359e-06) * currL [A]^3 * currR [A] * rollDeg [deg] * invGap
|
||||||
|
+ (+1.0594658745e-05) * currL [A]^3 * currR [A] * invGap^2
|
||||||
|
+ (-2.5937761805e-07) * currL [A]^3 * rollDeg [deg]^3
|
||||||
|
+ (-7.2227672945e-06) * currL [A]^3 * rollDeg [deg]^2 * invGap
|
||||||
|
+ (+9.6876113423e-05) * currL [A]^3 * rollDeg [deg] * invGap^2
|
||||||
|
+ (+8.5422144858e-03) * currL [A]^3 * invGap^3
|
||||||
|
+ (-1.0519386251e-08) * currL [A]^2 * currR [A]^4
|
||||||
|
+ (-1.1957411061e-08) * currL [A]^2 * currR [A]^3 * rollDeg [deg]
|
||||||
|
+ (+2.1222821118e-06) * currL [A]^2 * currR [A]^3 * invGap
|
||||||
|
+ (+4.4935102750e-08) * currL [A]^2 * currR [A]^2 * rollDeg [deg]^2
|
||||||
|
+ (-1.2807847845e-04) * currL [A]^2 * currR [A]^2 * invGap^2
|
||||||
|
+ (+5.7679319987e-08) * currL [A]^2 * currR [A] * rollDeg [deg]^3
|
||||||
|
+ (-1.4777475671e-05) * currL [A]^2 * currR [A] * rollDeg [deg]^2 * invGap
|
||||||
|
+ (-2.5449360510e-04) * currL [A]^2 * currR [A] * rollDeg [deg] * invGap^2
|
||||||
|
+ (-1.2272562713e-02) * currL [A]^2 * currR [A] * invGap^3
|
||||||
|
+ (+8.8287809685e-07) * currL [A]^2 * rollDeg [deg]^4
|
||||||
|
+ (+4.4586018461e-04) * currL [A]^2 * rollDeg [deg]^3 * invGap
|
||||||
|
+ (+3.8576999819e-02) * currL [A]^2 * rollDeg [deg]^2 * invGap^2
|
||||||
|
+ (+3.4044411238e-01) * currL [A]^2 * rollDeg [deg] * invGap^3
|
||||||
|
+ (+3.5950095140e+02) * currL [A]^2 * invGap^4
|
||||||
|
+ (-8.4967268776e-09) * currL [A] * currR [A]^5
|
||||||
|
+ (+1.3295050394e-08) * currL [A] * currR [A]^4 * rollDeg [deg]
|
||||||
|
+ (+1.7032328259e-06) * currL [A] * currR [A]^4 * invGap
|
||||||
|
+ (+7.0043846279e-08) * currL [A] * currR [A]^3 * rollDeg [deg]^2
|
||||||
|
+ (+3.3657944138e-06) * currL [A] * currR [A]^3 * rollDeg [deg] * invGap
|
||||||
|
+ (+6.8900834879e-05) * currL [A] * currR [A]^3 * invGap^2
|
||||||
|
+ (-5.7674824028e-08) * currL [A] * currR [A]^2 * rollDeg [deg]^3
|
||||||
|
+ (-1.4623729019e-05) * currL [A] * currR [A]^2 * rollDeg [deg]^2 * invGap
|
||||||
|
+ (+2.5450219980e-04) * currL [A] * currR [A]^2 * rollDeg [deg] * invGap^2
|
||||||
|
+ (-1.0593500843e-03) * currL [A] * currR [A]^2 * invGap^3
|
||||||
|
+ (+1.0484950490e-06) * currL [A] * currR [A] * rollDeg [deg]^4
|
||||||
|
+ (-3.0932389983e-10) * currL [A] * currR [A] * rollDeg [deg]^3 * invGap
|
||||||
|
+ (+5.6370243409e-03) * currL [A] * currR [A] * rollDeg [deg]^2 * invGap^2
|
||||||
|
+ (-6.1682354633e-07) * currL [A] * currR [A] * rollDeg [deg] * invGap^3
|
||||||
|
+ (+2.2923219836e+01) * currL [A] * currR [A] * invGap^4
|
||||||
|
+ (-2.3420164800e-05) * currL [A] * rollDeg [deg]^5
|
||||||
|
+ (-9.2739975478e-03) * currL [A] * rollDeg [deg]^4 * invGap
|
||||||
|
+ (-7.4080422659e-01) * currL [A] * rollDeg [deg]^3 * invGap^2
|
||||||
|
+ (-2.1756879968e+01) * currL [A] * rollDeg [deg]^2 * invGap^3
|
||||||
|
+ (-3.5929041347e+02) * currL [A] * rollDeg [deg] * invGap^4
|
||||||
|
+ (+1.3909390562e+01) * currL [A] * invGap^5
|
||||||
|
+ (+6.1617129177e-09) * currR [A]^6
|
||||||
|
+ (+3.7175730938e-08) * currR [A]^5 * rollDeg [deg]
|
||||||
|
+ (-2.1826492898e-06) * currR [A]^5 * invGap
|
||||||
|
+ (+3.0887363778e-08) * currR [A]^4 * rollDeg [deg]^2
|
||||||
|
+ (-2.5321109476e-06) * currR [A]^4 * rollDeg [deg] * invGap
|
||||||
|
+ (+2.0057675659e-04) * currR [A]^4 * invGap^2
|
||||||
|
+ (+2.5936417813e-07) * currR [A]^3 * rollDeg [deg]^3
|
||||||
|
+ (-7.9156716870e-06) * currR [A]^3 * rollDeg [deg]^2 * invGap
|
||||||
|
+ (-9.6870862064e-05) * currR [A]^3 * rollDeg [deg] * invGap^2
|
||||||
|
+ (-1.1576514633e-02) * currR [A]^3 * invGap^3
|
||||||
|
+ (+1.0210733379e-06) * currR [A]^2 * rollDeg [deg]^4
|
||||||
|
+ (-4.4586066717e-04) * currR [A]^2 * rollDeg [deg]^3 * invGap
|
||||||
|
+ (+3.8287512141e-02) * currR [A]^2 * rollDeg [deg]^2 * invGap^2
|
||||||
|
+ (-3.4044501906e-01) * currR [A]^2 * rollDeg [deg] * invGap^3
|
||||||
|
+ (+3.6814992351e+02) * currR [A]^2 * invGap^4
|
||||||
|
+ (+2.3420159192e-05) * currR [A] * rollDeg [deg]^5
|
||||||
|
+ (-9.2337309491e-03) * currR [A] * rollDeg [deg]^4 * invGap
|
||||||
|
+ (+7.4080431520e-01) * currR [A] * rollDeg [deg]^3 * invGap^2
|
||||||
|
+ (-2.1520950367e+01) * currR [A] * rollDeg [deg]^2 * invGap^3
|
||||||
|
+ (+3.5929051935e+02) * currR [A] * rollDeg [deg] * invGap^4
|
||||||
|
+ (+1.4109361400e+01) * currR [A] * invGap^5
|
||||||
|
+ (+1.7232857807e-04) * rollDeg [deg]^6
|
||||||
|
+ (-1.5228368774e-09) * rollDeg [deg]^5 * invGap
|
||||||
|
+ (+7.4229949571e+00) * rollDeg [deg]^4 * invGap^2
|
||||||
|
+ (-4.7116044996e-06) * rollDeg [deg]^3 * invGap^3
|
||||||
|
+ (+4.0058823232e+02) * rollDeg [deg]^2 * invGap^4
|
||||||
|
+ (+7.4032294285e-04) * rollDeg [deg] * invGap^5
|
||||||
|
+ (-1.3151563792e+02) * invGap^6
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
TORQUE [mN*m] =
|
||||||
|
-2.5609643910e+00
|
||||||
|
+ (+1.2736755974e+01) * currL [A]
|
||||||
|
+ (-1.3082527996e+01) * currR [A]
|
||||||
|
+ (-1.3305641735e+01) * rollDeg [deg]
|
||||||
|
+ (+4.4151637366e+01) * invGap
|
||||||
|
+ (-6.3923649292e-02) * currL [A]^2
|
||||||
|
+ (-1.1560951475e-02) * currL [A] * currR [A]
|
||||||
|
+ (+1.8386370689e+00) * currL [A] * rollDeg [deg]
|
||||||
|
+ (-4.5922645259e+02) * currL [A] * invGap
|
||||||
|
+ (+9.8233200925e-02) * currR [A]^2
|
||||||
|
+ (+1.8386371393e+00) * currR [A] * rollDeg [deg]
|
||||||
|
+ (+4.7236896866e+02) * currR [A] * invGap
|
||||||
|
+ (+9.1214755261e-01) * rollDeg [deg]^2
|
||||||
|
+ (-1.0749540976e+03) * rollDeg [deg] * invGap
|
||||||
|
+ (-1.7435331556e+02) * invGap^2
|
||||||
|
+ (+1.3682740756e-03) * currL [A]^3
|
||||||
|
+ (+1.9533070101e-03) * currL [A]^2 * currR [A]
|
||||||
|
+ (+1.9657240805e-02) * currL [A]^2 * rollDeg [deg]
|
||||||
|
+ (+2.0525258396e+00) * currL [A]^2 * invGap
|
||||||
|
+ (-2.0087168696e-03) * currL [A] * currR [A]^2
|
||||||
|
+ (-7.7704180912e-03) * currL [A] * currR [A] * rollDeg [deg]
|
||||||
|
+ (+4.6783332660e-01) * currL [A] * currR [A] * invGap
|
||||||
|
+ (-1.5697787523e-01) * currL [A] * rollDeg [deg]^2
|
||||||
|
+ (-2.5279026405e+01) * currL [A] * rollDeg [deg] * invGap
|
||||||
|
+ (-2.6796288784e+03) * currL [A] * invGap^2
|
||||||
|
+ (-6.9397567186e-04) * currR [A]^3
|
||||||
|
+ (+1.9657238531e-02) * currR [A]^2 * rollDeg [deg]
|
||||||
|
+ (-3.1136966908e+00) * currR [A]^2 * invGap
|
||||||
|
+ (+1.8298169417e-01) * currR [A] * rollDeg [deg]^2
|
||||||
|
+ (-2.5279027176e+01) * currR [A] * rollDeg [deg] * invGap
|
||||||
|
+ (+2.5386184669e+03) * currR [A] * invGap^2
|
||||||
|
+ (+6.2974733858e-01) * rollDeg [deg]^3
|
||||||
|
+ (-1.3210284466e+01) * rollDeg [deg]^2 * invGap
|
||||||
|
+ (+4.7007171607e+04) * rollDeg [deg] * invGap^2
|
||||||
|
+ (-3.3555302551e+02) * invGap^3
|
||||||
|
+ (-1.4305035722e-04) * currL [A]^4
|
||||||
|
+ (-1.6602474226e-05) * currL [A]^3 * currR [A]
|
||||||
|
+ (-1.2886823540e-04) * currL [A]^3 * rollDeg [deg]
|
||||||
|
+ (+6.0143039665e-03) * currL [A]^3 * invGap
|
||||||
|
+ (+1.2951511025e-05) * currL [A]^2 * currR [A]^2
|
||||||
|
+ (-5.1776437670e-04) * currL [A]^2 * currR [A] * rollDeg [deg]
|
||||||
|
+ (-3.8569371878e-02) * currL [A]^2 * currR [A] * invGap
|
||||||
|
+ (+5.2166822420e-03) * currL [A]^2 * rollDeg [deg]^2
|
||||||
|
+ (-6.2117182881e-01) * currL [A]^2 * rollDeg [deg] * invGap
|
||||||
|
+ (+9.0620919904e+01) * currL [A]^2 * invGap^2
|
||||||
|
+ (+1.3270592660e-06) * currL [A] * currR [A]^3
|
||||||
|
+ (-5.1776417343e-04) * currL [A] * currR [A]^2 * rollDeg [deg]
|
||||||
|
+ (+4.2265553856e-02) * currL [A] * currR [A]^2 * invGap
|
||||||
|
+ (-2.2041148798e-04) * currL [A] * currR [A] * rollDeg [deg]^2
|
||||||
|
+ (+2.4509139646e-01) * currL [A] * currR [A] * rollDeg [deg] * invGap
|
||||||
|
+ (-4.9160159525e+00) * currL [A] * currR [A] * invGap^2
|
||||||
|
+ (-1.7295894521e-01) * currL [A] * rollDeg [deg]^3
|
||||||
|
+ (-6.7494817486e+00) * currL [A] * rollDeg [deg]^2 * invGap
|
||||||
|
+ (-8.6427297569e+02) * currL [A] * rollDeg [deg] * invGap^2
|
||||||
|
+ (+8.5965909925e+03) * currL [A] * invGap^3
|
||||||
|
+ (-1.6550913550e-05) * currR [A]^4
|
||||||
|
+ (-1.2886821877e-04) * currR [A]^3 * rollDeg [deg]
|
||||||
|
+ (-3.0326211668e-02) * currR [A]^3 * invGap
|
||||||
|
+ (-5.1175783571e-03) * currR [A]^2 * rollDeg [deg]^2
|
||||||
|
+ (-6.2117181767e-01) * currR [A]^2 * rollDeg [deg] * invGap
|
||||||
|
+ (-7.5004948389e+01) * currR [A]^2 * invGap^2
|
||||||
|
+ (-1.7295894543e-01) * currR [A] * rollDeg [deg]^3
|
||||||
|
+ (+5.7289182157e+00) * currR [A] * rollDeg [deg]^2 * invGap
|
||||||
|
+ (-8.6427297453e+02) * currR [A] * rollDeg [deg] * invGap^2
|
||||||
|
+ (-8.1554262787e+03) * currR [A] * invGap^3
|
||||||
|
+ (-8.0890042154e-02) * rollDeg [deg]^4
|
||||||
|
+ (+1.7462417614e+02) * rollDeg [deg]^3 * invGap
|
||||||
|
+ (+5.9548102453e+01) * rollDeg [deg]^2 * invGap^2
|
||||||
|
+ (-9.1016550502e+04) * rollDeg [deg] * invGap^3
|
||||||
|
+ (-1.2462037728e+02) * invGap^4
|
||||||
|
+ (-1.2219304153e-05) * currL [A]^5
|
||||||
|
+ (-6.3133366268e-06) * currL [A]^4 * currR [A]
|
||||||
|
+ (+5.1514166444e-07) * currL [A]^4 * rollDeg [deg]
|
||||||
|
+ (+1.1879557083e-03) * currL [A]^4 * invGap
|
||||||
|
+ (-1.0954778098e-06) * currL [A]^3 * currR [A]^2
|
||||||
|
+ (-5.8420851161e-06) * currL [A]^3 * currR [A] * rollDeg [deg]
|
||||||
|
+ (+2.5831185234e-03) * currL [A]^3 * currR [A] * invGap
|
||||||
|
+ (+7.9254089698e-05) * currL [A]^3 * rollDeg [deg]^2
|
||||||
|
+ (-1.7220995748e-03) * currL [A]^3 * rollDeg [deg] * invGap
|
||||||
|
+ (-4.0028155701e-01) * currL [A]^3 * invGap^2
|
||||||
|
+ (+1.7079834365e-06) * currL [A]^2 * currR [A]^3
|
||||||
|
+ (-7.8935650691e-06) * currL [A]^2 * currR [A]^2 * rollDeg [deg]
|
||||||
|
+ (-3.2330488590e-04) * currL [A]^2 * currR [A]^2 * invGap
|
||||||
|
+ (+9.9753661971e-05) * currL [A]^2 * currR [A] * rollDeg [deg]^2
|
||||||
|
+ (+7.2169993632e-03) * currL [A]^2 * currR [A] * rollDeg [deg] * invGap
|
||||||
|
+ (+5.4494940800e-01) * currL [A]^2 * currR [A] * invGap^2
|
||||||
|
+ (-1.5381149512e-03) * currL [A]^2 * rollDeg [deg]^3
|
||||||
|
+ (-1.9907607357e-01) * currL [A]^2 * rollDeg [deg]^2 * invGap
|
||||||
|
+ (+1.0517964842e+01) * currL [A]^2 * rollDeg [deg] * invGap^2
|
||||||
|
+ (-5.9716223372e+02) * currL [A]^2 * invGap^3
|
||||||
|
+ (+5.7682329953e-06) * currL [A] * currR [A]^4
|
||||||
|
+ (-5.8420830129e-06) * currL [A] * currR [A]^3 * rollDeg [deg]
|
||||||
|
+ (-3.1387669824e-03) * currL [A] * currR [A]^3 * invGap
|
||||||
|
+ (-9.2627696858e-05) * currL [A] * currR [A]^2 * rollDeg [deg]^2
|
||||||
|
+ (+7.2169983158e-03) * currL [A] * currR [A]^2 * rollDeg [deg] * invGap
|
||||||
|
+ (-5.4112374537e-01) * currL [A] * currR [A]^2 * invGap^2
|
||||||
|
+ (-3.1504005406e-05) * currL [A] * currR [A] * rollDeg [deg]^3
|
||||||
|
+ (-1.7005015703e-04) * currL [A] * currR [A] * rollDeg [deg]^2 * invGap
|
||||||
|
+ (-2.3976914010e+00) * currL [A] * currR [A] * rollDeg [deg] * invGap^2
|
||||||
|
+ (+2.0162876449e+01) * currL [A] * currR [A] * invGap^3
|
||||||
|
+ (+3.0541236561e-02) * currL [A] * rollDeg [deg]^4
|
||||||
|
+ (+6.0909890705e+00) * currL [A] * rollDeg [deg]^3 * invGap
|
||||||
|
+ (+1.8686476537e+02) * currL [A] * rollDeg [deg]^2 * invGap^2
|
||||||
|
+ (+5.4569961037e+03) * currL [A] * rollDeg [deg] * invGap^3
|
||||||
|
+ (+3.6521334233e+03) * currL [A] * invGap^4
|
||||||
|
+ (+1.2356741024e-05) * currR [A]^5
|
||||||
|
+ (+5.1514541610e-07) * currR [A]^4 * rollDeg [deg]
|
||||||
|
+ (-3.2093123475e-04) * currR [A]^4 * invGap
|
||||||
|
+ (-9.5040930219e-05) * currR [A]^3 * rollDeg [deg]^2
|
||||||
|
+ (-1.7221002130e-03) * currR [A]^3 * rollDeg [deg] * invGap
|
||||||
|
+ (+6.5615872016e-01) * currR [A]^3 * invGap^2
|
||||||
|
+ (-1.5381149487e-03) * currR [A]^2 * rollDeg [deg]^3
|
||||||
|
+ (+2.0395948760e-01) * currR [A]^2 * rollDeg [deg]^2 * invGap
|
||||||
|
+ (+1.0517964822e+01) * currR [A]^2 * rollDeg [deg] * invGap^2
|
||||||
|
+ (+4.9837046412e+02) * currR [A]^2 * invGap^3
|
||||||
|
+ (-3.0694304201e-02) * currR [A] * rollDeg [deg]^4
|
||||||
|
+ (+6.0909890702e+00) * currR [A] * rollDeg [deg]^3 * invGap
|
||||||
|
+ (-1.7712281328e+02) * currR [A] * rollDeg [deg]^2 * invGap^2
|
||||||
|
+ (+5.4569961045e+03) * currR [A] * rollDeg [deg] * invGap^3
|
||||||
|
+ (-3.4643372364e+03) * currR [A] * invGap^4
|
||||||
|
+ (-3.1967283950e-01) * rollDeg [deg]^5
|
||||||
|
+ (+7.2465373581e-01) * rollDeg [deg]^4 * invGap
|
||||||
|
+ (-3.7694440105e+03) * rollDeg [deg]^3 * invGap^2
|
||||||
|
+ (+4.4429019352e+01) * rollDeg [deg]^2 * invGap^3
|
||||||
|
+ (-3.9730669147e+04) * rollDeg [deg] * invGap^4
|
||||||
|
+ (-3.1813627757e+01) * invGap^5
|
||||||
|
+ (+2.0551811986e-07) * currL [A]^6
|
||||||
|
+ (-2.2810121436e-07) * currL [A]^5 * currR [A]
|
||||||
|
+ (-1.3383146324e-07) * currL [A]^5 * rollDeg [deg]
|
||||||
|
+ (+1.6049756900e-04) * currL [A]^5 * invGap
|
||||||
|
+ (-5.6453359321e-07) * currL [A]^4 * currR [A]^2
|
||||||
|
+ (+2.6031671041e-07) * currL [A]^4 * currR [A] * rollDeg [deg]
|
||||||
|
+ (+1.2744387021e-05) * currL [A]^4 * currR [A] * invGap
|
||||||
|
+ (+2.8978174669e-06) * currL [A]^4 * rollDeg [deg]^2
|
||||||
|
+ (-9.3438872511e-05) * currL [A]^4 * rollDeg [deg] * invGap
|
||||||
|
+ (-3.2491823205e-03) * currL [A]^4 * invGap^2
|
||||||
|
+ (+3.7946676912e-09) * currL [A]^3 * currR [A]^3
|
||||||
|
+ (+1.7599444391e-07) * currL [A]^3 * currR [A]^2 * rollDeg [deg]
|
||||||
|
+ (+2.4965108992e-05) * currL [A]^3 * currR [A]^2 * invGap
|
||||||
|
+ (+1.1286476678e-06) * currL [A]^3 * currR [A] * rollDeg [deg]^2
|
||||||
|
+ (+4.5300600434e-05) * currL [A]^3 * currR [A] * rollDeg [deg] * invGap
|
||||||
|
+ (-1.5200582693e-02) * currL [A]^3 * currR [A] * invGap^2
|
||||||
|
+ (+2.7490628440e-06) * currL [A]^3 * rollDeg [deg]^3
|
||||||
|
+ (-8.2493098857e-04) * currL [A]^3 * rollDeg [deg]^2 * invGap
|
||||||
|
+ (+2.3944496913e-02) * currL [A]^3 * rollDeg [deg] * invGap^2
|
||||||
|
+ (+1.2946953968e+00) * currL [A]^3 * invGap^3
|
||||||
|
+ (+5.9211265580e-07) * currL [A]^2 * currR [A]^4
|
||||||
|
+ (+1.7599464286e-07) * currL [A]^2 * currR [A]^3 * rollDeg [deg]
|
||||||
|
+ (-3.7773169680e-05) * currL [A]^2 * currR [A]^3 * invGap
|
||||||
|
+ (-7.7324017411e-08) * currL [A]^2 * currR [A]^2 * rollDeg [deg]^2
|
||||||
|
+ (+1.6224376321e-04) * currL [A]^2 * currR [A]^2 * rollDeg [deg] * invGap
|
||||||
|
+ (+1.1125393841e-03) * currL [A]^2 * currR [A]^2 * invGap^2
|
||||||
|
+ (-1.8213825115e-08) * currL [A]^2 * currR [A] * rollDeg [deg]^3
|
||||||
|
+ (-1.1582951117e-03) * currL [A]^2 * currR [A] * rollDeg [deg]^2 * invGap
|
||||||
|
+ (-2.7423615740e-02) * currL [A]^2 * currR [A] * rollDeg [deg] * invGap^2
|
||||||
|
+ (-1.8375761343e+00) * currL [A]^2 * currR [A] * invGap^3
|
||||||
|
+ (+1.8148082155e-05) * currL [A]^2 * rollDeg [deg]^4
|
||||||
|
+ (+2.5582361106e-02) * currL [A]^2 * rollDeg [deg]^3 * invGap
|
||||||
|
+ (+1.8719567169e+00) * currL [A]^2 * rollDeg [deg]^2 * invGap^2
|
||||||
|
+ (-7.9375540256e+00) * currL [A]^2 * rollDeg [deg] * invGap^3
|
||||||
|
+ (+1.3851610060e+03) * currL [A]^2 * invGap^4
|
||||||
|
+ (+3.6572413364e-07) * currL [A] * currR [A]^5
|
||||||
|
+ (+2.6031864309e-07) * currL [A] * currR [A]^4 * rollDeg [deg]
|
||||||
|
+ (-1.3740560576e-05) * currL [A] * currR [A]^4 * invGap
|
||||||
|
+ (-1.1634704791e-06) * currL [A] * currR [A]^3 * rollDeg [deg]^2
|
||||||
|
+ (+4.5300591111e-05) * currL [A] * currR [A]^3 * rollDeg [deg] * invGap
|
||||||
|
+ (+1.7827349035e-02) * currL [A] * currR [A]^3 * invGap^2
|
||||||
|
+ (-1.8220930542e-08) * currL [A] * currR [A]^2 * rollDeg [deg]^3
|
||||||
|
+ (+1.0311063391e-03) * currL [A] * currR [A]^2 * rollDeg [deg]^2 * invGap
|
||||||
|
+ (-2.7423615867e-02) * currL [A] * currR [A]^2 * rollDeg [deg] * invGap^2
|
||||||
|
+ (+1.8202040056e+00) * currL [A] * currR [A]^2 * invGap^3
|
||||||
|
+ (+1.4693588980e-05) * currL [A] * currR [A] * rollDeg [deg]^4
|
||||||
|
+ (+2.0391970869e-03) * currL [A] * currR [A] * rollDeg [deg]^3 * invGap
|
||||||
|
+ (-5.1289752136e-03) * currL [A] * currR [A] * rollDeg [deg]^2 * invGap^2
|
||||||
|
+ (+1.3585046026e+01) * currL [A] * currR [A] * rollDeg [deg] * invGap^3
|
||||||
|
+ (-2.5312909994e+01) * currL [A] * currR [A] * invGap^4
|
||||||
|
+ (-1.2892918352e-03) * currL [A] * rollDeg [deg]^5
|
||||||
|
+ (-4.2744856185e-01) * currL [A] * rollDeg [deg]^4 * invGap
|
||||||
|
+ (-4.5230140146e+01) * currL [A] * rollDeg [deg]^3 * invGap^2
|
||||||
|
+ (-1.2691925261e+03) * currL [A] * rollDeg [deg]^2 * invGap^3
|
||||||
|
+ (-1.7946912044e+04) * currL [A] * rollDeg [deg] * invGap^4
|
||||||
|
+ (+9.8200368161e+02) * currL [A] * invGap^5
|
||||||
|
+ (+1.4402604620e-07) * currR [A]^6
|
||||||
|
+ (-1.3383078112e-07) * currR [A]^5 * rollDeg [deg]
|
||||||
|
+ (-1.5877721700e-04) * currR [A]^5 * invGap
|
||||||
|
+ (-1.8463799734e-06) * currR [A]^4 * rollDeg [deg]^2
|
||||||
|
+ (-9.3438911534e-05) * currR [A]^4 * rollDeg [deg] * invGap
|
||||||
|
+ (-2.0062450751e-03) * currR [A]^4 * invGap^2
|
||||||
|
+ (+2.7490636967e-06) * currR [A]^3 * rollDeg [deg]^3
|
||||||
|
+ (+1.0293575893e-03) * currR [A]^3 * rollDeg [deg]^2 * invGap
|
||||||
|
+ (+2.3944496992e-02) * currR [A]^3 * rollDeg [deg] * invGap^2
|
||||||
|
+ (-2.1505766179e+00) * currR [A]^3 * invGap^3
|
||||||
|
+ (-4.8050087258e-05) * currR [A]^2 * rollDeg [deg]^4
|
||||||
|
+ (+2.5582361094e-02) * currR [A]^2 * rollDeg [deg]^3 * invGap
|
||||||
|
+ (-1.8925775930e+00) * currR [A]^2 * rollDeg [deg]^2 * invGap^2
|
||||||
|
+ (-7.9375540232e+00) * currR [A]^2 * rollDeg [deg] * invGap^3
|
||||||
|
+ (-1.1457282555e+03) * currR [A]^2 * invGap^4
|
||||||
|
+ (-1.2892918417e-03) * currR [A] * rollDeg [deg]^5
|
||||||
|
+ (+4.3570132351e-01) * currR [A] * rollDeg [deg]^4 * invGap
|
||||||
|
+ (-4.5230140146e+01) * currR [A] * rollDeg [deg]^3 * invGap^2
|
||||||
|
+ (+1.2356182202e+03) * currR [A] * rollDeg [deg]^2 * invGap^3
|
||||||
|
+ (-1.7946912045e+04) * currR [A] * rollDeg [deg] * invGap^4
|
||||||
|
+ (-9.3157391357e+02) * currR [A] * invGap^5
|
||||||
|
+ (+1.9887971069e-03) * rollDeg [deg]^6
|
||||||
|
+ (+4.1532416818e+00) * rollDeg [deg]^5 * invGap
|
||||||
|
+ (-3.7128109697e+00) * rollDeg [deg]^4 * invGap^2
|
||||||
|
+ (+1.9150570779e+04) * rollDeg [deg]^3 * invGap^3
|
||||||
|
+ (+1.9562107621e+01) * rollDeg [deg]^2 * invGap^4
|
||||||
|
+ (-1.0765823661e+04) * rollDeg [deg] * invGap^5
|
||||||
|
+ (-6.8540115783e+00) * invGap^6
|
||||||
BIN
lev_sim/recordings/sim_20260221_134810.mp4
Normal file
BIN
lev_sim/recordings/sim_20260221_142649.mp4
Normal file
BIN
lev_sim/recordings/sim_20260221_212028.mp4
Normal file
BIN
lev_sim/recordings/sim_20260221_223407.mp4
Normal file
BIN
lev_sim/recordings/sim_20260221_225018.mp4
Normal file
11
lev_sim/temp.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"height_kp": 80.05607483893696,
|
||||||
|
"height_ki": 0,
|
||||||
|
"height_kd": 7.09266287860531,
|
||||||
|
"roll_kp": -0.600856607986966,
|
||||||
|
"roll_ki": 0,
|
||||||
|
"roll_kd": -0.1,
|
||||||
|
"pitch_kp": 50.3415835489009009,
|
||||||
|
"pitch_ki": 0.02319184022898008,
|
||||||
|
"pitch_kd": 0.017632648760979346
|
||||||
|
}
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
#include "Controller.hpp"
|
|
||||||
#include <Arduino.h>
|
|
||||||
|
|
||||||
// CONTROLLER CONSTANTS
|
|
||||||
float MAX_INTEGRAL_TERM = 1e4;
|
|
||||||
|
|
||||||
void FullController::update() {
|
|
||||||
Left.readMM();
|
|
||||||
Right.readMM();
|
|
||||||
Front.readMM();
|
|
||||||
Back.readMM(); // read and update dists/oor for all sensors.
|
|
||||||
|
|
||||||
oor = Left.oor || Right.oor || Front.oor || Back.oor;
|
|
||||||
|
|
||||||
avgControl();
|
|
||||||
LRControl(); // run pwm functions.
|
|
||||||
FBControl();
|
|
||||||
|
|
||||||
FLPWM = constrain(avgPWM + LDiffPWM + FDiffPWM, -CAP, CAP);
|
|
||||||
BLPWM = constrain(avgPWM + LDiffPWM + BDiffPWM, -CAP, CAP);
|
|
||||||
FRPWM = constrain(avgPWM + RDiffPWM + FDiffPWM, -CAP, CAP);
|
|
||||||
BRPWM = constrain(avgPWM + RDiffPWM + BDiffPWM, -CAP, CAP);
|
|
||||||
|
|
||||||
// FLPWM = avgPWM;
|
|
||||||
// BLPWM = avgPWM;
|
|
||||||
// FRPWM = avgPWM;
|
|
||||||
// BRPWM = avgPWM;
|
|
||||||
}
|
|
||||||
|
|
||||||
void FullController::zeroPWMs() {
|
|
||||||
FLPWM = 0;
|
|
||||||
BLPWM = 0;
|
|
||||||
FRPWM = 0;
|
|
||||||
BRPWM = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void FullController::sendOutputs() {
|
|
||||||
if (!outputOn) {
|
|
||||||
zeroPWMs();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The following assumes 0 direction drives repulsion and 1 direction drives
|
|
||||||
// attraction. Using direct register writes to maintain fast PWM mode set by
|
|
||||||
// setupFastPWM()
|
|
||||||
digitalWrite(dirFL, FLPWM < 0);
|
|
||||||
OCR2A = abs(FLPWM); // Pin 11 -> Timer 2A
|
|
||||||
digitalWrite(dirBL, BLPWM < 0);
|
|
||||||
OCR1A = abs(BLPWM); // Pin 9 -> Timer 1A
|
|
||||||
digitalWrite(dirFR, FRPWM < 0);
|
|
||||||
OCR2B = abs(FRPWM); // Pin 3 -> Timer 2B
|
|
||||||
digitalWrite(dirBR, BRPWM < 0);
|
|
||||||
OCR1B = abs(BRPWM); // Pin 10 -> Timer 1B
|
|
||||||
}
|
|
||||||
|
|
||||||
void FullController::avgControl() {
|
|
||||||
avg = (Left.mmVal + Right.mmVal + Front.mmVal + Back.mmVal) * 0.25f;
|
|
||||||
float eCurr = AvgRef - avg;
|
|
||||||
|
|
||||||
avgError.eDiff = eCurr - avgError.e;
|
|
||||||
if (!oor) {
|
|
||||||
avgError.eInt += eCurr;
|
|
||||||
avgError.eInt =
|
|
||||||
constrain(avgError.eInt, -MAX_INTEGRAL_TERM, MAX_INTEGRAL_TERM);
|
|
||||||
}
|
|
||||||
avgError.e = eCurr;
|
|
||||||
|
|
||||||
avgPWM = pwmFunc(avgConsts, avgError);
|
|
||||||
}
|
|
||||||
|
|
||||||
void FullController::LRControl() {
|
|
||||||
float diff = Right.mmVal - Left.mmVal; // how far above the right is the left?
|
|
||||||
float eCurr = diff - LRDiffRef; // how different is that from the reference?
|
|
||||||
// positive -> Left repels, Right attracts.
|
|
||||||
K_MAP rConsts = {
|
|
||||||
LConsts.attracting,
|
|
||||||
LConsts.repelling}; // apply attracting to repelling and vice versa.
|
|
||||||
|
|
||||||
LRDiffErr.eDiff = eCurr - LRDiffErr.e;
|
|
||||||
|
|
||||||
if (!oor) {
|
|
||||||
LRDiffErr.eInt += eCurr;
|
|
||||||
LRDiffErr.eInt =
|
|
||||||
constrain(LRDiffErr.eInt, -MAX_INTEGRAL_TERM, MAX_INTEGRAL_TERM);
|
|
||||||
}
|
|
||||||
|
|
||||||
LRDiffErr.e = eCurr;
|
|
||||||
|
|
||||||
LDiffPWM = pwmFunc(LConsts, LRDiffErr);
|
|
||||||
RDiffPWM = -pwmFunc(rConsts, LRDiffErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
void FullController::FBControl() {
|
|
||||||
float diff = Back.mmVal - Front.mmVal; // how far above the back is the front?
|
|
||||||
float eCurr = diff - FBDiffRef; // how different is that from ref? pos.->Front
|
|
||||||
// must repel, Back must attract
|
|
||||||
K_MAP bConsts = {FConsts.attracting, FConsts.repelling};
|
|
||||||
|
|
||||||
FBDiffErr.eDiff = eCurr - FBDiffErr.e;
|
|
||||||
|
|
||||||
if (!oor) {
|
|
||||||
FBDiffErr.eInt += eCurr;
|
|
||||||
FBDiffErr.eInt =
|
|
||||||
constrain(FBDiffErr.eInt, -MAX_INTEGRAL_TERM, MAX_INTEGRAL_TERM);
|
|
||||||
}
|
|
||||||
|
|
||||||
FBDiffErr.e = eCurr;
|
|
||||||
|
|
||||||
FDiffPWM = pwmFunc(FConsts, FBDiffErr);
|
|
||||||
BDiffPWM = -pwmFunc(bConsts, FBDiffErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
int16_t FullController::pwmFunc(K_MAP consts, Errors errs) {
|
|
||||||
if (oor)
|
|
||||||
return 0;
|
|
||||||
Constants constants = (errs.e < 0) ? consts.attracting : consts.repelling;
|
|
||||||
return (int)(constants.kp * errs.e + constants.ki * errs.eInt +
|
|
||||||
constants.kd * errs.eDiff);
|
|
||||||
}
|
|
||||||
|
|
||||||
void FullController::report() {
|
|
||||||
// CSV Format: Left,Right,Front,Back,Avg,FLPWM,BLPWM,FRPWM,BRPWM,ControlOn
|
|
||||||
Serial.print(Left.mmVal);
|
|
||||||
Serial.print(",");
|
|
||||||
Serial.print(Right.mmVal);
|
|
||||||
Serial.print(",");
|
|
||||||
Serial.print(Front.mmVal);
|
|
||||||
Serial.print(",");
|
|
||||||
Serial.print(Back.mmVal);
|
|
||||||
Serial.print(",");
|
|
||||||
Serial.print(avg);
|
|
||||||
Serial.print(",");
|
|
||||||
|
|
||||||
Serial.print(FLPWM);
|
|
||||||
Serial.print(",");
|
|
||||||
Serial.print(BLPWM);
|
|
||||||
Serial.print(",");
|
|
||||||
Serial.print(FRPWM);
|
|
||||||
Serial.print(",");
|
|
||||||
Serial.print(BRPWM);
|
|
||||||
Serial.print(",");
|
|
||||||
|
|
||||||
Serial.println(outputOn);
|
|
||||||
}
|
|
||||||
|
|
||||||
void FullController::updateAvgPID(Constants repel, Constants attract) {
|
|
||||||
avgConsts.repelling = repel;
|
|
||||||
avgConsts.attracting = attract;
|
|
||||||
}
|
|
||||||
|
|
||||||
void FullController::updateLRPID(Constants down, Constants up) {
|
|
||||||
LConsts.repelling = down;
|
|
||||||
LConsts.attracting = up;
|
|
||||||
}
|
|
||||||
|
|
||||||
void FullController::updateFBPID(Constants down, Constants up) {
|
|
||||||
FConsts.repelling = down;
|
|
||||||
FConsts.attracting = up;
|
|
||||||
}
|
|
||||||
|
|
||||||
void FullController::updateReferences(float avgReference, float lrDiffReference, float fbDiffReference) {
|
|
||||||
AvgRef = avgReference;
|
|
||||||
LRDiffRef = lrDiffReference;
|
|
||||||
FBDiffRef = fbDiffReference;
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
#ifndef CONTROLLER_HPP
|
|
||||||
#define CONTROLLER_HPP
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
#include "IndSensorMap.hpp"
|
|
||||||
|
|
||||||
// PIN MAPPING
|
|
||||||
#define dirFR 2
|
|
||||||
#define pwmFR 3
|
|
||||||
#define dirBR 4
|
|
||||||
#define pwmBR 10
|
|
||||||
#define pwmFL 11
|
|
||||||
#define dirFL 7
|
|
||||||
#define dirBL 8
|
|
||||||
#define pwmBL 9
|
|
||||||
|
|
||||||
#define CAP 250
|
|
||||||
|
|
||||||
typedef struct Constants {
|
|
||||||
float kp;
|
|
||||||
float ki;
|
|
||||||
float kd;
|
|
||||||
} Constants;
|
|
||||||
|
|
||||||
typedef struct K_MAP {
|
|
||||||
Constants repelling;
|
|
||||||
Constants attracting;
|
|
||||||
} K_MAP;
|
|
||||||
|
|
||||||
typedef struct FullConsts {
|
|
||||||
K_MAP avg;
|
|
||||||
K_MAP lColl; // repelling is applied to attracting and vice versa for the Right and Back collectives.
|
|
||||||
K_MAP fColl;
|
|
||||||
} FullConsts;
|
|
||||||
|
|
||||||
typedef struct Errors {
|
|
||||||
float e;
|
|
||||||
float eDiff;
|
|
||||||
float eInt;
|
|
||||||
} Errors;
|
|
||||||
|
|
||||||
class FullController {
|
|
||||||
public:
|
|
||||||
bool oor;
|
|
||||||
bool outputOn;
|
|
||||||
|
|
||||||
FullController(IndSensor& l, IndSensor& r, IndSensor& f, IndSensor& b,
|
|
||||||
FullConsts fullConsts, float avgRef, float lrDiffRef, float fbDiffRef)
|
|
||||||
: Left(l), Right(r), Front(f), Back(b), AvgRef(avgRef), LRDiffRef(lrDiffRef),
|
|
||||||
FBDiffRef(fbDiffRef), avgConsts(fullConsts.avg), LConsts(fullConsts.lColl),
|
|
||||||
FConsts(fullConsts.fColl), avgError({0,0,0}), LRDiffErr({0,0,0}),
|
|
||||||
FBDiffErr({0,0,0}), oor(false), outputOn(false) {}
|
|
||||||
|
|
||||||
void update();
|
|
||||||
void zeroPWMs();
|
|
||||||
void sendOutputs();
|
|
||||||
void report();
|
|
||||||
|
|
||||||
// PID tuning methods
|
|
||||||
void updateAvgPID(Constants repel, Constants attract);
|
|
||||||
void updateLRPID(Constants down, Constants up);
|
|
||||||
void updateFBPID(Constants down, Constants up);
|
|
||||||
|
|
||||||
// Reference update methods
|
|
||||||
void updateReferences(float avgReference, float lrDiffReference, float fbDiffReference);
|
|
||||||
|
|
||||||
private:
|
|
||||||
void avgControl();
|
|
||||||
void LRControl();
|
|
||||||
void FBControl();
|
|
||||||
int16_t pwmFunc(K_MAP consts, Errors errs);
|
|
||||||
|
|
||||||
IndSensor& Front;
|
|
||||||
IndSensor& Back;
|
|
||||||
IndSensor& Right;
|
|
||||||
IndSensor& Left;
|
|
||||||
|
|
||||||
K_MAP avgConsts;
|
|
||||||
K_MAP LConsts;
|
|
||||||
K_MAP FConsts;
|
|
||||||
|
|
||||||
Errors avgError;
|
|
||||||
Errors LRDiffErr;
|
|
||||||
Errors FBDiffErr;
|
|
||||||
|
|
||||||
float AvgRef;
|
|
||||||
float LRDiffRef;
|
|
||||||
float FBDiffRef;
|
|
||||||
float avg;
|
|
||||||
|
|
||||||
int16_t avgPWM;
|
|
||||||
int16_t LDiffPWM;
|
|
||||||
int16_t RDiffPWM;
|
|
||||||
int16_t FDiffPWM;
|
|
||||||
int16_t BDiffPWM;
|
|
||||||
// Initially, I was going to make the Right and Back just the negatives,
|
|
||||||
// but having separate control functions running for each of these outputs might prove useful.
|
|
||||||
|
|
||||||
int16_t FLPWM;
|
|
||||||
int16_t BLPWM;
|
|
||||||
int16_t FRPWM;
|
|
||||||
int16_t BRPWM;
|
|
||||||
};
|
|
||||||
#endif // CONTROLLER_HPP
|
|
||||||