Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 56 additions & 4 deletions PQEnalyzer/plots/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

logger = get_logger(__name__)
SINGLE_PLOT_FIGURE_SIZE = (11, 7)
FOCUSED_RESIZE_DEBOUNCE_MS = 60


class Plot(metaclass=ABCMeta):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down
46 changes: 33 additions & 13 deletions PQEnalyzer/plots/plot_histogram.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -40,6 +54,7 @@ def __init__(self, app):
None
"""

self._kde_cache = {}
super().__init__(app)

return None
Expand All @@ -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,
Expand All @@ -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.
Expand Down
88 changes: 87 additions & 1 deletion tests/plots/test_gui_plots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading