diff --git a/pyproject.toml b/pyproject.toml index 7d6516c0fb..5225a727c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,6 +121,7 @@ dev = [ tools = [ "metadrive-simulator @ https://github.com/commaai/metadrive/releases/download/MetaDrive-minimal-0.4.2.4/metadrive_simulator-0.4.2.4-py3-none-any.whl ; (platform_machine != 'aarch64')", + "dearpygui>=2.1.0", ] [project.urls] diff --git a/tools/jotpluggler/data.py b/tools/jotpluggler/data.py new file mode 100644 index 0000000000..2d15d23b7b --- /dev/null +++ b/tools/jotpluggler/data.py @@ -0,0 +1,179 @@ +import threading +import numpy as np +from abc import ABC, abstractmethod +from typing import Any +from openpilot.tools.lib.logreader import LogReader +from openpilot.tools.lib.log_time_series import msgs_to_time_series + + +# TODO: support cereal/ZMQ streaming +class DataSource(ABC): + @abstractmethod + def load_data(self) -> dict[str, Any]: + pass + + @abstractmethod + def get_duration(self) -> float: + pass + + +class LogReaderSource(DataSource): + def __init__(self, route_name: str): + self.route_name = route_name + self._duration = 0.0 + self._start_time_mono = 0.0 + + def load_data(self) -> dict[str, Any]: + lr = LogReader(self.route_name) + raw_time_series = msgs_to_time_series(lr) + processed_data = self._expand_list_fields(raw_time_series) + + # Calculate timing information + times = [data['t'] for data in processed_data.values() if 't' in data and len(data['t']) > 0] + if times: + all_times = np.concatenate(times) + self._start_time_mono = all_times.min() + self._duration = all_times.max() - self._start_time_mono + + return {'time_series_data': processed_data, 'route_start_time_mono': self._start_time_mono, 'duration': self._duration} + + def get_duration(self) -> float: + return self._duration + + # TODO: lists are expanded, but lists of structs are not + def _expand_list_fields(self, time_series_data): + expanded_data = {} + for msg_type, data in time_series_data.items(): + expanded_data[msg_type] = {} + for field, values in data.items(): + if field == 't': + expanded_data[msg_type]['t'] = values + continue + + if isinstance(values, np.ndarray) and values.dtype == object: # ragged array + lens = np.fromiter((len(v) for v in values), dtype=int, count=len(values)) + max_len = lens.max() if lens.size else 0 + if max_len > 0: + arr = np.full((len(values), max_len), None, dtype=object) + for i, v in enumerate(values): + arr[i, : lens[i]] = v + for i in range(max_len): + sub_arr = arr[:, i] + expanded_data[msg_type][f"{field}/{i}"] = sub_arr + elif isinstance(values, np.ndarray) and values.ndim > 1: # regular array + for i in range(values.shape[1]): + col_data = values[:, i] + expanded_data[msg_type][f"{field}/{i}"] = col_data + else: + expanded_data[msg_type][field] = values + return expanded_data + + +class DataLoadedEvent: + def __init__(self, data: dict[str, Any]): + self.data = data + + +class Observer(ABC): + @abstractmethod + def on_data_loaded(self, event: DataLoadedEvent): + pass + + +class DataManager: + def __init__(self): + self.time_series_data = {} + self.loading = False + self.route_start_time_mono = 0.0 + self.duration = 100.0 + self._observers: list[Observer] = [] + + def add_observer(self, observer: Observer): + self._observers.append(observer) + + def remove_observer(self, observer: Observer): + if observer in self._observers: + self._observers.remove(observer) + + def _notify_observers(self, event: DataLoadedEvent): + for observer in self._observers: + observer.on_data_loaded(event) + + def get_current_value_for_path(self, path: str, time_s: float, last_index: int | None = None): + try: + abs_time_s = self.route_start_time_mono + time_s + msg_type, field_path = path.split('/', 1) + ts_data = self.time_series_data[msg_type] + t, v = ts_data['t'], ts_data[field_path] + + if len(t) == 0: + return None, None + + if last_index is None: # jump + idx = np.searchsorted(t, abs_time_s, side='right') - 1 + else: # continuous playback + idx = last_index + while idx < len(t) - 1 and t[idx + 1] < abs_time_s: + idx += 1 + + idx = max(0, idx) + return v[idx], idx + + except (KeyError, IndexError): + return None, None + + def get_all_paths(self) -> list[str]: + all_paths = [] + for msg_type, data in self.time_series_data.items(): + for key in data.keys(): + if key != 't': + all_paths.append(f"{msg_type}/{key}") + return all_paths + + def is_path_plottable(self, path: str) -> bool: + try: + msg_type, field_path = path.split('/', 1) + value_array = self.time_series_data.get(msg_type, {}).get(field_path) + if value_array is not None: # only numbers and bools are plottable + return np.issubdtype(value_array.dtype, np.number) or np.issubdtype(value_array.dtype, np.bool_) + except (ValueError, KeyError): + pass + return False + + def get_time_series_data(self, path: str) -> tuple | None: + try: + msg_type, field_path = path.split('/', 1) + ts_data = self.time_series_data[msg_type] + time_array = ts_data['t'] + plot_values = ts_data[field_path] + + if len(time_array) == 0: + return None + + rel_time_array = time_array - self.route_start_time_mono + return rel_time_array, plot_values + + except (KeyError, ValueError): + return None + + def load_route(self, route_name: str): + if self.loading: + return + + self.loading = True + data_source = LogReaderSource(route_name) + threading.Thread(target=self._load_in_background, args=(data_source,), daemon=True).start() + + def _load_in_background(self, data_source: DataSource): + try: + data = data_source.load_data() + self.time_series_data = data['time_series_data'] + self.route_start_time_mono = data['route_start_time_mono'] + self.duration = data['duration'] + + self._notify_observers(DataLoadedEvent(data)) + + except Exception as e: + print(f"Error loading route: {e}") + finally: + self.loading = False diff --git a/tools/jotpluggler/layout.py b/tools/jotpluggler/layout.py new file mode 100644 index 0000000000..764477fbe8 --- /dev/null +++ b/tools/jotpluggler/layout.py @@ -0,0 +1,304 @@ +import uuid +import dearpygui.dearpygui as dpg +from abc import ABC, abstractmethod +from openpilot.tools.jotpluggler.data import DataManager +from openpilot.tools.jotpluggler.views import ViewPanel, TimeSeriesPanel + + +class LayoutNode(ABC): + def __init__(self, node_id: str = None): + self.node_id = node_id or str(uuid.uuid4()) + self.tag = None + + @abstractmethod + def create_ui(self, parent_tag: str, width: int = -1, height: int = -1): + pass + + @abstractmethod + def destroy_ui(self): + pass + + @abstractmethod + def preserve_data(self): + pass + + +class LeafNode(LayoutNode): + """Leaf node that contains a single ViewPanel with controls""" + + def __init__(self, panel: ViewPanel, layout_manager=None, scale: float = 1.0, node_id: str = None): + super().__init__(node_id) + self.panel = panel + self.layout_manager = layout_manager + self.scale = scale + + def preserve_data(self): + self.panel.preserve_data() + + def create_ui(self, parent_tag: str, width: int = -1, height: int = -1): + """Create UI container with controls and panel""" + self.tag = f"leaf_{self.node_id}" + + with dpg.child_window(tag=self.tag, parent=parent_tag, border=True, width=-1, height=-1, no_scrollbar=True): + # Control bar + with dpg.group(horizontal=True): + dpg.add_input_text(tag=f"title_{self.node_id}", default_value=self.panel.title, width=int(100 * self.scale), callback=self._on_title_change) + dpg.add_combo( + items=["Time Series"], # "Camera", "Text Log", "Map View"], + tag=f"type_{self.node_id}", + default_value="Time Series", + width=int(100 * self.scale), + callback=self._on_type_change, + ) + dpg.add_button(label="Clear", callback=self._clear, width=int(50 * self.scale)) + dpg.add_button(label="Delete", callback=self._delete, width=int(50 * self.scale)) + dpg.add_button(label="Split H", callback=lambda: self._split("horizontal"), width=int(50 * self.scale)) + dpg.add_button(label="Split V", callback=lambda: self._split("vertical"), width=int(50 * self.scale)) + + dpg.add_separator() + + # Panel content area + panel_area_tag = f"panel_area_{self.node_id}" + with dpg.child_window(tag=panel_area_tag, border=False, height=-1, width=-1, no_scrollbar=True): + self.panel.create_ui(panel_area_tag) + + def destroy_ui(self): + if self.panel: + self.panel.destroy_ui() + if self.tag and dpg.does_item_exist(self.tag): + dpg.delete_item(self.tag) + + def _on_title_change(self, sender, app_data): + self.panel.title = app_data + + def _on_type_change(self, sender, app_data): + print(f"Panel type change requested: {app_data}") + + def _split(self, orientation: str): + if self.layout_manager: + self.layout_manager.split_node(self, orientation) + + def _clear(self): + if hasattr(self.panel, 'clear_all_series'): + self.panel.clear_all_series() + + def _delete(self): + if self.layout_manager: + self.layout_manager.delete_node(self) + + +class SplitterNode(LayoutNode): + """Splitter node that contains multiple child nodes""" + + def __init__(self, children: list[LayoutNode], orientation: str = "horizontal", node_id: str = None): + super().__init__(node_id) + self.children = children if children else [] + self.orientation = orientation + self.child_proportions = [1.0 / len(self.children) for _ in self.children] if self.children else [] + self.child_container_tags = [] # Track container tags for resizing (different from child tag) + + def preserve_data(self): + for child in self.children: + child.preserve_data() + + def add_child(self, child: LayoutNode, index: int = None): + if index is None: + self.children.append(child) + self.child_proportions.append(0.0) + else: + self.children.insert(index, child) + self.child_proportions.insert(index, 0.0) + self._redistribute_proportions() + + def remove_child(self, child: LayoutNode): + if child in self.children: + index = self.children.index(child) + self.children.remove(child) + self.child_proportions.pop(index) + child.destroy_ui() + if self.children: + self._redistribute_proportions() + + def replace_child(self, old_child: LayoutNode, new_child: LayoutNode): + try: + index = self.children.index(old_child) + self.children[index] = new_child + return index + except ValueError: + return None + + def _redistribute_proportions(self): + if self.children: + equal_proportion = 1.0 / len(self.children) + self.child_proportions = [equal_proportion for _ in self.children] + + def resize_children(self): + if not self.tag or not dpg.does_item_exist(self.tag): + return + + available_width, available_height = dpg.get_item_rect_size(dpg.get_item_parent(self.tag)) + + for i, container_tag in enumerate(self.child_container_tags): + if not dpg.does_item_exist(container_tag): + continue + + proportion = self.child_proportions[i] if i < len(self.child_proportions) else (1.0 / len(self.children)) + + if self.orientation == "horizontal": + new_width = max(100, int(available_width * proportion)) + dpg.configure_item(container_tag, width=new_width) + else: + new_height = max(100, int(available_height * proportion)) + dpg.configure_item(container_tag, height=new_height) + + child = self.children[i] if i < len(self.children) else None + if child and isinstance(child, SplitterNode): + child.resize_children() + + def create_ui(self, parent_tag: str, width: int = -1, height: int = -1): + self.tag = f"splitter_{self.node_id}" + self.child_container_tags = [] + + if self.orientation == "horizontal": + with dpg.group(tag=self.tag, parent=parent_tag, horizontal=True): + for i, child in enumerate(self.children): + proportion = self.child_proportions[i] + child_width = max(100, int(width * proportion)) + container_tag = f"child_container_{self.node_id}_{i}" + self.child_container_tags.append(container_tag) + + with dpg.child_window(tag=container_tag, width=child_width, height=-1, border=False, no_scrollbar=True, resizable_x=False): + child.create_ui(container_tag, child_width, height) + else: + with dpg.group(tag=self.tag, parent=parent_tag): + for i, child in enumerate(self.children): + proportion = self.child_proportions[i] + child_height = max(100, int(height * proportion)) + container_tag = f"child_container_{self.node_id}_{i}" + self.child_container_tags.append(container_tag) + + with dpg.child_window(tag=container_tag, width=-1, height=child_height, border=False, no_scrollbar=True, resizable_y=False): + child.create_ui(container_tag, width, child_height) + + def destroy_ui(self): + for child in self.children: + if child: + child.destroy_ui() + if self.tag and dpg.does_item_exist(self.tag): + dpg.delete_item(self.tag) + self.child_container_tags.clear() + + +class PlotLayoutManager: + def __init__(self, data_manager: DataManager, playback_manager, scale: float = 1.0): + self.data_manager = data_manager + self.playback_manager = playback_manager + self.scale = scale + self.root_node: LayoutNode = None + self.container_tag = "plot_layout_container" + self._initialize_default_layout() + + def _initialize_default_layout(self): + panel = TimeSeriesPanel(self.data_manager, self.playback_manager) + self.root_node = LeafNode(panel, layout_manager=self, scale=self.scale) + + def create_ui(self, parent_tag: str): + if dpg.does_item_exist(self.container_tag): + dpg.delete_item(self.container_tag) + + with dpg.child_window(tag=self.container_tag, parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True): + if self.root_node: + container_width, container_height = dpg.get_item_rect_size(self.container_tag) + self.root_node.create_ui(self.container_tag, container_width, container_height) + + def on_viewport_resize(self): + if isinstance(self.root_node, SplitterNode): + self.root_node.resize_children() + + def split_node(self, node: LeafNode, orientation: str): + # create new panel for the split + new_panel = TimeSeriesPanel(self.data_manager, self.playback_manager) # TODO: create same type of panel as the split + new_leaf = LeafNode(new_panel, layout_manager=self, scale=self.scale) + + parent_node, child_index = self._find_parent_and_index(node) + + if parent_node is None: # root node - create new splitter as root + node.preserve_data() + self.root_node = SplitterNode([node, new_leaf], orientation) + self._update_ui_for_node(self.root_node, self.container_tag) + elif isinstance(parent_node, SplitterNode) and parent_node.orientation == orientation: # same orientation - add to existing splitter + parent_node.add_child(new_leaf, child_index + 1) + self._update_ui_for_node(parent_node) + else: # different orientation - replace node with new splitter + node.preserve_data() + new_splitter = SplitterNode([node, new_leaf], orientation) + self._replace_child_in_parent(parent_node, node, new_splitter) + + def delete_node(self, node: LeafNode): # TODO: actually delete the node, not just the ui for the node + parent_node, child_index = self._find_parent_and_index(node) + + if parent_node is None: # root deletion - replace with new default + node.destroy_ui() + self._initialize_default_layout() + self._update_ui_for_node(self.root_node, self.container_tag) + elif isinstance(parent_node, SplitterNode): + parent_node.remove_child(node) + if len(parent_node.children) == 1: # collapse splitter --> leaf to just leaf + remaining_child = parent_node.children[0] + grandparent_node, parent_index = self._find_parent_and_index(parent_node) + + if grandparent_node is None: # promote remaining child to root + remaining_child.preserve_data() + parent_node.children.remove(remaining_child) + self.root_node = remaining_child + parent_node.destroy_ui() + self._update_ui_for_node(self.root_node, self.container_tag) + else: # replace splitter with remaining child in grandparent node + self._replace_child_in_parent(grandparent_node, parent_node, remaining_child) + else: # update splpitter contents + self._update_ui_for_node(parent_node) + + def _replace_child_in_parent(self, parent_node: SplitterNode, old_child: LayoutNode, new_child: LayoutNode): + old_child.preserve_data() # save data and for when recreating ui for the node + + child_index = parent_node.children.index(old_child) + child_container_tag = f"child_container_{parent_node.node_id}_{child_index}" + + parent_node.replace_child(old_child, new_child) + + # Clean up old child if it's being replaced (not just moved) + if old_child != new_child: + old_child.destroy_ui() + + if dpg.does_item_exist(child_container_tag): + dpg.delete_item(child_container_tag, children_only=True) + container_width, container_height = dpg.get_item_rect_size(child_container_tag) + new_child.create_ui(child_container_tag, container_width, container_height) + + def _update_ui_for_node(self, node: LayoutNode, container_tag: str = None): + node.preserve_data() + + if container_tag: # update node in a specific container (usually root) + dpg.delete_item(container_tag, children_only=True) + container_width, container_height = dpg.get_item_rect_size(container_tag) + node.create_ui(container_tag, container_width, container_height) + else: # update node in its current location (splitter updates) + if node.tag and dpg.does_item_exist(node.tag): + parent_container = dpg.get_item_parent(node.tag) + node.destroy_ui() + if parent_container and dpg.does_item_exist(parent_container): + parent_width, parent_height = dpg.get_item_rect_size(parent_container) + node.create_ui(parent_container, parent_width, parent_height) + + def _find_parent_and_index(self, target_node: LayoutNode) -> tuple: # TODO: probably can be stored in child + def search_recursive(node: LayoutNode, parent: LayoutNode = None, index: int = 0): + if node == target_node: + return parent, index + if isinstance(node, SplitterNode): + for i, child in enumerate(node.children): + result = search_recursive(child, node, i) + if result[0] is not None: + return result + return None, None + + return search_recursive(self.root_node) diff --git a/tools/jotpluggler/pluggle.py b/tools/jotpluggler/pluggle.py new file mode 100644 index 0000000000..955009c083 --- /dev/null +++ b/tools/jotpluggler/pluggle.py @@ -0,0 +1,240 @@ +import argparse +import pyautogui +import subprocess +import dearpygui.dearpygui as dpg +import threading +from openpilot.tools.jotpluggler.data import DataManager, Observer, DataLoadedEvent +from openpilot.tools.jotpluggler.views import DataTreeView +from openpilot.tools.jotpluggler.layout import PlotLayoutManager, SplitterNode, LeafNode + + +class PlaybackManager: + def __init__(self): + self.is_playing = False + self.current_time_s = 0.0 + self.duration_s = 100.0 + self.last_indices = {} + + def set_route_duration(self, duration: float): + self.duration_s = duration + self.seek(min(self.current_time_s, duration)) + + def toggle_play_pause(self): + if not self.is_playing and self.current_time_s >= self.duration_s: + self.seek(0.0) + self.is_playing = not self.is_playing + + def seek(self, time_s: float): + self.is_playing = False + self.current_time_s = max(0.0, min(time_s, self.duration_s)) + self.last_indices.clear() + + def update_time(self, delta_t: float) -> float: + if self.is_playing: + self.current_time_s = min(self.current_time_s + delta_t, self.duration_s) + if self.current_time_s >= self.duration_s: + self.is_playing = False + return self.current_time_s + + def update_index(self, path: str, new_idx: int | None): + if new_idx is not None: + self.last_indices[path] = new_idx + + +def calculate_avg_char_width(font): + sample_text = "abcdefghijklmnopqrstuvwxyz0123456789" + if size := dpg.get_text_size(sample_text, font=font): + return size[0] / len(sample_text) + return None + + +def format_and_truncate(value, available_width: float, avg_char_width: float) -> str: + s = str(value) + max_chars = int(available_width / avg_char_width) - 3 + if len(s) > max_chars: + return s[: max(0, max_chars)] + "..." + return s + + +class MainController(Observer): + def __init__(self, scale: float = 1.0): + self.ui_lock = threading.Lock() + self.scale = scale + self.data_manager = DataManager() + self.playback_manager = PlaybackManager() + self._create_global_themes() + self.data_tree_view = DataTreeView(self.data_manager, self.ui_lock) + self.plot_layout_manager = PlotLayoutManager(self.data_manager, self.playback_manager, scale=self.scale) + self.data_manager.add_observer(self) + self.avg_char_width = None + + def _create_global_themes(self): + with dpg.theme(tag="global_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_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) + + def on_data_loaded(self, event: DataLoadedEvent): + self.playback_manager.set_route_duration(event.data['duration']) + num_msg_types = len(event.data['time_series_data']) + dpg.set_value("load_status", f"Loaded {num_msg_types} message types") + dpg.configure_item("load_button", enabled=True) + dpg.configure_item("timeline_slider", max_value=event.data['duration']) + + def setup_ui(self): + with dpg.item_handler_registry(tag="tree_node_handler"): + dpg.add_item_toggled_open_handler(callback=self.data_tree_view.update_active_nodes_list) + + dpg.set_viewport_resize_callback(callback=self.on_viewport_resize) + + with dpg.window(tag="Primary Window"): + with dpg.group(horizontal=True): + # Left panel - Data tree + with dpg.child_window(label="Data Pool", width=300 * self.scale, tag="data_pool_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_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() + dpg.add_text("Available Data") + dpg.add_separator() + dpg.add_input_text(tag="search_input", width=-1, hint="Search fields...", callback=self.search_data) + dpg.add_separator() + with dpg.group(tag="data_tree_container", track_offset=True): + pass + + # Right panel - Plots and timeline + with dpg.group(): + with dpg.child_window(label="Plot Window", border=True, height=-(30 + 13 * self.scale), tag="main_plot_area"): + self.plot_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): + dpg.add_table_column(width_fixed=True, init_width_or_weight=int(50 * self.scale)) # Play button + dpg.add_table_column(width_stretch=True) # Timeline slider + dpg.add_table_column(width_fixed=True, init_width_or_weight=int(50 * self.scale)) # FPS counter + with dpg.table_row(): + dpg.add_button(label="Play", tag="play_pause_button", callback=self.toggle_play_pause, width=int(50 * self.scale)) + dpg.add_slider_float(tag="timeline_slider", default_value=0.0, label="", width=-1, callback=self.timeline_drag) + dpg.add_text("", tag="fps_counter") + + dpg.set_primary_window("Primary Window", True) + + def on_viewport_resize(self): + self.plot_layout_manager.on_viewport_resize() + + def load_route(self): + route_name = dpg.get_value("route_input").strip() + if route_name: + dpg.set_value("load_status", "Loading route...") + dpg.configure_item("load_button", enabled=False) + self.data_manager.load_route(route_name) + + def search_data(self): + search_term = dpg.get_value("search_input") + self.data_tree_view.search_data(search_term) + + def toggle_play_pause(self, sender): + self.playback_manager.toggle_play_pause() + label = "Pause" if self.playback_manager.is_playing else "Play" + dpg.configure_item(sender, label=label) + + def timeline_drag(self, sender, app_data): + self.playback_manager.seek(app_data) + dpg.configure_item("play_pause_button", label="Play") + + def update_frame(self, font): + with self.ui_lock: + if self.avg_char_width is None: + self.avg_char_width = calculate_avg_char_width(font) # must be calculated after first frame + + new_time = self.playback_manager.update_time(dpg.get_delta_time()) + if not dpg.is_item_active("timeline_slider"): + dpg.set_value("timeline_slider", new_time) + + self._update_timeline_indicators(new_time) + if not self.data_manager.loading and self.avg_char_width: + self._update_data_values() + + dpg.set_value("fps_counter", f"{dpg.get_frame_rate():.1f} FPS") + + def _update_data_values(self): + pool_width = dpg.get_item_rect_size("data_pool_window")[0] + value_column_width = pool_width * 0.5 + active_nodes = self.data_tree_view.active_leaf_nodes + + for node in active_nodes: + path = node.full_path + value_tag = f"value_{path}" + + if dpg.does_item_exist(value_tag) and dpg.is_item_visible(value_tag): + last_index = self.playback_manager.last_indices.get(path) + value, new_idx = self.data_manager.get_current_value_for_path(path, self.playback_manager.current_time_s, last_index) + + if value is not None: + self.playback_manager.update_index(path, new_idx) + formatted_value = format_and_truncate(value, value_column_width, self.avg_char_width) + dpg.set_value(value_tag, formatted_value) + + def _update_timeline_indicators(self, current_time_s: float): + def update_node_recursive(node): + if isinstance(node, LeafNode): + if hasattr(node.panel, 'update_timeline_indicator'): + node.panel.update_timeline_indicator(current_time_s) + elif isinstance(node, SplitterNode): + for child in node.children: + update_node_recursive(child) + + if self.plot_layout_manager.root_node: + update_node_recursive(self.plot_layout_manager.root_node) + + +def main(route_to_load=None): + dpg.create_context() + + # TODO: find better way of calculating display scaling + try: + w, h = next(tuple(map(int, l.split()[0].split('x'))) for l in subprocess.check_output(['xrandr']).decode().split('\n') if '*' in l) # actual resolution + scale = pyautogui.size()[0] / w # scaled resolution + except Exception: + scale = 1 + + with dpg.font_registry(): + default_font = dpg.add_font("selfdrive/assets/fonts/Inter-Regular.ttf", int(13 * scale)) + dpg.bind_font(default_font) + + viewport_width, viewport_height = int(1200 * scale), int(800 * scale) + mouse_x, mouse_y = pyautogui.position() # TODO: find better way of creating the window where the user is (default dpg behavior annoying on multiple displays) + dpg.create_viewport( + title='JotPluggler', width=viewport_width, height=viewport_height, x_pos=mouse_x - viewport_width // 2, y_pos=mouse_y - viewport_height // 2 + ) + dpg.setup_dearpygui() + + controller = MainController(scale=scale) + controller.setup_ui() + + if route_to_load: + dpg.set_value("route_input", route_to_load) + controller.load_route() + + dpg.show_viewport() + + # Main loop + while dpg.is_dearpygui_running(): + controller.update_frame(default_font) + dpg.render_dearpygui_frame() + + dpg.destroy_context() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="A tool for visualizing openpilot logs.") + parser.add_argument("route", nargs='?', default=None, help="Optional route name to load on startup.") + args = parser.parse_args() + main(route_to_load=args.route) diff --git a/tools/jotpluggler/views.py b/tools/jotpluggler/views.py new file mode 100644 index 0000000000..163f60e647 --- /dev/null +++ b/tools/jotpluggler/views.py @@ -0,0 +1,308 @@ +import os +import re +import uuid +import threading +import dearpygui.dearpygui as dpg +from abc import ABC, abstractmethod +from openpilot.tools.jotpluggler.data import Observer, DataLoadedEvent, DataManager + + +class ViewPanel(ABC): + """Abstract base class for all view panels that can be displayed in a plot container""" + + def __init__(self, panel_id: str = None): + self.panel_id = panel_id or str(uuid.uuid4()) + self.title = "Untitled Panel" + + @abstractmethod + def create_ui(self, parent_tag: str): + pass + + @abstractmethod + def destroy_ui(self): + pass + + @abstractmethod + def get_panel_type(self) -> str: + pass + + @abstractmethod + def preserve_data(self): + pass + + +class TimeSeriesPanel(ViewPanel, Observer): + def __init__(self, data_manager: DataManager, playback_manager, panel_id: str = None): + super().__init__(panel_id) + self.data_manager = data_manager + self.playback_manager = playback_manager + self.title = "Time Series Plot" + self.plotted_series: set[str] = set() + self.plot_tag = None + self.x_axis_tag = None + self.y_axis_tag = None + self.timeline_indicator_tag = None + self._ui_created = False + + # Store series data for restoration and legend management + self._preserved_series_data = [] # TODO: the way we do this right now doesn't make much sense + self._series_legend_tags = {} # Maps series_path to legend tag + + self.data_manager.add_observer(self) + + def preserve_data(self): + self._preserved_series_data = [] + if self.plotted_series and self._ui_created: + for series_path in self.plotted_series: + time_value_data = self.data_manager.get_time_series_data(series_path) + if time_value_data: + self._preserved_series_data.append((series_path, time_value_data)) + + def create_ui(self, parent_tag: str): + self.plot_tag = f"plot_{self.panel_id}" + self.x_axis_tag = f"{self.plot_tag}_x_axis" + self.y_axis_tag = f"{self.plot_tag}_y_axis" + self.timeline_indicator_tag = f"{self.plot_tag}_timeline" + + with dpg.plot(height=-1, width=-1, tag=self.plot_tag, parent=parent_tag, drop_callback=self._on_series_drop, payload_type="TIMESERIES_PAYLOAD"): + dpg.add_plot_legend() + dpg.add_plot_axis(dpg.mvXAxis, label="", tag=self.x_axis_tag) + dpg.add_plot_axis(dpg.mvYAxis, label="", 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") + + # Restore series from preserved data + if self._preserved_series_data: + self.plotted_series.clear() + for series_path, (rel_time_array, value_array) in self._preserved_series_data: + self._add_series_with_data(series_path, rel_time_array, value_array) + self._preserved_series_data = [] + + self._ui_created = True + + def update_timeline_indicator(self, current_time_s: float): + if not self._ui_created or not dpg.does_item_exist(self.timeline_indicator_tag): + return + + dpg.set_value(self.timeline_indicator_tag, [[current_time_s], [0]]) # vertical line position + + if self.plotted_series: # update legend labels with current values + for series_path in self.plotted_series: + last_index = self.playback_manager.last_indices.get(series_path) + value, new_idx = self.data_manager.get_current_value_for_path(series_path, current_time_s, last_index) + + if value is not None: + self.playback_manager.update_index(series_path, new_idx) + + if isinstance(value, (int, float)): + if isinstance(value, float): + formatted_value = f"{value:.4f}" if abs(value) < 1000 else f"{value:.3e}" + else: + formatted_value = str(value) + else: + formatted_value = str(value) + + # Update the series label to include current value + series_tag = f"series_{self.panel_id}_{series_path.replace('/', '_')}" + legend_label = f"{series_path}: {formatted_value}" + + if dpg.does_item_exist(series_tag): + dpg.configure_item(series_tag, label=legend_label) + + def _add_series_with_data(self, series_path: str, rel_time_array, value_array): + if series_path in self.plotted_series: + return False + + series_tag = f"series_{self.panel_id}_{series_path.replace('/', '_')}" + line_series_tag = dpg.add_line_series(x=rel_time_array.tolist(), y=value_array.tolist(), label=series_path, parent=self.y_axis_tag, tag=series_tag) + + dpg.bind_item_theme(line_series_tag, "global_line_theme") + + self.plotted_series.add(series_path) + dpg.fit_axis_data(self.x_axis_tag) + dpg.fit_axis_data(self.y_axis_tag) + return True + + def destroy_ui(self): + if self.plot_tag and dpg.does_item_exist(self.plot_tag): + dpg.delete_item(self.plot_tag) + + # self.data_manager.remove_observer(self) + self._series_legend_tags.clear() + self._ui_created = False + + def get_panel_type(self) -> str: + return "timeseries" + + def add_series(self, series_path: str) -> bool: + if series_path in self.plotted_series: + return False + + time_value_data = self.data_manager.get_time_series_data(series_path) + if time_value_data is None: + return False + + rel_time_array, value_array = time_value_data + return self._add_series_with_data(series_path, rel_time_array, value_array) + + def clear_all_series(self): + for series_path in self.plotted_series.copy(): + self.remove_series(series_path) + + def remove_series(self, series_path: str): + if series_path in self.plotted_series: + series_tag = f"series_{self.panel_id}_{series_path.replace('/', '_')}" + if dpg.does_item_exist(series_tag): + dpg.delete_item(series_tag) + self.plotted_series.remove(series_path) + if series_path in self._series_legend_tags: # Clean up legend tag mapping + del self._series_legend_tags[series_path] + + def on_data_loaded(self, event: DataLoadedEvent): + for series_path in self.plotted_series.copy(): + self._update_series_data(series_path) + + def _update_series_data(self, series_path: str): + time_value_data = self.data_manager.get_time_series_data(series_path) + if time_value_data is None: + return False + + rel_time_array, value_array = time_value_data + series_tag = f"series_{self.panel_id}_{series_path.replace('/', '_')}" + + if dpg.does_item_exist(series_tag): + dpg.set_value(series_tag, [rel_time_array.tolist(), value_array.tolist()]) + dpg.fit_axis_data(self.x_axis_tag) + dpg.fit_axis_data(self.y_axis_tag) + return True + else: + self.plotted_series.discard(series_path) + return False + + def _on_series_drop(self, sender, app_data, user_data): + series_path = app_data + self.add_series(series_path) + + +class DataTreeNode: + def __init__(self, name: str, full_path: str = ""): + self.name = name + self.full_path = full_path + self.children = {} + self.is_leaf = False + + +class DataTreeView(Observer): + def __init__(self, data_manager: DataManager, ui_lock: threading.Lock): + self.data_manager = data_manager + self.ui_lock = ui_lock + self.current_search = "" + self.data_tree = DataTreeNode(name="root") + self.active_leaf_nodes = [] + self.data_manager.add_observer(self) + + def on_data_loaded(self, event: DataLoadedEvent): + with self.ui_lock: + self.populate_data_tree() + + def populate_data_tree(self): + if not dpg.does_item_exist("data_tree_container"): + return + + dpg.delete_item("data_tree_container", children_only=True) + search_term = self.current_search.strip().lower() + + self.data_tree = DataTreeNode(name="root") + all_paths = self.data_manager.get_all_paths() + + for path in sorted(all_paths): + if not self._should_display_path(path, search_term): + continue + + parts = path.split('/') + current_node = self.data_tree + current_path_prefix = "" + + for part in parts: + current_path_prefix = f"{current_path_prefix}/{part}" if current_path_prefix else part + if part not in current_node.children: + current_node.children[part] = DataTreeNode(name=part, full_path=current_path_prefix) + current_node = current_node.children[part] + + current_node.is_leaf = True + + self._create_ui_from_tree_recursive(self.data_tree, "data_tree_container", search_term) + self.update_active_nodes_list() + + def _should_display_path(self, path: str, search_term: str) -> bool: + if 'DEPRECATED' in path and not os.environ.get('SHOW_DEPRECATED'): + return False + return not search_term or search_term in path.lower() + + def _natural_sort_key(self, node: DataTreeNode): + node_type_key = node.is_leaf + parts = [int(p) if p.isdigit() else p.lower() for p in re.split(r'(\d+)', node.name) if p] + return (node_type_key, parts) + + def _create_ui_from_tree_recursive(self, node: DataTreeNode, parent_tag: str, search_term: str): + sorted_children = sorted(node.children.values(), key=self._natural_sort_key) + + for child in sorted_children: + if child.is_leaf: + is_plottable = self.data_manager.is_path_plottable(child.full_path) + + # Create draggable item + with dpg.group(parent=parent_tag) as draggable_group: + with dpg.table(header_row=False, borders_innerV=False, borders_outerH=False, borders_outerV=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_text(child.name) + dpg.add_text("N/A", tag=f"value_{child.full_path}") + + # Add drag payload if plottable + if is_plottable: + with dpg.drag_payload(parent=draggable_group, drag_data=child.full_path, payload_type="TIMESERIES_PAYLOAD"): + dpg.add_text(f"Plot: {child.full_path}") + + else: + node_tag = f"tree_{child.full_path}" + label = child.name + + if '/' not in child.full_path: + sample_count = len(self.data_manager.time_series_data.get(child.full_path, {}).get('t', [])) + label = f"{child.name} ({sample_count} samples)" + + should_open = bool(search_term) and len(search_term) > 1 and any(search_term in path for path in self._get_all_descendant_paths(child)) + + with dpg.tree_node(label=label, parent=parent_tag, tag=node_tag, default_open=should_open): + dpg.bind_item_handler_registry(node_tag, "tree_node_handler") + self._create_ui_from_tree_recursive(child, node_tag, search_term) + + def _get_all_descendant_paths(self, node: DataTreeNode): + for child_name, child_node in node.children.items(): + child_name_lower = child_name.lower() + if child_node.is_leaf: + yield child_name_lower + else: + for path in self._get_all_descendant_paths(child_node): + yield f"{child_name_lower}/{path}" + + def search_data(self, search_term: str): + self.current_search = search_term + self.populate_data_tree() + + def update_active_nodes_list(self, sender=None, app_data=None, user_data=None): + self.active_leaf_nodes = self.get_active_leaf_nodes(self.data_tree) + + def get_active_leaf_nodes(self, node: DataTreeNode): + active_leaves = [] + for child in node.children.values(): + if child.is_leaf: + active_leaves.append(child) + else: + node_tag = f"tree_{child.full_path}" + if dpg.does_item_exist(node_tag) and dpg.get_value(node_tag): + active_leaves.extend(self.get_active_leaf_nodes(child)) + return active_leaves diff --git a/uv.lock b/uv.lock index e1678029cf..3d83e86bb6 100644 --- a/uv.lock +++ b/uv.lock @@ -451,6 +451,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fc/c0a3f4c4eaa5a22fbef91713474666e13d0ea2a69c84532579490a9f2cc8/dbus_next-0.2.3-py3-none-any.whl", hash = "sha256:58948f9aff9db08316734c0be2a120f6dc502124d9642f55e90ac82ffb16a18b", size = 57885, upload-time = "2021-07-25T22:11:25.466Z" }, ] +[[package]] +name = "dearpygui" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/fe/66293fc40254a29f060efd3398f2b1001ed79263ae1837db9ec42caa8f1d/dearpygui-2.1.0-cp311-cp311-macosx_10_6_x86_64.whl", hash = "sha256:03e5dc0b3dd2f7965e50bbe41f3316a814408064b582586de994d93afedb125c", size = 2100924, upload-time = "2025-07-07T14:20:00.602Z" }, + { url = "https://files.pythonhosted.org/packages/c4/4d/9fa1c3156ba7bbf4dc89e2e322998752fccfdc3575923a98dd6a4da48911/dearpygui-2.1.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b5b37710c3fa135c48e2347f39ecd1f415146e86db5d404707a0bf72d16bd304", size = 1874441, upload-time = "2025-07-07T14:20:09.165Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3c/af5673b50699e1734296a0b5bcef39bb6989175b001ad1f9b0e7888ad90d/dearpygui-2.1.0-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:b0cfd7ac7eaa090fc22d6aa60fc4b527fc631cee10c348e4d8df92bb39af03d2", size = 2636574, upload-time = "2025-07-07T14:20:14.951Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/ed4db0bb3d88e7a8c405472641419086bef9632c4b8b0489dc0c43519c0d/dearpygui-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:a9af54f96d3ef30c5db9d12cdf3266f005507396fb0da2e12e6b22b662161070", size = 1810266, upload-time = "2025-07-07T14:19:51.565Z" }, + { url = "https://files.pythonhosted.org/packages/55/9d/20a55786cc9d9266395544463d5db3be3528f7d5244bc52ba760de5dcc2d/dearpygui-2.1.0-cp312-cp312-macosx_10_6_x86_64.whl", hash = "sha256:1270ceb9cdb8ecc047c42477ccaa075b7864b314a5d09191f9280a24c8aa90a0", size = 2101499, upload-time = "2025-07-07T14:20:01.701Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/39d820796b7ac4d0ebf93306c1f031bf3516b159408286f1fb495c6babeb/dearpygui-2.1.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:ce9969eb62057b9d4c88a8baaed13b5fbe4058caa9faf5b19fec89da75aece3d", size = 1874385, upload-time = "2025-07-07T14:20:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/fc/26/c29998ffeb5eb8d638f307851e51a81c8bd4aeaf89ad660fc67ea4d1ac1a/dearpygui-2.1.0-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:a3ca8cf788db63ef7e2e8d6f277631b607d548b37606f080ca1b42b1f0a9b183", size = 2635863, upload-time = "2025-07-07T14:20:17.186Z" }, + { url = "https://files.pythonhosted.org/packages/28/9c/3ab33927f1d8c839c5b7033a33d44fc9f0aeb00c264fc9772cb7555a03c4/dearpygui-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:43f0e4db9402f44fc3683a1f5c703564819de18cc15a042de7f1ed1c8cb5d148", size = 1810460, upload-time = "2025-07-07T14:19:53.13Z" }, +] + [[package]] name = "dictdiffer" version = "0.9.0" @@ -622,10 +637,10 @@ name = "gymnasium" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cloudpickle" }, - { name = "farama-notifications" }, - { name = "numpy" }, - { name = "typing-extensions" }, + { name = "cloudpickle", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "farama-notifications", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "typing-extensions", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fd/17/c2a0e15c2cd5a8e788389b280996db927b923410de676ec5c7b2695e9261/gymnasium-1.2.0.tar.gz", hash = "sha256:344e87561012558f603880baf264ebc97f8a5c997a957b0c9f910281145534b0", size = 821142, upload-time = "2025-06-27T08:21:20.262Z" } wheels = [ @@ -903,22 +918,22 @@ name = "metadrive-simulator" version = "0.4.2.4" source = { url = "https://github.com/commaai/metadrive/releases/download/MetaDrive-minimal-0.4.2.4/metadrive_simulator-0.4.2.4-py3-none-any.whl" } dependencies = [ - { name = "filelock" }, - { name = "gymnasium" }, - { name = "lxml" }, - { name = "matplotlib" }, - { name = "numpy" }, - { name = "opencv-python-headless" }, - { name = "panda3d" }, - { name = "panda3d-gltf" }, - { name = "pillow" }, - { name = "progressbar" }, - { name = "psutil" }, - { name = "pygments" }, - { name = "requests" }, - { name = "shapely" }, - { name = "tqdm" }, - { name = "yapf" }, + { name = "filelock", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "gymnasium", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "lxml", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "matplotlib", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "opencv-python-headless", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "panda3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "panda3d-gltf", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "pillow", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "progressbar", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "psutil", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "pygments", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "requests", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "shapely", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "tqdm", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "yapf", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] wheels = [ { url = "https://github.com/commaai/metadrive/releases/download/MetaDrive-minimal-0.4.2.4/metadrive_simulator-0.4.2.4-py3-none-any.whl", hash = "sha256:fbf0ea9be67e65cd45d38ff930e3d49f705dd76c9ddbd1e1482e3f87b61efcef" }, @@ -1237,6 +1252,7 @@ dependencies = [ { name = "cffi" }, { name = "crcmod" }, { name = "cython" }, + { name = "dearpygui" }, { name = "future-fstrings" }, { name = "inputs" }, { name = "json-rpc" }, @@ -1327,6 +1343,7 @@ requires-dist = [ { name = "crcmod" }, { name = "cython" }, { name = "dbus-next", marker = "extra == 'dev'" }, + { name = "dearpygui", specifier = ">=2.1.0" }, { name = "dictdiffer", marker = "extra == 'dev'" }, { name = "future-fstrings" }, { name = "hypothesis", marker = "extra == 'testing'", specifier = "==6.47.*" }, @@ -1422,8 +1439,8 @@ name = "panda3d-gltf" version = "0.13" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "panda3d" }, - { name = "panda3d-simplepbr" }, + { name = "panda3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "panda3d-simplepbr", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/07/7f/9f18fc3fa843a080acb891af6bcc12262e7bdf1d194a530f7042bebfc81f/panda3d-gltf-0.13.tar.gz", hash = "sha256:d06d373bdd91cf530909b669f43080e599463bbf6d3ef00c3558bad6c6b19675", size = 25573, upload-time = "2021-05-21T05:46:32.738Z" } wheels = [ @@ -1435,8 +1452,8 @@ name = "panda3d-simplepbr" version = "0.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "panda3d" }, - { name = "typing-extensions" }, + { name = "panda3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "typing-extensions", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/be/c4d1ded04c22b357277cf6e6a44c1ab4abb285a700bd1991460460e05b99/panda3d_simplepbr-0.13.1.tar.gz", hash = "sha256:c83766d7c8f47499f365a07fe1dff078fc8b3054c2689bdc8dceabddfe7f1a35", size = 6216055, upload-time = "2025-03-30T16:57:41.087Z" } wheels = [ @@ -4173,9 +4190,9 @@ name = "pyopencl" version = "2025.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, - { name = "platformdirs" }, - { name = "pytools" }, + { name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "platformdirs", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "pytools", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/88/0ac460d3e2def08b2ad6345db6a13613815f616bbbd60c6f4bdf774f4c41/pyopencl-2025.1.tar.gz", hash = "sha256:0116736d7f7920f87b8db4b66a03f27b1d930d2e37ddd14518407cc22dd24779", size = 422510, upload-time = "2025-01-22T00:16:58.421Z" } wheels = [ @@ -4351,7 +4368,7 @@ wheels = [ [[package]] name = "pytest-xdist" -version = "3.7.1.dev24+g2b4372bd6" +version = "3.7.1.dev24+g2b4372b" source = { git = "https://github.com/sshane/pytest-xdist?rev=2b4372bd62699fb412c4fe2f95bf9f01bd2018da#2b4372bd62699fb412c4fe2f95bf9f01bd2018da" } dependencies = [ { name = "execnet" }, @@ -4393,9 +4410,9 @@ name = "pytools" version = "2024.1.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "platformdirs" }, - { name = "siphash24" }, - { name = "typing-extensions" }, + { name = "platformdirs", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "siphash24", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "typing-extensions", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ee/0f/56e109c0307f831b5d598ad73976aaaa84b4d0e98da29a642e797eaa940c/pytools-2024.1.10.tar.gz", hash = "sha256:9af6f4b045212c49be32bb31fe19606c478ee4b09631886d05a32459f4ce0a12", size = 81741, upload-time = "2024-07-17T18:47:38.287Z" } wheels = [ @@ -4719,7 +4736,7 @@ name = "shapely" version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ca/3c/2da625233f4e605155926566c0e7ea8dda361877f48e8b1655e53456f252/shapely-2.1.1.tar.gz", hash = "sha256:500621967f2ffe9642454808009044c21e5b35db89ce69f8a2042c2ffd0e2772", size = 315422, upload-time = "2025-05-19T11:04:41.265Z" } wheels = [ @@ -4948,7 +4965,7 @@ name = "yapf" version = "0.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "platformdirs" }, + { name = "platformdirs", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" } wheels = [