# GUI.py import threading import tkinter as tk from tkinter import ttk, filedialog, messagebox from pathlib import Path # ========================= # Backend (robusto) # ========================= try: import S_Langmuir as backend except Exception as e: backend = None _BACKEND_IMPORT_ERROR = str(e) def _get_backend_attr(name: str): if backend is None: return None return getattr(backend, name, None) run_full_catodos_tokens = _get_backend_attr("run_full_catodos_tokens") run_full_hilos_tokens = _get_backend_attr("run_full_hilos_tokens") run_check_ionico = _get_backend_attr("run_check_ionico") set_sonda = _get_backend_attr("set_sonda") # --------------------------- # Helpers (agrupación tokens) # --------------------------- def parse_token_map(s: str) -> dict[str, int]: s = (s or "").strip() if not s: return {} out: dict[str, int] = {} for chunk in s.split(","): chunk = chunk.strip() if not chunk: continue if "=" not in chunk: raise ValueError(f"Formato token_map inválido: '{chunk}'. Usa campo=indice") k, v = chunk.split("=", 1) k = k.strip() v = v.strip() if not k: raise ValueError("Hay un campo vacío en token_map.") out[k] = int(v) return out def parse_order(s: str) -> list[str]: s = (s or "").strip() if not s: return [] return [x.strip() for x in s.split(",") if x.strip()] def parse_group_tokens(filename: str, delimiter: str, token_map: dict[str, int], order: list[str]) -> str: stem = Path(filename).stem parts = [p.strip() for p in stem.split(delimiter) if p.strip() != ""] values = {} for k, idx in token_map.items(): values[k] = parts[idx] if 0 <= idx < len(parts) else "NA" folders = [] for k in order: v = str(values.get(k, "NA")).strip().replace("\\", "-").replace("/", "-") folders.append(v if v else "NA") return "/".join(folders) if folders else "otros" def _has_na(group: str) -> bool: if not group: return False return ("NA/" in group) or ("/NA" in group) or group.startswith("NA") or group.endswith("/NA") # --------------------------- # GUI # --------------------------- class App(tk.Tk): def __init__(self): super().__init__() self.title("IV Processor") self.geometry("1100x760") self.minsize(1000, 680) if backend is None: messagebox.showerror( "Backend no disponible", "No se ha podido importar S_Langmuir.py.\n\n" f"Error:\n{_BACKEND_IMPORT_ERROR}\n\n" "Asegúrate de que GUI.py y S_Langmuir.py están en la misma carpeta." ) # Estado self.mode = tk.StringVar(value="catodo") self.folder = tk.StringVar(value="") self.status = tk.StringVar(value="Listo.") self.busy = tk.BooleanVar(value=False) # ✅ Check iónico integrado (sin botón) self.do_ionico = tk.BooleanVar(value=False) # Warning banner self.warn = tk.StringVar(value="") self._preview_summary = {"bad": 0, "total": 0} # --------- CATODOS (TOKENS) --------- self.cat_delim = tk.StringVar(value="_") self.cat_token_map = tk.StringVar(value="tipo=0,presion=1,vcat=2,pol=3") self.cat_order = tk.StringVar(value="tipo,presion,vcat,pol") # --------- HILOS (TOKENS) ----------- self.hil_delim = tk.StringVar(value="_") self.hil_token_map = tk.StringVar(value="tipo=0,ich=1,vcp=2") self.hil_order = tk.StringVar(value="tipo,vcp,ich") # --------- SONDA (GUI) -------------- self.probe_geom = tk.StringVar(value="cilindrica") self.probe_r_mm = tk.StringVar(value="0.445") self.probe_L_mm = tk.StringVar(value="22.59") self._preview_job = None self._build() self._wire_traces() self._refresh_fields(initial=True) self._refresh_probe_fields() self._refresh_backend_state() # ---------------- UI build ---------------- def _build(self): pad = {"padx": 10, "pady": 6} # Mode f_mode = ttk.LabelFrame(self, text="Modo") f_mode.pack(fill="x", **pad) ttk.Radiobutton( f_mode, text="Cátodos", variable=self.mode, value="catodo", command=self._refresh_fields ).pack(side="left", padx=10, pady=8) ttk.Radiobutton( f_mode, text="Hilos", variable=self.mode, value="hilos", command=self._refresh_fields ).pack(side="left", padx=10, pady=8) # Folder f_folder = ttk.LabelFrame(self, text="Carpeta de datos") f_folder.pack(fill="x", **pad) row = ttk.Frame(f_folder) row.pack(fill="x", padx=10, pady=8) ttk.Entry(row, textvariable=self.folder).pack(side="left", fill="x", expand=True) ttk.Button(row, text="Elegir…", command=self._pick_folder).pack(side="left", padx=8) # Warning label self.lbl_warn = ttk.Label(self, textvariable=self.warn, foreground="darkorange") self.lbl_warn.pack(fill="x", padx=14, pady=(0, 6)) # ---------------- SONDA SETTINGS ---------------- self.f_probe = ttk.LabelFrame(self, text="Sonda (para n_e): geometría y dimensiones") self.f_probe.pack(fill="x", **pad) self.f_probe.columnconfigure(1, weight=1) self.f_probe.columnconfigure(3, weight=1) ttk.Label(self.f_probe, text="Geometría:").grid(row=0, column=0, sticky="w", padx=10, pady=6) rowg = ttk.Frame(self.f_probe) rowg.grid(row=0, column=1, sticky="w", padx=10, pady=6) ttk.Radiobutton(rowg, text="Cilíndrica", variable=self.probe_geom, value="cilindrica", command=self._refresh_probe_fields).pack(side="left", padx=(0, 12)) ttk.Radiobutton(rowg, text="Esférica", variable=self.probe_geom, value="esferica", command=self._refresh_probe_fields).pack(side="left") ttk.Label(self.f_probe, text="Radio r (mm):").grid(row=0, column=2, sticky="w", padx=10, pady=6) ttk.Entry(self.f_probe, textvariable=self.probe_r_mm, width=10).grid(row=0, column=3, sticky="w", padx=10, pady=6) ttk.Label(self.f_probe, text="Longitud L (mm):").grid(row=1, column=2, sticky="w", padx=10, pady=6) self.ent_L = ttk.Entry(self.f_probe, textvariable=self.probe_L_mm, width=10) self.ent_L.grid(row=1, column=3, sticky="w", padx=10, pady=6) self.lbl_probe_hint = ttk.Label( self.f_probe, text="Nota: L solo aplica a sonda cilíndrica. El backend debe exponer set_sonda(...) para que esto tenga efecto.", foreground="gray" ) self.lbl_probe_hint.grid(row=2, column=0, columnspan=4, sticky="w", padx=10, pady=(0, 6)) # ---------------- TOKENS SETTINGS: CATODOS ---------------- self.f_catodos = ttk.LabelFrame(self, text="Cátodos: agrupación (Tokens)") self.f_catodos.columnconfigure(1, weight=1) self.f_catodos.columnconfigure(3, weight=1) ttk.Label(self.f_catodos, text="Separador:").grid(row=0, column=0, sticky="w", padx=10, pady=6) ttk.Entry(self.f_catodos, textvariable=self.cat_delim, width=6).grid(row=0, column=1, sticky="w", padx=10, pady=6) ttk.Label(self.f_catodos, text="Mapa campo=índice:").grid(row=0, column=2, sticky="w", padx=10, pady=6) ttk.Entry(self.f_catodos, textvariable=self.cat_token_map).grid(row=0, column=3, sticky="ew", padx=10, pady=6) ttk.Label(self.f_catodos, text="Orden carpetas (coma):").grid(row=1, column=0, sticky="w", padx=10, pady=6) ttk.Entry(self.f_catodos, textvariable=self.cat_order).grid(row=1, column=1, columnspan=3, sticky="ew", padx=10, pady=6) # ---------------- TOKENS SETTINGS: HILOS ---------------- self.f_hilos = ttk.LabelFrame(self, text="Hilos: agrupación (Tokens)") self.f_hilos.columnconfigure(1, weight=1) self.f_hilos.columnconfigure(3, weight=1) ttk.Label(self.f_hilos, text="Separador:").grid(row=0, column=0, sticky="w", padx=10, pady=6) ttk.Entry(self.f_hilos, textvariable=self.hil_delim, width=6).grid(row=0, column=1, sticky="w", padx=10, pady=6) ttk.Label(self.f_hilos, text="Mapa campo=índice:").grid(row=0, column=2, sticky="w", padx=10, pady=6) ttk.Entry(self.f_hilos, textvariable=self.hil_token_map).grid(row=0, column=3, sticky="ew", padx=10, pady=6) ttk.Label(self.f_hilos, text="Orden carpetas (coma):").grid(row=1, column=0, sticky="w", padx=10, pady=6) ttk.Entry(self.f_hilos, textvariable=self.hil_order).grid(row=1, column=1, columnspan=3, sticky="ew", padx=10, pady=6) # ---------------- PREVIEW ---------------- f_prev = ttk.LabelFrame(self, text="Preview de agrupación (primeros 12 archivos)") f_prev.pack(fill="both", expand=True, **pad) self.txt_prev = tk.Text(f_prev, height=14, wrap="none") self.txt_prev.pack(fill="both", expand=True, padx=10, pady=8) # ---------------- ACTIONS ---------------- f_act = ttk.Frame(self) f_act.pack(fill="x", **pad) self.btn_preview = ttk.Button(f_act, text="Actualizar preview", command=self._preview) self.btn_preview.pack(side="left", padx=(10, 6), pady=10) self.btn_run = ttk.Button(f_act, text="Ejecutar", command=self._run) self.btn_run.pack(side="left", padx=6, pady=10) # ✅ CHECKBOX (en vez de botón) self.chk_iones = ttk.Checkbutton( f_act, text="Hacer check iónico al terminar", variable=self.do_ionico, command=self._refresh_backend_state ) self.chk_iones.pack(side="left", padx=(14, 6), pady=10) ttk.Label(f_act, textvariable=self.status).pack(side="right", padx=10) # ---------------- Wiring / state ---------------- def _wire_traces(self): vars_to_watch = [ self.folder, self.mode, self.cat_delim, self.cat_token_map, self.cat_order, self.hil_delim, self.hil_token_map, self.hil_order, self.probe_geom, self.probe_r_mm, self.probe_L_mm, ] for v in vars_to_watch: v.trace_add("write", lambda *_: self._schedule_preview()) def _schedule_preview(self): if self._preview_job: try: self.after_cancel(self._preview_job) except Exception: pass self._preview_job = self.after(350, self._preview) def _refresh_fields(self, initial: bool = False): if self.mode.get() == "catodo": self.f_catodos.pack(fill="x", padx=10, pady=6) self.f_hilos.pack_forget() else: self.f_hilos.pack(fill="x", padx=10, pady=6) self.f_catodos.pack_forget() if not initial: self._schedule_preview() self._refresh_backend_state() def _refresh_probe_fields(self): self.ent_L.config(state=("normal" if self.probe_geom.get() == "cilindrica" else "disabled")) def _refresh_backend_state(self): # Run principal if backend is None or self.busy.get(): self.btn_run.config(state="disabled") self.chk_iones.config(state="disabled") return ok_run = (run_full_catodos_tokens is not None) if self.mode.get() == "catodo" else (run_full_hilos_tokens is not None) self.btn_run.config(state=("normal" if ok_run else "disabled")) # Checkbox iónico solo si existe backend iónico ok_iones = (run_check_ionico is not None) self.chk_iones.config(state=("normal" if ok_iones else "disabled")) # Si no existe run_check_ionico, fuerza a False if not ok_iones: self.do_ionico.set(False) def _pick_folder(self): d = filedialog.askdirectory() if d: self.folder.set(d) def _set_busy(self, busy: bool): self.busy.set(busy) self.btn_run.config(state=("disabled" if busy else "normal")) self.btn_preview.config(state=("disabled" if busy else "normal")) self.chk_iones.config(state=("disabled" if busy else "normal")) if busy: self.status.set("Procesando…") else: self._refresh_backend_state() # ---------------- Probe handling ---------------- def _get_probe_config(self) -> tuple[str, float, float | None]: geom = (self.probe_geom.get() or "").strip().lower() if geom not in ("cilindrica", "esferica"): raise ValueError("Geometría de sonda inválida (cilindrica/esferica).") try: r_mm = float(str(self.probe_r_mm.get()).replace(",", ".")) except Exception: raise ValueError("Radio r (mm) no es numérico.") if r_mm <= 0: raise ValueError("Radio r (mm) debe ser > 0.") L_mm = None if geom == "cilindrica": try: L_mm = float(str(self.probe_L_mm.get()).replace(",", ".")) except Exception: raise ValueError("Longitud L (mm) no es numérica.") if L_mm <= 0: raise ValueError("Longitud L (mm) debe ser > 0.") return geom, r_mm, L_mm def _apply_probe_config(self): geom, r_mm, L_mm = self._get_probe_config() if set_sonda is None: raise RuntimeError("El backend no expone set_sonda(...).") set_sonda(geom=geom, rp_mm=r_mm, L_mm=L_mm) # ---------------- Preview ---------------- def _preview(self): folder = self.folder.get().strip() self.txt_prev.delete("1.0", "end") self.warn.set("") self._preview_summary = {"bad": 0, "total": 0} if not folder: self.txt_prev.insert("end", "Elige una carpeta.\n") return data_dir = Path(folder) if not data_dir.exists(): self.txt_prev.insert("end", "Carpeta no existe.\n") return files = sorted([p for p in data_dir.iterdir() if p.is_file() and p.suffix.lower() in (".txt", ".csv")]) if not files: self.txt_prev.insert("end", "No hay .txt/.csv en la carpeta.\n") return show = files[:12] total = len(files) n_otros = 0 n_na = 0 bad_examples: list[str] = [] try: if self.mode.get() == "catodo": delim = self.cat_delim.get() or "_" tmap = parse_token_map(self.cat_token_map.get()) order = parse_order(self.cat_order.get()) else: delim = self.hil_delim.get() or "_" tmap = parse_token_map(self.hil_token_map.get()) order = parse_order(self.hil_order.get()) for fp in files: g = parse_group_tokens(fp.name, delim, tmap, order) if g == "otros": n_otros += 1 if len(bad_examples) < 6: bad_examples.append(fp.name) if _has_na(g): n_na += 1 if len(bad_examples) < 6: bad_examples.append(fp.name) for fp in show: g = parse_group_tokens(fp.name, delim, tmap, order) self.txt_prev.insert("end", f"{fp.name}\n -> {g}\n\n") msgs = [] bad = 0 if n_otros > 0: msgs.append(f"{n_otros}/{total} -> 'otros'") bad += n_otros if n_na > 0: msgs.append(f"{n_na}/{total} contienen 'NA'") bad += n_na self._preview_summary = {"bad": bad, "total": total} if msgs: extra = (" | Ejemplos: " + "; ".join(bad_examples[:6])) if bad_examples else "" self.warn.set("⚠️ OJO: " + " · ".join(msgs) + extra) else: self.warn.set("✅ OK: patrón consistente.") except Exception as e: self.warn.set(f"⚠️ Error preview: {e}") self.txt_prev.insert("end", f"Error preview: {e}\n") # ---------------- Run pipeline (+ opcional iónico) ---------------- def _run(self): if self.busy.get(): return if backend is None: messagebox.showerror("Backend", "S_Langmuir.py no está importable.") return folder = self.folder.get().strip() if not folder: messagebox.showwarning("Falta carpeta", "Elige la carpeta de datos.") return data_dir = Path(folder) if not data_dir.exists(): messagebox.showerror("Error", "La carpeta no existe.") return # preview + warning self._preview() bad = self._preview_summary.get("bad", 0) total = self._preview_summary.get("total", 0) if total > 0 and bad > 0: if not messagebox.askyesno( "Aviso", f"Hay {bad}/{total} archivos que NO encajan con el patrón.\n¿Ejecutar igualmente?" ): return # aplica sonda try: self._apply_probe_config() except Exception as e: messagebox.showerror("Sonda", str(e)) return # backend function if self.mode.get() == "catodo" and run_full_catodos_tokens is None: messagebox.showerror("Backend", "Falta run_full_catodos_tokens en S_Langmuir.py") return if self.mode.get() == "hilos" and run_full_hilos_tokens is None: messagebox.showerror("Backend", "Falta run_full_hilos_tokens en S_Langmuir.py") return # si el usuario ha marcado iónico pero no existe función if self.do_ionico.get() and run_check_ionico is None: messagebox.showerror("Backend", "Has marcado check iónico pero falta run_check_ionico en S_Langmuir.py") return self._set_busy(True) t = threading.Thread(target=self._run_real, args=(data_dir, self.do_ionico.get()), daemon=True) t.start() def _run_real(self, data_dir: Path, do_ionico: bool): try: # 1) pipeline if self.mode.get() == "catodo": delim = self.cat_delim.get() or "_" tmap = parse_token_map(self.cat_token_map.get()) order = parse_order(self.cat_order.get()) run_full_catodos_tokens(data_dir, delim, tmap, order) else: delim = self.hil_delim.get() or "_" tmap = parse_token_map(self.hil_token_map.get()) order = parse_order(self.hil_order.get()) run_full_hilos_tokens(data_dir, delim, tmap, order) # 2) iónico opcional (sin botón) if do_ionico: out_paths = run_check_ionico(data_dir) n = len(out_paths) if out_paths is not None else 0 self.after(0, lambda: self.status.set("Terminado + Iones ✅")) self.after(0, lambda: messagebox.showinfo("OK", f"Proceso completado.\nCheck iónico: {n} Calc_iones.csv")) else: self.after(0, lambda: self.status.set("Terminado ✅")) self.after(0, lambda: messagebox.showinfo("OK", "Proceso completado.")) self.after(0, self._preview) except Exception as e: msg = str(e) self.after(0, lambda: self.status.set("Error ❌")) self.after(0, lambda m=msg: messagebox.showerror("Error", m)) finally: self.after(0, lambda: self._set_busy(False)) if __name__ == "__main__": App().mainloop()