Skip to content

Commit 502fbe4

Browse files
Plot a utility made compatible with Dielectric data
1 parent e0eafb9 commit 502fbe4

1 file changed

Lines changed: 96 additions & 138 deletions

File tree

pica/utils/PlotterUtil_GUI.py

Lines changed: 96 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -34,22 +34,6 @@
3434
except ImportError:
3535
PIL_AVAILABLE = False
3636

37-
38-
def _dummy_process_target():
39-
"""A picklable top-level function to satisfy multiprocessing on Windows."""
40-
pass
41-
42-
43-
def launch_new_instance():
44-
"""
45-
Launches a new instance of the plotter application in a separate process.
46-
This is necessary for creating independent windows.
47-
"""
48-
# This function is no longer needed, as the logic is handled directly
49-
# in the button command for clarity and robustness.
50-
pass
51-
52-
5337
def run_script_process(script_path):
5438
"""
5539
Wrapper function to execute a script in a new process.
@@ -314,18 +298,6 @@ def _create_left_panel(self, parent):
314298
10,
315299
5))
316300

317-
# --- Checkbox UI: Create a scrollable frame for file checkboxes ---
318-
list_container = ttk.Frame(file_frame, style='TFrame')
319-
list_container.grid(
320-
row=1,
321-
column=0,
322-
sticky='nsew',
323-
padx=10,
324-
pady=(
325-
0,
326-
10))
327-
list_container.rowconfigure(0, weight=1)
328-
329301
# --- Checkbox UI: Create a scrollable frame for file checkboxes ---
330302
list_container = ttk.Frame(file_frame, style='TFrame')
331303
list_container.grid(
@@ -361,6 +333,8 @@ def _create_left_panel(self, parent):
361333
self.file_list_frame.bind(
362334
"<Configure>", lambda e: file_canvas.configure(
363335
scrollregion=file_canvas.bbox("all")))
336+
# Bind mousewheel for easier navigation
337+
file_canvas.bind_all("<MouseWheel>", lambda e: file_canvas.yview_scroll(int(-1 * (e.delta / 120)), "units"))
364338

365339
self.column_source_var = tk.StringVar(
366340
value="Columns from: (no file selected)")
@@ -531,6 +505,30 @@ def log(self, message):
531505
self.console.see('end')
532506
self.console.config(state='disabled')
533507

508+
@staticmethod
509+
def _detect_delimiter(line):
510+
"""Infer delimiter from a header/data line. None means whitespace."""
511+
if '\t' in line:
512+
return '\t'
513+
if ',' in line:
514+
return ','
515+
if ';' in line:
516+
return ';'
517+
return None
518+
519+
@staticmethod
520+
def _dedupe_headers(headers):
521+
"""Cp, Cp'' -> stays distinct; true duplicates get _2, _3 suffixes."""
522+
seen, out = {}, []
523+
for h in headers:
524+
if h in seen:
525+
seen[h] += 1
526+
out.append(f"{h}_{seen[h]}")
527+
else:
528+
seen[h] = 0
529+
out.append(h)
530+
return out
531+
534532
def launch_new_instance_handler(self):
535533
"""
536534
Handles launching a new instance of the plotter. This logic correctly
@@ -553,7 +551,7 @@ def launch_new_instance_handler(self):
553551
def browse_files(self):
554552
filepaths = filedialog.askopenfilenames(
555553
title="Select a data file",
556-
filetypes=(("Data Files", "*.csv *.dat"), ("All files", "*.*"))
554+
filetypes=(("Data Files", "*.csv *.dat *.txt"), ("All files", "*.*"))
557555
)
558556
if not filepaths:
559557
return
@@ -663,154 +661,107 @@ def _set_active_file(self, filepath):
663661
self.load_file_data(filepath)
664662

665663
def _load_file_data_into_cache(self, filepath):
666-
"""Loads file data into the cache without affecting the UI state (e.g., active file)."""
664+
"""Loads file data into the cache using the positional parser."""
667665
if not filepath or not os.path.exists(filepath):
668666
return False
669667

670668
try:
671-
# Find the header row
672-
header_line_index = -1
673-
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
674-
for i, line in enumerate(f):
675-
line = line.strip()
676-
if line and not line.startswith('#') and (
677-
',' in line or '\t' in line):
678-
header_line_index = i
679-
break
680-
681-
if header_line_index == -1:
682-
raise ValueError("No valid data header row found.")
683-
684-
# Use genfromtxt to parse the data
685-
data_array = np.genfromtxt(
686-
filepath,
687-
delimiter=',',
688-
names=True,
689-
comments='#',
690-
autostrip=True,
691-
invalid_raise=False,
692-
skip_header=header_line_index)
693-
694-
if not isinstance(
695-
data_array,
696-
np.ndarray) or data_array.dtype is None or data_array.dtype.names is None:
697-
raise ValueError("Could not parse data from file.")
698-
699-
if data_array.size == 0:
700-
self.log(
701-
f"Warning: File '{os.path.basename(filepath)}' contains no valid data rows.")
702-
703-
headers = [name.strip() for name in data_array.dtype.names]
704-
705-
# Store data in our cache
669+
headers, delim, data_array = self._read_data_from_file(filepath)
706670
file_info = self.file_data_cache[filepath]
707671
file_info['headers'] = headers
708-
file_info['data'] = {name: data_array[name] for name in headers}
672+
file_info['delimiter'] = delim
673+
file_info['data'] = {h: data_array[:, j] for j, h in enumerate(headers)}
709674
file_info['mod_time'] = os.path.getmtime(filepath)
710675
file_info['size'] = os.path.getsize(filepath)
711676

712-
self.log(
713-
f"Cached {len(data_array)} data points from '{os.path.basename(filepath)}'.")
677+
self.log(f"Cached {data_array.shape[0]} data points from '{os.path.basename(filepath)}'.")
714678
return True
715-
716679
except Exception as e:
717-
# If loading fails, create an empty but valid cache entry to
718-
# prevent errors.
719680
if filepath in self.file_data_cache:
720681
self.file_data_cache[filepath].update(
721682
{"headers": [], "data": {}})
722-
723683
self.log(f"Error caching file '{os.path.basename(filepath)}': {e}")
724-
# We don't show a messagebox here to avoid spamming the user if they select multiple bad files.
725-
# The log message is sufficient.
726684
return False
727685

728686
def _find_header_row(self, filepath):
729-
"""Finds the header row index in a file."""
730-
header_line_index = -1
687+
"""Returns (header_index, delimiter, headers). Raises on failure."""
688+
def is_num(tok):
689+
try:
690+
float(tok)
691+
return True
692+
except ValueError:
693+
return False
694+
731695
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
732696
for i, line in enumerate(f):
733-
line = line.strip()
734-
if line and not line.startswith('#') and (',' in line or '\t' in line):
735-
header_line_index = i
736-
break
737-
return header_line_index
738-
739-
def _read_data_from_file(self, filepath, header_line_index):
740-
"""Reads data from a file using numpy.genfromtxt."""
741-
if header_line_index == -1:
742-
raise ValueError(
743-
"No valid data header row found. Ensure the file has a "
744-
"non-commented header with comma or tab-separated columns.")
745-
697+
s = line.strip()
698+
if not s or s.startswith('#'):
699+
continue
700+
delim = self._detect_delimiter(s)
701+
tokens = [t.strip() for t in (s.split(delim) if delim else s.split()) if t.strip()]
702+
if len(tokens) < 2:
703+
continue
704+
if not all(is_num(t) for t in tokens):
705+
return i, delim, self._dedupe_headers(tokens)
706+
# First real line is numeric -> headerless file
707+
return i - 1, delim, [f"Col{j + 1}" for j in range(len(tokens))]
708+
raise ValueError("No valid data header row found.")
709+
710+
def _read_data_from_file(self, filepath):
711+
"""Reads data by column index preserving special characters in headers."""
712+
header_idx, delim, headers = self._find_header_row(filepath)
746713
data_array = np.genfromtxt(
747714
filepath,
748-
delimiter=',',
749-
names=True,
715+
delimiter=delim,
750716
comments='#',
751717
autostrip=True,
752718
invalid_raise=False,
753-
skip_header=header_line_index)
719+
skip_header=header_idx + 1)
754720

755-
if (not isinstance(data_array, np.ndarray) or
756-
data_array.dtype is None or
757-
data_array.dtype.names is None):
758-
raise ValueError(
759-
"Could not parse data. The file may be empty, have an invalid format, or contain only comments.")
760-
761-
return data_array
762-
763-
def _update_cache_and_ui(self, filepath, data_array):
764-
"""Updates the file data cache and UI elements with new data."""
721+
data_array = np.atleast_2d(np.asarray(data_array, dtype=float))
765722
if data_array.size == 0:
766-
self.log(
767-
f"Warning: File '{os.path.basename(filepath)}' was loaded, but contains no valid data rows.")
768-
if filepath in self.file_data_cache:
769-
file_info = self.file_data_cache[filepath]
770-
file_info['headers'] = [
771-
name.strip() for name in data_array.dtype.names] if data_array.dtype.names else []
772-
file_info['data'] = {h: np.array(
773-
[]) for h in file_info['headers']}
774-
data_array = np.array([])
775-
776-
headers = [name.strip() for name in data_array.dtype.names]
777-
723+
data_array = np.empty((0, len(headers)))
724+
# Tolerate trailing-delimiter ghost columns
725+
if data_array.shape[1] > len(headers):
726+
data_array = data_array[:, :len(headers)]
727+
elif data_array.shape[1] < len(headers):
728+
raise ValueError(f"Found {data_array.shape[1]} data columns but {len(headers)} headers.")
729+
return headers, delim, data_array
730+
731+
def _update_cache_and_ui(self, filepath, headers, delim, data_array):
732+
"""Updates the file data cache and UI elements with new data."""
778733
file_info = self.file_data_cache[filepath]
779734
file_info['headers'] = headers
780-
file_info['data'] = {name: data_array[name] for name in headers}
735+
file_info['delimiter'] = delim
736+
file_info['data'] = {h: data_array[:, j] for j, h in enumerate(headers)}
781737
file_info['mod_time'] = os.path.getmtime(filepath)
782738
file_info['size'] = os.path.getsize(filepath)
783739

784740
self.x_col_cb['values'] = headers
785741
self.y_col_cb['values'] = headers
786-
787742
if len(headers) > 1:
788-
self.x_col_cb.set(headers[0])
789-
self.y_col_cb.set(headers[1])
743+
if not self.x_col_cb.get(): self.x_col_cb.set(headers[0])
744+
if not self.y_col_cb.get(): self.y_col_cb.set(headers[1])
790745
elif headers:
791-
self.x_col_cb.set(headers[0])
792-
793-
num_points = len(data_array)
794-
self.log(
795-
f"Loaded {num_points} data points from '{os.path.basename(filepath)}'.")
746+
if not self.x_col_cb.get(): self.x_col_cb.set(headers[0])
747+
self.log(f"Loaded {data_array.shape[0]} data points from '{os.path.basename(filepath)}'.")
796748

797749
def load_file_data(self, filepath):
798750
if not filepath:
799751
self.log("Cannot load data: No file selected.")
800752
return
801753

802-
self.stop_file_watcher()
754+
self.stop_file_watcher(log_message=False)
803755

804756
try:
805-
header_line_index = self._find_header_row(filepath)
806-
data_array = self._read_data_from_file(filepath, header_line_index)
807-
self._update_cache_and_ui(filepath, data_array)
757+
headers, delim, data_array = self._read_data_from_file(filepath)
758+
self._update_cache_and_ui(filepath, headers, delim, data_array)
808759

809760
except Exception as e:
810761
self._handle_load_error(filepath, e)
811762
finally:
812763
if self.active_filepath == filepath:
813-
self.start_file_watcher()
764+
self.start_file_watcher(log_message=False)
814765
self.plot_data()
815766

816767
def append_file_data(self):
@@ -819,7 +770,7 @@ def append_file_data(self):
819770
self.active_filepath):
820771
return
821772

822-
self.stop_file_watcher()
773+
self.stop_file_watcher(log_message=False)
823774
file_info = self.file_data_cache.get(self.active_filepath)
824775

825776
if not file_info or 'size' not in file_info:
@@ -870,10 +821,11 @@ def _parse_and_append_new_data(self, new_lines, file_info):
870821
if not new_lines:
871822
return 0
872823

873-
reader = csv.reader(new_lines)
824+
delim = file_info.get('delimiter')
874825
new_data = {h: [] for h in file_info['headers']}
875826
appended_count = 0
876-
for row in reader:
827+
for line in new_lines:
828+
row = [t.strip() for t in (line.split(delim) if delim else line.split()) if t.strip()]
877829
if len(row) != len(file_info['headers']):
878830
continue
879831
for i, header in enumerate(file_info['headers']):
@@ -982,6 +934,12 @@ def _finalize_plot(self, x_col, y_col, selected_filepaths):
982934
self.ax_main.set_xlabel(x_col)
983935
self.ax_main.set_ylabel(y_col)
984936

937+
# Log-scale with non-positive data warnings
938+
if self.x_log_var.get() and np.any(raw_x <= 0):
939+
self.log(f"Warning: X log-scale active for '{os.path.basename(filepath)}', but non-positive data exists.")
940+
if self.y_log_var.get() and np.any(raw_y <= 0):
941+
self.log(f"Warning: Y log-scale active for '{os.path.basename(filepath)}', but non-positive data exists.")
942+
985943
if len(selected_filepaths) == 1:
986944
self.ax_main.set_title(
987945
os.path.basename(
@@ -991,7 +949,6 @@ def _finalize_plot(self, x_col, y_col, selected_filepaths):
991949
self.ax_main.set_title(
992950
f"{y_col} vs. {x_col}",
993951
fontweight='bold')
994-
self.figure.tight_layout()
995952

996953
def _handle_load_error(self, filepath, e):
997954
"""Handles errors during file loading."""
@@ -1000,7 +957,6 @@ def _handle_load_error(self, filepath, e):
1000957
"path": filepath, "headers": [], "data": {}}
1001958
self.column_source_var.set("Columns from: (no file selected)")
1002959
self.active_filepath = None
1003-
self.plot_data()
1004960
self.x_col_cb.set('')
1005961
self.y_col_cb.set('')
1006962
self.x_col_cb['values'] = []
@@ -1018,18 +974,20 @@ def toggle_live_update(self):
1018974
else:
1019975
self.stop_file_watcher()
1020976

1021-
def start_file_watcher(self):
1022-
self.stop_file_watcher() # Ensure no multiple watchers are running
977+
def start_file_watcher(self, log_message=True):
978+
self.stop_file_watcher(log_message=False)
1023979
if self.live_update_var.get() and self.active_filepath:
1024-
self.log("Live update enabled. Watching for file changes...")
980+
if log_message:
981+
self.log("Live update enabled. Watching for file changes...")
1025982
self.file_watcher_job = self.root.after(
1026983
1000, self.check_for_updates)
1027984

1028-
def stop_file_watcher(self):
985+
def stop_file_watcher(self, log_message=True):
1029986
if self.file_watcher_job:
1030987
self.root.after_cancel(self.file_watcher_job)
1031988
self.file_watcher_job = None
1032-
self.log("Live update disabled.")
989+
if log_message:
990+
self.log("Live update disabled.")
1033991

1034992
def check_for_updates(self):
1035993
if not self.active_filepath or not self.live_update_var.get(

0 commit comments

Comments
 (0)