diff --git a/PQEnalyzer/apps/tui.py b/PQEnalyzer/apps/tui.py index 7787887..bbafdf7 100644 --- a/PQEnalyzer/apps/tui.py +++ b/PQEnalyzer/apps/tui.py @@ -23,6 +23,7 @@ PLOT_FEATURES_BY_KEY, enabled_feature_labels, ) +from ..plots.labels import parameter_label from ..plots.options import PlotOptions from ..plots.terminal_chart import build_terminal_chart from .file_watcher import FileChangeWatcher @@ -620,7 +621,7 @@ def render_table(self) -> None: summary = self.summaries[parameter] table.add_row( parameter, - summary.unit, + summary.unit or "n/a", str(summary.rows), format_value(summary.latest), format_value(summary.mean), @@ -642,10 +643,11 @@ def update_detail(self, parameter) -> None: return summary = self.summaries[parameter] + unit = summary.unit or "n/a" title = Text.assemble( (parameter, "bold #58a6ff"), (" Unit ", "#8b949e"), - (summary.unit, "bold #c9d1d9"), + (unit, "bold #c9d1d9"), (" Rows ", "#8b949e"), (str(summary.rows), "bold #c9d1d9"), ) @@ -709,7 +711,7 @@ def render_chart(self) -> None: summary = self.summaries[parameter] self.query_one("#chart-title", Static).update( Text.assemble( - (f"{parameter} / {summary.unit}", "bold #58a6ff"), + (parameter_label(parameter, summary.unit), "bold #58a6ff"), f" rows {summary.rows}", f" latest {format_value(summary.latest)}", )) diff --git a/PQEnalyzer/energy_access.py b/PQEnalyzer/energy_access.py index d387b07..afae550 100644 --- a/PQEnalyzer/energy_access.py +++ b/PQEnalyzer/energy_access.py @@ -125,9 +125,11 @@ def parameter_unit(energy, info_parameter: str) -> str: attribute = PARAMETER_ATTRIBUTES.get(info_parameter) unit_attribute = f"{attribute}_unit" if attribute is not None and hasattr(energy, unit_attribute): - return getattr(energy, unit_attribute) + unit = getattr(energy, unit_attribute) + else: + unit = energy.units[info_parameter] - return energy.units[info_parameter] + return unit or "" def parameter_unit_for_energies(energies: list, info_parameter: str) -> str: diff --git a/PQEnalyzer/plots/labels.py b/PQEnalyzer/plots/labels.py index 771ebe9..25d14f1 100644 --- a/PQEnalyzer/plots/labels.py +++ b/PQEnalyzer/plots/labels.py @@ -35,6 +35,17 @@ def unique_path_labels(filenames): return labels +def parameter_label(parameter, unit): + """ + Return a parameter label without inventing a missing unit. + """ + + if not unit: + return parameter + + return f"{parameter} / {unit}" + + def _unique_group_labels(members): max_depth = max(len(parts) for _, parts in members) diff --git a/PQEnalyzer/plots/plot.py b/PQEnalyzer/plots/plot.py index e1ed82e..f27151d 100644 --- a/PQEnalyzer/plots/plot.py +++ b/PQEnalyzer/plots/plot.py @@ -6,9 +6,10 @@ import matplotlib.pyplot as plt import matplotlib.animation as animation -from ..energy_access import parameter_unit_for_energies from .._logging import get_logger +from ..energy_access import parameter_unit_for_energies from .features import PLOT_FEATURES +from .labels import parameter_label from .options import PlotOptions from .theme import apply_figure_theme, apply_matplotlib_theme @@ -279,7 +280,7 @@ def parameter_axis_label(self, info_parameter: str) -> str: self.reader.energies, info_parameter, ) - return f"{info_parameter} / {unit}" + return parameter_label(info_parameter, unit) def style_single_plot( self, diff --git a/PQEnalyzer/plots/plot_dashboard.py b/PQEnalyzer/plots/plot_dashboard.py index 1103b1a..0c5cd8e 100644 --- a/PQEnalyzer/plots/plot_dashboard.py +++ b/PQEnalyzer/plots/plot_dashboard.py @@ -16,7 +16,7 @@ parameter_unit_for_energies, series, ) -from .labels import unique_path_labels +from .labels import parameter_label, unique_path_labels from .theme import ( apply_figure_theme, apply_matplotlib_theme, @@ -183,7 +183,12 @@ def __label_axis(self, ax, parameter, index): unit = parameter_unit_for_energies(self.reader.energies, parameter) palette = palette_for_appearance_mode( getattr(self.app, "appearance_mode", None)) - ax.set_title(f"{parameter} / {unit}", fontsize=9, loc="left", pad=6) + ax.set_title( + parameter_label(parameter, unit), + fontsize=9, + loc="left", + pad=6, + ) ax.set_title( self.__latest_value_title(parameter), fontsize=8, diff --git a/PQEnalyzer/plots/terminal_chart.py b/PQEnalyzer/plots/terminal_chart.py index aa11228..32aed61 100644 --- a/PQEnalyzer/plots/terminal_chart.py +++ b/PQEnalyzer/plots/terminal_chart.py @@ -11,7 +11,7 @@ series, ) from .features import iter_time_series_overlays -from .labels import unique_path_labels +from .labels import parameter_label, unique_path_labels from .theme import series_rgb @@ -57,9 +57,10 @@ def build_terminal_chart(reader, info_parameter, width=88, height=22, ) unit = parameter_unit_for_energies(reader.energies, info_parameter) - plt.title(f"{info_parameter} / {unit}") + label = parameter_label(info_parameter, unit) + plt.title(label) plt.xlabel(axis_label(reader.energies[0])) - plt.ylabel(f"{info_parameter} / {unit}") + plt.ylabel(label) chart = plt.build() plt.clear_figure() diff --git a/tests/apps/test_tui.py b/tests/apps/test_tui.py index 8272d36..f8cc9cb 100644 --- a/tests/apps/test_tui.py +++ b/tests/apps/test_tui.py @@ -35,6 +35,18 @@ def read_last(self): self.energies = [FakeEnergy([1.0, 3.0, 9.0])] +class FakeUnitlessReader: + + def __init__(self): + energy = FakeEnergy([300.0, 301.0, 302.0]) + energy.units["PARAMETER"] = None + self.filenames = ["qmcfc.en"] + self.energies = [energy] + + def read_last(self): + return None + + class FakeMultiParameterEnergy: info = { @@ -188,6 +200,29 @@ async def run_scenario(): asyncio.run(run_scenario()) +def test_tui_app_marks_missing_qmcfc_unit_without_leaking_none(): + app = TuiApp(FakeUnitlessReader(), watch=False) + + async def run_scenario(): + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + + table = app.query_one("#parameters", DataTable) + detail = app.query_one("#detail-title", Static) + assert table.get_row("PARAMETER")[1] == "n/a" + assert "Unit n/a" in str(detail.content) + + await pilot.press("enter") + await pilot.pause() + + chart_title = app.query_one("#chart-title", Static) + chart = app.query_one("#chart-canvas", Static) + assert "PARAMETER /" not in str(chart_title.content) + assert "None" not in str(chart.content) + + asyncio.run(run_scenario()) + + def test_tui_escape_restores_table_focus_and_vim_navigation(): app = TuiApp(FakeMultiParameterReader(), watch=False) diff --git a/tests/data/qmcfc-output.en b/tests/data/qmcfc-output.en new file mode 100644 index 0000000..86c3ff7 --- /dev/null +++ b/tests/data/qmcfc-output.en @@ -0,0 +1,3 @@ +1 2 300 1.0 -10 0 20 0 0 0 0 0 0 0 0 0 0 0 1000 0.500 0 1.0 +2 2 301 1.1 -11 0 21 0 0 0 0 0 0 0 0 0 0 0 1001 0.501 0 1.1 +3 2 302 1.2 -12 0 22 0 0 0 0 0 0 0 0 0 0 0 1002 0.502 0 1.2 diff --git a/tests/data/qmcfc-output.info b/tests/data/qmcfc-output.info new file mode 100644 index 0000000..7d3c8ec --- /dev/null +++ b/tests/data/qmcfc-output.info @@ -0,0 +1,16 @@ +------------------------------------------------------------------------------- +| QMCFC output information | +------------------------------------------------------------------------------- +| SIMULATION TIME 3.000 QM_MOLECULES 2.000 | +| TEMPERATURE 302.000 PRESSURE 1.200 | +| E(QM) -12.000 E(MM) 0.000 | +| E(KIN) 22.000 E(INTRA) 0.000 | +| E(BOND) 0.000 E(ANGLE) 0.000 | +| E(DIHEDRAL) 0.000 E(IMPROPER) 0.000 | +| E(COULOMB) 0.000 E(NONCOULOMB) 0.000 | +| E(CF) 0.000 E(CF_RF) 0.000 | +| E(RF) 0.000 E(THREEBODY) 0.000 | +| VOLUME 1002.000 DENSITY 0.502 | +| MOMENTUM 0.000 LOOPTIME 1.200 | +------------------------------------------------------------------------------- + diff --git a/tests/plots/test_gui_plots.py b/tests/plots/test_gui_plots.py index 3a5449b..3ae643c 100644 --- a/tests/plots/test_gui_plots.py +++ b/tests/plots/test_gui_plots.py @@ -37,9 +37,9 @@ def get(self): class FakeEnergy: - def __init__(self, values): + def __init__(self, values, unit="unit"): self.info = {"PARAMETER": "PARAMETER"} - self.units = {"PARAMETER": "unit"} + self.units = {"PARAMETER": unit} self.data = {"PARAMETER": np.array(values, dtype=float)} self.simulation_time = np.arange(1, len(values) + 1) @@ -284,6 +284,19 @@ def test_time_labels_use_custom_independent_axis_label(): assert plot.ax.get_xlabel() == "Optimization Step" +def test_time_plot_omits_missing_qmcfc_unit_from_labels(): + app = FakeApp([FakeEnergy([300, 301, 302], unit=None)]) + plot = PlotTime(app) + + plot.main_data("PARAMETER") + plot.labels("PARAMETER") + + assert plot.ax.get_ylabel() == "PARAMETER" + assert plot.ax.get_legend().get_texts()[0].get_text() == ( + "series-0.en (302)" + ) + + def test_time_main_data_disambiguates_duplicate_filenames(): app = FakeApp( [FakeEnergy([1, 2, 3, 4]), FakeEnergy([2, 3, 4, 5])], @@ -486,6 +499,19 @@ def test_dashboard_uses_custom_independent_axis_label(): assert plot.axes[-1].get_xlabel() == "Optimization Step" +def test_dashboard_omits_missing_qmcfc_units_from_titles(): + energy = FakeDashboardEnergy() + energy.units["TEMPERATURE"] = None + energy.units["PRESSURE"] = None + app = FakeApp([energy]) + plot = PlotDashboard(app) + + plot.redraw() + + assert plot.axes[0].get_title(loc="left") == "TEMPERATURE" + assert plot.axes[1].get_title(loc="left") == "PRESSURE" + + def test_dashboard_uses_compact_latest_titles_for_multiple_files(): first = FakeDashboardEnergy() second = FakeDashboardEnergy() diff --git a/tests/plots/test_labels.py b/tests/plots/test_labels.py index 437bfa2..67f972f 100644 --- a/tests/plots/test_labels.py +++ b/tests/plots/test_labels.py @@ -1,4 +1,9 @@ -from PQEnalyzer.plots.labels import unique_path_labels +from PQEnalyzer.plots.labels import parameter_label, unique_path_labels + + +def test_parameter_label_includes_only_available_units(): + assert parameter_label("TEMPERATURE", "K") == "TEMPERATURE / K" + assert parameter_label("TEMPERATURE", "") == "TEMPERATURE" def test_unique_path_labels_keep_unique_basenames(): diff --git a/tests/plots/test_terminal_chart.py b/tests/plots/test_terminal_chart.py index da07de0..83eac46 100644 --- a/tests/plots/test_terminal_chart.py +++ b/tests/plots/test_terminal_chart.py @@ -8,11 +8,11 @@ class FakeEnergy: - def __init__(self, values, time=None): + def __init__(self, values, time=None, unit="unit"): if time is None: time = [1, 2, 3] self.info = {"PARAMETER": "PARAMETER"} - self.units = {"PARAMETER": "unit"} + self.units = {"PARAMETER": unit} self.data = {"PARAMETER": np.array(values)} self.simulation_time = np.array(time) @@ -76,6 +76,15 @@ def test_terminal_chart_uses_custom_independent_axis_label(): assert "Optimization Step" in chart +def test_terminal_chart_omits_missing_qmcfc_unit(): + reader = FakeReader([FakeEnergy([300.0, 301.0, 302.0], unit=None)]) + + chart = build_terminal_chart(reader, "PARAMETER", width=48, height=12) + + assert "PARAMETER" in chart + assert "None" not in chart + + def test_terminal_chart_can_render_statistic_overlays(): reader = FakeReader([FakeEnergy([1.0, 2.0, 4.0, 8.0, 16.0])]) options = PlotOptions( diff --git a/tests/test_energy_access.py b/tests/test_energy_access.py index 25bda9c..751a1b6 100644 --- a/tests/test_energy_access.py +++ b/tests/test_energy_access.py @@ -65,6 +65,17 @@ def test_parameter_access_keeps_custom_parameter_fallback(): np.array([1.0, 2.0, 3.0])) +def test_parameter_access_normalizes_missing_qmcfc_units(): + energy = Reader( + ["tests/data/qmcfc-output.en"], + MDEngineFormat.QMCFC, + ).energies[0] + + assert parameter_unit(energy, "TEMPERATURE") == "" + assert parameter_unit(energy, "QM_MOLECULES") == "" + assert series(energy, "TEMPERATURE").unit == "" + + def test_concatenate_helpers_join_series_from_multiple_energy_files(): energies = [ read_energy("tests/data/md-01.en"),