diff --git a/nucleares_monitor/control_board_monitor.py b/nucleares_monitor/control_board_monitor.py index 9d095f3..b044fe9 100644 --- a/nucleares_monitor/control_board_monitor.py +++ b/nucleares_monitor/control_board_monitor.py @@ -23,6 +23,22 @@ import urllib.request from dataclasses import dataclass, field from datetime import datetime from typing import Dict, List, Optional, Tuple, Deque +import math # NOWE: do decymacji i obliczeń +# --- PyQtGraph availability check (PyQt5 lub PySide6 są OK) --- +import os +os.environ.setdefault("QT_LOGGING_RULES", "qt.core.qobject.connect=false") # opcjonalne wyciszenie warningów Qt + +import math + +# PyQtGraph: wykrywamy razem z jego własną warstwą Qt +_pyqtgraph_available = False +_pyqtgraph_unavailable_reason = "" +try: + import pyqtgraph as pg + from pyqtgraph.Qt import QtCore, QtWidgets # <<< ważne: ta sama warstwa co pyqtgraph + _pyqtgraph_available = True +except Exception as e: + _pyqtgraph_unavailable_reason = str(e) try: import tkinter as tk @@ -51,7 +67,7 @@ SERVER_HOST: str = "localhost" SERVER_PORT: int = 8785 REFRESH_INTERVAL_S: int = 1 REQUEST_TIMEOUT_S: float = 5.0 -USER_AGENT: str = "ControlBoardMonitor/1.14 (+tkinter)" +USER_AGENT: str = "ControlBoardMonitor/1.15 (+tkinter)" # Fallback defaults (extracted from your HTML) DEFAULT_VARS: List[str] = ['ALARMS_ACTIVE', 'AMBIENT_TEMPERATURE', 'CHEM_BORON_DOSAGE_ACTUAL', 'CHEM_BORON_DOSAGE_ORDERED', 'CHEM_BORON_FILTER_ACTUAL', 'CHEM_BORON_FILTER_ORDERED', 'CHEM_BORON_PPM', 'CHEM_TRUCK_CONNECTED', 'CHEM_TRUCK_IN_ZONE', 'CHEMICAL_CLEANING_PUMP_DRY_STATUS', 'CHEMICAL_CLEANING_PUMP_OVERLOAD_STATUS', 'CHEMICAL_CLEANING_PUMP_STATUS', 'CHEMICAL_DOSING_PUMP_DRY_STATUS', 'CHEMICAL_DOSING_PUMP_OVERLOAD_STATUS', 'CHEMICAL_DOSING_PUMP_STATUS', 'CHEMICAL_FILTER_PUMP_DRY_STATUS', 'CHEMICAL_FILTER_PUMP_OVERLOAD_STATUS', 'CHEMICAL_FILTER_PUMP_STATUS', 'CONDENSER_CIRCULATION_PUMP_ACTIVE', 'CONDENSER_CIRCULATION_PUMP_ORDERED_SPEED', 'CONDENSER_CIRCULATION_PUMP_OVERLOAD_STATUS', 'CONDENSER_CIRCULATION_PUMP_SPEED', 'CONDENSER_CIRCULATION_PUMP_SWITCH', 'CONDENSER_CONDENSATE_FLOW_RATE', 'CONDENSER_COOLANT_EVAPORATED', 'CONDENSER_EXTRACTION_FLOW_RATE', 'CONDENSER_PRESSURE', 'CONDENSER_TEMPERATURE', 'CONDENSER_VACUUM', 'CONDENSER_VACUUM_PUMP_ACTIVE', 'CONDENSER_VACUUM_PUMP_MODE', 'CONDENSER_VACUUM_PUMP_POWER', 'CONDENSER_VACUUM_RELIEF_VALVE_OPENING', 'CONDENSER_VAPOR_VOLUME', 'CONDENSER_VOLUME', 'COOLANT_CORE_CIRCULATION_PUMP_0_CAPACITY', 'COOLANT_CORE_CIRCULATION_PUMP_0_DRY_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_0_OVERLOAD_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_0_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_0_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_1_CAPACITY', 'COOLANT_CORE_CIRCULATION_PUMP_1_DRY_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_OVERLOAD_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_1_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_2_CAPACITY', 'COOLANT_CORE_CIRCULATION_PUMP_2_DRY_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_2_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_2_OVERLOAD_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_2_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_2_STATUS', 'COOLANT_CORE_FLOW_IN', 'COOLANT_CORE_FLOW_ORDERED_SPEED', 'COOLANT_CORE_FLOW_OUT', 'COOLANT_CORE_FLOW_REACHED_SPEED', 'COOLANT_CORE_FLOW_SPEED', 'COOLANT_CORE_MAX_PRESSURE', 'COOLANT_CORE_PRESSURE', 'COOLANT_CORE_PRIMARY_LOOP_LEVEL', 'COOLANT_CORE_QUANTITY_CIRCULATION_PUMPS_PRESENT', 'COOLANT_CORE_QUANTITY_FREIGHT_PUMPS_PRESENT', 'COOLANT_CORE_QUANTITY_IN_VESSEL', 'COOLANT_CORE_STATE', 'COOLANT_CORE_VESSEL_TEMPERATURE', 'COOLANT_SEC_0_LIQUID_VOLUME', 'COOLANT_SEC_0_PRESSURE', 'COOLANT_SEC_0_TEMPERATURE', 'COOLANT_SEC_0_VOLUME', 'COOLANT_SEC_1_LIQUID_VOLUME', 'COOLANT_SEC_1_PRESSURE', 'COOLANT_SEC_1_TEMPERATURE', 'COOLANT_SEC_1_VOLUME', 'COOLANT_SEC_2_LIQUID_VOLUME', 'COOLANT_SEC_2_PRESSURE', 'COOLANT_SEC_2_TEMPERATURE', 'COOLANT_SEC_2_VOLUME', 'COOLANT_SEC_CIRCULATION_PUMP_0_CAPACITY', 'COOLANT_SEC_CIRCULATION_PUMP_0_DRY_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_0_OVERLOAD_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_0_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_0_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_1_CAPACITY', 'COOLANT_SEC_CIRCULATION_PUMP_1_DRY_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_1_OVERLOAD_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_1_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_1_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_2_CAPACITY', 'COOLANT_SEC_CIRCULATION_PUMP_2_DRY_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_2_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_2_OVERLOAD_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_2_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_2_STATUS', 'CORE_BAY_1_HATCH_OPEN', 'CORE_BAY_1_STATE', 'CORE_BAY_2_HATCH_OPEN', 'CORE_BAY_2_STATE', 'CORE_BAY_3_HATCH_OPEN', 'CORE_BAY_3_STATE', 'CORE_BAY_4_HATCH_OPEN', 'CORE_BAY_4_STATE', 'CORE_BAY_5_HATCH_OPEN', 'CORE_BAY_5_STATE', 'CORE_BAY_6_HATCH_OPEN', 'CORE_BAY_6_STATE', 'CORE_BAY_7_HATCH_OPEN', 'CORE_BAY_7_STATE', 'CORE_BAY_8_HATCH_OPEN', 'CORE_BAY_8_STATE', 'CORE_BAY_9_HATCH_OPEN', 'CORE_BAY_9_STATE', 'CORE_CRITICAL_MASS_REACHED', 'CORE_CRITICAL_MASS_REACHED_COUNTER', 'CORE_EXTERNAL_COOLANT_RESERVOIR_VOLUME', 'CORE_FACTOR', 'CORE_FACTOR_CHANGE', 'CORE_FUEL_1_FISSIONABLE', 'CORE_FUEL_1_POWER_FACTOR', 'CORE_FUEL_1_TEMPERATURE', 'CORE_FUEL_2_FISSIONABLE', 'CORE_FUEL_2_POWER_FACTOR', 'CORE_FUEL_2_TEMPERATURE', 'CORE_FUEL_3_FISSIONABLE', 'CORE_FUEL_3_POWER_FACTOR', 'CORE_FUEL_3_TEMPERATURE', 'CORE_FUEL_4_FISSIONABLE', 'CORE_FUEL_4_POWER_FACTOR', 'CORE_FUEL_4_TEMPERATURE', 'CORE_FUEL_5_FISSIONABLE', 'CORE_FUEL_5_POWER_FACTOR', 'CORE_FUEL_5_TEMPERATURE', 'CORE_FUEL_6_FISSIONABLE', 'CORE_FUEL_6_POWER_FACTOR', 'CORE_FUEL_6_TEMPERATURE', 'CORE_FUEL_7_FISSIONABLE', 'CORE_FUEL_7_POWER_FACTOR', 'CORE_FUEL_7_TEMPERATURE', 'CORE_FUEL_8_FISSIONABLE', 'CORE_FUEL_8_POWER_FACTOR', 'CORE_FUEL_8_TEMPERATURE', 'CORE_FUEL_9_FISSIONABLE', 'CORE_FUEL_9_POWER_FACTOR', 'CORE_FUEL_9_TEMPERATURE', 'CORE_FUEL_AVG_FISSIONABLE', 'CORE_FUEL_AVG_POWER_FACTOR', 'CORE_FUEL_AVG_TEMPERATURE', 'CORE_HIGH_STEAM_PRESENT', 'CORE_IMMINENT_FUSION', 'CORE_INTEGRITY', 'CORE_IODINE_CUMULATIVE', 'CORE_IODINE_GENERATION', 'CORE_OPERATION_MODE', 'CORE_POOL_COOLANT_TANK_VOLUME', 'CORE_POOL_PUMP', 'CORE_PRESSURE', 'CORE_PRESSURE_MAX', 'CORE_PRESSURE_OPERATIVE', 'CORE_PRIMARY_CIRCUIT_COOLING_TANK_VOLUME', 'CORE_READY_FOR_START', 'CORE_STATE', 'CORE_STATE_CRITICALITY', 'CORE_STEAM_PRESENT', 'CORE_TEMP', 'CORE_TEMP_MAX', 'CORE_TEMP_MIN', 'CORE_TEMP_OPERATIVE', 'CORE_TEMP_RESIDUAL', 'CORE_WEAR', 'CORE_XENON_CUMULATIVE', 'CORE_XENON_GENERATION', 'EMERGENCY_BATTERIES_MODE', 'EMERGENCY_BATTERIES_POWER_OUTPUT_KW', 'EMERGENCY_GENERATOR_1_FUEL', 'EMERGENCY_GENERATOR_1_MAINTENANCE_NEEDED', 'EMERGENCY_GENERATOR_1_MODE', 'EMERGENCY_GENERATOR_1_PRESSURIZER', 'EMERGENCY_GENERATOR_1_STATUS', 'EMERGENCY_GENERATOR_2_FUEL', 'EMERGENCY_GENERATOR_2_MAINTENANCE_NEEDED', 'EMERGENCY_GENERATOR_2_MODE', 'EMERGENCY_GENERATOR_2_PRESSURIZER', 'EMERGENCY_GENERATOR_2_STATUS', 'EMERGENCY_GENERATOR_POWER_OUTPUT_KW', 'FREIGHT_PUMP_CONDENSER_ACTIVE', 'FREIGHT_PUMP_CONDENSER_SWITCH', 'FREIGHT_PUMP_EXTERNAL_ACTIVE', 'FREIGHT_PUMP_EXTERNAL_SWITCH', 'FREIGHT_PUMP_FEEDWATER_ACTIVE', 'FREIGHT_PUMP_FEEDWATER_SWITCH', 'FREIGHT_PUMP_INTERNAL_ACTIVE', 'FREIGHT_PUMP_INTERNAL_SWITCH', 'FUN_IS_ENABLED', 'GAME_DIFFICULTY', 'GAME_SIM_SPEED', 'GAME_VERSION', 'GENERATOR_0_A', 'GENERATOR_0_BREAKER', 'GENERATOR_0_HERTZ', 'GENERATOR_0_KW', 'GENERATOR_0_V', 'GENERATOR_1_A', 'GENERATOR_1_BREAKER', 'GENERATOR_1_HERTZ', 'GENERATOR_1_KW', 'GENERATOR_1_V', 'GENERATOR_2_A', 'GENERATOR_2_BREAKER', 'GENERATOR_2_HERTZ', 'GENERATOR_2_KW', 'GENERATOR_2_V', 'INSTALLED_LOOPS_JSON', 'INVENTORY_HTML', 'MAINTENANCE_REPORT_HTML', 'MSCV_0_OPENING_ACTUAL', 'MSCV_1_OPENING_ACTUAL', 'MSCV_2_OPENING_ACTUAL', 'POWER_DEMAND_MW', 'POWER_FROM_EXTERNAL_KW', 'POWER_FROM_TURBINE_KW', 'POWER_MAX_THEORETICAL_FINAL_PLANT_OUTPUT_MW', 'POWER_MAX_THEORETICAL_PLANT_OUTPUT_MW', 'RES_ABSORPTION_CAPACITY_MW', 'RES_DIVERT_SURPLUS_FROM_MW', 'RES_EFFECTIVELY_DERIVED_ENERGY_MW', 'RESISTOR_BANK_01_SWITCH', 'RESISTOR_BANK_02_SWITCH', 'RESISTOR_BANK_03_SWITCH', 'RESISTOR_BANK_04_SWITCH', 'RESISTOR_BANKS_JSON', 'RESISTOR_BANKS_MAIN_SWITCH', 'ROD_BANK_POS_0_ACTUAL', 'ROD_BANK_POS_0_ORDERED', 'ROD_BANK_POS_1_ACTUAL', 'ROD_BANK_POS_1_ORDERED', 'ROD_BANK_POS_2_ACTUAL', 'ROD_BANK_POS_2_ORDERED', 'ROD_BANK_POS_3_ACTUAL', 'ROD_BANK_POS_3_ORDERED', 'ROD_BANK_POS_4_ACTUAL', 'ROD_BANK_POS_4_ORDERED', 'ROD_BANK_POS_5_ACTUAL', 'ROD_BANK_POS_5_ORDERED', 'ROD_BANK_POS_6_ACTUAL', 'ROD_BANK_POS_6_ORDERED', 'ROD_BANK_POS_7_ACTUAL', 'ROD_BANK_POS_7_ORDERED', 'ROD_BANK_POS_8_ACTUAL', 'ROD_BANK_POS_8_ORDERED', 'RODS_ALIGNED', 'RODS_DEFORMED', 'RODS_MAX_TEMPERATURE', 'RODS_MOVEMENT_SPEED', 'RODS_MOVEMENT_SPEED_DECREASED_HIGH_TEMPERATURE', 'RODS_POS_ACTUAL', 'RODS_POS_ORDERED', 'RODS_POS_REACHED', 'RODS_QUANTITY', 'RODS_STATUS', 'RODS_TEMPERATURE', 'STEAM_EJECTOR_CONDENSER_RETURN_VALVE_ACTUAL', 'STEAM_EJECTOR_CONDENSER_RETURN_VALVE_ORDERED', 'STEAM_EJECTOR_MOTIVE', 'STEAM_EJECTOR_OPERATIONAL_MOTIVE_VALVE_ACTUAL', 'STEAM_EJECTOR_OPERATIONAL_MOTIVE_VALVE_ORDERED', 'STEAM_EJECTOR_STARTUP_MOTIVE_VALVE_ACTUAL', 'STEAM_EJECTOR_STARTUP_MOTIVE_VALVE_ORDERED', 'STEAM_GEN_0_BOILING_POINT', 'STEAM_GEN_0_EVAPORATED', 'STEAM_GEN_0_INLET', 'STEAM_GEN_0_OUTLET', 'STEAM_GEN_0_RETURN_FLOW_PLUS_CONDENSED', 'STEAM_GEN_0_STATUS', 'STEAM_GEN_0_VENT_SWITCH', 'STEAM_GEN_1_BOILING_POINT', 'STEAM_GEN_1_EVAPORATED', 'STEAM_GEN_1_INLET', 'STEAM_GEN_1_OUTLET', 'STEAM_GEN_1_RETURN_FLOW_PLUS_CONDENSED', 'STEAM_GEN_1_STATUS', 'STEAM_GEN_1_VENT_SWITCH', 'STEAM_GEN_2_BOILING_POINT', 'STEAM_GEN_2_EVAPORATED', 'STEAM_GEN_2_INLET', 'STEAM_GEN_2_OUTLET', 'STEAM_GEN_2_RETURN_FLOW_PLUS_CONDENSED', 'STEAM_GEN_2_STATUS', 'STEAM_GEN_2_VENT_SWITCH', 'STEAM_TURBINE_0_BYPASS_ACTUAL', 'STEAM_TURBINE_0_INSTALLED', 'STEAM_TURBINE_0_PRESSURE', 'STEAM_TURBINE_0_RPM', 'STEAM_TURBINE_0_TEMPERATURE', 'STEAM_TURBINE_0_TORQUE', 'STEAM_TURBINE_1_BYPASS_ACTUAL', 'STEAM_TURBINE_1_INSTALLED', 'STEAM_TURBINE_1_PRESSURE', 'STEAM_TURBINE_1_RPM', 'STEAM_TURBINE_1_TEMPERATURE', 'STEAM_TURBINE_1_TORQUE', 'STEAM_TURBINE_2_BYPASS_ACTUAL', 'STEAM_TURBINE_2_INSTALLED', 'STEAM_TURBINE_2_PRESSURE', 'STEAM_TURBINE_2_RPM', 'STEAM_TURBINE_2_TEMPERATURE', 'STEAM_TURBINE_2_TORQUE', 'TIME', 'TIME_DAY', 'TIME_STAMP', 'VACUUM_RETENTION_TANK_PRESSURE', 'VACUUM_RETENTION_TANK_VOLUME', 'VALVE_M01_OPEN', 'VALVE_M02_OPEN', 'VALVE_M03_OPEN', 'VALVE_PANEL_JSON', 'WEATHER_FORECAST_JSON', 'WEBSERVER_BATCH_GET', 'WEBSERVER_LIST_VARIABLES', 'WEBSERVER_LIST_VARIABLES_JSON', 'WEBSERVER_VIEW_VARIABLES'] @@ -322,6 +338,7 @@ class VarInfo: delta_last: Optional[float] = None history: Deque[float] = field(default_factory=lambda: deque(maxlen=3600)) history_delta: Deque[float] = field(default_factory=lambda: deque(maxlen=3600)) + hist_ver: int = 0 # rośnie przy każdym append do history (optymalizacja pod ploty) @dataclass class ActionTask: @@ -535,7 +552,7 @@ class Poller(threading.Thread): class App(tk.Tk): def __init__(self) -> None: super().__init__() - self.title("Control Board Monitor — v1.14") + self.title("Control Board Monitor — v1.15") self.geometry("1280x860") # Canonical registry: lower-case key -> VarInfo @@ -664,9 +681,35 @@ class App(tk.Tk): self.cycle_lbl.pack(side=tk.LEFT) self.protocol("WM_DELETE_WINDOW", self.on_close) + # DOMYŚLNE USTAWIENIA WYŚWIETLANIA (trzymane w tk.Variable, będą też zapisane w konfigu) + self.display_backend_var = tk.StringVar(value="matplotlib") # "matplotlib" | "canvas" | "pyqtgraph" + self.default_samples_var = tk.IntVar(value=200) # domyślna liczba próbek na wykres + self.default_maxpts_var = tk.IntVar(value=400) # ile maks. punktów realnie rysować - # Menubar - menubar = tk.Menu(self) + # Menu „Opcje” -> „Display” + menubar = tk.Menu(self) # <-- najpierw twórz menubar + + opmenu = tk.Menu(menubar, tearoff=0) + display_menu = tk.Menu(opmenu, tearoff=0) + + display_menu.add_radiobutton(label="Backend: Matplotlib", + variable=self.display_backend_var, value="matplotlib") + display_menu.add_radiobutton(label="Backend: Canvas (lite)", + variable=self.display_backend_var, value="canvas") + display_menu.add_radiobutton(label="Backend: PyQtGraph", + variable=self.display_backend_var, value="pyqtgraph", + state=("normal" if _pyqtgraph_available else "disabled")) + display_menu.add_separator() + + display_menu.add_command(label="Display Defaults…", + command=self.open_display_defaults_dialog) + # Sklej podmenu Display pod „Opcje” + opmenu.add_cascade(label="Display", menu=display_menu) + opmenu.add_separator() + opmenu.add_command(label="Arrange plot windows", command=self.arrange_plot_windows) + opmenu.add_command(label="Count plot windows", command=lambda: tk.messagebox.showinfo("Plots", f"Open plots: {self.count_plot_windows()}")) + + # Reszta menubar filemenu = tk.Menu(menubar, tearoff=False) filemenu.add_command(label="Save Configuration...", command=self.save_config) filemenu.add_command(label="Load Configuration...", command=self.load_config) @@ -683,8 +726,12 @@ class App(tk.Tk): funmenu.add_command(label="Reload Discovery", command=self.reload_discovery) funmenu.add_command(label="Select Variables…", command=self.open_selector) menubar.add_cascade(label="Function", menu=funmenu) - self.config(menu=menubar) + # Dodaj „Opcje” do menubar na końcu + menubar.add_cascade(label="Opcje", menu=opmenu) + + # Podłącz menubar do okna + self.config(menu=menubar) self.functions_list = list(DEFAULT_FUNCTIONS) self.func_combo["values"] = self.functions_list @@ -1110,17 +1157,16 @@ class App(tk.Tk): if focus_before and self.actions_tree.exists(focus_before): self.actions_tree.focus(focus_before) def _ensure_plot_timer(self): - if self._plot_timer is None: - # odświeżanie wykresów zsynchronizowane z głównym intervalem + if getattr(self, "_plot_timer", None) is None: try: - ms = int(max(100, float(self.refresh_var.get()) * 1000)) + ms = int(max(150, float(self.refresh_var.get()) * 1000)) # >= ~6 FPS except Exception: - ms = 500 + ms = 300 self._plot_timer = self.after(ms, self._plot_tick) def _plot_tick(self): - # jeśli nie ma żadnych okien – zatrzymaj timer - windows = [w for w in getattr(self, "_plot_windows", {}).values() if w.winfo_exists()] + windows = [w for w in getattr(self, "_plot_windows", {}).values() + if w.winfo_exists() and getattr(w, "_canvas", None)] if not windows: self._plot_timer = None return @@ -1130,44 +1176,79 @@ class App(tk.Tk): vi = self.vars.get(key) if not vi: continue + # ustaw tytuł okna: nazwa + aktualna wartość (bez nadmiernego odświeżania) + try: + 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) + win._last_title = new_title + except Exception: + pass - # ostatnie 100 próbek - vals = list(vi.history)[PLOT_SCALE:] if hasattr(vi, "history") else [] - dels = list(vi.history_delta)[PLOT_SCALE:] if hasattr(vi, "history_delta") else [] + # pomiń jeśli brak nowych danych od ostatniego rysowania + last_ver = getattr(win, "_last_seen_ver", -1) + if getattr(vi, "hist_ver", 0) == last_ver: + continue - # --- GÓRNY wykres: linia --- - xs_v = list(range(len(vals))) - win._line.set_data(xs_v, vals) - if vals: - vmin, vmax = min(vals), max(vals) + # pobierz dane (per-okno długość próbek) + try: + n = int(win._sample_len_var.get()) + except Exception: + n = 200 + 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 + + 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))) + + 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) - win._ax_val.set_ylabel("value") - # --- DOLNY wykres: słupki (bez ponownego tworzenia barów jeśli długość ta sama) --- - if len(dels) != win._bars_len: - # przebuduj tylko gdy zmienia się liczba słupków + # 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(range(len(dels)), dels) - win._bars_len = len(dels) + win._bars = win._ax_delta.bar(xs_b, dels_draw) + win._bars_len = len(dels_draw) else: - # szybka ścieżka – zaktualizuj wysokości - for b, h in zip(win._bars, dels): + for b, h in zip(win._bars, dels_draw): b.set_height(h) - if dels: - dmin, dmax = min(dels), max(dels) + 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) - 1)) + 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_ylabel("Δ") - win._ax_delta.set_xlabel("last 100 samples") - # tight_layout tylko po resize (ustawiane flagą) + 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() @@ -1175,17 +1256,18 @@ class App(tk.Tk): pass win._layout_dirty = False - # draw_idle = koalescencja try: win._canvas.draw_idle() except Exception: pass - # zaplanuj kolejny tick + win._last_seen_ver = getattr(vi, "hist_ver", win._last_seen_ver) + + # kolejny tick try: - ms = int(max(100, float(self.refresh_var.get()) * 1000)) + ms = int(max(150, float(self.refresh_var.get()) * 1000)) except Exception: - ms = 500 + ms = 300 self._plot_timer = self.after(ms, self._plot_tick) # --- Poller control / rendering --- @@ -1272,6 +1354,7 @@ class App(tk.Tk): if curr_num is not None: try: vi.history.append(curr_num) + vi.hist_ver += 1 # NOWE: sygnał dla wykresów, że są nowe dane except Exception: pass self.vars[key] = vi @@ -1319,76 +1402,132 @@ class App(tk.Tk): self.tree.selection_set(iid) finally: self.tree_menu.tk_popup(event.x_root, event.y_root) - def open_plot_window(self): - """Open (or focus) a per-variable plot window with last 100 values and last 100 deltas. - Top: values line + horizontal mean; Bottom: delta bars + horizontal mean. - Each subplot has its own normalized y-scale. Auto-refreshes every ~1s. - """ - sel = self.tree.selection() - if not sel: - messagebox.showinfo("No selection", "Select a variable first.") + + + 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: + tk.messagebox.showwarning("Plot", "Najpierw zaznacz zmienną w tabeli.") + return + iid = sel[0] + if not iid.startswith("var:"): + tk.messagebox.showwarning("Plot", "Wybierz wiersz ze zmienną (nie grupę).") + return + key = iid.split("var:", 1)[1] + else: + key = str(var_key).lower() + if key not in self.vars: + for k, vi in self.vars.items(): + if vi.display_name.lower() == str(var_key).lower(): + key = k + break + + if key not in self.vars: + tk.messagebox.showwarning("Plot", f"Zmienna '{var_key or key}' nie jest monitorowana.") return - iid = sel[0] - if not iid.startswith("var:"): - messagebox.showinfo("Select variable", "Select a specific variable row (not a group).") - return - key = iid.split("var:", 1)[1] - vi = self.vars.get(key) - if not vi: - messagebox.showinfo("Missing", "Selected variable is not available yet.") - return - if not HAS_MPL: - messagebox.showwarning("Plotting not available", - "Matplotlib is not installed. Install 'matplotlib' to enable plots.") + + backend = self.display_backend_var.get().lower() + if backend == "canvas": + self._open_canvas_window(key); return + if backend == "pyqtgraph": + if _pyqtgraph_available: + 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) + 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") + + canvas = FigureCanvasTkAgg(fig, master=win) + canvas.draw(); canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) + + (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_timer = None # globalny timer do wykresów - - w = self._plot_windows.get(key) - if w and w.winfo_exists(): - try: - w.lift(); w.focus_force() - return - except Exception: - pass - - win = tk.Toplevel(self) - win.title(f"Plot — {vi.display_name}") - win.geometry("820x520") - win.transient(self) - - fig = Figure(figsize=(8, 5), dpi=100) - ax_val = fig.add_subplot(211) - ax_delta = fig.add_subplot(212, sharex=ax_val) - - canvas = FigureCanvasTkAgg(fig, master=win) - canvas.draw() - # ... masz już fig, ax_val, ax_delta, canvas ... - canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) - self._plot_windows[key] = win - win._fig = fig - win._ax_val = ax_val - win._ax_delta = ax_delta - win._canvas = canvas - win._key = key - win._layout_dirty = True - - # prealokuj „artystów” – jedna linia i zestaw słupków - (win._line,) = ax_val.plot([], [], linewidth=1.2) - win._bars = ax_delta.bar([], []) - win._bars_len = 0 # ile aktualnie słupków - - # tight_layout tylko po resize - def _on_resize(_evt=None): - win._layout_dirty = True - canvas.mpl_connect("resize_event", _on_resize) - - # uruchom globalny tick jeśli nie działa self._ensure_plot_timer() + + + def open_display_defaults_dialog(self): + dlg = tk.Toplevel(self) + dlg.title("Display defaults") + dlg.geometry("360x220") + dlg.transient(self) + dlg.grab_set() + + # Backend + ttk.Label(dlg, text="Default backend:").pack(anchor="w", padx=10, pady=(10,2)) + back = tk.StringVar(value=self.display_backend_var.get()) + frm = ttk.Frame(dlg); frm.pack(anchor="w", padx=10) + ttk.Radiobutton(frm, text="Matplotlib", variable=back, value="matplotlib").pack(side=tk.LEFT, padx=(0,10)) + ttk.Radiobutton(frm, text="Canvas (lite)", variable=back, value="canvas").pack(side=tk.LEFT, padx=(0,10)) + state = "normal" if _pyqtgraph_available else "disabled" + ttk.Radiobutton(frm, text="PyQtGraph", variable=back, value="pyqtgraph", state=state).pack(side=tk.LEFT) + + # Samples / max points + box = ttk.Frame(dlg); box.pack(fill=tk.X, padx=10, pady=(12,2)) + ttk.Label(box, text="Default samples:").grid(row=0, column=0, sticky="w") + ds = tk.IntVar(value=int(self.default_samples_var.get())) + ttk.Spinbox(box, from_=20, to=5000, increment=10, textvariable=ds, width=6).grid(row=0, column=1, padx=6) + + ttk.Label(box, text="Default max draw pts:").grid(row=1, column=0, sticky="w", pady=(6,0)) + md = tk.IntVar(value=int(self.default_maxpts_var.get())) + ttk.Spinbox(box, from_=100, to=5000, increment=50, textvariable=md, width=6).grid(row=1, column=1, padx=6, pady=(6,0)) + + # Buttons + bar = ttk.Frame(dlg); bar.pack(fill=tk.X, pady=12, padx=10) + def apply(): + self.display_backend_var.set(back.get()) + self.default_samples_var.set(int(ds.get())) + self.default_maxpts_var.set(int(md.get())) + dlg.destroy() + tk.messagebox.showinfo("Display defaults", + "Zapisz te ustawienia na stałe przez File → Save Configuration.\n" + "Nowe wykresy będą używać tych domyślnych wartości.") + ttk.Button(bar, text="OK", command=apply).pack(side=tk.RIGHT, padx=6) + ttk.Button(bar, text="Cancel", command=dlg.destroy).pack(side=tk.RIGHT) + def open_thresholds_dialog(self): sel = self.tree.selection() expr_xsrc_vars = {} @@ -2158,6 +2297,12 @@ class App(tk.Tk): "vars": {k: self._serialize_varinfo(v) for k,v in self.vars.items() if k in self.variables_keys}, "scheduled_tasks": [self._serialize_task(t) for t in self.scheduler.list_tasks()], } + cfg["display_defaults"] = { + "backend": self.display_backend_var.get(), + "samples": int(self.default_samples_var.get()), + "max_draw_pts": int(self.default_maxpts_var.get()), + } + path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON","*.json")], title="Save Configuration") if not path: return import json @@ -2185,6 +2330,20 @@ class App(tk.Tk): vi = self._deserialize_varinfo(k, data) new_vars[k.lower()] = vi self.vars = new_vars + dd = cfg.get("display_defaults", {}) + try: + self.display_backend_var.set(dd.get("backend", "matplotlib")) + except Exception: + pass + try: + self.default_samples_var.set(int(dd.get("samples", 200))) + except Exception: + pass + try: + self.default_maxpts_var.set(int(dd.get("max_draw_pts", 400))) + except Exception: + pass + # replace scheduled tasks for t in [t.task_id for t in self.scheduler.list_tasks()]: self.scheduler.remove_task(t) @@ -2286,6 +2445,434 @@ class App(tk.Tk): except Exception: pass self.destroy() + 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._key = key + + tools = ttk.Frame(win); tools.pack(side=tk.TOP, fill=tk.X) + 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())) + 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) + + # dane do rysowania + win._line_items = [] # lista segmentów linii + win._bar_items = [] # lista prostokątów + + 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 + + # 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) + + def _on_close_canvas(): + try: + self._plot_windows_canvas.remove(win) + except Exception: + pass + win.destroy() + + win.protocol("WM_DELETE_WINDOW", _on_close_canvas) + + # 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 + + vals = list(vi_local.history)[-n:] + dels = list(vi_local.history_delta)[-n:] + + # 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 + + 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 + try: + ms = int(max(120, float(self.refresh_var.get())*1000)) + except Exception: + ms = 250 + win.after(ms, _tick) + + _tick() + def _open_pyqtgraph_window(self, key: str): + if not _pyqtgraph_available: + tk.messagebox.showwarning("PyQtGraph", f"Niedostępny: {_pyqtgraph_unavailable_reason or 'brak pyqtgraph/Qt'}") + return + + import pyqtgraph as pg + from pyqtgraph.Qt import QtCore, QtWidgets + + # Jedna aplikacja Qt na cały proces (tworzymy 1x) + if not hasattr(self, "_qt_app") or self._qt_app is None: + self._qt_app = pg.mkQApp("Control Board Monitor — plots") + + 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) + + curve = p1.plot([], [], pen=pg.mkPen(width=1)) + bars = p2.plot([], [], pen=None, symbol=None, fillLevel=0, + brush=(100, 100, 255, 160), stepMode=False) + + glw.resize(820, 520) + glw.setWindowTitle(f"{vi.display_name} — pyqtgraph") + glw.show() + # identyfikatory dla układarki + glw._key = key + glw._display_name = vi.display_name + + # Rejestr okien Qt do układania + if not hasattr(self, "_plot_windows_qt"): + self._plot_windows_qt = [] + self._plot_windows_qt.append(glw) + + # Usunięcie z rejestru przy zamknięciu + def _on_close(evt): + try: + self._plot_windows_qt.remove(glw) + except Exception: + pass + evt.accept() + glw.closeEvent = _on_close + + # Parametry rysowania + samples = int(self.default_samples_var.get()) + maxpts = int(self.default_maxpts_var.get()) + + # Timer Qt **z rodzicem** (wiąże z wątkiem GUI; nie zniknie przez GC) + glw._timer = QtCore.QTimer(glw) + + 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 + + def update(): + if key not in self.vars: + return + vi_local = self.vars[key] + 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) + + # 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 + + 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() + + 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)()]) + n_qt = len([w for w in getattr(self, "_plot_windows_qt", []) if getattr(w, "isVisible", lambda: False)()]) + return n_tk + n_qt + + # --- helpers: monitory (Windows) + fallback --- + def _enum_monitors(self): + monitors = [] + try: + import ctypes + from ctypes import wintypes + + class RECT(ctypes.Structure): + _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long), + ("right", ctypes.c_long), ("bottom", ctypes.c_long)] + + MONITORENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.c_ulong, ctypes.c_ulong, ctypes.POINTER(RECT), ctypes.c_double) + user32 = ctypes.windll.user32 + + def _cb(hMon, hDC, lprc, dwData): + r = lprc.contents + monitors.append((int(r.left), int(r.top), int(r.right), int(r.bottom))) + return 1 + + user32.EnumDisplayMonitors(0, 0, MONITORENUMPROC(_cb), 0) + if not monitors: + raise RuntimeError("No monitors from EnumDisplayMonitors") + except Exception: + # Fallback: pojedynczy ekran wg Tk + w = self.winfo_screenwidth() + h = self.winfo_screenheight() + monitors = [(0, 0, w, h)] + return monitors + + def _win_center(self, x, y, w, h): + return (x + w // 2, y + h // 2) + + def _which_monitor(self, monitors, x, y, w, h): + cx, cy = self._win_center(x, y, w, h) + best = 0; best_d = float("inf") + for i, (L, T, R, B) in enumerate(monitors): + if L <= cx <= R and T <= cy <= B: + return i + # odległość do środka monitora + mcx, mcy = (L + R) // 2, (T + B) // 2 + d = (mcx - cx) ** 2 + (mcy - cy) ** 2 + if d < best_d: + best, best_d = i, d + return best + + def _best_grid(self, n): + if n <= 0: + return (1, 1) + c = int(math.ceil(math.sqrt(n))) + r = int(math.ceil(n / c)) + return (r, c) + + def arrange_plot_windows(self): + """ + Układa wszystkie otwarte okna wykresów (Tk-Matplotlib, Tk-Canvas, Qt-PyQtGraph) + w siatki na poszczególnych monitorach. + + Zasady: + - grupujemy okna wg monitora, NA KTÓRYM AKTUALNIE SĄ otwarte, + - w każdej grupie sortujemy alfabetycznie wg nazwy zmiennej (display_name), + - wypełniamy siatkę od lewej do prawej, potem kolejny wiersz, + - jeśli n>=9 na danym monitorze → siatka pełnoekranowa, + jeśli n<9 → okna max ~połowa domyślnego rozmiaru (dodatkowo skalujemy w dół, gdy trzeba), + - zostawiamy marginesy na krawędziach (TOP_MARGIN, SIDE/BOTTOM), żeby tytuły nie „wychodziły” poza ekran. + """ + monitors = self._enum_monitors() + + # Zbierz okna (Tk MPL) + tk_wins = [] + for w in list(getattr(self, "_plot_windows", {}).values()): + if getattr(w, "winfo_exists", lambda: False)(): + try: + x = w.winfo_rootx(); y = w.winfo_rooty() + ww = w.winfo_width(); hh = w.winfo_height() + if ww <= 1 or hh <= 1: + geo = w.geometry() # "WxH+X+Y" + parts = geo.replace("x", "+").split("+") + ww = int(parts[0]); hh = int(parts[1]); x = int(parts[2]); y = int(parts[3]) + key = getattr(w, "_key", None) + name = None + if key and key in self.vars: + name = self.vars[key].display_name + if not name: + # fallback: tytuł okna + name = str(w.title() or "") + tk_wins.append(("tk", w, x, y, ww, hh, name)) + except Exception: + pass + + # Zbierz okna (Tk Canvas) + for w in list(getattr(self, "_plot_windows_canvas", [])): + if getattr(w, "winfo_exists", lambda: False)(): + try: + x = w.winfo_rootx(); y = w.winfo_rooty() + ww = w.winfo_width(); hh = w.winfo_height() + if ww <= 1 or hh <= 1: + geo = w.geometry() + parts = geo.replace("x", "+").split("+") + ww = int(parts[0]); hh = int(parts[1]); x = int(parts[2]); y = int(parts[3]) + key = getattr(w, "_key", None) + name = None + if key and key in self.vars: + name = self.vars[key].display_name + if not name: + name = str(w.title() or "") + tk_wins.append(("tk", w, x, y, ww, hh, name)) + except Exception: + pass + + # Zbierz okna (Qt pyqtgraph) + qt_wins = [] + for qw in list(getattr(self, "_plot_windows_qt", [])): + if getattr(qw, "isVisible", lambda: False)(): + try: + fg = qw.frameGeometry() + x = fg.x(); y = fg.y(); ww = fg.width(); hh = fg.height() + key = getattr(qw, "_key", None) + name = getattr(qw, "_display_name", None) + if not name and key and key in self.vars: + name = self.vars[key].display_name + if not name: + name = str(qw.windowTitle() or "") + qt_wins.append(("qt", qw, x, y, ww, hh, name)) + except Exception: + pass + + all_wins = tk_wins + qt_wins + if not all_wins: + tk.messagebox.showinfo("Arrange", "Brak otwartych okien wykresów.") + return + + # Grupowanie po monitorach (wg aktualnej pozycji) + groups = {i: [] for i in range(len(monitors))} + for kind, win, x, y, ww, hh, name in all_wins: + mid = self._which_monitor(monitors, x, y, ww, hh) + groups[mid].append((kind, win, name)) + + # USTAWIENIA rozmiaru/marginesów + DEF_W, DEF_H = 760, 420 + MAX_W, MAX_H = DEF_W // 2, DEF_H // 2 + SIDE_MARGIN = 12 + TOP_MARGIN = 40 # większy top, żeby tytuły Qt nie wychodziły + BOTTOM_MARGIN = 12 + CELL_PAD = 10 # odstęp między kratkami + + # Helper: najlepsza siatka (r,c) + def best_grid(n): + if n <= 0: return (1, 1) + c = int(math.ceil(math.sqrt(n))) + r = int(math.ceil(n / c)) + return (r, c) + + for midx, items in groups.items(): + if not items: + continue + + # Sortowanie alfabetycznie wg nazwy zmiennej (display_name) + items.sort(key=lambda it: (str(it[2]).lower(), str(it[2]))) + + L, T, R, B = monitors[midx] + mon_w = R - L + mon_h = B - T + + n = len(items) + rows, cols = best_grid(n) + full = (n >= 9) + + # wstępny wymiar kratki + if full: + cell_w = max(1, (mon_w - (cols + 1) * CELL_PAD - 2 * SIDE_MARGIN) // cols) + cell_h = max(1, (mon_h - (rows + 1) * CELL_PAD - TOP_MARGIN - BOTTOM_MARGIN) // rows) + else: + cell_w = min(MAX_W, max(1, (mon_w - (cols + 1) * CELL_PAD - 2 * SIDE_MARGIN) // cols)) + cell_h = min(MAX_H, max(1, (mon_h - (rows + 1) * CELL_PAD - TOP_MARGIN - BOTTOM_MARGIN) // rows)) + + # dopasowanie, jeśli dalej nie mieści + while (cell_w * cols + (cols + 1) * CELL_PAD + 2 * SIDE_MARGIN) > mon_w or \ + (cell_h * rows + (rows + 1) * CELL_PAD + TOP_MARGIN + BOTTOM_MARGIN) > mon_h: + cell_w = max(120, int(cell_w * 0.9)) + cell_h = max(100, int(cell_h * 0.9)) + if cell_w <= 120 and cell_h <= 100: + break + + # rozmiar całej siatki + pozycja startowa (centrowanie) + grid_w = cell_w * cols + (cols + 1) * CELL_PAD + grid_h = cell_h * rows + (rows + 1) * CELL_PAD + origin_x = L + SIDE_MARGIN + max(0, (mon_w - grid_w - 2 * SIDE_MARGIN) // 2) + origin_y = T + TOP_MARGIN + max(0, (mon_h - grid_h - TOP_MARGIN - BOTTOM_MARGIN) // 2) + + # „bezpieczny” rozmiar (obcięty o mały bufor, różne dekoracje) + cell_w_adj = max(50, cell_w - 8) + cell_h_adj = max(50, cell_h - 12) + + # Rozmieszczaj od lewej do prawej, potem kolejny wiersz + i = 0 + for c in range(cols): + for r in range(rows): + if i >= n: + break + kind, win, _nm = items[i] + + x = origin_x + CELL_PAD + c * (cell_w + CELL_PAD) + y = origin_y + CELL_PAD + r * (cell_h + CELL_PAD) + + # „clamp” do granic ekranu (z marginesami) + x = max(L + SIDE_MARGIN, min(x, R - SIDE_MARGIN - cell_w_adj)) + y = max(T + TOP_MARGIN, min(y, B - BOTTOM_MARGIN - cell_h_adj)) + + try: + if kind == "tk": + win.geometry(f"{cell_w_adj}x{cell_h_adj}+{x}+{y}") + else: + win.setGeometry(x, y, cell_w_adj, cell_h_adj) + except Exception: + pass + i += 1 + + # --- Utils --- def parse_first_float(value: str) -> Optional[float]: diff --git a/nucleares_monitor/mon.json b/nucleares_monitor/mon.json index aaeb444..37ac3cc 100644 --- a/nucleares_monitor/mon.json +++ b/nucleares_monitor/mon.json @@ -873,5 +873,10 @@ } } }, - "scheduled_tasks": [] + "scheduled_tasks": [], + "display_defaults": { + "backend": "pyqtgraph", + "samples": 200, + "max_draw_pts": 400 + } } \ No newline at end of file