518 lines
19 KiB
Python
518 lines
19 KiB
Python
# 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()
|