diff --git a/PQEnalyzer/plots/plot.py b/PQEnalyzer/plots/plot.py index 46c0ce8..0d6e130 100644 --- a/PQEnalyzer/plots/plot.py +++ b/PQEnalyzer/plots/plot.py @@ -17,6 +17,7 @@ logger = get_logger(__name__) SINGLE_PLOT_FIGURE_SIZE = (11, 7) +FOCUSED_RESIZE_DEBOUNCE_MS = 60 class Plot(metaclass=ABCMeta): @@ -59,6 +60,8 @@ def __init__(self, app): self.app = app self.reader = app.reader self.options = PlotOptions.from_app(app) + self._layout_signature = None + self._resize_after_id = None # read parameters from the app self.get_app_parameters() @@ -184,6 +187,7 @@ def update(frame): logger.warning("Plot refresh skipped: %s", error) return [] + self.invalidate_data_cache() self.ax.clear() self.apply_theme() self.plot_data() @@ -214,6 +218,7 @@ def refresh(self, show=True) -> None: logger.warning("Plot refresh skipped: %s", error) return None + self.invalidate_data_cache() self.redraw() # Show the plot @@ -261,17 +266,57 @@ def __key_press_event(self, event): def __resize_event(self, event): """ - Refit labels and remember a resized single-plot window. + Remember the new size and coalesce expensive layout work. """ - self.figure.tight_layout(pad=2.0) - self.figure.canvas.draw_idle() if hasattr(self.app, "remember_plot_size"): self.app.remember_plot_size( "single", self.figure.get_size_inches(), ) + schedule = getattr(self.app, "after", None) + cancel = getattr(self.app, "after_cancel", None) + if not callable(schedule) or not callable(cancel): + self.__apply_pending_resize() + return + + if self._resize_after_id is not None: + cancel(self._resize_after_id) + self._resize_after_id = schedule( + FOCUSED_RESIZE_DEBOUNCE_MS, + self.__apply_pending_resize, + ) + + def __apply_pending_resize(self): + """ + Refit the focused plot after the native resize burst settles. + """ + + self._resize_after_id = None + if self.figure.number not in plt.get_fignums(): + return + + self.__fit_layout(force=True) + self.figure.canvas.draw_idle() + + def __fit_layout(self, force=False): + """ + Refit labels only when plot geometry or typography can change. + """ + + signature = ( + type(self), + getattr(self, "info_parameter", None), + getattr(self.app, "plot_scale", 1.0), + tuple(str(filename) for filename in self.reader.filenames), + ) + if not force and signature == self._layout_signature: + return + + self.figure.tight_layout(pad=2.0) + self._layout_signature = signature + def plot_data(self) -> None: """ Render main data, enabled statistics and plot labels. @@ -288,7 +333,14 @@ def plot_data(self) -> None: self.labels(self.info_parameter) self.apply_theme() - self.figure.tight_layout(pad=2.0) + self.__fit_layout() + + return None + + def invalidate_data_cache(self) -> None: + """ + Discard subclass rendering caches after the reader changes. + """ return None diff --git a/PQEnalyzer/plots/plot_histogram.py b/PQEnalyzer/plots/plot_histogram.py index 05a5900..8f97fe3 100644 --- a/PQEnalyzer/plots/plot_histogram.py +++ b/PQEnalyzer/plots/plot_histogram.py @@ -1,7 +1,6 @@ """ Histogram/KDE plotting for PQ energy parameters. """ -from scipy.stats import gaussian_kde import numpy as np from ..energy_access import has_parameter, parameter_values @@ -15,6 +14,21 @@ logger = get_logger(__name__) +def _calculate_kde_curve(data): + """ + Calculate a KDE curve while keeping SciPy out of GUI startup. + """ + + from scipy.stats import gaussian_kde + + x = np.linspace( + min(data), + max(data), + 1000, + ) + return x, gaussian_kde(data)(x) + + class PlotHistogram(Plot): """ Plot kernel-density estimates for selected energy parameters. @@ -40,6 +54,7 @@ def __init__(self, app): None """ + self._kde_cache = {} super().__init__(app) return None @@ -64,22 +79,20 @@ def main_data(self, info_parameter: str) -> None: continue data = parameter_values(energy, info_parameter) + cache_key = (id(energy), info_parameter) + + if cache_key not in self._kde_cache: + if np.unique(data).size == 1: + self._kde_cache[cache_key] = None + else: + self._kde_cache[cache_key] = _calculate_kde_curve(data) - # check if zero data - if np.unique(data).size == 1: + curve = self._kde_cache[cache_key] + if curve is None: logger.warning("Data zero. No histogram available.") continue - # plot kde of histogram - kde = gaussian_kde(data) - - x = np.linspace( - min(data), - max(data), - 1000, - ) - - y = kde(x) + x, y = curve line = self.ax.plot( x, y, @@ -103,6 +116,13 @@ def main_data(self, info_parameter: str) -> None: return None + def invalidate_data_cache(self) -> None: + """ + Drop KDE curves after live data is re-read. + """ + + self._kde_cache.clear() + def labels(self, info_parameter: str) -> None: """ Set histogram labels and legend. diff --git a/tests/plots/test_gui_plots.py b/tests/plots/test_gui_plots.py index 8c78754..5582647 100644 --- a/tests/plots/test_gui_plots.py +++ b/tests/plots/test_gui_plots.py @@ -5,8 +5,12 @@ import matplotlib.pyplot as plt from PQEnalyzer.plots.plot_dashboard import PlotDashboard +from PQEnalyzer.plots import plot_histogram as plot_histogram_module from PQEnalyzer.plots.plot_histogram import PlotHistogram -from PQEnalyzer.plots.plot import SINGLE_PLOT_FIGURE_SIZE +from PQEnalyzer.plots.plot import ( + FOCUSED_RESIZE_DEBOUNCE_MS, + SINGLE_PLOT_FIGURE_SIZE, +) from PQEnalyzer.plots.plot_time import PlotTime from PQEnalyzer.plots.theme import PLOT_FONT_SIZES, series_color from PQEnalyzer.plots.value_readout import ( @@ -259,6 +263,32 @@ def test_histogram_labels_use_distribution_title_and_density_axis(): assert plot.ax.get_ylim()[0] == 0 +def test_histogram_reuses_kde_until_data_refresh(monkeypatch): + app = FakeApp([FakeEnergy([1, 2, 3, 4])]) + plot = PlotHistogram(app) + plot.info_parameter = "PARAMETER" + calculation_calls = [] + + def calculate_curve(data): + calculation_calls.append(data.copy()) + return np.array([1.0, 2.0]), np.array([0.2, 0.1]) + + monkeypatch.setattr( + plot_histogram_module, + "_calculate_kde_curve", + calculate_curve, + ) + + plot.redraw() + plot.redraw() + + assert len(calculation_calls) == 1 + + plot.refresh(show=False) + + assert len(calculation_calls) == 2 + + def test_time_main_data_uses_readable_filenames_and_latest_readout(): app = FakeApp([FakeEnergy([1, 2, 3, 4])]) plot = PlotTime(app) @@ -335,6 +365,62 @@ def test_single_plot_restores_and_remembers_resized_window(): ) +def test_single_plot_coalesces_rapid_native_resize_events(): + app = FakeScheduledApp([FakeEnergy([1, 2, 3, 4])]) + plot = PlotTime(app) + layout_calls = [] + draw_calls = [] + plot.figure.tight_layout = lambda **kwargs: layout_calls.append(kwargs) + plot.figure.canvas.draw_idle = lambda: draw_calls.append(True) + + plot.figure.set_size_inches(9, 6) + plot._Plot__resize_event(SimpleNamespace()) + first_after_id, delay, _ = app.after_calls[-1] + + plot.figure.set_size_inches(12, 8) + plot._Plot__resize_event(SimpleNamespace()) + _, _, final_callback = app.after_calls[-1] + + assert delay == FOCUSED_RESIZE_DEBOUNCE_MS + assert app.cancelled_after_ids == [first_after_id] + assert layout_calls == [] + assert draw_calls == [] + np.testing.assert_allclose( + app.remembered_plot_sizes["single"], + (12, 8), + ) + + final_callback() + + assert layout_calls == [{"pad": 2.0}] + assert draw_calls == [True] + assert plot._resize_after_id is None + + +def test_single_plot_reuses_layout_until_typography_changes(): + energy = FakeEnergy([1, 2, 3, 4]) + app = FakeApp([energy]) + plot = PlotTime(app) + plot.info_parameter = "PARAMETER" + layout_calls = [] + plot.figure.tight_layout = lambda **kwargs: layout_calls.append(kwargs) + + plot.redraw() + energy.data["PARAMETER"][-1] = 7.0 + plot.redraw() + + assert len(layout_calls) == 1 + assert plot.ax.get_legend_handles_labels()[1] == [ + "series-0.en (7 unit)" + ] + + app.plot_scale = 1.25 + plot.redraw() + plot.redraw() + + assert len(layout_calls) == 2 + + def test_single_plot_keyboard_shortcuts_change_plot_scale(): app = FakeApp([FakeEnergy([1, 2, 3, 4])]) plot = PlotTime(app)