From 03ddbaa1d4626e56ff81043c60da58c456757326 Mon Sep 17 00:00:00 2001 From: Tomas Aas-Hansen Date: Sat, 2 May 2026 18:01:47 +0200 Subject: [PATCH] =?UTF-8?q?PID-tuning=20=C3=B8kt=201=20ferdig.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/data.json | 34 +- data/pid_configs.json | 131 ++++++- lib/control_telemetry.py | 2 +- routes.py | 173 ++++++++- static/css/pid_tuning.css | 377 +++++++++++++++++++ static/js/debug.js | 25 -- static/js/graphs.js | 95 +++-- static/js/pid_tuning.js | 601 +++++++++++++++++++++++++++++++ static/js/pilot.js | 19 +- static/js/sensors.js | 26 +- static/templates/base.html | 3 + static/templates/debug.html | 9 +- static/templates/graphs.html | 37 ++ static/templates/layout.html | 1 - static/templates/pid_tuning.html | 181 ++++++++++ 15 files changed, 1593 insertions(+), 121 deletions(-) create mode 100644 static/css/pid_tuning.css create mode 100644 static/js/pid_tuning.js create mode 100644 static/templates/pid_tuning.html diff --git a/data/data.json b/data/data.json index 4b37f00..8b96466 100644 --- a/data/data.json +++ b/data/data.json @@ -1,24 +1,24 @@ { "imu": { - "yaw": -141.52, - "pitch": -3.62, - "roll": 179.58, - "yr": 0.04, - "pr": 0.07, - "rr": 0.05, - "ax": 0.001, - "ay": 0.001, - "az": 0.001 + "yaw": 136.61, + "pitch": 1.13, + "roll": -4.16, + "yr": -0.02, + "pr": -0.16, + "rr": -0.37, + "ax": 0.003, + "ay": 0.016, + "az": -0.003 }, "resources": { - "sequence": 1073, - "uptime_ms": 1080133, - "cpu_percent": 48, - "heap_used_percent": 2, - "heap_free_kb": 376, - "heap_total_kb": 384, - "thread_count": 14, - "udp_rx_count": 17301, + "sequence": 13648, + "uptime_ms": 13656555, + "cpu_percent": 3, + "heap_used_percent": 1, + "heap_free_kb": 502, + "heap_total_kb": 512, + "thread_count": 20, + "udp_rx_count": 218096, "udp_rx_errors": 0 } } \ No newline at end of file diff --git a/data/pid_configs.json b/data/pid_configs.json index 9e26dfe..652a556 100644 --- a/data/pid_configs.json +++ b/data/pid_configs.json @@ -1 +1,130 @@ -{} \ No newline at end of file +{ + "rollPitchRough": { + "surge": { + "kp": 0, + "ki": 0, + "kd": 0 + }, + "sway": { + "kp": 0, + "ki": 0, + "kd": 0 + }, + "heave": { + "kp": 0, + "ki": 0, + "kd": 0 + }, + "roll": { + "kp": 0.035, + "ki": 0, + "kd": 0.0015 + }, + "pitch": { + "kp": 0.05, + "ki": 0, + "kd": 0.001 + }, + "yaw": { + "kp": 0, + "ki": 0, + "kd": 0 + } + }, + "rollPitchRough2": { + "surge": { + "kp": 0, + "ki": 0, + "kd": 0 + }, + "sway": { + "kp": 0, + "ki": 0, + "kd": 0 + }, + "heave": { + "kp": 0, + "ki": 0, + "kd": 0 + }, + "roll": { + "kp": 0.03, + "ki": 0, + "kd": 0.0015 + }, + "pitch": { + "kp": 0.045, + "ki": 0, + "kd": 0.001 + }, + "yaw": { + "kp": 0, + "ki": 0, + "kd": 0 + } + }, + "rollPitchYawRough": { + "surge": { + "kp": 0, + "ki": 0, + "kd": 0 + }, + "sway": { + "kp": 0, + "ki": 0, + "kd": 0 + }, + "heave": { + "kp": 0, + "ki": 0, + "kd": 0 + }, + "roll": { + "kp": 0.03, + "ki": 0, + "kd": 0.0015 + }, + "pitch": { + "kp": 0.045, + "ki": 0, + "kd": 0.001 + }, + "yaw": { + "kp": -0.05, + "ki": 0, + "kd": 0 + } + }, + "rollPitchYawPrecise": { + "surge": { + "kp": 0, + "ki": 0, + "kd": 0 + }, + "sway": { + "kp": 0, + "ki": 0, + "kd": 0 + }, + "heave": { + "kp": 0, + "ki": 0, + "kd": 0 + }, + "roll": { + "kp": 0.03, + "ki": 0, + "kd": 0.003 + }, + "pitch": { + "kp": 0.045, + "ki": 0, + "kd": 0.006 + }, + "yaw": { + "kp": -0.15, + "ki": 0, + "kd": 0 + } + } +} \ No newline at end of file diff --git a/lib/control_telemetry.py b/lib/control_telemetry.py index b6e740e..7c7f987 100644 --- a/lib/control_telemetry.py +++ b/lib/control_telemetry.py @@ -31,7 +31,7 @@ AXES = ["surge", "sway", "heave", "roll", "pitch", "yaw"] FLOAT_COUNT = len(AXES) * 3 PACKET_SIZE = 4 + FLOAT_COUNT * 4 + 4 -HISTORY_CAPACITY = 240 # 24 seconds @ 10 Hz +HISTORY_CAPACITY = 3000 # 5 minutes @ 10 Hz LOG_DIR = logs_dir() CONTROL_LOG = log_path("control_telemetry.ndjson") diff --git a/routes.py b/routes.py index 521c87c..fd50de7 100644 --- a/routes.py +++ b/routes.py @@ -1,4 +1,5 @@ import json +import math import re from flask import Response, current_app, jsonify, render_template, request @@ -35,7 +36,9 @@ def _save_pid_configs(configs): _DEFAULT_IMU_AXES = {"yaw": "+yaw", "pitch": "+pitch", "roll": "+roll"} _DEFAULT_ACCEL_AXES = {"x": "+x", "y": "+y", "z": "+z"} _DEFAULT_OFFSET = {"x": 0.0, "y": 0.0, "z": 0.0} -ATTITUDE_LIMITS_DEG = {"roll": 45.0, "pitch": 45.0, "yaw": 180.0} +ATTITUDE_LIMITS_DEG = {"roll": 180.0, "pitch": 90.0, "yaw": 180.0} +CONTROL_AXES = ("surge", "sway", "heave", "roll", "pitch", "yaw") +ATTITUDE_AXES = ("roll", "pitch", "yaw") def _clamp(value, lower, upper): @@ -46,6 +49,51 @@ def _clamp(value, lower, upper): return value +def _normalize_angle_deg(value): + wrapped = ((float(value) + 180.0) % 360.0) - 180.0 + if wrapped == -180.0 and float(value) > 0: + return 180.0 + return wrapped + + +def _neutral_axis_values(): + return {axis: 0.0 for axis in CONTROL_AXES} + + +def _zero_pid_gains(): + return {axis: {"kp": 0.0, "ki": 0.0, "kd": 0.0} for axis in PID_AXES} + + +def _coerce_attitude_setpoints(data): + axes = {} + for axis in ATTITUDE_AXES: + if axis not in data: + continue + try: + value = float(data[axis]) + except (TypeError, ValueError): + continue + if not math.isfinite(value): + continue + limit = ATTITUDE_LIMITS_DEG[axis] + if axis in ("roll", "yaw"): + value = _normalize_angle_deg(value) + axes[axis] = _clamp(value, -limit, limit) + return axes + + +def _neutralize_thruster_command(): + """Force topside manual command output to neutral axes.""" + neutral = _neutral_axis_values() + ctrl = current_app.config.get("CONTROLLER") + if ctrl: + ctrl.set_debug_override(neutral) + bm = current_app.config.get("BITMASK") + if bm: + bm.set_from_axes(**neutral) + return neutral + + def _send_full_axis_config(): """Read all axis settings from config and send to MCU in one packet.""" imu_axes = config_handler.get_section("imu_axes") or _DEFAULT_IMU_AXES @@ -95,6 +143,11 @@ def debug(): """Render the debug slider page.""" return render_template("debug.html", attitude_limits=ATTITUDE_LIMITS_DEG) + @app.route("/pid-tuning") + def pid_tuning(): + """Render the PID tuning page.""" + return render_template("pid_tuning.html", attitude_limits=ATTITUDE_LIMITS_DEG) + @app.route("/graphs") def graphs(): """Render the IMU graphs page.""" @@ -408,7 +461,8 @@ def debug_override(): axes = {} for key in ("surge", "sway", "heave", "roll", "pitch", "yaw"): if key in data: - axes[key] = max(-1.0, min(1.0, float(data[key]))) + value = max(-1.0, min(1.0, float(data[key]))) + axes[key] = -value if key == "yaw" else value if not axes: return jsonify({"ok": False, "error": "No axes supplied"}), 400 @@ -427,15 +481,7 @@ def debug_attitude_setpoint(): client = current_app.config.get("SETPOINT_OVERRIDE") if not client: return jsonify({"ok": False, "error": "Setpoint override client unavailable"}), 503 - axes = {} - for axis, limit in ATTITUDE_LIMITS_DEG.items(): - if axis not in data: - continue - try: - value = float(data[axis]) - except (TypeError, ValueError): - continue - axes[axis] = _clamp(value, -limit, limit) + axes = _coerce_attitude_setpoints(data) if not axes: return jsonify({"ok": False, "error": "No valid attitude axes supplied"}), 400 try: @@ -475,6 +521,111 @@ def debug_clear(): return jsonify({"ok": True}) + @app.route("/api/pid/start", methods=["POST"]) + def start_pid_hold(): + """Start PID tuning from the current attitude and neutral manual command axes.""" + imu = current_app.config.get("IMU") + client = current_app.config.get("SETPOINT_OVERRIDE") + if not imu: + return jsonify({"ok": False, "error": "IMU receiver not running"}), 503 + if not client: + return jsonify({"ok": False, "error": "Setpoint override client unavailable"}), 503 + + stats = imu.get_stats() + age_ms = stats.get("age_ms") + if age_ms is None or age_ms > 2000: + return jsonify({"ok": False, "error": "IMU data is stale; PID start was blocked"}), 503 + + attitude = stats.get("last_data") or {} + try: + attitude_setpoints = _coerce_attitude_setpoints( + {axis: float(attitude[axis]) for axis in ATTITUDE_AXES} + ) + except (KeyError, TypeError, ValueError): + return jsonify({"ok": False, "error": "Current attitude is incomplete"}), 503 + if len(attitude_setpoints) != len(ATTITUDE_AXES): + return jsonify({"ok": False, "error": "Current attitude is incomplete"}), 503 + + neutral = _neutralize_thruster_command() + setpoints = {**neutral, **attitude_setpoints} + try: + client.clear_override() + state = client.send_override(setpoints, replay_attempts=5, replay_delay=0.1) + except Exception as exc: # pylint: disable=broad-except + client.set_error(str(exc)) + return jsonify({"ok": False, "error": str(exc), "neutralized": True}), 503 + + return jsonify( + { + "ok": True, + "setpoints": setpoints, + "state": state, + "neutralized": True, + "units": "deg", + } + ) + + @app.route("/api/pid/setpoints", methods=["POST"]) + def set_pid_attitude_setpoints(): + """Send roll/pitch/yaw PID attitude setpoints in VN-100 degrees.""" + data = request.get_json(force=True, silent=True) or {} + client = current_app.config.get("SETPOINT_OVERRIDE") + if not client: + return jsonify({"ok": False, "error": "Setpoint override client unavailable"}), 503 + axes = _coerce_attitude_setpoints(data) + if not axes: + return jsonify({"ok": False, "error": "No valid attitude setpoints supplied"}), 400 + try: + state = client.send_override(axes, replay_attempts=5, replay_delay=0.1) + except Exception as exc: # pylint: disable=broad-except + client.set_error(str(exc)) + return jsonify({"ok": False, "error": str(exc)}), 503 + return jsonify( + { + "ok": True, + "sent": axes, + "limits": ATTITUDE_LIMITS_DEG, + "state": state, + "units": "deg", + } + ) + + @app.route("/api/pid/zero_all", methods=["POST"]) + def zero_all_pid(): + """Force neutral manual command axes and send zero PID gains to the MCU.""" + neutral = _neutralize_thruster_command() + client = current_app.config.get("SETPOINT_OVERRIDE") + if client: + try: + client.clear_override() + except Exception: + pass + + zeros = _zero_pid_gains() + confirmed, attempts = send_pid_gains(zeros, timeout=1.0, max_retries=3) + if confirmed is None: + return ( + jsonify( + { + "ok": False, + "error": "Thruster axes neutralized, but no PID zero confirmation from MCU after %d attempts" + % attempts, + "neutralized": True, + "override": neutral, + } + ), + 504, + ) + return jsonify( + { + "ok": True, + "gains": confirmed, + "attempts": attempts, + "neutralized": True, + "override": neutral, + } + ) + # --- Gain endpoints --- @app.route("/api/controller/gains", methods=["GET"]) def get_gains(): diff --git a/static/css/pid_tuning.css b/static/css/pid_tuning.css new file mode 100644 index 0000000..01d0a4b --- /dev/null +++ b/static/css/pid_tuning.css @@ -0,0 +1,377 @@ +.pid-page-shell { + width: 100%; +} + +.pid-muted, +.pid-note, +.pid-feedback, +.pid-small-row { + color: #adb5bd; + font-size: 0.82rem; +} + +.pid-action-bar { + position: sticky; + top: 56px; + z-index: 20; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + margin-bottom: 1rem; + background: rgba(18, 24, 31, 0.98); + border: 1px solid #495057; + border-radius: 8px; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25); +} + +.pid-action-status, +.pid-action-controls, +.pid-button-row, +.pid-chart-buttons, +.pid-chart-legend, +.pid-small-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.pid-kicker { + color: #0dcaf0; + font-weight: 700; + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.08em; +} + +.pid-compact-label { + color: #adb5bd; + font-size: 0.75rem; + margin-bottom: 0; +} + +#pid-time-window { + width: 96px; +} + +.pid-zero-btn { + font-weight: 800; + letter-spacing: 0.03em; +} + +.pid-layout-grid { + display: grid; + grid-template-columns: minmax(350px, 440px) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.pid-left-stack, +.pid-right-stack { + display: grid; + gap: 1rem; +} + +.pid-panel .card-body { + padding: 1rem; +} + +.pid-panel-header, +.pid-chart-toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.pid-attitude-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; +} + +.pid-readout { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 0.75rem 0.5rem; + text-align: center; + min-width: 0; +} + +.pid-readout-label { + display: block; + color: #adb5bd; + font-size: 0.72rem; + text-transform: uppercase; +} + +.pid-readout-value { + display: block; + color: #0dcaf0; + font-family: Consolas, Monaco, monospace; + font-size: clamp(1rem, 2vw, 1.35rem); + font-weight: 700; + white-space: nowrap; +} + +.pid-setpoint-grid { + display: grid; + gap: 0.55rem; + margin: 0.75rem 0; +} + +.pid-setpoint-row { + display: grid; + grid-template-columns: 58px minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; +} + +.pid-setpoint-row label, +.pid-axis-label, +.pid-slider-header label { + color: #f8f9fa; + font-weight: 700; + margin: 0; +} + +.pid-gain-box { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 0.75rem; + margin-top: 0.8rem; + background: rgba(255, 255, 255, 0.035); +} + +.pid-box-title { + color: #0dcaf0; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.pid-gain-grid { + display: grid; + grid-template-columns: minmax(60px, 0.9fr) repeat(3, minmax(64px, 1fr)); + gap: 0.4rem; + align-items: center; +} + +.pid-gain-head { + color: #adb5bd; + font-size: 0.72rem; + text-align: center; + text-transform: uppercase; +} + +.pid-input, +.setpoint-input { + border: 1px solid rgba(255, 255, 255, 0.15); + font-family: Consolas, Monaco, monospace; + text-align: center; +} + +.pid-input:focus, +.setpoint-input:focus { + border-color: #0dcaf0; + box-shadow: 0 0 0 0.12rem rgba(13, 202, 240, 0.25); +} + +.pid-config-row { + display: grid; + grid-template-columns: minmax(115px, 1fr) auto minmax(115px, 1fr) auto auto auto; + gap: 0.45rem; + align-items: center; + margin-top: 0.9rem; +} + +.pid-slider-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + margin-top: 0.75rem; +} + +.pid-slider-card { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 0.65rem; + min-width: 0; +} + +.pid-slider-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.slider-value { + color: #0dcaf0; + font-family: Consolas, Monaco, monospace; + font-weight: 700; +} + +.debug-slider { + cursor: pointer; + accent-color: #6c757d; +} + +.debug-slider.positive { + accent-color: #198754; +} + +.debug-slider.negative { + accent-color: #dc3545; +} + +.debug-slider.zero { + accent-color: #6c757d; +} + +.pid-chart-panel { + min-height: 520px; +} + +.pid-chart-toolbar { + margin-bottom: 0.5rem; +} + +.pid-chart-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + margin-top: 0.75rem; +} + +.pid-chart-card { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 8px; + padding: 0.75rem; + min-width: 0; +} + +.pid-chart-title { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.5rem; + color: #f8f9fa; + font-weight: 700; + margin-bottom: 0.35rem; +} + +.pid-chart-values { + color: #adb5bd; + font-family: Consolas, Monaco, monospace; + font-size: 0.72rem; + font-weight: 400; + white-space: nowrap; +} + +.pid-chart-card canvas { + width: 100% !important; + height: 260px !important; +} + +.pid-chart-legend span { + color: #adb5bd; + font-size: 0.78rem; +} + +.pid-chart-legend i { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 2px; + margin-right: 0.25rem; + vertical-align: -1px; +} + +.legend-setpoint { + background: #0dcaf0; +} + +.legend-position { + background: #20c997; +} + +.legend-error { + background: #ffc107; +} + +.pid-telemetry-table th, +.pid-telemetry-table td { + text-align: center; + white-space: nowrap; +} + +.pid-command-preview { + margin: 0.75rem 0 0; + padding: 0.75rem; + background: rgba(0, 0, 0, 0.32); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: #dce7ef; + font-size: 0.78rem; + max-height: 210px; + overflow: auto; +} + +@media (max-width: 1200px) { + .pid-layout-grid { + grid-template-columns: 1fr; + } + + .pid-chart-grid { + grid-template-columns: 1fr; + } + + .pid-chart-card canvas { + height: 210px !important; + } +} + +@media (max-width: 720px) { + .pid-action-bar { + align-items: stretch; + } + + .pid-action-bar, + .pid-action-status, + .pid-action-controls { + flex-direction: column; + } + + .pid-action-controls > * { + width: 100%; + } + + #pid-time-window { + width: 100%; + } + + .pid-attitude-grid, + .pid-slider-grid { + grid-template-columns: 1fr; + } + + .pid-setpoint-row { + grid-template-columns: 1fr; + } + + .pid-config-row { + grid-template-columns: 1fr; + } + + .pid-chart-title { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/static/js/debug.js b/static/js/debug.js index b05b4c9..2cf556e 100644 --- a/static/js/debug.js +++ b/static/js/debug.js @@ -459,7 +459,6 @@ const imuRoll = document.getElementById("imu-roll"); const imuPktCount = document.getElementById("imu-pkt-count"); const imuAge = document.getElementById("imu-age"); - const imuTareInfo = document.getElementById("imu-tare-info"); function fmtDeg(v) { const n = parseFloat(v); @@ -494,35 +493,11 @@ imuStatus.className = "badge bg-secondary me-2"; } - // Tare info - const t = s.tare_offset; - if (t && (t.yaw !== 0 || t.pitch !== 0 || t.roll !== 0)) { - imuTareInfo.textContent = - "Y:" + t.yaw.toFixed(1) + " P:" + t.pitch.toFixed(1) + " R:" + t.roll.toFixed(1); - } else { - imuTareInfo.textContent = "none"; - } } catch (_) { /* silent */ } } - document.getElementById("btn-tare").addEventListener("click", async () => { - try { - await fetch("/api/imu/tare", { method: "POST" }); - } catch (e) { - console.error("Tare failed:", e); - } - }); - - document.getElementById("btn-clear-tare").addEventListener("click", async () => { - try { - await fetch("/api/imu/tare", { method: "DELETE" }); - } catch (e) { - console.error("Clear tare failed:", e); - } - }); - pollIMU(); setInterval(pollIMU, 200); diff --git a/static/js/graphs.js b/static/js/graphs.js index 29b2c4a..ee96423 100644 --- a/static/js/graphs.js +++ b/static/js/graphs.js @@ -69,24 +69,41 @@ // Store all raw samples for CSV export: { key: [{ x, y }, ...] } const allData = {}; + const setpointData = {}; - function makeChart(canvasId, label, unit, color, key) { + function makeChart(canvasId, label, unit, color, key, showSetpoint) { yLocks[key] = { min: null, max: null }; allData[key] = []; + if (showSetpoint) setpointData[key] = []; var ctx = document.getElementById(canvasId).getContext("2d"); + var datasets = [{ + label: label, + data: allData[key], + borderColor: color, + borderWidth: 1.5, + pointRadius: 0, + tension: 0.2, + fill: false, + }]; + + if (showSetpoint) { + datasets.push({ + label: label + " Setpoint", + data: setpointData[key], + borderColor: "#ff4d6d", + borderWidth: 2, + pointRadius: 0, + tension: 0.08, + borderDash: [8, 4], + fill: false, + }); + } + return new Chart(ctx, { type: "line", data: { - datasets: [{ - label: label, - data: allData[key], - borderColor: color, - borderWidth: 1.5, - pointRadius: 0, - tension: 0.2, - fill: false, - }], + datasets: datasets, }, options: { responsive: true, @@ -119,10 +136,10 @@ return "t = " + items[0].parsed.x.toFixed(1) + " s"; }, label: function (item) { - return label + ": " + item.parsed.y.toFixed(2) + " " + unit; + return item.dataset.label + ": " + item.parsed.y.toFixed(2) + " " + unit; }, }, - displayColors: false, + displayColors: true, backgroundColor: "rgba(0,0,0,0.8)", titleFont: { size: 11 }, bodyFont: { size: 11 }, @@ -153,9 +170,9 @@ var charts = {}; var chartDefs = { - yaw: { canvas: "chart-yaw", label: "Yaw", unit: "deg", color: "#0dcaf0" }, - pitch: { canvas: "chart-pitch", label: "Pitch", unit: "deg", color: "#ffc107" }, - roll: { canvas: "chart-roll", label: "Roll", unit: "deg", color: "#198754" }, + yaw: { canvas: "chart-yaw", label: "Yaw", unit: "deg", color: "#0dcaf0", setpoint: true }, + pitch: { canvas: "chart-pitch", label: "Pitch", unit: "deg", color: "#ffc107", setpoint: true }, + roll: { canvas: "chart-roll", label: "Roll", unit: "deg", color: "#198754", setpoint: true }, yr: { canvas: "chart-yr", label: "Yaw Rate", unit: "deg/s", color: "#0dcaf0" }, pr: { canvas: "chart-pr", label: "Pitch Rate", unit: "deg/s", color: "#ffc107" }, rr: { canvas: "chart-rr", label: "Roll Rate", unit: "deg/s", color: "#198754" }, @@ -163,7 +180,7 @@ for (var key in chartDefs) { var d = chartDefs[key]; - charts[key] = makeChart(d.canvas, d.label, d.unit, d.color, key); + charts[key] = makeChart(d.canvas, d.label, d.unit, d.color, key, d.setpoint); } var startTime = Date.now(); @@ -182,14 +199,25 @@ while (ds.length > 0 && ds[0].x < cutoff) { ds.shift(); } + var setpointSeries = setpointData[key]; + if (setpointSeries) { + while (setpointSeries.length > 0 && setpointSeries[0].x < cutoff) { + setpointSeries.shift(); + } + } } async function poll() { if (paused) return; try { - var res = await fetch("/api/sensors"); - if (!res.ok) return; - var d = await res.json(); + var responses = await Promise.all([ + fetch("/api/sensors"), + fetch("/api/control/telemetry"), + ]); + if (!responses[0].ok) return; + var d = await responses[0].json(); + var telemetry = responses[1].ok ? await responses[1].json() : {}; + var setpoints = telemetry.ok && telemetry.telemetry ? telemetry.telemetry.setpoint || {} : {}; var t = parseFloat(((Date.now() - startTime) / 1000).toFixed(2)); var values = { @@ -199,6 +227,10 @@ for (var k in charts) { allData[k].push({ x: t, y: values[k] }); + if (setpointData[k]) { + var setpoint = typeof setpoints[k] === "number" ? setpoints[k] : null; + setpointData[k].push({ x: t, y: setpoint }); + } trimData(k); applyYLocks(k); charts[k].update("none"); @@ -211,6 +243,7 @@ function clearAll() { for (var k in charts) { allData[k].length = 0; + if (setpointData[k]) setpointData[k].length = 0; charts[k].resetZoom(); charts[k].update(); } @@ -230,6 +263,9 @@ for (var k in allData) { allData[k].forEach(function (pt) { timeSet[pt.x] = true; }); } + for (var spKey in setpointData) { + setpointData[spKey].forEach(function (pt) { timeSet[pt.x] = true; }); + } var times = Object.keys(timeSet).map(Number).sort(function (a, b) { return a - b; }); if (times.length === 0) { @@ -244,14 +280,27 @@ lookup[k] = {}; allData[k].forEach(function (pt) { lookup[k][pt.x] = pt.y; }); }); + var setpointLookup = {}; + ["yaw", "pitch", "roll"].forEach(function (k) { + setpointLookup[k] = {}; + (setpointData[k] || []).forEach(function (pt) { setpointLookup[k][pt.x] = pt.y; }); + }); - var header = "time_s,yaw_deg,pitch_deg,roll_deg,yaw_rate_dps,pitch_rate_dps,roll_rate_dps"; + var header = "time_s,yaw_deg,yaw_setpoint_deg,pitch_deg,pitch_setpoint_deg,roll_deg,roll_setpoint_deg,yaw_rate_dps,pitch_rate_dps,roll_rate_dps"; var rows = [header]; times.forEach(function (t) { var row = t.toFixed(2); - keys.forEach(function (k) { - var v = lookup[k][t]; - row += "," + (v !== undefined ? v.toFixed(3) : ""); + var yaw = lookup.yaw[t]; + var yawSet = setpointLookup.yaw[t]; + var pitch = lookup.pitch[t]; + var pitchSet = setpointLookup.pitch[t]; + var roll = lookup.roll[t]; + var rollSet = setpointLookup.roll[t]; + var yr = lookup.yr[t]; + var pr = lookup.pr[t]; + var rr = lookup.rr[t]; + [yaw, yawSet, pitch, pitchSet, roll, rollSet, yr, pr, rr].forEach(function (v) { + row += "," + (v !== undefined && v !== null ? v.toFixed(3) : ""); }); rows.push(row); }); diff --git a/static/js/pid_tuning.js b/static/js/pid_tuning.js new file mode 100644 index 0000000..bc81ced --- /dev/null +++ b/static/js/pid_tuning.js @@ -0,0 +1,601 @@ +// PID tuning page controls. +(function () { + "use strict"; + + const AXES = ["surge", "sway", "heave", "roll", "pitch", "yaw"]; + const ROT_AXES = ["roll", "pitch", "yaw"]; + const PID_GAINS = ["kp", "ki", "kd"]; + const SEND_INTERVAL_MS = 50; + + const attitudeLimits = window.pidTuningAttitudeLimits || { roll: 180, pitch: 90, yaw: 180 }; + let overrideActive = false; + let sendTimer = null; + let latestImu = {}; + let latestTelemetry = null; + const localSetpoints = { roll: NaN, pitch: NaN, yaw: NaN }; + + const pageStatus = document.getElementById("pid-page-status"); + const linkStatus = document.getElementById("pid-link-status"); + const imuAge = document.getElementById("pid-imu-age"); + const pidStatus = document.getElementById("pid-status"); + const setpointStatus = document.getElementById("setpoint-status"); + const setpointFeedback = document.getElementById("setpoint-feedback"); + const telemetryAge = document.getElementById("telemetry-age"); + const telemetryBody = document.getElementById("pid-telemetry-body"); + const rovStatus = document.getElementById("rov-status"); + + function isFiniteNumber(value) { + return typeof value === "number" && Number.isFinite(value); + } + + function fmt(value, digits) { + if (!isFiniteNumber(value)) return "--"; + return value.toFixed(digits == null ? 2 : digits); + } + + function normalizeAngle(value) { + let wrapped = ((value + 180) % 360) - 180; + if (wrapped === -180 && value > 0) wrapped = 180; + return wrapped; + } + + function angleError(setpoint, position) { + if (!isFiniteNumber(setpoint) || !isFiniteNumber(position)) return NaN; + return normalizeAngle(setpoint - position); + } + + function setBadge(el, text, cls) { + if (!el) return; + el.textContent = text; + el.className = "badge " + cls; + } + + function setPageStatus(text, cls) { + setBadge(pageStatus, text, cls || "bg-secondary"); + } + + function setPidStatus(text, cls) { + setBadge(pidStatus, text, cls || "bg-secondary"); + } + + function setSetpointStatus(text, cls) { + setBadge(setpointStatus, text, cls || "bg-secondary"); + } + + function setFeedback(text, cls) { + if (!setpointFeedback) return; + setpointFeedback.textContent = text; + setpointFeedback.className = "pid-feedback " + (cls || ""); + } + + function clampSetpoint(axis, value) { + const limit = Number(attitudeLimits[axis] || 180); + let next = Number(value); + if (!Number.isFinite(next)) return NaN; + if (axis === "roll" || axis === "yaw") next = normalizeAngle(next); + return Math.max(-limit, Math.min(limit, next)); + } + + function getTelemetrySetpoint(axis) { + const fromTelemetry = latestTelemetry && latestTelemetry.setpoint ? Number(latestTelemetry.setpoint[axis]) : NaN; + if (Number.isFinite(fromTelemetry)) return fromTelemetry; + return localSetpoints[axis]; + } + + function updateTelemetryTable() { + if (!telemetryBody) return; + const frag = document.createDocumentFragment(); + ROT_AXES.forEach((axis) => { + const setpoint = getTelemetrySetpoint(axis); + const position = Number(latestImu[axis]); + const error = angleError(setpoint, position); + const tr = document.createElement("tr"); + const tdAxis = document.createElement("td"); + const tdSet = document.createElement("td"); + const tdPos = document.createElement("td"); + const tdErr = document.createElement("td"); + tdAxis.textContent = axis.toUpperCase(); + tdSet.textContent = fmt(setpoint, 2); + tdPos.textContent = fmt(position, 2); + tdErr.textContent = fmt(error, 2); + if (isFiniteNumber(error) && Math.abs(error) > 10) tdErr.className = "text-warning"; + if (isFiniteNumber(error) && Math.abs(error) > 25) tdErr.className = "text-danger"; + tr.append(tdAxis, tdSet, tdPos, tdErr); + frag.appendChild(tr); + }); + telemetryBody.innerHTML = ""; + telemetryBody.appendChild(frag); + } + + function updateTelemetryAge(snapshot) { + if (!telemetryAge) return; + const ageMs = snapshot && snapshot.timestamp ? Math.max(0, Date.now() - snapshot.timestamp * 1000) : null; + if (ageMs == null) setBadge(telemetryAge, "NO TELEMETRY", "bg-secondary"); + else if (ageMs < 750) setBadge(telemetryAge, ageMs.toFixed(0) + " ms", "bg-success"); + else if (ageMs < 2500) setBadge(telemetryAge, ageMs.toFixed(0) + " ms", "bg-warning text-dark"); + else setBadge(telemetryAge, "STALE", "bg-danger"); + } + + async function pollImuAndTelemetry() { + const imuReq = fetch("/api/imu/status").then((res) => res.json()); + const telemetryReq = fetch("/api/control/telemetry").then((res) => res.json()); + const results = await Promise.allSettled([imuReq, telemetryReq]); + + if (results[0].status === "fulfilled" && results[0].value.ok) { + const stats = results[0].value.stats || {}; + const data = stats.last_data || {}; + latestImu = { + roll: Number(data.roll), + pitch: Number(data.pitch), + yaw: Number(data.yaw), + }; + if (imuAge) imuAge.textContent = stats.age_ms != null ? stats.age_ms : "--"; + } + + if (results[1].status === "fulfilled" && results[1].value.ok) { + latestTelemetry = results[1].value.telemetry || null; + updateTelemetryAge(latestTelemetry); + } + + updateTelemetryTable(); + } + + function getSliderValue(axis) { + const slider = document.getElementById("slider-" + axis); + return slider ? parseInt(slider.value, 10) / 100 : 0; + } + + function getAllSliderValues() { + const values = {}; + AXES.forEach((axis) => { + values[axis] = getSliderValue(axis); + }); + return values; + } + + function updateValueDisplay(axis) { + const value = getSliderValue(axis); + const label = document.getElementById("val-" + axis); + const slider = document.getElementById("slider-" + axis); + if (label) label.textContent = value.toFixed(2); + if (!slider) return; + slider.classList.remove("positive", "negative", "zero"); + if (value > 0.005) slider.classList.add("positive"); + else if (value < -0.005) slider.classList.add("negative"); + else slider.classList.add("zero"); + } + + function resetSlider(axis) { + const slider = document.getElementById("slider-" + axis); + if (!slider) return; + slider.value = 0; + updateValueDisplay(axis); + } + + async function sendOverride() { + if (!overrideActive) return; + try { + await fetch("/api/debug/override", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(getAllSliderValues()), + }); + } catch (err) { + console.error("Failed to send override:", err); + } + } + + function resetOverrideUi() { + overrideActive = false; + if (sendTimer) { + clearInterval(sendTimer); + sendTimer = null; + } + const btnEnable = document.getElementById("btn-enable"); + const btnDisable = document.getElementById("btn-disable"); + const status = document.getElementById("debug-status"); + if (btnEnable) btnEnable.disabled = false; + if (btnDisable) btnDisable.disabled = true; + setBadge(status, "INACTIVE", "bg-secondary"); + } + + function enableOverride() { + if (overrideActive) return; + overrideActive = true; + const btnEnable = document.getElementById("btn-enable"); + const btnDisable = document.getElementById("btn-disable"); + const status = document.getElementById("debug-status"); + if (btnEnable) btnEnable.disabled = true; + if (btnDisable) btnDisable.disabled = false; + setBadge(status, "ACTIVE", "bg-danger"); + sendOverride(); + sendTimer = setInterval(sendOverride, SEND_INTERVAL_MS); + } + + async function disableOverride() { + resetOverrideUi(); + try { + await fetch("/api/debug/clear", { method: "POST" }); + } catch (err) { + console.error("Failed to clear override:", err); + } + } + + function activateNeutralOverride() { + AXES.forEach(resetSlider); + enableOverride(); + } + + function readPidFields() { + const gains = {}; + AXES.forEach((axis) => { + gains[axis] = {}; + PID_GAINS.forEach((gain) => { + const el = document.getElementById("pid-" + axis + "-" + gain); + gains[axis][gain] = el ? parseFloat(el.value) || 0 : 0; + }); + }); + return gains; + } + + function fillPidFields(gains) { + AXES.forEach((axis) => { + PID_GAINS.forEach((gain) => { + const el = document.getElementById("pid-" + axis + "-" + gain); + if (el && gains && gains[axis] && gains[axis][gain] != null) { + el.value = gains[axis][gain]; + } + }); + }); + } + + function zeroGainPayload() { + const zeros = {}; + AXES.forEach((axis) => { + zeros[axis] = { kp: 0, ki: 0, kd: 0 }; + }); + return zeros; + } + + async function requestPidGains() { + setPidStatus("REQUESTING", "bg-warning text-dark"); + const btn = document.getElementById("btn-pid-request"); + if (btn) btn.disabled = true; + try { + const res = await fetch("/api/pid/gains"); + const data = await res.json(); + if (data.ok) { + fillPidFields(data.gains); + setPidStatus("LOADED", "bg-success"); + } else { + setPidStatus("NO RESPONSE", "bg-danger"); + } + } catch (err) { + setPidStatus("ERROR", "bg-danger"); + console.error("PID request failed:", err); + } finally { + if (btn) btn.disabled = false; + } + } + + async function sendPidGains() { + setPidStatus("SENDING", "bg-warning text-dark"); + const btn = document.getElementById("btn-pid-send"); + if (btn) btn.disabled = true; + try { + const res = await fetch("/api/pid/gains", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(readPidFields()), + }); + const data = await res.json(); + if (data.ok) { + fillPidFields(data.gains); + setPidStatus(data.attempts > 1 ? "CONFIRMED RETRY " + data.attempts : "CONFIRMED", "bg-success"); + } else { + setPidStatus(data.error || "NO RESPONSE", "bg-danger"); + } + } catch (err) { + setPidStatus("ERROR", "bg-danger"); + console.error("PID send failed:", err); + } finally { + if (btn) btn.disabled = false; + } + } + + async function zeroAllPidAndThrusters() { + activateNeutralOverride(); + fillPidFields(zeroGainPayload()); + setPidStatus("NEUTRALIZING", "bg-warning text-dark"); + setPageStatus("NEUTRALIZING", "bg-warning text-dark"); + document.querySelectorAll(".js-zero-all-pid").forEach((btn) => { + btn.disabled = true; + }); + try { + const res = await fetch("/api/pid/zero_all", { method: "POST" }); + const data = await res.json().catch(() => ({})); + if (data.gains) fillPidFields(data.gains); + if (res.ok && data.ok) { + setPidStatus("ZERO CONFIRMED", "bg-success"); + setPageStatus("NEUTRAL HOLD", "bg-danger"); + } else { + setPidStatus("NEUTRAL, PID NO REPLY", "bg-danger"); + setPageStatus("NEUTRAL, CHECK MCU", "bg-danger"); + } + } catch (err) { + setPidStatus("NEUTRAL, ERROR", "bg-danger"); + setPageStatus("NEUTRAL, ERROR", "bg-danger"); + console.error("Zero PID failed:", err); + } finally { + document.querySelectorAll(".js-zero-all-pid").forEach((btn) => { + btn.disabled = false; + }); + } + } + + function fillSetpointFields(setpoints) { + ROT_AXES.forEach((axis) => { + const value = setpoints && Number(setpoints[axis]); + if (Number.isFinite(value)) { + localSetpoints[axis] = value; + const el = document.getElementById("setpoint-" + axis); + if (el) el.value = value.toFixed(1); + } + }); + } + + async function startPid() { + activateNeutralOverride(); + setSetpointStatus("STARTING", "bg-warning text-dark"); + setPageStatus("STARTING PID", "bg-warning text-dark"); + document.querySelectorAll(".js-start-pid").forEach((btn) => { + btn.disabled = true; + }); + try { + const res = await fetch("/api/pid/start", { method: "POST" }); + const data = await res.json(); + if (data.ok) { + fillSetpointFields(data.setpoints || {}); + setSetpointStatus("ACTIVE", "bg-danger"); + setPageStatus("PID HOLD ACTIVE", "bg-success"); + setFeedback("Started from current IMU attitude with neutral manual command axes.", "text-success"); + } else { + setSetpointStatus("BLOCKED", "bg-danger"); + setPageStatus("START BLOCKED", "bg-danger"); + setFeedback(data.error || "Start failed.", "text-danger"); + } + } catch (err) { + setSetpointStatus("ERROR", "bg-danger"); + setPageStatus("START ERROR", "bg-danger"); + setFeedback("Error: " + err.message, "text-danger"); + } finally { + document.querySelectorAll(".js-start-pid").forEach((btn) => { + btn.disabled = false; + }); + } + } + + function readSetpointInputs() { + const payload = {}; + ROT_AXES.forEach((axis) => { + const el = document.getElementById("setpoint-" + axis); + if (!el || el.value === "") return; + const value = clampSetpoint(axis, parseFloat(el.value)); + if (Number.isFinite(value)) { + payload[axis] = value; + el.value = value.toFixed(1); + } + }); + return payload; + } + + async function sendSetpoints() { + const payload = readSetpointInputs(); + if (!Object.keys(payload).length) { + setFeedback("Enter at least one angle setpoint.", "text-warning"); + return; + } + setSetpointStatus("SENDING", "bg-warning text-dark"); + try { + const res = await fetch("/api/pid/setpoints", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (data.ok) { + fillSetpointFields(data.sent || {}); + setSetpointStatus("ACTIVE", "bg-danger"); + setFeedback("Sent setpoints: " + Object.entries(data.sent || {}).map(([k, v]) => k + "=" + v.toFixed(1)).join(", "), "text-success"); + } else { + setSetpointStatus("ERROR", "bg-danger"); + setFeedback(data.error || "Setpoint send failed.", "text-danger"); + } + } catch (err) { + setSetpointStatus("ERROR", "bg-danger"); + setFeedback("Error: " + err.message, "text-danger"); + } + } + + async function clearSetpoints() { + try { + await fetch("/api/debug/clear", { method: "POST" }); + ROT_AXES.forEach((axis) => { + localSetpoints[axis] = NaN; + }); + resetOverrideUi(); + setSetpointStatus("IDLE", "bg-secondary"); + setPageStatus("READY", "bg-secondary"); + setFeedback("Setpoints cleared.", "text-light"); + } catch (err) { + setFeedback("Error: " + err.message, "text-danger"); + } + } + + async function pollRovStatus() { + try { + const res = await fetch("/api/rov/status"); + const data = await res.json(); + if (rovStatus) { + rovStatus.textContent = JSON.stringify( + { command: data.command, uplink: data.uplink, resource: data.resource }, + null, + 2 + ); + } + const ackAge = data.uplink && isFiniteNumber(data.uplink.last_ack_age_ms) ? data.uplink.last_ack_age_ms : null; + if (ackAge == null) setBadge(linkStatus, "LINK IDLE", "bg-secondary"); + else if (ackAge < 1000) setBadge(linkStatus, "LINK LIVE", "bg-success"); + else if (ackAge < 3000) setBadge(linkStatus, "LINK STALE", "bg-warning text-dark"); + else setBadge(linkStatus, "LINK DEGRADED", "bg-danger"); + } catch (err) { + if (rovStatus) rovStatus.textContent = "Error fetching status"; + setBadge(linkStatus, "LINK ERROR", "bg-danger"); + } + } + + async function refreshConfigList() { + const select = document.getElementById("pid-config-select"); + if (!select) return; + try { + const res = await fetch("/api/pid/configs"); + const data = await res.json(); + select.innerHTML = ''; + (data.configs || []).forEach((name) => { + const opt = document.createElement("option"); + opt.value = name; + opt.textContent = name; + select.appendChild(opt); + }); + } catch (err) { + console.error("Failed to list PID configs:", err); + } + } + + function setConfigStatus(text, cls) { + setBadge(document.getElementById("pid-config-status"), text, cls || "bg-secondary"); + } + + async function saveConfig() { + const nameEl = document.getElementById("pid-config-name"); + const name = nameEl ? nameEl.value.trim() : ""; + if (!name) { + setConfigStatus("Name", "bg-warning text-dark"); + return; + } + try { + const res = await fetch("/api/pid/configs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, gains: readPidFields() }), + }); + const data = await res.json(); + if (data.ok) { + setConfigStatus("Saved", "bg-success"); + refreshConfigList(); + } else { + setConfigStatus(data.error || "Error", "bg-danger"); + } + } catch (_) { + setConfigStatus("Error", "bg-danger"); + } + } + + async function loadConfig() { + const select = document.getElementById("pid-config-select"); + const name = select ? select.value : ""; + if (!name) { + setConfigStatus("Select", "bg-warning text-dark"); + return; + } + try { + const res = await fetch("/api/pid/configs/" + encodeURIComponent(name)); + const data = await res.json(); + if (data.ok) { + fillPidFields(data.gains); + const nameEl = document.getElementById("pid-config-name"); + if (nameEl) nameEl.value = name; + setConfigStatus("Loaded", "bg-success"); + } else { + setConfigStatus(data.error || "Missing", "bg-danger"); + } + } catch (_) { + setConfigStatus("Error", "bg-danger"); + } + } + + async function deleteConfig() { + const select = document.getElementById("pid-config-select"); + const name = select ? select.value : ""; + if (!name) { + setConfigStatus("Select", "bg-warning text-dark"); + return; + } + if (!window.confirm('Delete tune "' + name + '"?')) return; + try { + const res = await fetch("/api/pid/configs/" + encodeURIComponent(name), { method: "DELETE" }); + const data = await res.json(); + if (data.ok) { + setConfigStatus("Deleted", "bg-success"); + refreshConfigList(); + } else { + setConfigStatus(data.error || "Error", "bg-danger"); + } + } catch (_) { + setConfigStatus("Error", "bg-danger"); + } + } + + function wireEvents() { + document.querySelectorAll(".js-start-pid").forEach((btn) => btn.addEventListener("click", startPid)); + document.querySelectorAll(".js-zero-all-pid").forEach((btn) => btn.addEventListener("click", zeroAllPidAndThrusters)); + + const btnEnable = document.getElementById("btn-enable"); + const btnDisable = document.getElementById("btn-disable"); + const btnResetAll = document.getElementById("btn-reset-all"); + if (btnEnable) btnEnable.addEventListener("click", enableOverride); + if (btnDisable) btnDisable.addEventListener("click", disableOverride); + if (btnResetAll) btnResetAll.addEventListener("click", () => AXES.forEach(resetSlider)); + + AXES.forEach((axis) => { + const slider = document.getElementById("slider-" + axis); + if (!slider) return; + slider.addEventListener("input", () => updateValueDisplay(axis)); + slider.addEventListener("dblclick", () => resetSlider(axis)); + updateValueDisplay(axis); + }); + + document.querySelectorAll(".js-use-current").forEach((btn) => { + btn.addEventListener("click", () => { + const axis = btn.dataset.axis; + const el = document.getElementById("setpoint-" + axis); + const value = Number(latestImu[axis]); + if (el && Number.isFinite(value)) el.value = clampSetpoint(axis, value).toFixed(1); + }); + }); + + const btnSendSetpoints = document.getElementById("btn-send-setpoints"); + const btnClearSetpoints = document.getElementById("btn-clear-setpoints"); + if (btnSendSetpoints) btnSendSetpoints.addEventListener("click", sendSetpoints); + if (btnClearSetpoints) btnClearSetpoints.addEventListener("click", clearSetpoints); + + const btnPidRequest = document.getElementById("btn-pid-request"); + const btnPidSend = document.getElementById("btn-pid-send"); + if (btnPidRequest) btnPidRequest.addEventListener("click", requestPidGains); + if (btnPidSend) btnPidSend.addEventListener("click", sendPidGains); + + const btnPidSave = document.getElementById("btn-pid-save"); + const btnPidLoad = document.getElementById("btn-pid-load"); + const btnPidDelete = document.getElementById("btn-pid-delete"); + if (btnPidSave) btnPidSave.addEventListener("click", saveConfig); + if (btnPidLoad) btnPidLoad.addEventListener("click", loadConfig); + if (btnPidDelete) btnPidDelete.addEventListener("click", deleteConfig); + } + + wireEvents(); + refreshConfigList(); + pollImuAndTelemetry(); + pollRovStatus(); + setInterval(pollImuAndTelemetry, 200); + setInterval(pollRovStatus, 500); +})(); diff --git a/static/js/pilot.js b/static/js/pilot.js index d25e2b1..338d333 100644 --- a/static/js/pilot.js +++ b/static/js/pilot.js @@ -131,9 +131,6 @@ if (pitchG) pitchG.setAttribute("transform", transform); } - // ── Sensor calibration state ────────────────────────────────── - let calibrationOffset = { roll: 0, pitch: 0, yaw: 0 }; - function fmtAngle(v) { return (v >= 0 ? "+" : "") + v.toFixed(1) + "\u00B0"; } @@ -174,24 +171,24 @@ const d = await res.json(); // VN-100S provides fused yaw/pitch/roll directly - const cal = { - roll: (d.roll || 0) - calibrationOffset.roll, - pitch: (d.pitch || 0) - calibrationOffset.pitch, - yaw: (d.yaw || 0) - calibrationOffset.yaw, + const angles = { + roll: d.roll || 0, + pitch: d.pitch || 0, + yaw: d.yaw || 0, }; // Update readouts const elRoll = document.getElementById("hud-roll"); const elPitch = document.getElementById("hud-pitch"); const elHdg = document.getElementById("hud-heading"); - if (elRoll) elRoll.textContent = fmtAngle(cal.roll); - if (elPitch) elPitch.textContent = fmtAngle(cal.pitch); + if (elRoll) elRoll.textContent = fmtAngle(angles.roll); + if (elPitch) elPitch.textContent = fmtAngle(angles.pitch); // Heading (yaw mapped to 0-360) - const heading = ((cal.yaw % 360) + 360) % 360; + const heading = ((angles.yaw % 360) + 360) % 360; if (elHdg) elHdg.textContent = heading.toFixed(0).padStart(3, "0"); updateCompass(heading); - updateHorizon(cal.roll, cal.pitch); + updateHorizon(angles.roll, angles.pitch); } catch (_) { /* silent */ } } diff --git a/static/js/sensors.js b/static/js/sensors.js index 7953a9a..e5f4101 100644 --- a/static/js/sensors.js +++ b/static/js/sensors.js @@ -1,18 +1,3 @@ -// Calibration offset (set when user clicks calibrate) -let calibrationOffset = { roll: 0, pitch: 0, yaw: 0 }; - -function applyCalibration(orientation) { - return { - roll: orientation.roll - calibrationOffset.roll, - pitch: orientation.pitch - calibrationOffset.pitch, - yaw: orientation.yaw - calibrationOffset.yaw - }; -} - -function calibrate(current) { - calibrationOffset = { roll: current.roll, pitch: current.pitch, yaw: current.yaw }; -} - function getAngleStatus(angle, threshold = 15) { const absAngle = Math.abs(angle); if (absAngle < threshold / 2) return "status-ok"; @@ -51,10 +36,7 @@ async function updateSensors() { roll: data.roll || 0, }; - const { roll, pitch, yaw } = applyCalibration(raw); - - // Store for calibration button - window.latestOrientation = raw; + const { roll, pitch, yaw } = raw; sensorData.replaceChildren(); const container = document.createElement("div"); @@ -70,9 +52,3 @@ async function updateSensors() { setInterval(updateSensors, 100); updateSensors(); - -document.getElementById("calibrate-btn").addEventListener("click", () => { - if (window.latestOrientation) { - calibrate(window.latestOrientation); - } -}); diff --git a/static/templates/base.html b/static/templates/base.html index 66d20c5..d4ebb08 100644 --- a/static/templates/base.html +++ b/static/templates/base.html @@ -45,6 +45,9 @@ + diff --git a/static/templates/debug.html b/static/templates/debug.html index f9f6e0e..54a955c 100644 --- a/static/templates/debug.html +++ b/static/templates/debug.html @@ -17,8 +17,6 @@
IMU – VN-100S
NO DATA - -
@@ -47,8 +45,7 @@
IMU – VN-100S
Packets: 0 | - Age: -- ms | - Tare: none + Age: -- ms
@@ -321,8 +318,8 @@
Attitude Setpoint Override (°)
IDLE

- Push exact roll, pitch, and yaw setpoints down to the flight controller. Values are clamped to ±{{ attitude_limits.roll }}° (roll/pitch) - and ±{{ attitude_limits.yaw }}° (yaw). Use this for heading-hold experiments without touching the raw thruster sliders. + Push exact roll, pitch, and yaw setpoints down to the flight controller. Values are clamped to the configured VN-100 angle ranges. + Use this for heading-hold experiments without touching the raw thruster sliders.

diff --git a/static/templates/graphs.html b/static/templates/graphs.html index 55763ca..a244451 100644 --- a/static/templates/graphs.html +++ b/static/templates/graphs.html @@ -52,6 +52,30 @@ font-size: 0.75rem; color: #888; } + .series-legend { + display: flex; + align-items: center; + gap: 0.7rem; + flex-wrap: wrap; + margin-top: 0.35rem; + font-size: 0.75rem; + color: #adb5bd; + } + .series-legend span::before { + content: ""; + display: inline-block; + width: 18px; + height: 3px; + margin-right: 0.35rem; + vertical-align: middle; + border-radius: 2px; + } + .legend-imu::before { + background: var(--imu-color); + } + .legend-setpoint::before { + background: #ff4d6d; + }
@@ -75,6 +99,7 @@
IMU Graphs

Scroll to zoom time axis. Shift+scroll to zoom Y-axis. Click+drag to pan. Double-click to reset zoom. Set min/max to lock Y-axis. Hover for readout. + Yaw, pitch, and roll include the active PID setpoint.

@@ -91,6 +116,10 @@
Yaw
+
+ IMU yaw + Setpoint +
@@ -121,6 +150,10 @@
Pitch
+
+ IMU pitch + Setpoint +
@@ -151,6 +184,10 @@
Roll
+
+ IMU roll + Setpoint +
diff --git a/static/templates/layout.html b/static/templates/layout.html index 2074ad9..8513820 100644 --- a/static/templates/layout.html +++ b/static/templates/layout.html @@ -65,7 +65,6 @@
Thruster Status
Sensor Data (9DOF)
-
Loading...
diff --git a/static/templates/pid_tuning.html b/static/templates/pid_tuning.html new file mode 100644 index 0000000..507fb32 --- /dev/null +++ b/static/templates/pid_tuning.html @@ -0,0 +1,181 @@ +{% extends "base.html" %} + +{% block title %}PID Tuning{% endblock %} + +{% block content %} +{% set translational_axes = [("surge", "Surge"), ("sway", "Sway"), ("heave", "Heave")] %} +{% set rotational_axes = [("roll", "Roll"), ("pitch", "Pitch"), ("yaw", "Yaw")] %} +{% set all_axes = translational_axes + rotational_axes %} + + + + +
+
+
+ PID Tuning + READY + LINK IDLE + IMU -- ms +
+
+ + +
+
+ +
+
+
+
+
+
Override Controls
+ INACTIVE +
+
+ + + +
+
+ {% for axis, label in all_axes %} +
+
+ + 0.00 +
+ +
+ {% endfor %} +
+
+
+ +
+
+
+
Angle Setpoints
+ IDLE +
+
+ VN-100 YPR degrees: roll and yaw use -180 to +180. Pitch uses -90 to +90. +
+
+ {% for axis, label in rotational_axes %} +
+ +
+ + deg +
+ +
+ {% endfor %} +
+
+ + + +
+
Waiting for input.
+
+
+
+ +
+
+
+
+
PID Values
+ READY +
+
+ + + +
+ +
+
Roll, Pitch, Yaw
+
+
Axis
+
P
+
I
+
D
+ {% for axis, label in rotational_axes %} + + + + + {% endfor %} +
+
+ +
+
Surge, Sway, Heave
+
+
Axis
+
P
+
I
+
D
+ {% for axis, label in translational_axes %} + + + + + {% endfor %} +
+
+ +
+ + + + + + - +
+
+
+ +
+
+
+
Telemetry and Link
+ NO TELEMETRY +
+
+ + + + + + + + + + + + +
AxisSetpointPositionError
Waiting for packets...
+
+
Loading...
+
+
+
+
+
+ + +{% endblock %}