jotpluggler: store and load layouts (#36148)

* store and load layouts

* torque controller layout

* ignore missing yaml stubs for mypy
pull/36149/head
Jimmy 4 days ago committed by GitHub
parent 826c5e96a1
commit 63df46bf22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 132
      tools/jotpluggler/layout.py
  2. 128
      tools/jotpluggler/layouts/torque-controller.yaml
  3. 83
      tools/jotpluggler/pluggle.py
  4. 27
      tools/jotpluggler/views.py

@ -20,17 +20,35 @@ class LayoutManager:
self.tabs: dict = {0: {"name": "Tab 1", "panel_layout": initial_panel_layout}}
self._next_tab_id = self.active_tab + 1
self._create_tab_themes()
def _create_tab_themes(self):
for tag, color in (("active_tab_theme", (37, 37, 38, 255)), ("inactive_tab_theme", (70, 70, 75, 255))):
with dpg.theme(tag=tag):
for cmp, target in ((dpg.mvChildWindow, dpg.mvThemeCol_ChildBg), (dpg.mvInputText, dpg.mvThemeCol_FrameBg), (dpg.mvImageButton, dpg.mvThemeCol_Button)):
with dpg.theme_component(cmp):
dpg.add_theme_color(target, color)
with dpg.theme(tag="tab_bar_theme"):
with dpg.theme_component(dpg.mvChildWindow):
dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (51, 51, 55, 255))
def to_dict(self) -> dict:
return {
"tabs": {
str(tab_id): {
"name": tab_data["name"],
"panel_layout": tab_data["panel_layout"].to_dict()
}
for tab_id, tab_data in self.tabs.items()
}
}
def clear_and_load_from_dict(self, data: dict):
tab_ids_to_close = list(self.tabs.keys())
for tab_id in tab_ids_to_close:
self.close_tab(tab_id, force=True)
for tab_id_str, tab_data in data["tabs"].items():
tab_id = int(tab_id_str)
panel_layout = PanelLayoutManager.load_from_dict(
tab_data["panel_layout"], self.data_manager, self.playback_manager,
self.worker_manager, self.scale
)
self.tabs[tab_id] = {
"name": tab_data["name"],
"panel_layout": panel_layout
}
self.active_tab = min(self.tabs.keys()) if self.tabs else 0
self._next_tab_id = max(self.tabs.keys()) + 1 if self.tabs else 1
def create_ui(self, parent_tag: str):
if dpg.does_item_exist(self.container_tag):
@ -52,7 +70,7 @@ class LayoutManager:
def _create_tab_ui(self, tab_id: int, tab_name: str):
text_size = int(13 * self.scale)
tab_width = int(120 * self.scale)
tab_width = int(140 * self.scale)
with dpg.child_window(width=tab_width, height=-1, border=False, no_scrollbar=True, tag=f"tab_window_{tab_id}", parent="tab_bar_group"):
with dpg.group(horizontal=True, tag=f"tab_group_{tab_id}"):
dpg.add_input_text(
@ -70,20 +88,21 @@ class LayoutManager:
def _create_tab_content(self):
with dpg.child_window(tag=self.tab_content_tag, parent=self.container_tag, border=False, width=-1, height=-1, no_scrollbar=True, no_scroll_with_mouse=True):
active_panel_layout = self.tabs[self.active_tab]["panel_layout"]
active_panel_layout.create_ui()
if self.active_tab in self.tabs:
active_panel_layout = self.tabs[self.active_tab]["panel_layout"]
active_panel_layout.create_ui()
def add_tab(self):
new_panel_layout = PanelLayoutManager(self.data_manager, self.playback_manager, self.worker_manager, self.scale)
new_tab = {"name": f"Tab {self._next_tab_id + 1}", "panel_layout": new_panel_layout}
self.tabs[ self._next_tab_id] = new_tab
self._create_tab_ui( self._next_tab_id, new_tab["name"])
self.tabs[self._next_tab_id] = new_tab
self._create_tab_ui(self._next_tab_id, new_tab["name"])
dpg.move_item("add_tab_button", parent="tab_bar_group") # move plus button to end
self.switch_tab( self._next_tab_id)
self.switch_tab(self._next_tab_id)
self._next_tab_id += 1
def close_tab(self, tab_id: int):
if len(self.tabs) <= 1:
def close_tab(self, tab_id: int, force = False):
if len(self.tabs) <= 1 and not force:
return # don't allow closing the last tab
tab_to_close = self.tabs[tab_id]
@ -94,7 +113,7 @@ class LayoutManager:
dpg.delete_item(tag)
del self.tabs[tab_id]
if self.active_tab == tab_id: # switch to another tab if we closed the active one
if self.active_tab == tab_id and self.tabs: # switch to another tab if we closed the active one
self.active_tab = next(iter(self.tabs.keys()))
self._switch_tab_content()
dpg.bind_item_theme(f"tab_window_{self.active_tab}", "active_tab_theme")
@ -134,6 +153,8 @@ class PanelLayoutManager:
self.scale = scale
self.active_panels: list = []
self.parent_tag = "tab_content_area"
self._queue_resize = False
self._created_handler_tags: set[str] = set()
self.grip_size = int(GRIP_SIZE * self.scale)
self.min_pane_size = int(MIN_PANE_SIZE * self.scale)
@ -141,19 +162,70 @@ class PanelLayoutManager:
initial_panel = TimeSeriesPanel(data_manager, playback_manager, worker_manager)
self.layout: dict = {"type": "panel", "panel": initial_panel}
def to_dict(self) -> dict:
return self._layout_to_dict(self.layout)
def _layout_to_dict(self, layout: dict) -> dict:
if layout["type"] == "panel":
return {
"type": "panel",
"panel": layout["panel"].to_dict()
}
else: # split
return {
"type": "split",
"orientation": layout["orientation"],
"proportions": layout["proportions"],
"children": [self._layout_to_dict(child) for child in layout["children"]]
}
@classmethod
def load_from_dict(cls, data: dict, data_manager, playback_manager, worker_manager, scale: float = 1.0):
manager = cls(data_manager, playback_manager, worker_manager, scale)
manager.layout = manager._dict_to_layout(data)
return manager
def _dict_to_layout(self, data: dict) -> dict:
if data["type"] == "panel":
panel_data = data["panel"]
if panel_data["type"] == "timeseries":
panel = TimeSeriesPanel.load_from_dict(
panel_data, self.data_manager, self.playback_manager, self.worker_manager
)
return {"type": "panel", "panel": panel}
else:
# Handle future panel types here or make a general mapping
raise ValueError(f"Unknown panel type: {panel_data['type']}")
else: # split
return {
"type": "split",
"orientation": data["orientation"],
"proportions": data["proportions"],
"children": [self._dict_to_layout(child) for child in data["children"]]
}
def create_ui(self):
self.active_panels.clear()
if dpg.does_item_exist(self.parent_tag):
dpg.delete_item(self.parent_tag, children_only=True)
self._cleanup_all_handlers()
container_width, container_height = dpg.get_item_rect_size(self.parent_tag)
if container_width == 0 and container_height == 0:
self._queue_resize = True
self._create_ui_recursive(self.layout, self.parent_tag, [], container_width, container_height)
def destroy_ui(self):
self._cleanup_ui_recursive(self.layout, [])
self._cleanup_all_handlers()
self.active_panels.clear()
def _cleanup_all_handlers(self):
for handler_tag in list(self._created_handler_tags):
if dpg.does_item_exist(handler_tag):
dpg.delete_item(handler_tag)
self._created_handler_tags.clear()
def _create_ui_recursive(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int):
if layout["type"] == "panel":
self._create_panel_ui(layout, parent_tag, path, width, height)
@ -165,13 +237,14 @@ class PanelLayoutManager:
panel = layout["panel"]
self.active_panels.append(panel)
text_size = int(13 * self.scale)
bar_height = (text_size + 24) if width < int(279 * self.scale + 64) else (text_size + 8) # adjust height to allow for scrollbar
bar_height = (text_size + 24) if width < int(329 * self.scale + 64) else (text_size + 8) # adjust height to allow for scrollbar
with dpg.child_window(parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True):
with dpg.group(horizontal=True):
with dpg.child_window(tag=panel_tag, width=-(text_size + 16), height=bar_height, horizontal_scrollbar=True, no_scroll_with_mouse=True, border=False):
with dpg.group(horizontal=True):
dpg.add_input_text(default_value=panel.title, width=int(100 * self.scale), callback=lambda s, v: setattr(panel, "title", v))
# if you change the widths make sure to change the sum of widths (currently 329 * scale)
dpg.add_input_text(default_value=panel.title, width=int(150 * self.scale), callback=lambda s, v: setattr(panel, "title", v))
dpg.add_combo(items=["Time Series"], default_value="Time Series", width=int(100 * self.scale))
dpg.add_button(label="Clear", callback=lambda: self.clear_panel(panel), width=int(40 * self.scale))
dpg.add_image_button(texture_tag="split_h_texture", callback=lambda: self.split_panel(path, 0), width=text_size, height=text_size)
@ -280,11 +353,16 @@ class PanelLayoutManager:
handler_tag = f"{self._path_to_tag(path, f'grip_{i}')}_handler"
if dpg.does_item_exist(handler_tag):
dpg.delete_item(handler_tag)
self._created_handler_tags.discard(handler_tag)
for i, child in enumerate(layout["children"]):
self._cleanup_ui_recursive(child, path + [i])
def update_all_panels(self):
if self._queue_resize:
if (size := dpg.get_item_rect_size(self.parent_tag)) != [0, 0]:
self._queue_resize = False
self._resize_splits_recursive(self.layout, [], *size)
for panel in self.active_panels:
panel.update()
@ -308,7 +386,7 @@ class PanelLayoutManager:
self._resize_splits_recursive(child_layout, child_path, child_width, child_height)
else: # leaf node/panel - adjust bar height to allow for scrollbar
panel_tag = self._path_to_tag(path, "panel")
if width is not None and width < int(279 * self.scale + 64): # scaled widths of the elements in top bar + fixed 8 padding on left and right of each item
if width is not None and width < int(329 * self.scale + 64): # scaled widths of the elements in top bar + fixed 8 padding on left and right of each item
dpg.configure_item(panel_tag, height=(int(13 * self.scale) + 24))
else:
dpg.configure_item(panel_tag, height=(int(13 * self.scale) + 8))
@ -342,16 +420,18 @@ class PanelLayoutManager:
def _create_grip(self, parent_tag: str, path: list[int], grip_index: int, orientation: int):
grip_tag = self._path_to_tag(path, f"grip_{grip_index}")
handler_tag = f"{grip_tag}_handler"
width, height = [(self.grip_size, -1), (-1, self.grip_size)][orientation]
with dpg.child_window(tag=grip_tag, parent=parent_tag, width=width, height=height, no_scrollbar=True, border=False):
button_tag = dpg.add_button(label="", width=-1, height=-1)
with dpg.item_handler_registry(tag=f"{grip_tag}_handler"):
with dpg.item_handler_registry(tag=handler_tag):
user_data = (path, grip_index, orientation)
dpg.add_item_active_handler(callback=self._on_grip_drag, user_data=user_data)
dpg.add_item_deactivated_handler(callback=self._on_grip_end, user_data=user_data)
dpg.bind_item_handler_registry(button_tag, f"{grip_tag}_handler")
dpg.bind_item_handler_registry(button_tag, handler_tag)
self._created_handler_tags.add(handler_tag)
def _on_grip_drag(self, sender, app_data, user_data):
path, grip_index, orientation = user_data

@ -0,0 +1,128 @@
tabs:
'0':
name: Lateral Plan Conformance
panel_layout:
type: split
orientation: 1
proportions:
- 0.3333333333333333
- 0.3333333333333333
- 0.3333333333333333
children:
- type: panel
panel:
type: timeseries
title: desired vs actual
series_paths:
- controlsState/lateralControlState/torqueState/desiredLateralAccel
- controlsState/lateralControlState/torqueState/actualLateralAccel
- type: panel
panel:
type: timeseries
title: ff vs output
series_paths:
- controlsState/lateralControlState/torqueState/f
- carState/steeringPressed
- carControl/actuators/torque
- type: panel
panel:
type: timeseries
title: vehicle speed
series_paths:
- carState/vEgo
'1':
name: Actuator Performance
panel_layout:
type: split
orientation: 1
proportions:
- 0.3333333333333333
- 0.3333333333333333
- 0.3333333333333333
children:
- type: panel
panel:
type: timeseries
title: calc vs learned latAccelFactor
series_paths:
- liveTorqueParameters/latAccelFactorFiltered
- liveTorqueParameters/latAccelFactorRaw
- carParams/lateralTuning/torque/latAccelFactor
- type: panel
panel:
type: timeseries
title: learned latAccelOffset
series_paths:
- liveTorqueParameters/latAccelOffsetRaw
- liveTorqueParameters/latAccelOffsetFiltered
- type: panel
panel:
type: timeseries
title: calc vs learned friction
series_paths:
- liveTorqueParameters/frictionCoefficientFiltered
- liveTorqueParameters/frictionCoefficientRaw
- carParams/lateralTuning/torque/friction
'2':
name: Vehicle Dynamics
panel_layout:
type: split
orientation: 1
proportions:
- 0.3333333333333333
- 0.3333333333333333
- 0.3333333333333333
children:
- type: panel
panel:
type: timeseries
title: initial vs learned steerRatio
series_paths:
- carParams/steerRatio
- liveParameters/steerRatio
- type: panel
panel:
type: timeseries
title: initial vs learned tireStiffnessFactor
series_paths:
- carParams/tireStiffnessFactor
- liveParameters/stiffnessFactor
- type: panel
panel:
type: timeseries
title: live steering angle offsets
series_paths:
- liveParameters/angleOffsetDeg
- liveParameters/angleOffsetAverageDeg
'3':
name: Controller PIF Terms
panel_layout:
type: split
orientation: 1
proportions:
- 0.3333333333333333
- 0.3333333333333333
- 0.3333333333333333
children:
- type: panel
panel:
type: timeseries
title: ff vs output
series_paths:
- carControl/actuators/torque
- controlsState/lateralControlState/torqueState/f
- carState/steeringPressed
- type: panel
panel:
type: timeseries
title: PIF terms
series_paths:
- controlsState/lateralControlState/torqueState/f
- controlsState/lateralControlState/torqueState/p
- controlsState/lateralControlState/torqueState/i
- type: panel
panel:
type: timeseries
title: road roll angle
series_paths:
- liveParameters/roll

@ -7,6 +7,8 @@ import dearpygui.dearpygui as dpg
import multiprocessing
import uuid
import signal
import yaml # type: ignore
from openpilot.common.swaglog import cloudlog
from openpilot.common.basedir import BASEDIR
from openpilot.tools.jotpluggler.data import DataManager
from openpilot.tools.jotpluggler.datatree import DataTree
@ -131,17 +133,27 @@ class MainController:
self.data_manager.add_observer(self.on_data_loaded)
def _create_global_themes(self):
with dpg.theme(tag="global_line_theme"):
with dpg.theme(tag="line_theme"):
with dpg.theme_component(dpg.mvLineSeries):
scaled_thickness = max(1.0, self.scale)
dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots)
with dpg.theme(tag="global_timeline_theme"):
with dpg.theme(tag="timeline_theme"):
with dpg.theme_component(dpg.mvInfLineSeries):
scaled_thickness = max(1.0, self.scale)
dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots)
dpg.add_theme_color(dpg.mvPlotCol_Line, (255, 0, 0, 128), category=dpg.mvThemeCat_Plots)
for tag, color in (("active_tab_theme", (37, 37, 38, 255)), ("inactive_tab_theme", (70, 70, 75, 255))):
with dpg.theme(tag=tag):
for cmp, target in ((dpg.mvChildWindow, dpg.mvThemeCol_ChildBg), (dpg.mvInputText, dpg.mvThemeCol_FrameBg), (dpg.mvImageButton, dpg.mvThemeCol_Button)):
with dpg.theme_component(cmp):
dpg.add_theme_color(target, color)
with dpg.theme(tag="tab_bar_theme"):
with dpg.theme_component(dpg.mvChildWindow):
dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (51, 51, 55, 255))
def on_data_loaded(self, data: dict):
duration = data.get('duration', 0.0)
self.playback_manager.set_route_duration(duration)
@ -165,6 +177,56 @@ class MainController:
dpg.configure_item("timeline_slider", max_value=duration)
def save_layout_to_yaml(self, filepath: str):
layout_dict = self.layout_manager.to_dict()
with open(filepath, 'w') as f:
yaml.dump(layout_dict, f, default_flow_style=False, sort_keys=False)
def load_layout_from_yaml(self, filepath: str):
with open(filepath) as f:
layout_dict = yaml.safe_load(f)
self.layout_manager.clear_and_load_from_dict(layout_dict)
self.layout_manager.create_ui("main_plot_area")
def save_layout_dialog(self):
if dpg.does_item_exist("save_layout_dialog"):
dpg.delete_item("save_layout_dialog")
with dpg.file_dialog(
directory_selector=False, show=True, callback=self._save_layout_callback,
tag="save_layout_dialog", width=int(700 * self.scale), height=int(400 * self.scale),
default_filename="layout", default_path="layouts"
):
dpg.add_file_extension(".yaml")
def load_layout_dialog(self):
if dpg.does_item_exist("load_layout_dialog"):
dpg.delete_item("load_layout_dialog")
with dpg.file_dialog(
directory_selector=False, show=True, callback=self._load_layout_callback,
tag="load_layout_dialog", width=int(700 * self.scale), height=int(400 * self.scale), default_path="layouts"
):
dpg.add_file_extension(".yaml")
def _save_layout_callback(self, sender, app_data):
filepath = app_data['file_path_name']
try:
self.save_layout_to_yaml(filepath)
dpg.set_value("load_status", f"Layout saved to {os.path.basename(filepath)}")
except Exception:
dpg.set_value("load_status", "Error saving layout")
cloudlog.exception(f"Error saving layout to {filepath}")
dpg.delete_item("save_layout_dialog")
def _load_layout_callback(self, sender, app_data):
filepath = app_data['file_path_name']
try:
self.load_layout_from_yaml(filepath)
dpg.set_value("load_status", f"Layout loaded from {os.path.basename(filepath)}")
except Exception:
dpg.set_value("load_status", "Error loading layout")
cloudlog.exception(f"Error loading layout from {filepath}:")
dpg.delete_item("load_layout_dialog")
def setup_ui(self):
with dpg.texture_registry():
script_dir = os.path.dirname(os.path.realpath(__file__))
@ -175,21 +237,30 @@ class MainController:
with dpg.window(tag="Primary Window"):
with dpg.group(horizontal=True):
# Left panel - Data tree
with dpg.child_window(label="Sidebar", width=300 * self.scale, tag="sidebar_window", border=True, resizable_x=True):
with dpg.child_window(label="Sidebar", width=int(300 * self.scale), tag="sidebar_window", border=True, resizable_x=True):
with dpg.group(horizontal=True):
dpg.add_input_text(tag="route_input", width=-75 * self.scale, hint="Enter route name...")
dpg.add_input_text(tag="route_input", width=int(-75 * self.scale), hint="Enter route name...")
dpg.add_button(label="Load", callback=self.load_route, tag="load_button", width=-1)
dpg.add_text("Ready to load route", tag="load_status")
dpg.add_separator()
with dpg.table(header_row=False, policy=dpg.mvTable_SizingStretchProp):
dpg.add_table_column(init_width_or_weight=0.5)
dpg.add_table_column(init_width_or_weight=0.5)
with dpg.table_row():
dpg.add_button(label="Save Layout", callback=self.save_layout_dialog, width=-1)
dpg.add_button(label="Load Layout", callback=self.load_layout_dialog, width=-1)
dpg.add_separator()
self.data_tree.create_ui("sidebar_window")
# Right panel - Plots and timeline
with dpg.group(tag="right_panel"):
with dpg.child_window(label="Plot Window", border=True, height=-(32 + 13 * self.scale), tag="main_plot_area"):
with dpg.child_window(label="Plot Window", border=True, height=int(-(32 + 13 * self.scale)), tag="main_plot_area"):
self.layout_manager.create_ui("main_plot_area")
with dpg.child_window(label="Timeline", border=True):
with dpg.table(header_row=False, borders_innerH=False, borders_innerV=False, borders_outerH=False, borders_outerV=False):
with dpg.table(header_row=False):
btn_size = int(13 * self.scale)
dpg.add_table_column(width_fixed=True, init_width_or_weight=(btn_size + 8)) # Play button
dpg.add_table_column(width_stretch=True) # Timeline slider

@ -33,6 +33,15 @@ class ViewPanel(ABC):
def update(self):
pass
@abstractmethod
def to_dict(self) -> dict:
pass
@classmethod
@abstractmethod
def load_from_dict(cls, data: dict, data_manager, playback_manager, worker_manager):
pass
class TimeSeriesPanel(ViewPanel):
def __init__(self, data_manager, playback_manager, worker_manager, panel_id: str | None = None):
@ -55,6 +64,20 @@ class TimeSeriesPanel(ViewPanel):
self._queued_x_sync: tuple | None = None
self._queued_reallow_x_zoom = False
def to_dict(self) -> dict:
return {
"type": "timeseries",
"title": self.title,
"series_paths": list(self._series_data.keys())
}
@classmethod
def load_from_dict(cls, data: dict, data_manager, playback_manager, worker_manager):
panel = cls(data_manager, playback_manager, worker_manager)
panel.title = data.get("title", "Time Series Plot")
panel._series_data = {path: (np.array([]), np.array([])) for path in data.get("series_paths", [])}
return panel
def create_ui(self, parent_tag: str):
self.data_manager.add_observer(self.on_data_loaded)
self.playback_manager.add_x_axis_observer(self._on_x_axis_sync)
@ -63,7 +86,7 @@ class TimeSeriesPanel(ViewPanel):
dpg.add_plot_axis(dpg.mvXAxis, no_label=True, tag=self.x_axis_tag)
dpg.add_plot_axis(dpg.mvYAxis, no_label=True, tag=self.y_axis_tag)
timeline_series_tag = dpg.add_inf_line_series(x=[0], label="Timeline", parent=self.y_axis_tag, tag=self.timeline_indicator_tag)
dpg.bind_item_theme(timeline_series_tag, "global_timeline_theme")
dpg.bind_item_theme(timeline_series_tag, "timeline_theme")
self._new_data = True
self._ui_created = True
@ -199,7 +222,7 @@ class TimeSeriesPanel(ViewPanel):
dpg.set_value(series_tag, (time_array, value_array.astype(float)))
else:
line_series_tag = dpg.add_line_series(x=time_array, y=value_array.astype(float), label=series_path, parent=self.y_axis_tag, tag=series_tag)
dpg.bind_item_theme(line_series_tag, "global_line_theme")
dpg.bind_item_theme(line_series_tag, "line_theme")
dpg.fit_axis_data(self.x_axis_tag)
dpg.fit_axis_data(self.y_axis_tag)
plot_duration = dpg.get_axis_limits(self.x_axis_tag)[1] - dpg.get_axis_limits(self.x_axis_tag)[0]

Loading…
Cancel
Save