From 53c33c888d48b3b6b6872dc96d7ace3737d15edf Mon Sep 17 00:00:00 2001 From: Michal Tuszowski Date: Tue, 21 Oct 2025 12:28:39 +0200 Subject: [PATCH] 1.16-interim2 --- nucleares_monitor/control_board_monitor.py | 1152 ++++++++++++-------- 1 file changed, 693 insertions(+), 459 deletions(-) diff --git a/nucleares_monitor/control_board_monitor.py b/nucleares_monitor/control_board_monitor.py index 9f0b4e5..1b1e50e 100644 --- a/nucleares_monitor/control_board_monitor.py +++ b/nucleares_monitor/control_board_monitor.py @@ -25,15 +25,69 @@ from datetime import datetime from typing import Dict, List, Optional, Tuple, Deque import math # NOWE: do decymacji i obliczeń import traceback +import signal +import sys + + # --- Qt log & DPI: uspokojenie Qt na multi-monitor (opcjonalne, ale pomaga) --- import os os.environ.setdefault("QT_LOGGING_RULES", "qt.core.qobject.connect=false") os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "0") os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "0") +try: + import faulthandler as _fh +except Exception: + _fh = None # brak modułu lub inny problem – pomijamy + +def _fh_install_signal_dump(sig_name: str): + """Próbuje włączyć dump stacka pod danym sygnałem. + 1) preferuj _fh.register jeśli dostępny, + 2) w przeciwnym razie zwykły signal.signal z fallbackiem na dump_traceback. + """ + if _fh is None: + return + sig = getattr(signal, sig_name, None) + if sig is None: + return + + # Najpierw spróbuj natywnego rejestru faulthandlera (jeśli istnieje w tej wersji Pythona) + if hasattr(_fh, "register"): + try: + _fh.register(sig, file=sys.stderr, all_threads=True) + return + except Exception: + # Brak wsparcia / błąd rejestracji – spróbuj fallbacku + pass + + # Fallback: zwykły handler sygnału, który zrzuci stack wszystkich wątków + def _dump(_signo, _frame): + try: + _fh.dump_traceback(file=sys.stderr, all_threads=True) + except Exception: + raise RuntimeError("Faulthandler dump_traceback failed") + + try: + signal.signal(sig, _dump) + except Exception: + # Nie udało się – trudno, po prostu odpuszczamy ten sygnał + raise RuntimeError(f"Faulthandler signal registration failed for {sig_name}") + +# Włącz faulthandler globalnie (o ile jest) +if _fh is not None: + try: + _fh.enable(all_threads=True) + except Exception: + # Nie blokuj uruchomienia aplikacji – to tylko narzędzie diagnostyczne + pass + # Spróbuj podczepić kilka sensownych sygnałów; ignoruj, jeśli ich nie ma na danej platformie + for _sig_name in ("SIGBREAK", "SIGTERM", "SIGINT"): + _fh_install_signal_dump(_sig_name) +# --- koniec bezpiecznej inicjalizacji faulthandlera --- + DEBUG_LOG = True # możesz wyłączyć na False gdy już będzie stabilnie -def _dbg(msg: str): +def DBGL(msg: str): if not DEBUG_LOG: return try: @@ -41,10 +95,29 @@ def _dbg(msg: str): with open("cbm_debug.log", "a", encoding="utf-8") as f: f.write(f"{datetime.now().strftime('%H:%M:%S.%f')} {msg}\n") except Exception: - pass + DBGEX("DBGL") -def _dbg_exc(where: str): - _dbg(f"[EXC] {where}\n{traceback.format_exc()}") +def DBGEX(where: str): + DBGL(f"[EXC] {where}\n{traceback.format_exc()}") +# 3) global excepthook (main thread) +def _global_excepthook(exctype, value, tb): + DBGL("[GLOBAL EXC] " + "".join(traceback.format_exception(exctype, value, tb))) + sys.__excepthook__(exctype, value, tb) +sys.excepthook = _global_excepthook + +# 4) thread excepthook (Python 3.8+) +def _thread_excepthook(args): + DBGL("[THREAD EXC] " + "".join(traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback))) +threading.excepthook = _thread_excepthook + +# 5) Qt message handler (jeśli PySide6 / pyqtgraph dostępne) +try: + from PySide6 import QtCore + def _qt_msg_handler(mode, ctx, message): + DBGL(f"[QT] {message}") + QtCore.qInstallMessageHandler(_qt_msg_handler) +except Exception: + DBGEX("Qt qInstallMessageHandler") SAFE_GLOBALS = { "__builtins__": {}, @@ -71,6 +144,7 @@ try: from pyqtgraph.Qt import QtCore, QtWidgets _pyqtgraph_available = True except Exception as e: + DBGEX(f"pyqtgraph import {e}") _pyqtgraph_unavailable_reason = str(e) try: @@ -90,6 +164,7 @@ try: PLOT_SCALE = -200 except Exception: + DBGEX("matplotlib import") HAS_MPL = False # --- Daemon thread spawner ---------------------------------------------------- def spawn_daemon(name: str, target, *args, **kwargs): @@ -124,14 +199,17 @@ def build_base_url(host: str, port: int) -> str: return f"http://{host}:{port}/" def _request(req: urllib.request.Request) -> Tuple[int, str, Dict[str, str]]: + DBGL(f"[HTTP] {req}") with _HTTP_LOCK: try: with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_S) as resp: + status = resp.getcode() body_bytes = resp.read() try: body = body_bytes.decode("utf-8", errors="replace") except Exception: + DBGEX("_request decode utf-8") body = body_bytes.decode("latin-1", errors="replace") headers = {k.lower(): v for k, v in resp.getheaders()} return status, body, headers @@ -139,24 +217,30 @@ def _request(req: urllib.request.Request) -> Tuple[int, str, Dict[str, str]]: try: body = e.read().decode("utf-8", errors="replace") except Exception: + DBGEX("_request HTTPError decode utf-8") body = str(e) return e.code, body, dict(e.headers or {}) except Exception as e: + DBGEX("_request") + return 0, str(e), {} def http_get(base_url: str, params: Dict[str, str]) -> Tuple[int, str, Dict[str, str]]: url = base_url + "?" + urllib.parse.urlencode(params) req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Connection": "close"}) + DBGL(f"[HTTP] GET {url}") return _request(req) def http_post_query(base_url: str, params: Dict[str, str]) -> Tuple[int, str, Dict[str, str]]: # EXACT behavior requested: POST with query string, no body. url = base_url + "?" + urllib.parse.urlencode(params) req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Connection": "close"}, method="POST") + DBGL(f"[HTTP] POST {url}") return _request(req) def http_get_root(base_url: str) -> Tuple[int, str, Dict[str, str]]: req = urllib.request.Request(base_url, headers={"User-Agent": USER_AGENT, "Connection": "close"}) + DBGL(f"[HTTP] GET ROOT {base_url}") return _request(req) def coerce_preview(value: str, maxlen: int = 80) -> str: @@ -197,6 +281,7 @@ def parse_weblist_names(body: str) -> Tuple[List[str], List[str]]: try: data = json.loads(body) except Exception: + DBGEX("parse_weblist_names json.loads") return [], [] get_names: List[str] = [] post_names: List[str] = [] @@ -251,6 +336,7 @@ def parse_batch_values(body: str) -> Dict[str, str]: try: data = json.loads(body) except Exception: + DBGEX("parse_batch_values json.loads") return {} # Jeśli serwer owinął odpowiedź w {"values": {...}}, wyciągamy środek: if isinstance(data, dict) and isinstance(data.get("values"), dict): @@ -263,6 +349,7 @@ def parse_batch_values(body: str) -> Dict[str, str]: try: values[k] = v if isinstance(v, str) else json.dumps(v) except Exception: + DBGEX("parse_batch_values json.dumps") values[k] = str(v) return values def detect_batch_payload_type(body: str) -> str: @@ -273,6 +360,7 @@ def detect_batch_payload_type(body: str) -> str: if isinstance(data, dict): return "flat" except Exception: + DBGEX("detect_batch_payload_type json.loads") pass return "unknown" def eval_threshold_expr(expr: str, @@ -304,6 +392,7 @@ def eval_threshold_expr(expr: str, val = safe_eval(expr, env) if 'safe_eval' in globals() else eval(expr, {"__builtins__": {}}, env) return float(val) except Exception: + DBGEX("eval_threshold_expr") return None # ===================== @@ -452,7 +541,8 @@ class ActionScheduler(threading.Thread): def gv(n): try: return self.get_value_cb(n) if n else None - except Exception: + except Exception: + DBGEX("Scheduler.run_task_once->get_value_cb") return None # stara wersja: # x = gv(task.x_src); y = gv(task.y_src); z = gv(task.z_src) @@ -481,6 +571,7 @@ class ActionScheduler(threading.Thread): computed = eval_user_expression(task.expr, x, y, z) value_to_send = str(computed) except Exception as e: + DBGEX(f"Scheduler.run_task_once->eval_user_expression: {e}") # Return synthetic error without posting return 0, f"Expression error: {e}", {} base = self.get_base_url_cb() @@ -524,7 +615,7 @@ class ActionScheduler(threading.Thread): t.next_run = t.last_run + max(0.0, float(t.interval_s)) except Exception: # nie blokujemy pętli scheduler’a na wyjątkach z pojedynczego taska - _dbg_exc("Scheduler.run->run_task_once") + DBGEX("Scheduler.run->run_task_once") # krótka, przerywalna drzemka # (nie używamy time.sleep; dzięki temu zamknięcie jest natychmiastowe) @@ -556,14 +647,24 @@ class Poller(threading.Thread): def run(self) -> None: base_url = build_base_url(self.host, self.port) + DBGL("[Poller] start") + while not self.stop_event.is_set(): cycle_start = time.time() try: - self.ui_queue.put(("cycle_start", datetime.now().strftime("%H:%M:%S"))) - # główny odczyt (BATCH lub fallback na pojedyncze) - status, body, _ = http_get(base_url, {"variable": "WEBSERVER_BATCH_GET"}) + try: + self.ui_queue.put(("cycle_start", datetime.now().strftime("%H:%M:%S"))) + # główny odczyt (BATCH lub fallback na pojedyncze) + status, body, _ = http_get(base_url, {"variable": "WEBSERVER_BATCH_GET"}) + except Exception: + DBGEX("Poller.run->http_get(BATCH_GET)") + status, body, _ = None, None, None if status == 200: - values_map = parse_batch_values(body) + try: + values_map = parse_batch_values(body) + except Exception: + DBGEX("Poller.run->parse_batch_values") + values_map = None if values_map: lower_map = {k.lower(): v for k, v in values_map.items()} self.ui_queue.put(("batch", lower_map)) @@ -574,11 +675,14 @@ class Poller(threading.Thread): self.ui_queue.put(("error", key, 206, "Not in BATCH_GET payload")) else: for key in list(self.variables_keys): - st, b, _h = http_get(base_url, {"variable": key}) - if st == 200: - self.ui_queue.put(("update", key, b, st)) - else: - self.ui_queue.put(("error", key, st, coerce_preview(b, 200))) + try: + st, b, _h = http_get(base_url, {"variable": key}) + if st == 200: + self.ui_queue.put(("update", key, b, st)) + else: + self.ui_queue.put(("error", key, st, coerce_preview(b, 200))) + except Exception: + DBGEX(f"Poller.run->http_get({key})") else: for key in list(self.variables_keys): st, b, _h = http_get(base_url, {"variable": key}) @@ -588,7 +692,7 @@ class Poller(threading.Thread): self.ui_queue.put(("error", key, st, coerce_preview(b, 200))) except Exception: # nigdy nie wywalamy wątku na zewnątrz - _dbg_exc("Poller.run(main loop)") + DBGEX("Poller.run(main loop)") # OBSŁUGA PAUZY – aktywnie czekamy, ale przerywalnie while self.paused_event.is_set() and not self.stop_event.is_set(): @@ -607,12 +711,12 @@ class Poller(threading.Thread): chunk = min(0.1, remaining - waited) self.stop_event.wait(chunk) waited += chunk - + DBGL("[Poller] stop") # sygnalizacja zakończenia try: self.ui_queue.put(("stopped", "")) except Exception: - _dbg_exc("Poller.run(stop)") + DBGEX("Poller.run(stop)") # ===================== @@ -709,7 +813,7 @@ class App(tk.Tk): style = ttk.Style(self) try: style.theme_use("clam") - except Exception: pass + except Exception: DBGEX("App.__init__ theme_use clam") # Context menu self.tree_menu = tk.Menu(self, tearoff=False) @@ -838,36 +942,80 @@ class App(tk.Tk): self.actions_tree.bind("", self.on_actions_right_click) if _pyqtgraph_available: self._qt_ensure_app() - self.after(16, self._pump_qt_events) def _qt_ensure_app(self): """Utwórz (raz) QApplication – wspólną dla wszystkich okien Qt.""" try: from pyqtgraph.Qt import QtWidgets except Exception: + DBGEX("Qt import in _qt_ensure_app") self._qt_app = None return if getattr(self, "_qt_app", None) is None: self._qt_app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + def _ensure_qt_pump(self): + if getattr(self, "_qt_pump_on", False): + return + self._qt_pump_on = True + self.after(16, self._pump_qt_events) + + def _maybe_stop_qt_pump(self): + """Wyłącz pompowanie zdarzeń Qt jeśli żadne okno Qt nie jest już widoczne.""" + try: + has_visible_qt = False + for w in getattr(self, "_plot_windows", {}).values(): + if getattr(w, "_backend", "") == "pyqtgraph": + qtwin = getattr(w, "_qt_win", None) or getattr(w, "_qt_widget", None) + # Najprostsze i najpewniejsze: sprawdzamy widoczność/istnienie + if qtwin is not None and hasattr(qtwin, "isVisible") and qtwin.isVisible(): + has_visible_qt = True + break + if not has_visible_qt: + self._qt_pump_on = False + except Exception as e: + DBGEX(f"[qt] stop-pump check error: {e!r}") + # Na wszelki wypadek nie wyłączaj pompy na błędzie def _pump_qt_events(self): + """Pompowanie zdarzeń Qt z pętli Tk – bez blokowania i bez reentrancji.""" try: + # Jeśli nie ma okien QT – nie pompuj if not getattr(self, "_plot_windows", None): - return # nic do pompowania + return if not any(getattr(w, "_backend", "") == "pyqtgraph" for w in self._plot_windows.values()): return - from pyqtgraph.Qt import QtWidgets + + from pyqtgraph.Qt import QtWidgets, QtCore app = getattr(self, "_qt_app", None) or QtWidgets.QApplication.instance() - if app is not None: - app.processEvents() - except Exception as e: - _dbg_exc("_pump_qt_events") - if not getattr(self, "_closing", False): - self.after(16, self._pump_qt_events) + if app is None: + return + + # Reentrancy guard – jeśli jesteśmy już w środku, odpuść + if getattr(self, "_qt_pumping", False): + return + + self._qt_pumping = True + try: + # Nie blokuj; przetwórz tylko to, co już jest w kolejce + app.sendPostedEvents() + app.processEvents(QtCore.QEventLoop.AllEvents | QtCore.QEventLoop.DontWait) + finally: + self._qt_pumping = False + + except Exception: + DBGEX("_pump_qt_events") + finally: + # Pętla cykliczna – ale tylko jeśli aplikacja nie zamyka się i wciąż mamy okna QT + if not getattr(self, "_closing", False) and any( + getattr(w, "_backend", "") == "pyqtgraph" for w in getattr(self, "_plot_windows", {}).values() + ): + # ~60 FPS max + self.after(16, self._pump_qt_events) def _safe_bool(self, tkvar, default=False): try: return bool(tkvar.get()) except Exception: + DBGEX("get_safe_bool") return default @@ -884,11 +1032,13 @@ class App(tk.Tk): try: return sum(lst) / float(n) except Exception: + DBGEX("get_stats_for avg") return None try: n = max(1, int(self.avg_window_var.get())) except Exception: + DBGEX("get_stats_for avg_window_var") n = 60 if vi: @@ -1262,154 +1412,151 @@ class App(tk.Tk): if kept: self.actions_tree.selection_set(kept) if focus_before and self.actions_tree.exists(focus_before): - self.actions_tree.focus(focus_before) + self.actions_tree.focus(focus_before) + # --- [DROP-IN] jeden zegar do wszystkich backendów --------------------------- def _ensure_plot_timer(self): - if getattr(self, "_plot_timer", None) is None: + """Gwarantuje, że działa pojedynczy timer do odświeżania wszystkich wykresów.""" + if getattr(self, "_plot_timer", None) is None and not getattr(self, "_closing", False): try: - ms = int(max(150, float(self.refresh_var.get()) * 1000)) # >= ~6 FPS + ms = int(max(100, float(self.refresh_var.get()) * 1000)) # ~10 FPS max (wg refresh) except Exception: - ms = 300 - if self._closing: - return - self._plot_timer = self.after(ms, self._plot_tick) if not self._closing else None + DBGEX("_ensure_plot_timer refresh_var") + ms = 200 + self._plot_timer = self.after(ms, self._plot_tick) + + def _decimate(self, seq, max_pts: int): + """Prosta decymacja do maks. liczby punktów: zwraca (xs, ys).""" + if not seq: + return [], [] + if len(seq) <= max_pts: + return list(range(len(seq))), list(seq) + step = max(1, int(math.ceil(len(seq) / float(max_pts)))) + ys = seq[::step] + xs = list(range(0, len(seq), step))[:len(ys)] + return xs, ys def _plot_tick(self): - windows = [w for w in getattr(self, "_plot_windows", {}).values() - if w.winfo_exists() and getattr(w, "_canvas", None)] - if not windows: + """ + Jeden tick: iteruje po wszystkich oknach w self._plot_windows + i wywołuje odpowiednie aktualizacje dla backendu okna. + """ + wins = list(getattr(self, "_plot_windows", {}).items()) if hasattr(self, "_plot_windows") else [] + if not wins: self._plot_timer = None return - for win in windows: + # Zbierz żywe okna per backend + alive = [] + for key, win in wins: + backend = getattr(win, "_backend", "") try: + if backend in ("matplotlib", "canvas"): + # Tk toplevel – sprawdź, czy okno istnieje + if hasattr(win, "winfo_exists") and win.winfo_exists(): + alive.append((key, win)) + elif backend == "pyqtgraph": + # Qt – sprawdź, czy widget widoczny + qt = getattr(win, "_qt_widget", None) + if qt is not None: + try: + vis = bool(qt.isVisible()) + except Exception: + DBGEX("pyqtgraph isVisible") + vis = True + if vis: + alive.append((key, win)) + else: + # nieznane (pomijamy) + pass + except Exception: + DBGEX("_plot_tick(check window alive)") + # okno padło – usuwamy z rejestru + try: + self._plot_windows.pop(key, None) + except Exception: + DBGEX("_plot_tick(remove dead window)") + pass - key = getattr(win, "_key", None) + if not alive: + self._plot_timer = None + return + + for key, win in alive: + try: vi = self.vars.get(key) if not vi: continue - # ustaw tytuł okna: nazwa + aktualna wartość (bez nadmiernego odświeżania) + + # Aktualizacja tytułu (nazwa + ostatnia wartość) – bez nadmiernego spamowania val_num = parse_first_float(vi.last_value) new_title = f"{vi.display_name} — {val_num:.6g}" if val_num is not None else f"{vi.display_name}" if getattr(win, "_last_title", None) != new_title: - win.title(new_title) + try: + # Tk + if hasattr(win, "title"): + win.title(new_title) + except Exception: + DBGEX("_plot_tick(update title Tk)") + pass + try: + # Qt + if getattr(win, "_backend", "") == "pyqtgraph": + qtw = getattr(win, "_qt_widget", None) + if qtw is not None and hasattr(qtw, "setWindowTitle"): + qtw.setWindowTitle(new_title) + except Exception: + DBGEX("_plot_tick(update title Qt)") + pass win._last_title = new_title - # pomiń jeśli brak nowych danych od ostatniego rysowania + # Pomiń rysowanie, gdy brak nowych danych last_ver = getattr(win, "_last_seen_ver", -1) if getattr(vi, "hist_ver", 0) == last_ver: continue - # pobierz dane (per-okno długość próbek) + # Pobranie danych (per-okno liczba próbek i max rys. punktów) try: - n = int(win._sample_len_var.get()) + n = int(getattr(win, "_sample_len_var", self.default_samples_var).get()) except Exception: - n = 200 + DBGEX("_plot_tick(get sample length)") + n = int(self.default_samples_var.get()) + try: + max_pts = int(getattr(win, "_max_draw_var", self.default_maxpts_var).get()) + except Exception: + DBGEX("_plot_tick(get max points)") + max_pts = int(self.default_maxpts_var.get()) + vals = list(vi.history)[-n:] if hasattr(vi, "history") else [] dels = list(vi.history_delta)[-n:] if hasattr(vi, "history_delta") else [] - # decymacja (ogranicz rysowane punkty do _max_draw_pts) - try: - max_pts = int(getattr(win, "_max_draw_pts", int(win._max_draw_var.get()))) - except Exception: - max_pts = 400 + xs_v, vals_draw = self._decimate(vals, max_pts) + xs_b, dels_draw = self._decimate(dels, max_pts) - if len(vals) > max_pts: - step = max(1, math.ceil(len(vals) / max_pts)) - vals_draw = vals[::step] - xs_v = list(range(0, len(vals), step))[:len(vals_draw)] - else: - vals_draw = vals - xs_v = list(range(len(vals))) + # Progi efektywne (odporne na None) + thr = self._effective_thresholds(vi) if hasattr(self, "_effective_thresholds") else {} + # backend-specyficzny update + b = getattr(win, "_backend", "") + if b == "matplotlib": + self._plot_update_matplotlib(win, xs_v, vals_draw, xs_b, dels_draw, thr) + elif b == "canvas": + self._plot_update_canvas(win, xs_v, vals_draw, xs_b, dels_draw, thr) + elif b == "pyqtgraph": + self._plot_update_pyqtgraph(win, xs_v, vals_draw, xs_b, dels_draw, thr) - win._line.set_data(xs_v, vals_draw) - if vals_draw: - vmin, vmax = min(vals_draw), max(vals_draw) - span = (vmax - vmin) or 1.0 - m = 0.08 * span - win._ax_val.set_xlim(0, max(1, len(xs_v) - 1)) - win._ax_val.set_ylim(vmin - m, vmax + m) - # --- progi i wskaźnik najbliższego progu (Matplotlib) --- - if getattr(win, "_backend", "") == "matplotlib": - show_thr = bool(getattr(win, "_show_thr_var", tk.BooleanVar(value=True)).get()) - for ln in win._thr_lines.values(): - ln.set_visible(False) - win._tri_val.set_visible(False) - win._tri_delta.set_visible(False) - - if show_thr and vals_draw: - thr = self._effective_thresholds(vi) - vmin, vmax = win._ax_val.get_ylim() - def within(y): return (y is not None) and (vmin <= y <= vmax) - - for thr_name in ("dead_low","low","mid","high","extreme"): - y = thr.get(thr_name) - if within(y): - win._thr_lines[thr_name].set_ydata([y, y]) - win._thr_lines[thr_name].set_visible(True) - - # wskaźnik: najbliższy próg do aktualnej wartości - cur = parse_first_float(vi.last_value) - candidates = [(k, thr[k]) for k in ("dead_low","low","mid","high","extreme") if thr.get(k) is not None] - if cur is not None and candidates: - k_best, y_best = min(candidates, key=lambda kv: abs(kv[1]-cur)) - # kolor zgodny z linią - color_map = { - "dead_low": "#FBC02D", "low": "#FFF59D", "mid": "#2ECC71", - "high": "#FF8A80", "extreme": "#D50000", - } - col = color_map.get(k_best, "#2ECC71") - # trójkąt w dół, jeśli próg < aktualna; w górę, jeśli próg > aktualna - up = (y_best > cur) - char = "▲" if up else "▼" - win._tri_val.set_text(char); win._tri_val.set_color(col); win._tri_val.set_visible(True) - win._tri_delta.set_text(char); win._tri_delta.set_color(col); win._tri_delta.set_visible(True) - - # słupki delty z taką samą decymacją - if len(dels) > max_pts: - step_b = max(1, math.ceil(len(dels) / max_pts)) - dels_draw = dels[::step_b] - xs_b = list(range(0, len(dels), step_b))[:len(dels_draw)] - else: - dels_draw = dels - xs_b = list(range(len(dels))) - - if len(dels_draw) != win._bars_len: - for b in win._bars: - b.remove() - win._bars = win._ax_delta.bar(xs_b, dels_draw) - win._bars_len = len(dels_draw) - else: - for b, h in zip(win._bars, dels_draw): - b.set_height(h) - - if dels_draw: - dmin, dmax = min(dels_draw), max(dels_draw) - span = (dmax - dmin) or 1.0 - m = 0.08 * span - win._ax_delta.set_xlim(0, max(1, len(dels_draw) - 1)) - win._ax_delta.set_ylim(dmin - m, dmax + m) - - win._ax_delta.set_xlabel(f"last {len(vals)} samples") - - # tight_layout tylko po resize - if getattr(win, "_layout_dirty", False): - try: - win._fig.tight_layout() - except Exception: - pass - win._layout_dirty = False - - win._canvas.draw_idle() win._last_seen_ver = getattr(vi, "hist_ver", win._last_seen_ver) except Exception: - _dbg_exc("_plot_tick(one window)") + DBGEX("_plot_tick(one window)") continue + # kolejny tick try: - ms = int(max(150, float(self.refresh_var.get()) * 1000)) + ms = int(max(100, float(self.refresh_var.get()) * 1000)) except Exception: - ms = 300 - self._plot_timer = self.after(ms, self._plot_tick) + DBGEX("_plot_tick(refresh_var)") + ms = 200 + if not getattr(self, "_closing", False): + self._plot_timer = self.after(ms, self._plot_tick) + # --- Poller control / rendering --- def start_polling(self) -> None: @@ -1489,6 +1636,7 @@ class App(tk.Tk): try: vi.history_delta.append(vi.delta_last) except Exception: + DBGEX("Scheduler.run_task_once->get_value_cb") pass else: vi.delta_last = None @@ -1497,6 +1645,7 @@ class App(tk.Tk): vi.history.append(curr_num) vi.hist_ver += 1 # NOWE: sygnał dla wykresów, że są nowe dane except Exception: + DBGEX("Scheduler.run_task_once->get_value_cb") pass self.vars[key] = vi self.evaluate_thresholds(vi, value) @@ -1546,7 +1695,6 @@ class App(tk.Tk): def open_plot_window(self, var_key: str | None = None): - # Ustal monitored key po IID 'var:' if var_key is None: sel = self.tree.selection() if not sel: @@ -1577,74 +1725,8 @@ class App(tk.Tk): self._open_pyqtgraph_window(key); return tk.messagebox.showwarning("PyQtGraph", "PyQtGraph nie jest dostępny – użyj Matplotlib lub Canvas.") return - - # === Matplotlib (domyślnie) === - import matplotlib - matplotlib.use("Agg") - from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg - from matplotlib.figure import Figure - - vi = self.vars[key] - win = tk.Toplevel(self) - # pierwszy tytuł bez wartości; aktualna wartość będzie dopisywana w _plot_tick - win.title(f"{vi.display_name} — plot") - win.geometry("760x420") - win._key = key - win._backend = "matplotlib" - - tools = ttk.Frame(win); tools.pack(side=tk.TOP, fill=tk.X) - win._show_thr_var = tk.BooleanVar(value=bool(self.display_show_thresholds_var.get())) - ttk.Checkbutton(tools, text="Show thresholds", variable=win._show_thr_var).pack(side=tk.LEFT, padx=(8,6)) - - ttk.Label(tools, text="Samples:").pack(side=tk.LEFT, padx=(8,2)) - win._sample_len_var = tk.IntVar(value=int(self.default_samples_var.get())) - ttk.Spinbox(tools, from_=20, to=5000, increment=10, - textvariable=win._sample_len_var, width=6).pack(side=tk.LEFT) - - ttk.Label(tools, text="Max draw pts:").pack(side=tk.LEFT, padx=(10,2)) - win._max_draw_var = tk.IntVar(value=int(self.default_maxpts_var.get())) - sp = ttk.Spinbox(tools, from_=100, to=5000, increment=50, - textvariable=win._max_draw_var, width=6) - sp.pack(side=tk.LEFT) - win._max_draw_pts = int(win._max_draw_var.get()) - sp.configure(command=lambda w=win: setattr(w, "_max_draw_pts", int(w._max_draw_var.get()))) - - fig = Figure(figsize=(8,4), dpi=100) - ax_val = fig.add_subplot(2,1,1) - ax_delta = fig.add_subplot(2,1,2, sharex=ax_val) - ax_val.set_ylabel("value"); ax_delta.set_ylabel("Δ"); ax_delta.set_xlabel("samples") - # threshold lines (na osi wartości) - win._thr_lines = { - "dead_low": ax_val.axhline(0, color="#FBC02D", linewidth=1.2, linestyle="--", visible=False), # ciemny żółty - "low": ax_val.axhline(0, color="#FFF59D", linewidth=1.2, linestyle="--", visible=False), # jasny żółty - "mid": ax_val.axhline(0, color="#2ECC71", linewidth=1.0, linestyle=":", visible=False), # zielony (środek) - "high": ax_val.axhline(0, color="#FF8A80", linewidth=1.2, linestyle="--", visible=False), # jasny czerwony - "extreme": ax_val.axhline(0, color="#D50000", linewidth=1.2, linestyle="--", visible=False), # ciemny czerwony - } - - canvas = FigureCanvasTkAgg(fig, master=win) - canvas.draw(); canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) - # wskazniki 'najbliższego progu' (po lewej; poza obszarem osi) - win._tri_val = ax_val.text(-0.03, -0.10, "▲", transform=ax_val.transAxes, - ha="left", va="top", color="#2ECC71", fontsize=12, - clip_on=False, visible=False) - win._tri_delta = ax_delta.text(-0.03, 1.10, "▲", transform=ax_delta.transAxes, - ha="left", va="bottom", color="#2ECC71", fontsize=12, - clip_on=False, visible=False) - - (line,) = ax_val.plot([], [], linewidth=1.2, antialiased=False) - bars = ax_delta.bar([], [], linewidth=0, antialiased=False) - - win._fig = fig; win._ax_val = ax_val; win._ax_delta = ax_delta - win._canvas = canvas; win._line = line; win._bars = bars; win._bars_len = 0 - win._last_seen_ver = -1 - win._layout_dirty = True - canvas.mpl_connect("resize_event", lambda _evt=None: setattr(win, "_layout_dirty", True)) - - if not hasattr(self, "_plot_windows"): - self._plot_windows = {} - self._plot_windows[key] = win - self._ensure_plot_timer() + # domyślnie matplotlib + self._open_matplotlib_window(key) @@ -1845,6 +1927,7 @@ class App(tk.Tk): try: return float(s), None except Exception: + DBGEX("App.__init__ theme_use clam") # potraktuj jako wyrażenie (np. "x_avg - 5") return None, s @@ -1900,6 +1983,7 @@ class App(tk.Tk): cur_x = parse_first_float(vi.last_value) self.on_state_change(vi, vi.last_state, vi.last_state, cur_x) except Exception: + DBGEX("App.open_thresholds_dialog->do_save on_state_change") pass dlg.destroy() self._reschedule_state_tasks_for(vi) @@ -1980,6 +2064,7 @@ class App(tk.Tk): try: self.scheduler.remove_task(tid) except Exception: + DBGEX("App.on_state_change remove_task") pass # stop any repeating expr task for previous state etid = self.expr_state_tasks.pop((vi.key, prev_state), None) @@ -1987,6 +2072,7 @@ class App(tk.Tk): try: self.scheduler.remove_task(etid) except Exception: + DBGEX("App.on_state_change remove_task expr") pass # then handle enter actions for new_state self.on_enter_state(vi, new_state, x) @@ -2018,8 +2104,9 @@ class App(tk.Tk): dur = 400 if state in ("DEAD_LOW", "EXTREME_HIGH") else 300 winsound.Beep(freq, dur) except Exception: + DBGEX("App.on_enter_state winsound.Beep") try: self.bell() - except Exception: pass + except Exception: DBGEX("App.on_enter_state bell") expr_key = { "DEAD_LOW": "expr_dead_low", "LOW": "expr_low", @@ -2173,15 +2260,17 @@ class App(tk.Tk): try: self.tree.tag_configure(tag, background=hex_color) except Exception: + DBGEX("App._get_color_tag") pass return tag - def _effective_thresholds(self, vi:"WarInfo") -> dict: + def _effective_thresholds(self, vi:"VarInfo") -> dict: return self._compute_effective_thresholds(vi) def _compute_effective_thresholds(self, vi: "VarInfo") -> dict[str, float | None]: """ Liczy progi efektywne: bierzemy liczby z konfiguracji + nadpisujemy je, jeśli zdefiniowano formuły expr_thr_*. Zwracamy też 'mid'=(low+high)/2 jeżeli możliwe. """ + DBGL(f"[_thr] key={vi.key} start") t = vi.thresholds stats_x = self.get_stats_for(vi.key) stats_y = self.get_stats_for(getattr(t, "y_source", None)) @@ -2190,11 +2279,13 @@ class App(tk.Tk): def eff(num, expr): if expr: try: + DBGL(f"[_thr] expr {expr} for {vi.key} -> try") v = eval_threshold_expr(expr, stats_x, stats_y, stats_z) + DBGL(f"[_thr] expr {expr} for {vi.key} -> {v}") if v is not None: return float(v) except Exception: - _dbg_exc("eval_threshold_expr") + DBGEX("eval_threshold_expr") return float(num) if num is not None else None dead = eff(getattr(t, "dead_low", None), getattr(t, "expr_thr_dead_low", None)) @@ -2202,6 +2293,7 @@ class App(tk.Tk): high = eff(getattr(t, "high", None), getattr(t, "expr_thr_high", None)) ext = eff(getattr(t, "extreme_high", None), getattr(t, "expr_thr_extreme_high", None)) mid = (low + high) / 2.0 if (low is not None and high is not None) else None + DBGL(f"[_thr] key={vi.key} done -> dead={dead} low={low} high={high} ext={ext} mid={mid}") return {"dead_low": dead, "low": low, "high": high, "extreme": ext, "mid": mid} def evaluate_thresholds(self, vi: VarInfo, value: str) -> None: @@ -2237,7 +2329,7 @@ class App(tk.Tk): vi.last_state = state except Exception: - _dbg_exc("evaluate_thresholds") + DBGEX("evaluate_thresholds") def _state_color(self, vi, x_value=None) -> str: """Kolor tła w hex na bazie progów efektywnych; odporne na None.""" try: @@ -2250,6 +2342,7 @@ class App(tk.Tk): try: x = float(x_value) if x_value is not None else parse_first_float(vi.last_value) except Exception: + DBGEX("_state_color parse_first_float") x = None if x is None: return "#E9ECEF" # brak danych @@ -2290,7 +2383,7 @@ class App(tk.Tk): t = (x - thr_high) / max(denom, 1e-9) return _hex(_lerp(R_LO, R_HI, t)) except Exception: - _dbg_exc("_state_color") + DBGEX("_state_color") return "#E9ECEF" @@ -2332,6 +2425,7 @@ class App(tk.Tk): try: delta_str = f"{vi.delta_last:+.6g}" except Exception: + DBGEX("_state_color delta_last") delta_str = str(vi.delta_last) # Δ avg(N) @@ -2347,6 +2441,7 @@ class App(tk.Tk): davg_val = sum(dvals) / float(n) davg_str = f"{davg_val:.6g}" except Exception: + DBGEX("_state_color davg_last") davg_str = "N/A" # value avg(N) @@ -2358,6 +2453,7 @@ class App(tk.Tk): avg_val = sum(vals) / float(n) avg_str = f"{avg_val:.6g}" except Exception: + DBGEX("_state_color avg_last") avg_str = "N/A" x = parse_first_float(vi.last_value) @@ -2392,6 +2488,7 @@ class App(tk.Tk): try: val = float(self.refresh_var.get()) except Exception: + DBGEX("_watch_refresh_interval parse float") val = self._last_refresh_val if val != self._last_refresh_val: self._last_refresh_val = val @@ -2400,6 +2497,7 @@ class App(tk.Tk): self.poller.refresh_interval = max(0.1, val) self.status_lbl.configure(text=f"Running… (refresh {self.poller.refresh_interval}s)") except Exception: + DBGEX("_watch_refresh_interval set refresh_interval") pass self.after(2000, self._watch_refresh_interval) @@ -2547,18 +2645,22 @@ class App(tk.Tk): try: self.display_backend_var.set(dd.get("backend", "matplotlib")) except Exception: + DBGEX("load_config display_backend_var") pass try: self.default_samples_var.set(int(dd.get("samples", 200))) except Exception: + DBGEX("load_config default_samples_var") pass try: self.default_maxpts_var.set(int(dd.get("max_draw_pts", 400))) except Exception: + DBGEX("load_config default_maxpts_var") pass try: self.display_show_thresholds_var.set(bool(dd.get("show_thresholds", True))) except Exception: + DBGEX("load_config display_show_thresholds_var") pass # replace scheduled tasks @@ -2654,17 +2756,21 @@ class App(tk.Tk): try: win, timer = tup except Exception: + DBGEX("on_close get timer") continue try: timer.stop(); timer.deleteLater() except Exception: + DBGEX("on_close stop timer") pass try: win.close() except Exception: + DBGEX("on_close close win") pass self._qt_windows.clear() except Exception: + DBGEX("on_close clear windows") pass try: @@ -2673,6 +2779,7 @@ class App(tk.Tk): self.paused_event.clear() self.poller.join(timeout=1.5) except Exception: + DBGEX("on_close poller") pass # zatrzymaj scheduler @@ -2681,6 +2788,7 @@ class App(tk.Tk): self.scheduler.stop() # ustawia wewnętrzny _stop Event self.scheduler.join(timeout=1.5) except Exception: + DBGEX("on_close scheduler") pass # zatrzymaj timer do plotów, jeśli działa @@ -2688,6 +2796,7 @@ class App(tk.Tk): if getattr(self, "_plot_timer", None): self.after_cancel(self._plot_timer) except Exception: + DBGEX("on_close plot_timer") pass # zamknij okna wykresów @@ -2696,22 +2805,27 @@ class App(tk.Tk): try: win.destroy() except Exception: + DBGEX("on_close destroy plot win") pass except Exception: + DBGEX("on_close plot_windows") pass self.destroy() - + # --- [DROP-IN] otwieranie okna Canvas (lite) -------------------------------- def _open_canvas_window(self, key: str): vi = self.vars[key] win = tk.Toplevel(self) - win.title(f"{vi.display_name} — canvas") - win.geometry("760x420") - win._backend = "canvas" + win.title(f"{vi.display_name} — plot") + win.geometry("740x360") win._key = key + win._backend = "canvas" tools = ttk.Frame(win); tools.pack(side=tk.TOP, fill=tk.X) + win._show_thr_var = tk.BooleanVar(value=bool(self.display_show_thresholds_var.get())) + ttk.Checkbutton(tools, text="Show thresholds", variable=win._show_thr_var).pack(side=tk.LEFT, padx=(8,6)) + ttk.Label(tools, text="Samples:").pack(side=tk.LEFT, padx=(8,2)) win._sample_len_var = tk.IntVar(value=int(self.default_samples_var.get())) ttk.Spinbox(tools, from_=20, to=5000, increment=10, @@ -2722,269 +2836,387 @@ class App(tk.Tk): ttk.Spinbox(tools, from_=100, to=5000, increment=50, textvariable=win._max_draw_var, width=6).pack(side=tk.LEFT) - # Dwie części: górna (linia wartości), dolna (słupki Δ) - win._canvas = tk.Canvas(win, bg="#ffffff", highlightthickness=0) - win._canvas.pack(fill=tk.BOTH, expand=True) + cv = tk.Canvas(win, background="white", highlightthickness=0) + cv.pack(fill=tk.BOTH, expand=True) + win._tk_canvas = cv + # id narysowanych elementów (żeby kasować przy nast. klatce) + win._cv_ids = [] + win._last_seen_ver = -1 - # dane do rysowania - win._line_items = [] # lista segmentów linii - win._bar_items = [] # lista prostokątów + if not hasattr(self, "_plot_windows"): + self._plot_windows = {} + self._plot_windows[key] = win + self._ensure_plot_timer() - def _tick(): - if not win.winfo_exists(): - return - key_local = getattr(win, "_key", None) - vi_local = self.vars.get(key_local) - if not vi_local: - win.after(400, _tick); return + # --- [DROP-IN] aktualizacja Canvas (lite) ----------------------------------- + def _plot_update_canvas(self, win, xs_v, vals_draw, xs_b, dels_draw, thr: dict): + try: + cv = win._tk_canvas + for iid in getattr(win, "_cv_ids", []): + try: cv.delete(iid) + except Exception: DBGEX("_plot_update_canvas delete iid") + win._cv_ids = [] - # Title z wartością - try: - val_num = parse_first_float(vi_local.last_value) - win.title(f"{vi_local.display_name} — {val_num:.6g}" if val_num is not None else vi_local.display_name) - except Exception: - pass - # …po utworzeniu Toplevel win… - if not hasattr(self, "_plot_windows_canvas"): - self._plot_windows_canvas = [] - self._plot_windows_canvas.append(win) + W = max(10, cv.winfo_width()) + H = max(10, cv.winfo_height()) + H1 = int(H*0.6) + H2 = H - H1 - def _on_close_canvas(): - try: - self._plot_windows_canvas.remove(win) - except Exception: - pass - win.destroy() + # mapowanie Y (górny wykres) + if vals_draw: + vmin, vmax = min(vals_draw), max(vals_draw) + if vmax == vmin: + vmax = vmin + 1.0 + def y1(v): return int((1.0 - (v - vmin)/(vmax - vmin)) * (H1-20)) + 10 + def x(i): return int((i / max(1, len(vals_draw)-1)) * (W-20)) + 10 - win.protocol("WM_DELETE_WINDOW", _on_close_canvas) + # polyline + pts = [] + for i, v in enumerate(vals_draw): + pts.append(x(i)); pts.append(y1(v)) + if len(pts) >= 4: + win._cv_ids.append(cv.create_line(*pts, width=2)) - # dane - try: n = int(win._sample_len_var.get()) - except Exception: n = 200 - try: max_pts = int(win._max_draw_var.get()) - except Exception: max_pts = 400 + # progi + if bool(getattr(win, "_show_thr_var", self.display_show_thresholds_var).get()): + def within(y): return y is not None and (vmin <= y <= vmax) + colors = { + "dead_low": "#FBC02D", "low": "#FFF59D", "mid": "#2ECC71", + "high": "#FF8A80", "extreme": "#D50000", + } + for name in ("dead_low","low","mid","high","extreme"): + y = thr.get(name) + if within(y): + Y = y1(y) + win._cv_ids.append(cv.create_line(10, Y, W-10, Y, dash=(4,3), fill=colors[name])) - vals = list(vi_local.history)[-n:] - dels = list(vi_local.history_delta)[-n:] + # wskaźnik najbliższego progu + cur = vals_draw[-1] + cands = [(k, thr.get(k)) for k in ("dead_low","low","mid","high","extreme") if thr.get(k) is not None] + if cands: + k_best, y_best = min(cands, key=lambda kv: abs(kv[1]-cur)) + up = (y_best > cur) + char = "▲" if up else "▼" + col = {"dead_low": "#FBC02D","low": "#FFF59D","mid": "#2ECC71","high":"#FF8A80","extreme":"#D50000"}[k_best] + win._cv_ids.append(cv.create_text(18, 8, text=char, fill=col, anchor="nw")) - # decymacja - def decimate(arr, m): - if len(arr) <= m: - xs = list(range(len(arr))); return xs, arr - step = max(1, int(math.ceil(len(arr) / m))) - arr2 = arr[::step]; xs = list(range(0, len(arr), step))[:len(arr2)] - return xs, arr2 + # dolny wykres (Δ) jako słupki + if dels_draw: + dmin, dmax = min(dels_draw), max(dels_draw) + if dmax == dmin: + dmax = dmin + 1.0 + def y2(v): return H1 + 10 + int((1.0 - (v - dmin)/(dmax - dmin)) * (H2-20)) + def x2(i): return int((i / max(1, len(dels_draw)-1)) * (W-20)) + 10 - xs_v, vals_d = decimate(vals, max_pts) - xs_b, dels_d = decimate(dels, max_pts) - - # geometra płótna - cw = max(10, win._canvas.winfo_width()) - ch = max(10, win._canvas.winfo_height()) - mid = ch // 2 - top_h = int(ch * 0.62) - bot_y0 = top_h + 1 - win._canvas.delete("all") - - # oś X mapping - def map_x(i, npts): - return int((i / max(1, npts-1)) * (cw-20)) + 10 - - # linia wartości - if vals_d: - vmin, vmax = min(vals_d), max(vals_d) - span = (vmax - vmin) or 1.0 - def map_y(v): - return int((1 - (v - vmin) / span) * (top_h-20)) + 10 - last = None - for i, v in enumerate(vals_d): - x = map_x(i, len(vals_d)); y = map_y(v) - if last is not None: - win._canvas.create_line(last[0], last[1], x, y) - last = (x, y) - - # słupki Δ - if dels_d: - dmin, dmax = min(dels_d), max(dels_d) - span = (dmax - dmin) or 1.0 - def map_yb(v): - # dolny panel - y = int((1 - (v - dmin)/span) * (ch - bot_y0 - 20)) + bot_y0 + 10 - return y - bw = max(1, int((cw-20) / max(1, len(dels_d)))) - for i, v in enumerate(dels_d): - x = map_x(i, len(dels_d)); y = map_yb(v) - win._canvas.create_rectangle(x, y, x+bw, ch-8, outline="", fill="#8888ff") - - # kolejny tick - if self._closing or not win.winfo_exists(): - return - try: - ms = int(max(120, float(self.refresh_var.get())*1000)) - except Exception: - ms = 250 - win.after(ms, _tick) - - _tick() + bw = max(1, int((W-20) / max(1, len(dels_draw)))) + for i, v in enumerate(dels_draw): + X = x2(i) + Y = y2(v) + win._cv_ids.append(cv.create_rectangle(X, Y, X+bw, H-10, width=0)) + # wskaźnik – tylko strzałka (kolor jak wyżej), rysowana nad osią + # (prosto: nie liczymy „najbliższego” drugi raz – to kosmetyka) + except Exception: + DBGEX("_plot_update_canvas") + # --- [DROP-IN] otwieranie okna PyQtGraph (bez QTimer) ----------------------- def _open_pyqtgraph_window(self, key: str): if not _pyqtgraph_available: - tk.messagebox.showwarning("PyQtGraph", "PyQtGraph nie jest dostępny – użyj Matplotlib lub Canvas.") + tk.messagebox.showwarning("PyQtGraph", "PyQtGraph nie jest dostępny.") return + vi = self.vars[key] + if not vi: + tk.messagebox.showerror("Plot", f'Zmienna "{key}" nie jest monitorowana') + return + + self._qt_ensure_app() - if key not in self.vars: - tk.messagebox.showwarning("Plot", f"Zmienna '{key}' nie jest monitorowana.") + + app = getattr(self, "_qt_app", None) or QtWidgets.QApplication.instance() + if app is None: + tk.messagebox.showerror("PyQtGraph", "Brak QApplication – pyqtgraph nie może wystartować.") return + # Qt window + glw = pg.GraphicsLayoutWidget(show=True, title=f"{vi.display_name} — plot") + glw.resize(780, 480) + glw.setWindowTitle(f"{vi.display_name} — plot") + + p1 = glw.addPlot(row=0, col=0) + p2 = glw.addPlot(row=1, col=0) + p2.setXLink(p1) + p1.showGrid(x=True, y=True, alpha=0.2) + p2.showGrid(x=True, y=True, alpha=0.2) + + curve = p1.plot([], [], pen=None) # ustawimy pen później przez setData + # BarGraphItem dla delt + bars = None + bars_x = [] + bars_w = 1.0 + + # Linie progów (InfiniteLine) + thr_lines = { + "dead_low": pg.InfiniteLine(angle=0, pen=pg.mkPen("#FBC02D", width=2, style=QtCore.Qt.DashLine)), + "low": pg.InfiniteLine(angle=0, pen=pg.mkPen("#FFF59D", width=2, style=QtCore.Qt.DashLine)), + "mid": pg.InfiniteLine(angle=0, pen=pg.mkPen("#2ECC71", width=1, style=QtCore.Qt.DotLine)), + "high": pg.InfiniteLine(angle=0, pen=pg.mkPen("#FF8A80", width=2, style=QtCore.Qt.DashLine)), + "extreme": pg.InfiniteLine(angle=0, pen=pg.mkPen("#D50000", width=2, style=QtCore.Qt.DashLine)), + } + for ln in thr_lines.values(): + ln.setVisible(False); p1.addItem(ln) + + # Wskaźnik najbliższego progu: TextItem po lewej + tri_val = pg.TextItem("", anchor=(0,0)) + tri_delta = pg.TextItem("", anchor=(0,1)) + tri_val.setColor("#2ECC71"); tri_delta.setColor("#2ECC71") + tri_val.setVisible(False); tri_delta.setVisible(False) + p1.addItem(tri_val); p2.addItem(tri_delta) + + # wrapper (bez Tk toplevel – tylko referencje i ustawienia) + class _QtHandle: + pass + win = _QtHandle() + win._backend = "pyqtgraph" + win._key = key + win._qt_widget = glw + win._p1 = p1; win._p2 = p2 + win._curve = curve + win._bars = bars + win._bars_x = bars_x + win._bars_w = bars_w + win._thr_lines = thr_lines + win._tri_val = tri_val + win._tri_delta = tri_delta + win._sample_len_var = tk.IntVar(value=int(self.default_samples_var.get())) + win._max_draw_var = tk.IntVar(value=int(self.default_maxpts_var.get())) + win._show_thr_var = tk.BooleanVar(value=bool(self.display_show_thresholds_var.get())) + win._last_seen_ver = -1 + win._last_title = None + + if not hasattr(self, "_plot_windows"): + self._plot_windows = {} + self._plot_windows[key] = win + + self._ensure_qt_pump() # włącz pompowanie zdarzeń Qt w pętli Tk + self._ensure_plot_timer() + + # --- [DROP-IN] aktualizacja PyQtGraph --------------------------------------- + def _plot_update_pyqtgraph(self, win, xs_v, vals_draw, xs_b, dels_draw, thr: dict): + try: + w = getattr(win, "_qt_widget", None) + if w is None or not hasattr(w, "isVisible") or not w.isVisible(): + return + + p1 = win._p1; p2 = win._p2 + # krzywa wartości + if vals_draw: + if win._curve.opts["pen"] is None: + win._curve.setPen(pg.mkPen(width=2)) + win._curve.setData(xs_v, vals_draw) + + p1.setXRange(0, max(1, (xs_v[-1] if xs_v else 1)), padding=0.02) + vmin, vmax = min(vals_draw), max(vals_draw) + vmin, vmax = min(vals_draw), max(vals_draw) + span = (vmax - vmin) or 1.0 + p1.setYRange(vmin - 0.03*span, vmax + 0.03*span, padding=0.1) + + # --- delty (słupki) --- + if dels_draw: + dmin, dmax = min(dels_draw), max(dels_draw) + dspan = (dmax - dmin) or 1.0 + p2.setYRange(dmin - 0.03*dspan, dmax + 0.03*dspan, padding=0.0) + + # len(dels_draw) == len(xs_b) + if win._bars is None: + # pierwszy raz twórz BarGraphItem + # (barWidth będzie aktualizowany przy zmianie rozdzielczości) + win._bars = pg.BarGraphItem(x=xs_b, height=dels_draw, width=1.0) + p2.addItem(win._bars) + else: + win._bars.setOpts(x=xs_b, height=dels_draw) + + # aktualizuj szerokość słupków + n = max(1, len(xs_b)) + win._bars_w = max(1.0, float(len(xs_b)) / n) # symboliczne – pyqtgraph skaluje z X + try: + win._bars.setOpts(width=1.0) # stała, bo mamy indeksy jako X + except Exception: + DBGEX("_plot_update_pyqtgraph set bar width") + + # --- progi + wskaźnik --- + for ln in win._thr_lines.values(): + ln.setVisible(False) + win._tri_val.setVisible(False); win._tri_delta.setVisible(False) + + if bool(getattr(win, "_show_thr_var", self.display_show_thresholds_var).get()) and vals_draw: + # linie pokaż tylko, jeśli są w zakresie Y aktualnego widoku + vmin, vmax = p1.viewRange()[1] + def within(y): return (y is not None) and (vmin <= y <= vmax) + + for name in ("dead_low","low","mid","high","extreme"): + y = thr.get(name) + if within(y): + ln = win._thr_lines[name] + ln.setValue(float(y)) + ln.setVisible(True) + + # wskaźnik najbliższego progu + cur = vals_draw[-1] + cands = [(k, thr.get(k)) for k in ("dead_low","low","mid","high","extreme") if thr.get(k) is not None] + if cands: + k_best, y_best = min(cands, key=lambda kv: abs(kv[1]-cur)) + up = (y_best > cur) + char = "▲" if up else "▼" + col = {"dead_low":"#FBC02D","low":"#FFF59D","mid":"#2ECC71","high":"#FF8A80","extreme":"#D50000"}[k_best] + win._tri_val.setText(char); win._tri_val.setColor(col); win._tri_val.setVisible(True) + win._tri_delta.setText(char); win._tri_delta.setColor(col); win._tri_delta.setVisible(True) + try: + x0 = p1.viewRange()[0][0] + y0 = p1.viewRange()[1][0] + y1 = p2.viewRange()[1][1] + win._tri_val.setPos(x0, y0) + win._tri_delta.setPos(x0, y1) + except Exception: + DBGEX("_plot_update_pyqtgraph set tri pos") + + except Exception: + DBGEX("_plot_update_pyqtgraph") + + + # --- [DROP-IN] otwieranie okna Matplotlib ----------------------------------- + def _open_matplotlib_window(self, key: str): + import matplotlib + matplotlib.use("Agg") + from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg + from matplotlib.figure import Figure + vi = self.vars[key] + win = tk.Toplevel(self) + win.title(f"{vi.display_name} — plot") + win.geometry("760x420") + win._key = key + win._backend = "matplotlib" - # ... wcześniej: vi = self.vars[key] - glw = pg.GraphicsLayoutWidget(title=f"{vi.display_name}") - p1 = glw.addPlot(row=0, col=0); p1.showGrid(x=True, y=True) - p2 = glw.addPlot(row=1, col=0); p2.showGrid(x=True, y=True) + tools = ttk.Frame(win); tools.pack(side=tk.TOP, fill=tk.X) + win._show_thr_var = tk.BooleanVar(value=bool(self.display_show_thresholds_var.get())) + ttk.Checkbutton(tools, text="Show thresholds", variable=win._show_thr_var).pack(side=tk.LEFT, padx=(8,6)) - curve = p1.plot([], [], pen=pg.mkPen(width=1)) - bars = p2.plot([], [], pen=None, symbol=None, fillLevel=0, - brush=(100, 100, 255, 160), stepMode=False) + ttk.Label(tools, text="Samples:").pack(side=tk.LEFT, padx=(8,2)) + win._sample_len_var = tk.IntVar(value=int(self.default_samples_var.get())) + ttk.Spinbox(tools, from_=20, to=5000, increment=10, + textvariable=win._sample_len_var, width=6).pack(side=tk.LEFT) - # linie progów – tworzone raz - glw._thr_lines = { - "dead_low": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#FBC02D", width=1), movable=False), - "low": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#FFF59D", width=1), movable=False), - "mid": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#2ECC71", width=1, style=QtCore.Qt.DotLine), movable=False), - "high": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#FF8A80", width=1), movable=False), - "extreme": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#D50000", width=1), movable=False), + ttk.Label(tools, text="Max draw pts:").pack(side=tk.LEFT, padx=(10,2)) + win._max_draw_var = tk.IntVar(value=int(self.default_maxpts_var.get())) + sp = ttk.Spinbox(tools, from_=100, to=5000, increment=50, + textvariable=win._max_draw_var, width=6) + sp.pack(side=tk.LEFT) + win._max_draw_pts = int(win._max_draw_var.get()) + sp.configure(command=lambda w=win: setattr(w, "_max_draw_pts", int(w._max_draw_var.get()))) + + fig = Figure(figsize=(8,4), dpi=100) + ax_val = fig.add_subplot(2,1,1) + ax_delta = fig.add_subplot(2,1,2, sharex=ax_val) + ax_val.set_ylabel("value"); ax_delta.set_ylabel("Δ"); ax_delta.set_xlabel("samples") + + # linie progów + win._thr_lines = { + "dead_low": ax_val.axhline(0, color="#FBC02D", linewidth=1.2, linestyle="--", visible=False), + "low": ax_val.axhline(0, color="#FFF59D", linewidth=1.2, linestyle="--", visible=False), + "mid": ax_val.axhline(0, color="#2ECC71", linewidth=1.0, linestyle=":", visible=False), + "high": ax_val.axhline(0, color="#FF8A80", linewidth=1.2, linestyle="--", visible=False), + "extreme": ax_val.axhline(0, color="#D50000", linewidth=1.2, linestyle="--", visible=False), } - for _ln in glw._thr_lines.values(): - _ln.setVisible(False); p1.addItem(_ln) - glw._tri = pg.TextItem("", color=(46,204,113)); glw._tri.setVisible(False); p1.addItem(glw._tri) + canvas = FigureCanvasTkAgg(fig, master=win) + canvas.draw(); canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) + # wskaźniki (trójkąty) + win._tri_val = ax_val.text(-0.03, -0.10, "▲", transform=ax_val.transAxes, + ha="left", va="top", color="#2ECC71", fontsize=12, + clip_on=False, visible=False) + win._tri_delta = ax_delta.text(-0.03, 1.10, "▲", transform=ax_delta.transAxes, + ha="left", va="bottom", color="#2ECC71", fontsize=12, + clip_on=False, visible=False) - # okno - glw.resize(820, 520) - glw.setWindowTitle(f"{vi.display_name} — pyqtgraph") - glw.show() - glw._key = key - glw._display_name = vi.display_name + (line,) = ax_val.plot([], [], linewidth=1.2, antialiased=False) + bars = ax_delta.bar([], [], linewidth=0, antialiased=False) - # rejestr okien - if not hasattr(self, "_plot_windows_qt"): - self._plot_windows_qt = [] - self._plot_windows_qt.append(glw) + win._fig = fig; win._ax_val = ax_val; win._ax_delta = ax_delta + win._canvas = canvas; win._line = line; win._bars = bars; win._bars_len = 0 + win._last_seen_ver = -1 + win._layout_dirty = True + canvas.mpl_connect("resize_event", lambda _evt=None: setattr(win, "_layout_dirty", True)) - # sprzątanie - def _on_close(evt): - try: - t = getattr(glw, "_timer", None) - if t: t.stop(); t.deleteLater() - except Exception: - pass - try: - self._plot_windows_qt.remove(glw) - except Exception: - pass - evt.accept() - glw.closeEvent = _on_close + if not hasattr(self, "_plot_windows"): + self._plot_windows = {} + self._plot_windows[key] = win + self._ensure_plot_timer() - # parametry rysowania + # --- [DROP-IN] aktualizacja Matplotlib -------------------------------------- + def _plot_update_matplotlib(self, win, xs_v, vals_draw, xs_b, dels_draw, thr: dict): try: - samples = int(self.default_samples_var.get()) + # wartości + win._line.set_data(xs_v, vals_draw) + if vals_draw: + vmin, vmax = min(vals_draw), max(vals_draw) + span = (vmax - vmin) or 1.0 + m = 0.08 * span + win._ax_val.set_xlim(0, max(1, len(xs_v) - 1)) + win._ax_val.set_ylim(vmin - m, vmax + m) + + # delty (słupki) + if len(dels_draw) != getattr(win, "_bars_len", 0): + for b in getattr(win, "_bars", []): + try: b.remove() + except Exception: DBGEX("_plot_update_matplotlib remove bar") + win._bars = win._ax_delta.bar(xs_b, dels_draw) + win._bars_len = len(dels_draw) + else: + for b, h in zip(win._bars, dels_draw): + b.set_height(h) + if dels_draw: + dmin, dmax = min(dels_draw), max(dels_draw) + span = (dmax - dmin) or 1.0 + m = 0.08 * span + win._ax_delta.set_xlim(0, max(1, len(dels_draw) - 1)) + win._ax_delta.set_ylim(dmin - m, dmax + m) + win._ax_delta.set_xlabel(f"last {len(vals_draw)} samples") + + # progi + wskaźnik + for ln in win._thr_lines.values(): + ln.set_visible(False) + win._tri_val.set_visible(False); win._tri_delta.set_visible(False) + + if bool(getattr(win, "_show_thr_var", self.display_show_thresholds_var).get()) and vals_draw: + vmin, vmax = win._ax_val.get_ylim() + def within(y): return (y is not None) and (vmin <= y <= vmax) + for name in ("dead_low","low","mid","high","extreme"): + y = thr.get(name) + if within(y): + win._thr_lines[name].set_ydata([y, y]) + win._thr_lines[name].set_visible(True) + + cur = vals_draw[-1] if vals_draw else None + cands = [(k, thr.get(k)) for k in ("dead_low","low","mid","high","extreme") if thr.get(k) is not None] + if (cur is not None) and cands: + k_best, y_best = min(cands, key=lambda kv: abs(kv[1]-cur)) + color_map = { + "dead_low": "#FBC02D", "low": "#FFF59D", "mid": "#2ECC71", + "high": "#FF8A80", "extreme": "#D50000", + } + col = color_map.get(k_best, "#2ECC71") + up = (y_best > cur) + char = "▲" if up else "▼" + win._tri_val.set_text(char); win._tri_val.set_color(col); win._tri_val.set_visible(True) + win._tri_delta.set_text(char); win._tri_delta.set_color(col); win._tri_delta.set_visible(True) + + if getattr(win, "_layout_dirty", False): + try: win._fig.tight_layout() + except Exception: DBGEX("_plot_update_matplotlib tight_layout") + win._layout_dirty = False + + win._canvas.draw_idle() except Exception: - samples = 100 - try: - maxpts = int(self.default_maxpts_var.get()) - except Exception: - maxpts = 500 - - def decimate(arr, m): - if len(arr) <= m: - return list(range(len(arr))), arr - step = max(1, int(math.ceil(len(arr) / m))) - arr2 = arr[::step] - xs = list(range(0, len(arr), step))[:len(arr2)] - return xs, arr2 - - # reentrancy guard + log - glw._in_update = False - def _log_qt(msg): - try: - with open("qt_plot_err.log", "a", encoding="utf-8") as f: - f.write(msg + "\n") - except Exception: - pass - - def update(): - if glw._in_update: - return - glw._in_update = True - try: - vi_local = self.vars.get(key) - if not vi_local: - return - - vals = list(vi_local.history)[-samples:] - dels = list(vi_local.history_delta)[-samples:] - xs_v, vals_d = decimate(vals, maxpts) - xs_b, dels_d = decimate(dels, maxpts) - curve.setData(xs_v, vals_d) - bars.setData(xs_b, dels_d) - - # progi i wskaźnik - for _ln in glw._thr_lines.values(): - _ln.setVisible(False) - glw._tri.setVisible(False) - - show_thr = self._safe_bool(self.display_show_thresholds_var, False) - if show_thr and vals_d: - thr = self._effective_thresholds(vi_local) - - # UWAGA: bierz zakres z danych, nie z viewRange (viewRange potrafi być zmienne w trakcie resize) - ymin, ymax = min(vals_d), max(vals_d) - def within(y): return (y is not None) and (ymin <= y <= ymax) - - for thr_name in ("dead_low","low","mid","high","extreme"): - y = thr.get(thr_name) - if within(y): - glw._thr_lines[thr_name].setPos(y) - glw._thr_lines[thr_name].setVisible(True) - - cur = parse_first_float(vi_local.last_value) - candidates = [(n, thr[n]) for n in ("dead_low","low","mid","high","extreme") if thr.get(n) is not None] - if cur is not None and candidates: - k_best, y_best = min(candidates, key=lambda kv: abs(kv[1]-cur)) - color_map = {"dead_low": (251,192,45), "low": (255,245,157), "mid": (46,204,113), - "high": (255,138,128), "extreme": (213,0,0)} - col = color_map.get(k_best, (46,204,113)) - up = (y_best > cur); char = "▲" if up else "▼" - glw._tri.setColor(col); glw._tri.setText(char) - # ustaw w bezpiecznej pozycji przy lewej krawędzi, w obrębie danych - x_left = xs_v[0] if xs_v else 0 - y_pos = ymin if up else ymax - glw._tri.setPos(x_left, y_pos) - glw._tri.setVisible(True) - - # tytuł z aktualną wartością - try: - val_num = parse_first_float(vi_local.last_value) - glw.setWindowTitle(f"{vi_local.display_name} — {val_num:.6g}" if val_num is not None else vi_local.display_name) - except Exception: - pass - - except Exception as e: - _log_qt(f"[update] {type(e).__name__}: {e}") - finally: - glw._in_update = False - - # jeden QTimer – jako child okna - glw._timer = QtCore.QTimer(glw) - try: - ms = max(80, int(float(self.refresh_var.get()) * 1000)) - except Exception: - ms = 160 - glw._timer.timeout.connect(update) - glw._timer.start(ms) - update() - + DBGEX("_plot_update_matplotlib") def count_plot_windows(self) -> int: n_tk = len([w for w in getattr(self, "_plot_windows", {}).values() if getattr(w, "winfo_exists", lambda: False)()]) @@ -3015,6 +3247,7 @@ class App(tk.Tk): raise RuntimeError("No monitors from EnumDisplayMonitors") except Exception: # Fallback: pojedynczy ekran wg Tk + DBGEX("_enum_monitors") w = self.winfo_screenwidth() h = self.winfo_screenheight() monitors = [(0, 0, w, h)] @@ -3078,7 +3311,7 @@ class App(tk.Tk): name = str(w.title() or "") tk_wins.append(("tk", w, x, y, ww, hh, name)) except Exception: - pass + DBGEX("arrange_plot_windows Tk MPL") # Zbierz okna (Tk Canvas) for w in list(getattr(self, "_plot_windows_canvas", [])): @@ -3098,7 +3331,7 @@ class App(tk.Tk): name = str(w.title() or "") tk_wins.append(("tk", w, x, y, ww, hh, name)) except Exception: - pass + DBGEX("arrange_plot_windows Tk Canvas") # Zbierz okna (Qt pyqtgraph) qt_wins = [] @@ -3115,7 +3348,7 @@ class App(tk.Tk): name = str(qw.windowTitle() or "") qt_wins.append(("qt", qw, x, y, ww, hh, name)) except Exception: - pass + DBGEX("arrange_plot_windows Qt pyqtgraph") all_wins = tk_wins + qt_wins if not all_wins: @@ -3205,7 +3438,7 @@ class App(tk.Tk): else: win.setGeometry(x, y, cell_w_adj, cell_h_adj) except Exception: - pass + DBGEX("arrange_plot_windows set geometry") i += 1 @@ -3219,6 +3452,7 @@ def parse_first_float(value: str) -> Optional[float]: try: return float(m.group(0)) except Exception: + DBGEX("parse_first_float") return None return None @@ -3266,7 +3500,7 @@ def main() -> None: # (dopasuj, jeśli trzymasz referencję gdzie indziej) app.on_close() except Exception: - pass + DBGEX("graceful_shutdown") atexit.register(_graceful_shutdown) app.mainloop()