From cd7cb862fd235ccaeeb9e7d44410a34cc8fca7d2 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:26:12 -0600 Subject: [PATCH] plots --- .../the_pond/assets/components/router.js | 2 + .../the_pond/assets/components/sidebar.js | 1 + .../assets/components/tools/plots.css | 212 +++++++++ .../the_pond/assets/components/tools/plots.js | 447 ++++++++++++++++++ .../system/the_pond/templates/index.html | 1 + frogpilot/system/the_pond/the_pond.py | 199 ++++++++ 6 files changed, 862 insertions(+) create mode 100644 frogpilot/system/the_pond/assets/components/tools/plots.css create mode 100644 frogpilot/system/the_pond/assets/components/tools/plots.js diff --git a/frogpilot/system/the_pond/assets/components/router.js b/frogpilot/system/the_pond/assets/components/router.js index a4b99f5f1..7c2c60c75 100644 --- a/frogpilot/system/the_pond/assets/components/router.js +++ b/frogpilot/system/the_pond/assets/components/router.js @@ -14,6 +14,7 @@ import { ScreenRecordings } from "/assets/components/recordings/screen_recording import { Sidebar } from "/assets/components/sidebar.js" import { SpeedLimits } from "/assets/components/tools/speed_limits.js" import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260303t" +import { LivePlots } from "/assets/components/tools/plots.js" import { ThemeMaker } from "/assets/components/tools/theme_maker.js" import { TmuxLog } from "/assets/components/tools/tmux.js" import { ToggleControl } from "/assets/components/tools/toggles.js" @@ -43,6 +44,7 @@ function Root() { createRoute("settings", "/settings/:section/:subsection?", SettingsView), createRoute("speed_limits", "/download_speed_limits", SpeedLimits), createRoute("model_manager", "/manage_models", ModelManager), + createRoute("plots", "/plots", LivePlots), createRoute("thememaker", "/theme_maker", ThemeMaker), createRoute("tmux", "/manage_tmux", TmuxLog), createRoute("toggles", "/manage_toggles", ToggleControl), diff --git a/frogpilot/system/the_pond/assets/components/sidebar.js b/frogpilot/system/the_pond/assets/components/sidebar.js index d37bb99d4..3012e28ac 100644 --- a/frogpilot/system/the_pond/assets/components/sidebar.js +++ b/frogpilot/system/the_pond/assets/components/sidebar.js @@ -20,6 +20,7 @@ const MenuItems = { { name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" }, { name: "Galaxy", link: "/galaxy", icon: "bi-globe2" }, { name: "Model Manager", link: "/manage_models", icon: "bi-cpu" }, + { name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" }, { name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" }, { name: "Tmux Log", link: "/manage_tmux", icon: "bi-terminal" }, { name: "Toggles", link: "/manage_toggles", icon: "bi-toggle-on" }, diff --git a/frogpilot/system/the_pond/assets/components/tools/plots.css b/frogpilot/system/the_pond/assets/components/tools/plots.css new file mode 100644 index 000000000..b311d5ac7 --- /dev/null +++ b/frogpilot/system/the_pond/assets/components/tools/plots.css @@ -0,0 +1,212 @@ +.plotsPage { + max-width: var(--width-xxxxl); +} + +.plotCard { + background-color: var(--secondary-bg); + border-radius: var(--border-radius-md); + color: var(--text-color); + max-width: 95%; + padding: var(--border-radius-xl); +} + +.plotStatusCard { + margin-bottom: var(--margin-base); +} + +.plotDescription { + margin: 0; +} + +.plotStatusGrid { + display: grid; + gap: 0.5em 1em; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: var(--margin-base); +} + +.plotStatusGrid p, +.plotRangeRow span, +.plotLegendItem { + font-size: var(--font-size-sm); + margin: 0; +} + +.plotActions { + display: flex; + gap: var(--gap-sm); + margin-top: var(--margin-base); +} + +.plotButton { + background: var(--main-fg); + border: none; + border-radius: var(--border-radius-sm); + color: var(--text-color); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-demi-bold); + padding: var(--padding-sm) var(--padding-base); +} + +.plotButton:hover { + transform: var(--hover-scale-sm); + transition: transform var(--transition-fast), background-color var(--transition-fast); +} + +.plotCharts { + display: grid; + gap: var(--gap-sm); + grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); + max-width: 95%; +} + +.plotAdvancedRow { + margin: var(--margin-base) 0; +} + +.plotChartCard { + max-width: 100%; +} + +.plotCardHeader { + align-items: baseline; + display: flex; + gap: var(--gap-sm); + justify-content: space-between; +} + +.plotSource { + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +.plotLegend { + display: flex; + gap: var(--gap-sm); + margin-bottom: var(--margin-sm); +} + +.plotLegendItem { + align-items: center; + display: inline-flex; + gap: var(--gap-xs); +} + +.plotLegendLine { + border-radius: var(--border-radius-sm); + display: inline-block; + height: 3px; + width: 22px; +} + +.plotLegendLine.desired, +.plotLine.desired { + background-color: var(--main-fg); + stroke: var(--main-fg); +} + +.plotLegendLine.actual, +.plotLine.actual { + background-color: var(--success-fg); + stroke: var(--success-fg); +} + +.plotLegendLine.p, +.plotLine.p { + background-color: #63b3ff; + stroke: #63b3ff; +} + +.plotLegendLine.i, +.plotLine.i { + background-color: #63d79d; + stroke: #63d79d; +} + +.plotLegendLine.d, +.plotLine.d { + background-color: #f0b35e; + stroke: #f0b35e; +} + +.plotLegendLine.f, +.plotLine.f { + background-color: #d08bff; + stroke: #d08bff; +} + +.plotSvgWrap { + background: #0b0b18; + border: 1px solid var(--track-color); + border-radius: var(--border-radius-sm); + min-height: 260px; + overflow: hidden; +} + +.plotSvg { + display: block; + height: 260px; + width: 100%; +} + +.plotLine { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 3; +} + +.plotGridLine { + stroke: rgba(255, 255, 255, 0.08); + stroke-width: 1; +} + +.plotZeroLine { + stroke: rgba(255, 255, 255, 0.35); + stroke-dasharray: 6 6; + stroke-width: 1; +} + +.plotRangeRow { + display: flex; + justify-content: space-between; + margin-top: var(--margin-xs); +} + +.plotEmpty { + align-items: center; + color: var(--text-muted); + display: flex; + font-size: var(--font-size-sm); + height: 260px; + justify-content: center; +} + +.plotError { + color: var(--danger-fg); + margin-top: var(--margin-sm); +} + +@media only screen and (max-width: 768px) and (orientation: portrait) { + .plotCard, + .plotCharts { + max-width: 100%; + } + + .plotCharts { + grid-template-columns: 1fr; + } + + .plotStatusGrid { + grid-template-columns: 1fr; + } + + .plotActions { + flex-direction: column; + } + + .plotLegend { + flex-direction: column; + gap: var(--gap-xs); + } +} diff --git a/frogpilot/system/the_pond/assets/components/tools/plots.js b/frogpilot/system/the_pond/assets/components/tools/plots.js new file mode 100644 index 000000000..caa5ccb46 --- /dev/null +++ b/frogpilot/system/the_pond/assets/components/tools/plots.js @@ -0,0 +1,447 @@ +import { html, reactive } from "https://esm.sh/@arrow-js/core" + +const state = reactive({ + loading: true, + error: "", + paused: false, + showAdvancedTerms: false, + live: null, + samples: [], +}) + +let initialized = false +let pollHandle = null +let lastTimestamp = 0 + +const MAX_POINTS = 240 +const POLL_INTERVAL_MS = 500 +const SVG_WIDTH = 1000 +const SVG_HEIGHT = 260 +const ADVANCED_TERMS_KEY = "plotsShowAdvancedTerms" + +function isPlotsRouteActive() { + return window.location.pathname === "/plots" +} + +function toNumber(value, fallback = 0) { + const n = Number(value) + return Number.isFinite(n) ? n : fallback +} + +function formatValue(value, digits = 2) { + return toNumber(value).toFixed(digits) +} + +function formatAge(seconds) { + const value = Math.max(0, toNumber(seconds)) + if (value < 1) return `${Math.round(value * 1000)} ms` + return `${value.toFixed(1)} s` +} + +function formatSourceLabel(kind, source) { + const normalizedKind = String(kind || "").toLowerCase() + const normalizedSource = String(source || "").toLowerCase() + + if (normalizedKind === "lateral") { + if (normalizedSource === "torquestate") return "Steering controller output" + if (normalizedSource === "curvature") return "Path model estimate" + } + + if (normalizedKind === "longitudinal") { + if (normalizedSource.includes("livelocationkalman")) return "Control output + calibrated acceleration" + if (normalizedSource === "controlsstate") return "Planner target output" + } + + if (normalizedKind === "lateralterms") { + if (normalizedSource === "torquestate") return "Steering torque controller" + if (normalizedSource === "pidstate") return "Steering angle PID controller" + } + + if (normalizedKind === "longitudinalterms") { + if (normalizedSource === "controlsstate") return "Longitudinal controller terms" + } + + return "Live control signal" +} + +function stopPolling() { + if (!pollHandle) return + clearTimeout(pollHandle) + pollHandle = null +} + +function pushSample(payload) { + const timestamp = toNumber(payload.timestamp, 0) + if (!timestamp || timestamp <= 0 || timestamp === lastTimestamp) return + + lastTimestamp = timestamp + state.samples.push({ + timestamp, + desiredLateralAccel: toNumber(payload.desiredLateralAccel), + actualLateralAccel: toNumber(payload.actualLateralAccel), + desiredLongitudinalAccel: toNumber(payload.desiredLongitudinalAccel), + actualLongitudinalAccel: toNumber(payload.actualLongitudinalAccel), + lateralP: toNumber(payload.lateralP), + lateralI: toNumber(payload.lateralI), + lateralD: toNumber(payload.lateralD), + lateralF: toNumber(payload.lateralF), + longitudinalUpAccelCmd: toNumber(payload.longitudinalUpAccelCmd), + longitudinalUiAccelCmd: toNumber(payload.longitudinalUiAccelCmd), + longitudinalUfAccelCmd: toNumber(payload.longitudinalUfAccelCmd), + }) + + if (state.samples.length > MAX_POINTS) { + state.samples.splice(0, state.samples.length - MAX_POINTS) + } +} + +async function fetchLiveData() { + const response = await fetch("/api/plots/live") + const payload = await response.json() + + if (!response.ok) { + throw new Error(payload.error || response.statusText || "Failed to load live plot data") + } + + state.live = payload + state.error = "" + state.loading = false + + if (!payload.stale) { + pushSample(payload) + } +} + +function ensurePolling() { + if (pollHandle) return + + const poll = async () => { + if (!isPlotsRouteActive()) { + pollHandle = null + return + } + + if (!state.paused) { + try { + await fetchLiveData() + } catch (error) { + state.error = error?.message || String(error) + state.loading = false + } + } + + pollHandle = setTimeout(poll, POLL_INTERVAL_MS) + } + + pollHandle = setTimeout(poll, POLL_INTERVAL_MS) +} + +function clearHistory() { + state.samples = [] + lastTimestamp = 0 +} + +function togglePaused() { + state.paused = !state.paused + if (state.paused) { + stopPolling() + return + } + + if (!isPlotsRouteActive()) return + + fetchLiveData().catch((error) => { + state.error = error?.message || String(error) + }) + ensurePolling() +} + +function toggleAdvancedTerms() { + state.showAdvancedTerms = !state.showAdvancedTerms + try { + localStorage.setItem(ADVANCED_TERMS_KEY, state.showAdvancedTerms ? "1" : "0") + } catch (error) { + console.warn("Failed to persist plots advanced terms preference", error) + } +} + +function yForValue(value, min, max) { + const safeMin = toNumber(min, -1) + const safeMax = toNumber(max, 1) + const clamped = Math.max(safeMin, Math.min(safeMax, toNumber(value))) + const span = Math.max(1e-6, safeMax - safeMin) + return ((safeMax - clamped) / span) * SVG_HEIGHT +} + +function computeRange(samples, desiredKey, actualKey) { + let maxAbs = 0 + for (const sample of samples) { + maxAbs = Math.max( + maxAbs, + Math.abs(toNumber(sample?.[desiredKey])), + Math.abs(toNumber(sample?.[actualKey])), + ) + } + + const halfSpan = Math.max(1.5, Math.ceil((maxAbs * 1.25) * 10) / 10) + return { min: -halfSpan, max: halfSpan } +} + +function buildPolyline(samples, key, min, max) { + if (!samples.length) return "" + + const lastIndex = Math.max(1, samples.length - 1) + return samples.map((sample, index) => { + const x = (index / lastIndex) * SVG_WIDTH + const y = yForValue(sample?.[key], min, max) + return `${x.toFixed(2)},${y.toFixed(2)}` + }).join(" ") +} + +function computeRangeForKeys(samples, keys) { + let maxAbs = 0 + for (const sample of samples) { + for (const key of keys) { + maxAbs = Math.max(maxAbs, Math.abs(toNumber(sample?.[key]))) + } + } + + const halfSpan = Math.max(0.15, Math.ceil((maxAbs * 1.35) * 1000) / 1000) + return { min: -halfSpan, max: halfSpan } +} + +function latestSampleValue(key) { + const latest = state.samples[state.samples.length - 1] + if (latest) return latest[key] + return toNumber(state.live?.[key]) +} + +function PlotCard(title, desiredKey, actualKey, sourceKind, sourceLabel, desiredLabel = "Target", actualLabel = "Measured") { + const hasData = state.samples.length > 1 + const range = computeRange(state.samples, desiredKey, actualKey) + const desiredPolyline = buildPolyline(state.samples, desiredKey, range.min, range.max) + const actualPolyline = buildPolyline(state.samples, actualKey, range.min, range.max) + const zeroY = yForValue(0, range.min, range.max) + const topQuarterY = yForValue(range.max * 0.5, range.min, range.max) + const bottomQuarterY = yForValue(range.min * 0.5, range.min, range.max) + + return html` +
+
+

${title}

+ Source: ${formatSourceLabel(sourceKind, sourceLabel)} +
+ +
+ ${desiredLabel}: ${formatValue(latestSampleValue(desiredKey))} + ${actualLabel}: ${formatValue(latestSampleValue(actualKey))} +
+ +
+ ${hasData ? html` + + + + + + + + ` : html` +
Waiting for enough live samples...
+ `} +
+ +
+ ${formatValue(range.max, 1)} m/s² + 0.0 m/s² + ${formatValue(range.min, 1)} m/s² +
+
+ ` +} + +function LateralTermsPlotCard(sourceLabel) { + const hasData = state.samples.length > 1 + const keys = ["lateralP", "lateralI", "lateralD", "lateralF"] + const range = computeRangeForKeys(state.samples, keys) + const zeroY = yForValue(0, range.min, range.max) + const topQuarterY = yForValue(range.max * 0.5, range.min, range.max) + const bottomQuarterY = yForValue(range.min * 0.5, range.min, range.max) + + return html` +
+
+

Lateral Controller Terms

+ Source: ${formatSourceLabel("lateralTerms", sourceLabel)} +
+ +
+ P: ${formatValue(latestSampleValue("lateralP"), 3)} + I: ${formatValue(latestSampleValue("lateralI"), 3)} + D: ${formatValue(latestSampleValue("lateralD"), 3)} + F: ${formatValue(latestSampleValue("lateralF"), 3)} +
+ +
+ ${hasData ? html` + + + + + + + + + + ` : html` +
Waiting for enough live samples...
+ `} +
+ +
+ ${formatValue(range.max, 3)} + 0.000 + ${formatValue(range.min, 3)} +
+
+ ` +} + +function LongitudinalTermsPlotCard(sourceLabel) { + const hasData = state.samples.length > 1 + const keys = ["longitudinalUpAccelCmd", "longitudinalUiAccelCmd", "longitudinalUfAccelCmd"] + const range = computeRangeForKeys(state.samples, keys) + const zeroY = yForValue(0, range.min, range.max) + const topQuarterY = yForValue(range.max * 0.5, range.min, range.max) + const bottomQuarterY = yForValue(range.min * 0.5, range.min, range.max) + + return html` +
+
+

Longitudinal Accel Cmd Terms

+ Source: ${formatSourceLabel("longitudinalTerms", sourceLabel)} +
+ +
+ Up: ${formatValue(latestSampleValue("longitudinalUpAccelCmd"), 3)} + Ui: ${formatValue(latestSampleValue("longitudinalUiAccelCmd"), 3)} + Uf: ${formatValue(latestSampleValue("longitudinalUfAccelCmd"), 3)} +
+ +
+ ${hasData ? html` + + + + + + + + + ` : html` +
Waiting for enough live samples...
+ `} +
+ +
+ ${formatValue(range.max, 3)} + 0.000 + ${formatValue(range.min, 3)} +
+
+ ` +} + +async function initialize() { + try { + state.showAdvancedTerms = localStorage.getItem(ADVANCED_TERMS_KEY) === "1" + } catch (error) { + state.showAdvancedTerms = false + } + + try { + await fetchLiveData() + } catch (error) { + state.error = error?.message || String(error) + state.loading = false + } finally { + ensurePolling() + } +} + +export function LivePlots() { + if (!initialized) { + initialized = true + initialize() + } + if (!state.paused) { + ensurePolling() + } + + return html` +
+

Plots

+ +
+

+ Live comparison view for tuning diagnostics. If desired tracks actual closely, tuning is likely good. Large divergence often points to model mismatch or limits. +

+ +
+

Onroad: ${state.live?.isOnroad ? "Yes" : "No"}

+

Sample Age: ${formatAge(state.live?.sampleAgeSeconds)}

+

Vehicle Speed: ${formatValue(state.live?.speed)} m/s

+

Samples: ${state.samples.length}

+
+ +
+ + +
+ + ${state.error ? html`

Error: ${state.error}

` : ""} + ${state.live?.lastError ? html`

Source Error: ${state.live.lastError}

` : ""} +
+ +
+ ${PlotCard( + "Lateral Response (m/s²)", + "desiredLateralAccel", + "actualLateralAccel", + "lateral", + state.live?.lateralSource, + "Target", + "Measured", + )} + ${PlotCard( + "Longitudinal Response (m/s²)", + "desiredLongitudinalAccel", + "actualLongitudinalAccel", + "longitudinal", + state.live?.longitudinalSource, + "Target", + "Measured", + )} +
+ +
+ +
+ + ${state.showAdvancedTerms ? html` +
+ ${LateralTermsPlotCard(state.live?.lateralTermsSource)} + ${LongitudinalTermsPlotCard(state.live?.longitudinalTermsSource)} +
+ ` : ""} + + ${state.loading ? html`

Loading live data...

` : ""} +
+ ` +} diff --git a/frogpilot/system/the_pond/templates/index.html b/frogpilot/system/the_pond/templates/index.html index 8060a1ad4..531a74528 100644 --- a/frogpilot/system/the_pond/templates/index.html +++ b/frogpilot/system/the_pond/templates/index.html @@ -28,6 +28,7 @@ + diff --git a/frogpilot/system/the_pond/the_pond.py b/frogpilot/system/the_pond/the_pond.py index 88d79b9ec..79fbab7e6 100644 --- a/frogpilot/system/the_pond/the_pond.py +++ b/frogpilot/system/the_pond/the_pond.py @@ -162,9 +162,192 @@ _fast_update_state = { "progressDetail": "", } +_PLOTS_POLL_INTERVAL_S = 0.5 +_PLOTS_CLIENT_IDLE_TIMEOUT_S = 15.0 +_PLOTS_SAMPLE_STALE_AFTER_S = 1.5 + +_plots_lock = threading.Lock() +_plots_worker_thread = None +_plots_last_client_request_ts = 0.0 +_plots_state = { + "timestamp": 0.0, + "desiredLateralAccel": 0.0, + "actualLateralAccel": 0.0, + "desiredLongitudinalAccel": 0.0, + "actualLongitudinalAccel": 0.0, + "lateralP": 0.0, + "lateralI": 0.0, + "lateralD": 0.0, + "lateralF": 0.0, + "longitudinalUpAccelCmd": 0.0, + "longitudinalUiAccelCmd": 0.0, + "longitudinalUfAccelCmd": 0.0, + "speed": 0.0, + "lateralSource": "curvature", + "longitudinalSource": "controlsState + liveLocationKalman", + "lateralTermsSource": "unknown", + "longitudinalTermsSource": "controlsState", + "sampleIndex": 0, + "lastError": "", +} + def _normalize_fingerprint_make_key(make_value): return str(make_value or "").strip().lower() +def _safe_float(value, default=0.0): + try: + return float(value) + except Exception: + return float(default) + +def _extract_lateral_accel_values(controls_state, speed_mps): + v_ego = max(0.0, _safe_float(speed_mps)) + speed_sq = v_ego * v_ego + + try: + lateral_state = controls_state.lateralControlState + if lateral_state.which() == "torqueState": + torque_state = lateral_state.torqueState + desired = _safe_float(getattr(torque_state, "desiredLateralAccel", 0.0)) + actual = _safe_float(getattr(torque_state, "actualLateralAccel", 0.0)) + if abs(desired) > 1e-3 or abs(actual) > 1e-3: + return desired, actual, "torqueState" + except Exception: + pass + + desired_curvature = _safe_float(getattr(controls_state, "desiredCurvature", 0.0)) + actual_curvature = _safe_float(getattr(controls_state, "curvature", 0.0)) + return desired_curvature * speed_sq, actual_curvature * speed_sq, "curvature" + +def _extract_longitudinal_accel_values(controls_state, live_location_kalman): + desired = _safe_float(getattr(controls_state, "upAccelCmd", 0.0)) + \ + _safe_float(getattr(controls_state, "uiAccelCmd", 0.0)) + \ + _safe_float(getattr(controls_state, "ufAccelCmd", 0.0)) + + actual = 0.0 + source = "controlsState + liveLocationKalman" + try: + accel_calibrated = getattr(live_location_kalman, "accelerationCalibrated", None) + if accel_calibrated and getattr(accel_calibrated, "valid", False): + accel_values = list(getattr(accel_calibrated, "value", [])) + if len(accel_values) > 0: + actual = _safe_float(accel_values[0], 0.0) + except Exception: + source = "controlsState" + + return desired, actual, source + +def _extract_lateral_controller_terms(controls_state): + terms = { + "lateralP": 0.0, + "lateralI": 0.0, + "lateralD": 0.0, + "lateralF": 0.0, + } + source = "unknown" + + try: + lateral_state = controls_state.lateralControlState + which = lateral_state.which() + if which == "torqueState": + torque_state = lateral_state.torqueState + terms["lateralP"] = _safe_float(getattr(torque_state, "p", 0.0)) + terms["lateralI"] = _safe_float(getattr(torque_state, "i", 0.0)) + terms["lateralD"] = _safe_float(getattr(torque_state, "d", 0.0)) + terms["lateralF"] = _safe_float(getattr(torque_state, "f", 0.0)) + source = "torqueState" + elif which == "pidState": + pid_state = lateral_state.pidState + terms["lateralP"] = _safe_float(getattr(pid_state, "p", 0.0)) + terms["lateralI"] = _safe_float(getattr(pid_state, "i", 0.0)) + terms["lateralF"] = _safe_float(getattr(pid_state, "f", 0.0)) + source = "pidState" + elif which: + source = which + except Exception: + pass + + return terms, source + +def _extract_longitudinal_controller_terms(controls_state): + terms = { + "longitudinalUpAccelCmd": _safe_float(getattr(controls_state, "upAccelCmd", 0.0)), + "longitudinalUiAccelCmd": _safe_float(getattr(controls_state, "uiAccelCmd", 0.0)), + "longitudinalUfAccelCmd": _safe_float(getattr(controls_state, "ufAccelCmd", 0.0)), + } + return terms, "controlsState" + +def _plots_worker(): + global _plots_worker_thread + + try: + sm = messaging.SubMaster(["controlsState", "liveLocationKalman"], poll="controlsState") + except Exception as exception: + with _plots_lock: + _plots_state["lastError"] = str(exception) + _plots_worker_thread = None + return + + while True: + with _plots_lock: + idle_for = time.monotonic() - _plots_last_client_request_ts + + if idle_for >= _PLOTS_CLIENT_IDLE_TIMEOUT_S: + break + + try: + sm.update(0) + + controls_state = sm["controlsState"] + live_location_kalman = sm["liveLocationKalman"] + speed = _safe_float(getattr(controls_state, "vPid", 0.0)) + + desired_lateral, actual_lateral, lateral_source = _extract_lateral_accel_values(controls_state, speed) + desired_longitudinal, actual_longitudinal, longitudinal_source = _extract_longitudinal_accel_values(controls_state, live_location_kalman) + lateral_terms, lateral_terms_source = _extract_lateral_controller_terms(controls_state) + longitudinal_terms, longitudinal_terms_source = _extract_longitudinal_controller_terms(controls_state) + + with _plots_lock: + _plots_state.update({ + "timestamp": time.time(), + "desiredLateralAccel": round(desired_lateral, 4), + "actualLateralAccel": round(actual_lateral, 4), + "desiredLongitudinalAccel": round(desired_longitudinal, 4), + "actualLongitudinalAccel": round(actual_longitudinal, 4), + "lateralP": round(lateral_terms["lateralP"], 4), + "lateralI": round(lateral_terms["lateralI"], 4), + "lateralD": round(lateral_terms["lateralD"], 4), + "lateralF": round(lateral_terms["lateralF"], 4), + "longitudinalUpAccelCmd": round(longitudinal_terms["longitudinalUpAccelCmd"], 4), + "longitudinalUiAccelCmd": round(longitudinal_terms["longitudinalUiAccelCmd"], 4), + "longitudinalUfAccelCmd": round(longitudinal_terms["longitudinalUfAccelCmd"], 4), + "speed": round(speed, 4), + "lateralSource": lateral_source, + "longitudinalSource": longitudinal_source, + "lateralTermsSource": lateral_terms_source, + "longitudinalTermsSource": longitudinal_terms_source, + "sampleIndex": int(_plots_state.get("sampleIndex", 0)) + 1, + "lastError": "", + }) + except Exception as exception: + with _plots_lock: + _plots_state["lastError"] = str(exception) + + time.sleep(_PLOTS_POLL_INTERVAL_S) + + with _plots_lock: + _plots_worker_thread = None + +def _ensure_plots_worker(): + global _plots_worker_thread, _plots_last_client_request_ts + + with _plots_lock: + _plots_last_client_request_ts = time.monotonic() + if _plots_worker_thread and _plots_worker_thread.is_alive(): + return + _plots_worker_thread = threading.Thread(target=_plots_worker, daemon=True) + _plots_worker_thread.start() + def _set_fast_update_state(**kwargs): with _fast_update_lock: _fast_update_state.update(kwargs) @@ -1936,6 +2119,22 @@ def setup(app): }, } + @app.route("/api/plots/live", methods=["GET"]) + def get_live_plots(): + _ensure_plots_worker() + with _plots_lock: + payload = dict(_plots_state) + + timestamp = _safe_float(payload.get("timestamp", 0.0), 0.0) + age_seconds = max(0.0, time.time() - timestamp) if timestamp else 999.0 + + return jsonify({ + **payload, + "isOnroad": params.get_bool("IsOnroad"), + "sampleAgeSeconds": round(age_seconds, 3), + "stale": age_seconds > _PLOTS_SAMPLE_STALE_AFTER_S, + }), 200 + @app.route("/api/update/fast/status", methods=["GET"]) def get_fast_update_status(): state_data = _get_fast_update_state()