From 9161e1f6b5a032ce17ba9eedf6ccd571e4d0a851 Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Fri, 20 Jun 2025 12:23:22 +0200 Subject: [PATCH] add report info into log, change gui --- config/config.json | 23 +++- radar_data_reader/core/app_controller.py | 30 +++-- radar_data_reader/core/data_enums.py | 69 ++++++++++ radar_data_reader/core/export_profiles.py | 32 +++-- radar_data_reader/gui/main_window.py | 4 +- .../gui/profile_editor_window.py | 124 ++++++++++-------- 6 files changed, 202 insertions(+), 80 deletions(-) create mode 100644 radar_data_reader/core/data_enums.py diff --git a/config/config.json b/config/config.json index b4c479f..ab709ea 100644 --- a/config/config.json +++ b/config/config.json @@ -8,27 +8,38 @@ "fields": [ { "column_name": "batch_id", - "data_path": "batch_id" + "data_path": "batch_id", + "translate_with_enum": false }, { "column_name": "batch_counter", - "data_path": "main_header.ge_header.signal_descr.batch_counter" + "data_path": "main_header.ge_header.signal_descr.batch_counter", + "translate_with_enum": false }, { "column_name": "ttag", - "data_path": "main_header.ge_header.signal_descr.ttag" + "data_path": "main_header.ge_header.signal_descr.ttag", + "translate_with_enum": false }, { "column_name": "master_mode", - "data_path": "main_header.ge_header.mode.master_mode" + "data_path": "main_header.ge_header.mode.master_mode", + "translate_with_enum": true }, { "column_name": "operation_mode", - "data_path": "main_header.ge_header.mode.operation_mode" + "data_path": "main_header.ge_header.mode.operation_mode", + "translate_with_enum": false }, { "column_name": "range_scale", - "data_path": "main_header.ge_header.mode.range_scale" + "data_path": "main_header.ge_header.mode.range_scale", + "translate_with_enum": false + }, + { + "column_name": "waveform", + "data_path": "main_header.ge_header.mode.waveform", + "translate_with_enum": true } ] } diff --git a/radar_data_reader/core/app_controller.py b/radar_data_reader/core/app_controller.py index 732fca4..ebe9df0 100644 --- a/radar_data_reader/core/app_controller.py +++ b/radar_data_reader/core/app_controller.py @@ -15,17 +15,21 @@ from typing import List, Any, Dict from ..utils.config_manager import ConfigManager from ..core.file_reader import run_worker_process from ..core.data_structures import DataBatch +from ..core.data_enums import ENUM_REGISTRY, get_enum_name from ..utils import logger from ..gui.profile_editor_window import ProfileEditorWindow -from ..core.export_profiles import ExportProfile +from ..core.export_profiles import ExportProfile, ExportField log = logger.get_logger(__name__) -def _get_value_from_path(batch: DataBatch, path: str) -> Any: +def _get_value_from_path(batch: DataBatch, field: ExportField) -> Any: """ - Safely retrieves a value from a DataBatch object by navigating a dot-separated path. + Safely retrieves a value from a DataBatch object using the path from an ExportField. + If the field is marked for translation, it attempts to convert the numeric value + to its string representation using the corresponding Enum. """ + path = field.data_path try: parts = path.split('.') if not parts: @@ -40,7 +44,15 @@ def _get_value_from_path(batch: DataBatch, path: str) -> Any: return "N/A" current_obj = getattr(current_obj, part, None) - return current_obj if current_obj is not None else "N/A" + value = current_obj if current_obj is not None else "N/A" + + # --- ENUM TRANSLATION LOGIC --- + if field.translate_with_enum and isinstance(value, int): + enum_class = ENUM_REGISTRY.get(path) + if enum_class: + return get_enum_name(enum_class, value) + + return value except AttributeError: log.warning(f"Could not find attribute for path: {path}") @@ -83,15 +95,14 @@ class AppController: self.view.update_export_profiles(profiles, active_profile_name) def _propose_output_filepath(self, input_path_str: str): - """Proposes a default output path based on the input path.""" if not input_path_str: return proposed_path = Path(input_path_str).with_suffix('.csv') self.view.set_output_filepath(str(proposed_path)) def select_file(self): - self.current_path = self.view.get_filepath() - if filepath := self.view.ask_open_filename(self.current_path): + current_path = self.view.get_filepath() + if filepath := self.view.ask_open_filename(current_path): self.view.set_filepath(filepath) self._propose_output_filepath(filepath) @@ -190,14 +201,14 @@ class AppController: return try: - row_values = [_get_value_from_path(batch, field.data_path) for field in self.active_export_profile.fields] + # Pass the entire ExportField object to the helper function + row_values = [_get_value_from_path(batch, field) for field in self.active_export_profile.fields] self.csv_writer.writerow(row_values) self.csv_file_handle.flush() except Exception as e: log.error(f"An unexpected error occurred during CSV row writing: {e}", exc_info=True) def _log_summary(self, stats: Dict[str, int]): - """Formats and logs a summary of the processing results.""" log.info("--- Processing Summary ---") batches = stats.get("batches_processed", 0) @@ -245,7 +256,6 @@ class AppController: self.is_processing = False self.worker_process = None - # Log the final summary if stats := msg.get("stats"): self._log_summary(stats) diff --git a/radar_data_reader/core/data_enums.py b/radar_data_reader/core/data_enums.py new file mode 100644 index 0000000..da26e1d --- /dev/null +++ b/radar_data_reader/core/data_enums.py @@ -0,0 +1,69 @@ +""" +This module contains Enum definitions that mirror the C++ enums, +used for translating numeric data fields into human-readable strings. +""" +from enum import Enum, unique + +# Using @unique ensures that no two enum members have the same value. +# We use a helper function to handle unknown values gracefully. + +def get_enum_name(enum_class, value): + """Tries to get the name of an enum member, returns the value itself if not found.""" + try: + return enum_class(value).name + except ValueError: + return value + +@unique +class MasterMode(Enum): + """Maps to the m2str function in postprocess.cpp""" + IDLE = 0 + IBIT = 1 + GM = 2 + DBS = 3 + RWS = 4 + VS = 5 + ACM = 6 + TWS = 7 + SEA1 = 8 + SEA2 = 9 + GMTI = 10 + BCN = 11 + SAM = 12 + TA = 13 + WA = 14 + STT = 15 + DTT = 16 + SSTT = 17 + ACQ = 18 + FTT = 19 + AGR = 20 + SAR = 21 + INV = 22 + DUMMY = 23 + ETET = 24 + BOOT = 25 + +@unique +class Waveform(Enum): + """Maps to the wf2str function in postprocess.cpp""" + IDLE = 0 + LPRF = 1 + MPRF = 2 + HPRF = 3 + LPRFNC = 4 + MPRF_SHORT = 5 + AGR_SHORT = 6 + AGR_LONG = 7 + MPRF_LOOK_UP = 8 + MPRF_ADAPTIVE = 9 + AUTO_WITHOUT_LPRF_NC = 10 + +# This dictionary acts as a registry, mapping a data path to the Enum class +# that can be used to translate its value. This will be used by the Profile Editor. +ENUM_REGISTRY = { + "main_header.ge_header.mode.master_mode": MasterMode, + "main_header.ge_header.mode.waveform": Waveform, + # Add other mappings here as we identify them + # "path.to.another.field": AnotherEnum, +} \ No newline at end of file diff --git a/radar_data_reader/core/export_profiles.py b/radar_data_reader/core/export_profiles.py index ff74938..0f326cd 100644 --- a/radar_data_reader/core/export_profiles.py +++ b/radar_data_reader/core/export_profiles.py @@ -2,7 +2,7 @@ Defines the data structures for managing export profiles. """ from dataclasses import dataclass, field -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any # Using slots for performance, similar to data_structures.py DC_KWARGS = {'slots': True} @@ -16,21 +16,20 @@ class ExportField: Attributes: column_name (str): The name of the column in the CSV file header. data_path (str): A dot-separated path to access the value within - the DataBatch object. E.g., "header.header_data.signal_descr.ttag". + the DataBatch object. E.g., "main_header.ge_header.mode.master_mode". + translate_with_enum (bool): If True, the numeric value will be translated + to its string representation using a corresponding Enum. + Defaults to False. """ column_name: str data_path: str + translate_with_enum: bool = False @dataclass(**DC_KWARGS) class ExportProfile: """ Represents a full export profile, including its name and the list of fields. - - Attributes: - name (str): The unique name of the profile (e.g., "Navigation Data"). - fields (List[ExportField]): An ordered list of fields to export. The order - determines the column order in the CSV. """ name: str fields: List[ExportField] = field(default_factory=list) @@ -40,7 +39,11 @@ class ExportProfile: return { "name": self.name, "fields": [ - {"column_name": f.column_name, "data_path": f.data_path} + { + "column_name": f.column_name, + "data_path": f.data_path, + "translate_with_enum": f.translate_with_enum, + } for f in self.fields ], } @@ -50,5 +53,16 @@ class ExportProfile: """Creates an ExportProfile instance from a dictionary.""" name = data.get("name", "Unnamed Profile") fields_data = data.get("fields", []) - fields = [ExportField(**field_data) for field_data in fields_data] + + fields = [] + for field_data in fields_data: + # Ensure backward compatibility with old configs that don't have the flag + translate_flag = field_data.get("translate_with_enum", False) + fields.append( + ExportField( + column_name=field_data.get("column_name", ""), + data_path=field_data.get("data_path", ""), + translate_with_enum=translate_flag, + ) + ) return ExportProfile(name=name, fields=fields) \ No newline at end of file diff --git a/radar_data_reader/gui/main_window.py b/radar_data_reader/gui/main_window.py index 190d737..e668bad 100644 --- a/radar_data_reader/gui/main_window.py +++ b/radar_data_reader/gui/main_window.py @@ -124,10 +124,10 @@ class MainWindow(tk.Frame): ttk.Label(status_frame, text="Progress:", anchor="e").grid(row=1, column=0, padx=(10, 5), pady=2, sticky="e") ttk.Label(status_frame, textvariable=self.progress_text_var, anchor="w").grid(row=1, column=1, padx=5, pady=2, sticky="w") - ttk.Label(status_frame, text="File Batch Counter:", anchor="e").grid(row=2, column=0, padx=(10, 5), pady=2, sticky="e") + ttk.Label(status_frame, text="Batch Counter:", anchor="e").grid(row=2, column=0, padx=(10, 5), pady=2, sticky="e") ttk.Label(status_frame, textvariable=self.file_batch_counter_var, anchor="w").grid(row=2, column=1, padx=5, pady=2, sticky="w") - ttk.Label(status_frame, text="File TimeTag:", anchor="e").grid(row=3, column=0, padx=(10, 5), pady=(2, 5), sticky="e") + ttk.Label(status_frame, text="TimeTag:", anchor="e").grid(row=3, column=0, padx=(10, 5), pady=(2, 5), sticky="e") ttk.Label(status_frame, textvariable=self.timetag_var, anchor="w").grid(row=3, column=1, padx=5, pady=(2, 5), sticky="w") # --- Log Console Frame --- diff --git a/radar_data_reader/gui/profile_editor_window.py b/radar_data_reader/gui/profile_editor_window.py index 5636bab..f417bcb 100644 --- a/radar_data_reader/gui/profile_editor_window.py +++ b/radar_data_reader/gui/profile_editor_window.py @@ -9,6 +9,7 @@ import copy from typing import List, Type, Dict, Any, Union, Optional from ..core import data_structures as ds +from ..core.data_enums import ENUM_REGISTRY from ..core.export_profiles import ExportProfile, ExportField from ..utils import logger @@ -40,20 +41,20 @@ class ProfileEditorWindow(tk.Toplevel): def _init_window(self): """Initializes window properties.""" self.title("Export Profile Editor") - self.geometry("900x600") + self.geometry("1000x600") # Increased width for new column self.transient(self.master) self.grab_set() def _init_vars(self): """Initializes Tkinter variables.""" self.selected_profile_name = tk.StringVar() - self.selected_fields_var = tk.Variable(value=[]) def _create_widgets(self): """Creates the main layout and widgets for the editor.""" main_pane = ttk.PanedWindow(self, orient=tk.HORIZONTAL) main_pane.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + # --- Left Frame: Profile Management --- profile_mgmt_frame = ttk.LabelFrame(main_pane, text="Profiles") main_pane.add(profile_mgmt_frame, weight=1) profile_mgmt_frame.columnconfigure(0, weight=1) @@ -67,10 +68,11 @@ class ProfileEditorWindow(tk.Toplevel): btn_frame = ttk.Frame(profile_mgmt_frame) btn_frame.grid(row=1, column=0, sticky="ew", padx=5) - btn_frame.columnconfigure((0, 1, 2), weight=1) + btn_frame.columnconfigure((0, 1), weight=1) ttk.Button(btn_frame, text="New", command=self._on_new_profile).grid(row=0, column=0, sticky="ew", padx=2) ttk.Button(btn_frame, text="Delete", command=self._on_delete_profile).grid(row=0, column=1, sticky="ew", padx=2) + # --- Middle Frame: Available Fields --- fields_frame = ttk.LabelFrame(main_pane, text="Available Fields") main_pane.add(fields_frame, weight=2) fields_frame.rowconfigure(0, weight=1) @@ -81,8 +83,9 @@ class ProfileEditorWindow(tk.Toplevel): self.fields_tree.configure(yscrollcommand=ysb.set) ysb.grid(row=0, column=1, sticky='ns') + # --- Right Frame: Selected Fields and Actions --- selected_frame_container = ttk.Frame(main_pane) - main_pane.add(selected_frame_container, weight=2) + main_pane.add(selected_frame_container, weight=3) selected_frame_container.rowconfigure(0, weight=1) selected_frame_container.columnconfigure(1, weight=1) @@ -97,34 +100,53 @@ class ProfileEditorWindow(tk.Toplevel): selected_fields_frame.grid(row=0, column=1, sticky="nsew") selected_fields_frame.rowconfigure(0, weight=1) selected_fields_frame.columnconfigure(0, weight=1) - self.selected_listbox = tk.Listbox(selected_fields_frame, listvariable=self.selected_fields_var, selectmode=tk.SINGLE) - self.selected_listbox.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) - ysb_list = ttk.Scrollbar(selected_fields_frame, orient='vertical', command=self.selected_listbox.yview) - self.selected_listbox.config(yscrollcommand=ysb_list.set) - ysb_list.grid(row=0, column=1, sticky='ns') - + + self.selected_tree = ttk.Treeview(selected_fields_frame, columns=("display_name", "translate"), show="headings", selectmode="browse") + self.selected_tree.heading("display_name", text="Field Name") + self.selected_tree.heading("translate", text="Translate") + self.selected_tree.column("display_name", width=200, stretch=True) + self.selected_tree.column("translate", width=100, anchor="center", stretch=False) + self.selected_tree.grid(row=0, column=0, sticky="nsew") + self.selected_tree.bind("", self._on_selected_tree_click) + + # --- Bottom Frame: Save/Cancel --- bottom_frame = ttk.Frame(self) bottom_frame.pack(fill=tk.X, padx=10, pady=(0, 10)) ttk.Button(bottom_frame, text="Save & Close", command=self._on_save_and_close).pack(side=tk.RIGHT) ttk.Button(bottom_frame, text="Cancel", command=self._on_close).pack(side=tk.RIGHT, padx=5) + def _on_selected_tree_click(self, event): + region = self.selected_tree.identify_region(event.x, event.y) + if region != "cell": + return + + column_id = self.selected_tree.identify_column(event.x) + if column_id != "#2": # Column index for "translate" + return + + item_id = self.selected_tree.identify_row(event.y) + if not item_id: + return + + profile = self._get_current_profile() + if not profile: + return + + field_index = int(item_id) + field = profile.fields[field_index] + + if field.data_path in ENUM_REGISTRY: + field.translate_with_enum = not field.translate_with_enum + self._load_profile_into_ui() + def _populate_available_fields_tree(self): - """Introspects the data structures and populates the Treeview correctly.""" self.fields_tree.delete(*self.fields_tree.get_children()) - - # --- Root 1: Batch-level properties --- batch_root = self.fields_tree.insert("", "end", iid="batch_properties", text="Batch Properties") - # Add batch_id directly as it's a top-level property self.fields_tree.insert(batch_root, "end", iid="batch_id", text="batch_id", values=("batch_id", "batch_id")) - - # --- Root 2: Header data properties --- - # The user is interested in the contents of the header block. - header_root = self.fields_tree.insert("", "end", iid="header_data", text="Header Data (from DspHeaderIn)") - # We start the recursion from GeHeader, with a base path pointing to it. + header_root = self.fields_tree.insert("", "end", iid="header_data", text="Header Data") self._recursive_populate_tree(ds.GeHeader, header_root, "main_header.ge_header") def _recursive_populate_tree(self, class_obj: Type, parent_id: str, base_path: str): - """Recursively explores dataclasses to build the tree, starting from a base path.""" if not dataclasses.is_dataclass(class_obj): return @@ -133,7 +155,6 @@ class ProfileEditorWindow(tk.Toplevel): current_path = f"{base_path}.{field_name}" node_id = f"{parent_id}_{field_name}" - # Determine the type to inspect, handling Optional and List type_to_inspect = field.type if hasattr(type_to_inspect, '__origin__') and type_to_inspect.__origin__ in [Union, list]: args = getattr(type_to_inspect, '__args__', []) @@ -141,15 +162,13 @@ class ProfileEditorWindow(tk.Toplevel): if non_none_args: type_to_inspect = non_none_args[0] - # If it's another dataclass, create a branch and recurse if dataclasses.is_dataclass(type_to_inspect): child_node = self.fields_tree.insert(parent_id, "end", iid=node_id, text=field_name) self._recursive_populate_tree(type_to_inspect, node_id, current_path) - # If it's a simple type, create a leaf else: display_text = f"{field_name}" - if field.type in LEAF_TYPES: - display_text = f"{field_name} [{field.type.__name__}]" + if base_path + "." + field_name in ENUM_REGISTRY: + display_text += " (Enum)" self.fields_tree.insert(parent_id, "end", iid=node_id, text=display_text, values=(field_name, current_path)) def _load_profiles_to_combobox(self): @@ -157,7 +176,7 @@ class ProfileEditorWindow(tk.Toplevel): self.profile_combobox['values'] = profile_names if profile_names: self.selected_profile_name.set(profile_names[0]) - self._load_profile_into_ui() + self._load_profile_into_ui() def _get_current_profile(self) -> Optional[ExportProfile]: name = self.selected_profile_name.get() @@ -167,41 +186,49 @@ class ProfileEditorWindow(tk.Toplevel): self._load_profile_into_ui() def _load_profile_into_ui(self): + for i in self.selected_tree.get_children(): + self.selected_tree.delete(i) + profile = self._get_current_profile() if not profile: - self.selected_fields_var.set([]) return - display_list = [f.column_name for f in profile.fields] - self.selected_fields_var.set(display_list) - + for index, field in enumerate(profile.fields): + is_translatable = field.data_path in ENUM_REGISTRY + checkbox_char = "☐" + if is_translatable: + checkbox_char = "☑" if field.translate_with_enum else "☐" + + self.selected_tree.insert( + "", "end", iid=str(index), + values=(field.column_name, checkbox_char) + ) + def _add_field(self): selected_item_id = self.fields_tree.focus() - if not selected_item_id: - return + if not selected_item_id: return item_values = self.fields_tree.item(selected_item_id, "values") if not item_values or len(item_values) < 2: - messagebox.showinfo("Cannot Add Field", "Please select a specific data field (a leaf node), not a category.", parent=self) + messagebox.showinfo("Cannot Add Field", "Please select a specific data field.", parent=self) return column_name, data_path = item_values - profile = self._get_current_profile() if not profile: return if any(f.data_path == data_path for f in profile.fields): - messagebox.showinfo("Duplicate Field", f"The field '{column_name}' is already in the profile.", parent=self) + messagebox.showinfo("Duplicate Field", "This field is already in the profile.", parent=self) return profile.fields.append(ExportField(column_name=column_name, data_path=data_path)) self._load_profile_into_ui() def _remove_field(self): - selection = self.selected_listbox.curselection() + selection = self.selected_tree.selection() if not selection: return - index_to_remove = selection[0] + index_to_remove = int(selection[0]) profile = self._get_current_profile() if not profile: return @@ -209,10 +236,10 @@ class ProfileEditorWindow(tk.Toplevel): self._load_profile_into_ui() def _move_field(self, direction: int): - selection = self.selected_listbox.curselection() + selection = self.selected_tree.selection() if not selection: return - index = selection[0] + index = int(selection[0]) new_index = index + direction profile = self._get_current_profile() @@ -222,12 +249,11 @@ class ProfileEditorWindow(tk.Toplevel): fields = profile.fields fields.insert(new_index, fields.pop(index)) self._load_profile_into_ui() - self.selected_listbox.selection_set(new_index) + self.selected_tree.selection_set(str(new_index)) def _on_new_profile(self): name = simpledialog.askstring("New Profile", "Enter a name for the new profile:", parent=self) - if not name or not name.strip(): - return + if not name or not name.strip(): return if any(p.name == name for p in self.profiles): messagebox.showerror("Error", f"A profile with the name '{name}' already exists.", parent=self) @@ -258,16 +284,8 @@ class ProfileEditorWindow(tk.Toplevel): def _on_close(self): if self._check_unsaved_changes(): - response = messagebox.askyesnocancel( - "Unsaved Changes", - "You have unsaved changes. Would you like to save them before closing?", - parent=self - ) - if response is True: - self._on_save_and_close() - elif response is False: - self.destroy() - else: - return + response = messagebox.askyesnocancel("Unsaved Changes", "You have unsaved changes. Would you like to save them?", parent=self) + if response is True: self._on_save_and_close() + elif response is False: self.destroy() else: self.destroy() \ No newline at end of file