From 3da80a9478fee49a3a52a831abbe72b66d106483 Mon Sep 17 00:00:00 2001 From: Parthib Roy <159463257+proy30@users.noreply.github.com> Date: Fri, 9 Aug 2024 10:24:01 -0700 Subject: [PATCH] Trame Dashboard (#651) * Added trame dashboard `impactx-dashboard` Co-authored-by: Axel Huebl --- .gitignore | 6 + docs/source/index.rst | 1 + docs/source/install/dependencies.rst | 15 + docs/source/usage/dashboard.rst | 87 ++++ setup.py | 5 + .../impactx/dashboard/Analyze/__init__.py | 0 .../dashboard/Analyze/analyzeFunctions.py | 101 ++++ .../plot_ParameterEvolutionOverS/overS.py | 44 ++ .../plot_PhaseSpaceProjections/phaseSpace.py | 77 ++++ .../phaseSpaceSettings.py | 121 +++++ .../impactx/dashboard/Analyze/plotsMain.py | 252 ++++++++++ .../impactx/dashboard/Input/__init__.py | 0 .../Input/distributionParameters/__init__.py | 0 .../distributionMain.py | 212 +++++++++ .../dashboard/Input/generalFunctions.py | 263 +++++++++++ .../Input/inputParameters/__init__.py | 0 .../Input/inputParameters/inputFunctions.py | 66 +++ .../Input/inputParameters/inputMain.py | 153 +++++++ .../Input/latticeConfiguration/__init__.py | 0 .../Input/latticeConfiguration/latticeMain.py | 433 ++++++++++++++++++ .../impactx/dashboard/Input/trameFunctions.py | 43 ++ .../impactx/dashboard/Toolbar/__init__.py | 0 .../impactx/dashboard/Toolbar/toolbarMain.py | 74 +++ src/python/impactx/dashboard/__init__.py | 5 + src/python/impactx/dashboard/__main__.py | 92 ++++ .../impactx/dashboard/jupyterApplication.py | 22 + src/python/impactx/dashboard/requirements.txt | 8 + src/python/impactx/dashboard/start.py | 24 + src/python/impactx/dashboard/trame_setup.py | 15 + 29 files changed, 2119 insertions(+) create mode 100644 docs/source/usage/dashboard.rst create mode 100644 src/python/impactx/dashboard/Analyze/__init__.py create mode 100644 src/python/impactx/dashboard/Analyze/analyzeFunctions.py create mode 100644 src/python/impactx/dashboard/Analyze/plot_ParameterEvolutionOverS/overS.py create mode 100644 src/python/impactx/dashboard/Analyze/plot_PhaseSpaceProjections/phaseSpace.py create mode 100644 src/python/impactx/dashboard/Analyze/plot_PhaseSpaceProjections/phaseSpaceSettings.py create mode 100644 src/python/impactx/dashboard/Analyze/plotsMain.py create mode 100644 src/python/impactx/dashboard/Input/__init__.py create mode 100644 src/python/impactx/dashboard/Input/distributionParameters/__init__.py create mode 100644 src/python/impactx/dashboard/Input/distributionParameters/distributionMain.py create mode 100644 src/python/impactx/dashboard/Input/generalFunctions.py create mode 100644 src/python/impactx/dashboard/Input/inputParameters/__init__.py create mode 100644 src/python/impactx/dashboard/Input/inputParameters/inputFunctions.py create mode 100644 src/python/impactx/dashboard/Input/inputParameters/inputMain.py create mode 100644 src/python/impactx/dashboard/Input/latticeConfiguration/__init__.py create mode 100644 src/python/impactx/dashboard/Input/latticeConfiguration/latticeMain.py create mode 100644 src/python/impactx/dashboard/Input/trameFunctions.py create mode 100644 src/python/impactx/dashboard/Toolbar/__init__.py create mode 100644 src/python/impactx/dashboard/Toolbar/toolbarMain.py create mode 100644 src/python/impactx/dashboard/__init__.py create mode 100644 src/python/impactx/dashboard/__main__.py create mode 100644 src/python/impactx/dashboard/jupyterApplication.py create mode 100644 src/python/impactx/dashboard/requirements.txt create mode 100644 src/python/impactx/dashboard/start.py create mode 100644 src/python/impactx/dashboard/trame_setup.py diff --git a/.gitignore b/.gitignore index d45913ed6..2bea61af9 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,9 @@ cmake-build-*/ .DS_Store .AppleDouble .LSOverride + +##################### +# Trame Dashboard (output files are temporary) # +##################### +src/python/impactx/diags* +src/python/impactx/*.png diff --git a/docs/source/index.rst b/docs/source/index.rst index 2b9aae57f..6ab1b75e0 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -68,6 +68,7 @@ Usage usage/examples usage/python usage/parameters + usage/dashboard usage/workflows Data Analysis diff --git a/docs/source/install/dependencies.rst b/docs/source/install/dependencies.rst index d2ccf0a8d..9fff947c9 100644 --- a/docs/source/install/dependencies.rst +++ b/docs/source/install/dependencies.rst @@ -35,6 +35,9 @@ Optional dependencies include: - `quantiphy `__ - `openPMD-api `__ - see our ``requirements.txt`` file for compatible versions + - web browser/Jupyter Dashboard: `trame `__ + + - see our ``src/python/impactx/dashboard/requirements.txt`` file for all packages If you are on a high-performance computing (HPC) system, then :ref:`please see our separate HPC documentation `. @@ -102,6 +105,12 @@ For OpenMP support, you will further need: conda install -c conda-forge llvm-openmp +For the ImpactX browser/Jupyter dashboard dependencies, install from the ImpactX source directory: + +.. code-block:: bash + + python3 -m pip install -r src/python/impactx/dashboard/requirements.txt + Spack (Linux/macOS) ------------------- @@ -144,6 +153,12 @@ Now install the WarpX/ImpactX dependencies in a new development environment: spack install python3 -m pip install jupyter matplotlib numpy openpmd-api openpmd-viewer pandas quantiphy scipy virtualenv yt +For the ImpactX browser/Jupyter dashboard dependencies, install from the ImpactX source directory: + +.. code-block:: bash + + python3 -m pip install -r src/python/impactx/dashboard/requirements.txt + In new terminal sessions, re-activate the environment with .. code-block:: bash diff --git a/docs/source/usage/dashboard.rst b/docs/source/usage/dashboard.rst new file mode 100644 index 000000000..bcb4fd3e5 --- /dev/null +++ b/docs/source/usage/dashboard.rst @@ -0,0 +1,87 @@ +.. _usage-dashboard: + +Dashboard +========= + +ImpactX Dashboard is a browser-based interface to ImpactX. +It provides a graphical interface to a subset of ImpactX functionality. + +.. note:: + + ImpactX Dashboard is provided as a preview and continues to be developed. + Let us know in GitHub `discussions `__ and `issues `__ how it works for you and what kind of workflows you would like to run in it. + + +Launching the Dashboard +----------------------- + +The ImpactX Dashboard can be run in two modes, as a standalone browser application or inside a Jupyter notebook. + +1. **Standalone browser application:** + After installation of ImpactX including the Python modules, launch: + + .. code-block:: bash + + impactx-dashboard + +2. **JupyterLab:** + Start `JupyterLab `__, e.g., logging into a Jupyter service at an HPC center or locally on your computer using: + + .. code-block:: bash + + jupyter-lab + + Inside JupyterLab, run the following Python code in a notebook to initialize and display the dashboard: + + .. code-block:: python + + from impactx.dashboard import JupyterApp + + # Create new application instance + app = JupyterApp() + + # Start the server and wait for the UI to be ready + await app.ui.ready + + # Display the UI in the JupyterLab notebook + app.ui + + +Navigation +---------- + +The user-friendly interface includes multiple tabs and menu options, intended to be navigated from top to bottom: + +- **Input Tab**: Allows to adjust simulation input parameters. +- **Run Tab**: Enables to run simulations and monitor their progress. +- **Analyze Tab**: Provides tools to visualize and analyze simulation results. + +.. figure:: https://gist.githubusercontent.com/ax3l/b56aa3c3261f9612e276f3198b34f771/raw/11bfe461a24e1daa7fd2d663c686b0fcc2b6e305/dashboard.png + :align: center + :width: 75% + :alt: phase space ellipse + + Input section in the dashboard. + + +Developers +---------- + +Additional Dependencies +""""""""""""""""""""""" + +Additional dependencies to ImpactX for the dashboard are included relative ImpactX source directory: + +.. code-block:: bash + + python -m pip install -r src/python/impactx/dashboard/requirements.txt + +Python Module +""""""""""""" + +After installing only the ImpactX Python bindings, one can directly run the dashboard modules from the source tree during development, too. +For this, navigate in the ImpactX source directory to the ``src/python/impactx`` directory and run: + + .. code-block:: bash + + python -m dashboard diff --git a/setup.py b/setup.py index 93a5a3aa6..e56ca982c 100644 --- a/setup.py +++ b/setup.py @@ -282,4 +282,9 @@ def build_extension(self, ext): # new PEP 639 format license="BSD-3-Clause-LBNL", license_files=["LICENSE"], + entry_points={ + "console_scripts": [ + "impactx-dashboard=impactx.dashboard.__main__:main", + ], + }, ) diff --git a/src/python/impactx/dashboard/Analyze/__init__.py b/src/python/impactx/dashboard/Analyze/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/python/impactx/dashboard/Analyze/analyzeFunctions.py b/src/python/impactx/dashboard/Analyze/analyzeFunctions.py new file mode 100644 index 000000000..7132ec344 --- /dev/null +++ b/src/python/impactx/dashboard/Analyze/analyzeFunctions.py @@ -0,0 +1,101 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +import pandas as pd + +from ..trame_setup import setup_server + +server, state, ctrl = setup_server() + +# ----------------------------------------------------------------------------- +# Content +# ----------------------------------------------------------------------------- + + +class AnalyzeFunctions: + """ + Helper functions for + preparing the contents for the 'Analyze' page + """ + + # ----------------------------------------------------------------------------- + # Functions for Beam Characteristic and Ref particle data table + # ----------------------------------------------------------------------------- + + @staticmethod + def load_data(file_path): + """ + Reads data from the provided file path. + :param file_path: The path to the file to be read. + :return: A DataFrame containing the data from the file. + """ + + df = pd.read_csv(file_path, sep=" ") + return df + + @staticmethod + def convert_to_dict(combined_data): + """ + Converts data to dictionary format + for Vuetify data table. + :param combined_data: The DataFrame to be converted. + :return: A tuple containing the data as a list of dictionaries and the headers. + """ + + dictionary = combined_data.to_dict(orient="records") + columns = combined_data.columns + headers = [ + {"text": column.strip(), "value": column.strip()} for column in columns + ] + return dictionary, headers + + @staticmethod + def combine_files(file1_name, file2_name): + """ + Merges two files together. + :param file1_name: The name of the first file. + :param file2_name: The name of the second file. + :return: A DataFrame containing the merged data from the two files. + """ + + file1 = AnalyzeFunctions.load_data(file1_name) + file2 = AnalyzeFunctions.load_data(file2_name) + return pd.merge(file1, file2, how="outer") + + @staticmethod + def filter_headers(allHeaders, selected_headers): + """ + Retrieves only user-selected headers. + :param allHeaders: The list of all headers. + :param selected_headers: The list of headers selected by the user. + :return: A list of filtered headers based on user selection. + """ + + filtered_headers = [] + for selectedHeader in allHeaders: + if selectedHeader["value"] in selected_headers: + filtered_headers.append(selectedHeader) + return filtered_headers + + @staticmethod + def filter_data(allData, selected_headers): + """ + Retrieves data for user-selected headers. + :param allData: The list of all data. + :param selected_headers: The list of headers selected by the user. + :return: A list of filtered data based on user selection. + """ + + filtered_data = [] + for row in allData: + filtered_row = {} + for key, value in row.items(): + if key in selected_headers: + filtered_row[key] = value + filtered_data.append(filtered_row) + return filtered_data diff --git a/src/python/impactx/dashboard/Analyze/plot_ParameterEvolutionOverS/overS.py b/src/python/impactx/dashboard/Analyze/plot_ParameterEvolutionOverS/overS.py new file mode 100644 index 000000000..eec4ccf9b --- /dev/null +++ b/src/python/impactx/dashboard/Analyze/plot_ParameterEvolutionOverS/overS.py @@ -0,0 +1,44 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +import plotly.graph_objects as go + + +def line_plot_1d(selected_headers, filtered_data): + """ + Generates a 1D line plot using Plotly based on selected headers and filtered data. + """ + + x_axis = selected_headers[0] if len(selected_headers) > 1 else None + y_axis = selected_headers[1:] if len(selected_headers) > 2 else None + + x = [row[x_axis] for row in filtered_data] if x_axis else [] + + figure_data = [] + if y_axis: + for column in y_axis: + y = [row[column] for row in filtered_data] + trace = go.Scatter( + x=x, + y=y, + mode="lines+markers", + name=column, + line=dict(width=2), + marker=dict(size=8), + ) + figure_data.append(trace) + + return go.Figure( + data=figure_data, + layout=go.Layout( + title="Plot Over S", + xaxis=dict(title="s"), + yaxis=dict(title=""), + margin=dict(l=20, r=20, t=25, b=30), + ), + ) diff --git a/src/python/impactx/dashboard/Analyze/plot_PhaseSpaceProjections/phaseSpace.py b/src/python/impactx/dashboard/Analyze/plot_PhaseSpaceProjections/phaseSpace.py new file mode 100644 index 000000000..ed027ca71 --- /dev/null +++ b/src/python/impactx/dashboard/Analyze/plot_PhaseSpaceProjections/phaseSpace.py @@ -0,0 +1,77 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +from ...trame_setup import setup_server + +server, state, ctrl = setup_server() + +import base64 +import io + +from impactx import Config, ImpactX + +from ...Input.distributionParameters.distributionMain import distribution_parameters +from ...Input.latticeConfiguration.latticeMain import lattice_elements +from ..plot_PhaseSpaceProjections.phaseSpaceSettings import adjusted_settings_plot + +# Call MPI_Init and MPI_Finalize only once: +if Config.have_mpi: + from mpi4py import MPI # noqa + + +def fig_to_base64(fig): + """ + Puts png in trame-compatible form + """ + buf = io.BytesIO() + fig.savefig(buf, format="png") + buf.seek(0) + return base64.b64encode(buf.read()).decode("utf-8") + + +def run_simulation(): + """ + This tests using ImpactX and Pandas Dataframes + """ + sim = ImpactX() + + sim.particle_shape = state.particle_shape + sim.space_charge = False + sim.slice_step_diagnostics = True + sim.init_grids() + + # init particle beam + kin_energy_MeV = state.kin_energy_MeV + bunch_charge_C = state.bunch_charge_C + npart = state.npart + + # reference particle + pc = sim.particle_container() + ref = pc.ref_particle() + ref.set_charge_qe(-1.0).set_mass_MeV(0.510998950).set_kin_energy_MeV(kin_energy_MeV) + + distribution = distribution_parameters() + sim.add_particles(bunch_charge_C, distribution, npart) + + lattice_configuration = lattice_elements() + + sim.lattice.extend(lattice_configuration) + + # simulate + sim.evolve() + + fig = adjusted_settings_plot(pc) + fig_original = pc.plot_phasespace() + + if fig_original is not None: + image_base64 = fig_to_base64(fig_original) + state.image_data = f"data:image/png;base64, {image_base64}" + + sim.finalize() + + return fig diff --git a/src/python/impactx/dashboard/Analyze/plot_PhaseSpaceProjections/phaseSpaceSettings.py b/src/python/impactx/dashboard/Analyze/plot_PhaseSpaceProjections/phaseSpaceSettings.py new file mode 100644 index 000000000..91c3b2b70 --- /dev/null +++ b/src/python/impactx/dashboard/Analyze/plot_PhaseSpaceProjections/phaseSpaceSettings.py @@ -0,0 +1,121 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + + +def adjusted_settings_plot(self, num_bins=50, root_rank=0): + """ + Plot the longitudinal and transverse phase space projections with matplotlib. + + Parameters + ---------- + self : ImpactXParticleContainer_* + The particle container class in ImpactX + num_bins : int, default=50 + The number of bins for spatial and momentum directions per plot axis. + root_rank : int, default=0 + MPI root rank to reduce to in parallel runs. + + Returns + ------- + A matplotlib figure with containing the plot. + For MPI-parallel ranks, the figure is only created on the root_rank. + """ + import matplotlib.pyplot as plt + import numpy as np + from scipy.stats import gaussian_kde + + # Beam Characteristics + # rbc = self.reduced_beam_characteristics() + # update for plot unit system + m2mm = 1.0e3 + rad2mrad = 1.0e3 + + # Data Extraction + df = self.to_df(local=True) + + # Matplotlib canvas: figure and plottable axes areas + fig, axes = plt.subplots(1, 3, figsize=(12, 3)) + (ax_xpx, ax_ypy, ax_tpt) = axes + + # Plotting data points if df is not None + if df is not None: + # update for plot unit system + df["position_x"] = df["position_x"].multiply(m2mm) + df["position_y"] = df["position_y"].multiply(m2mm) + df["position_t"] = df["position_t"].multiply(m2mm) + df["momentum_x"] = df["momentum_x"].multiply(rad2mrad) + df["momentum_y"] = df["momentum_y"].multiply(rad2mrad) + df["momentum_t"] = df["momentum_t"].multiply(rad2mrad) + + def scatter_density_plot(ax, x, y, xlabel, ylabel, title): + xy = np.vstack([x, y]) + z = gaussian_kde(xy)(xy) + scatter = ax.scatter(x, y, c=z, cmap="viridis", alpha=0.5) + ax.set_xlabel(xlabel) + ax.set_ylabel(ylabel) + ax.set_title(title) + ax.axis("equal") + return scatter + + scatter_xpx = scatter_density_plot( + ax_xpx, + df["position_x"], + df["momentum_x"], + "Δ x [mm]", + "Δ p_x [mrad]", + "", + ) + scatter_ypy = scatter_density_plot( + ax_ypy, + df["position_y"], + df["momentum_y"], + "Δ y [mm]", + "Δ p_y [mrad]", + "", + ) + scatter_tpt = scatter_density_plot( + ax_tpt, + df["position_t"], + df["momentum_t"], + "Δ ct [mm]", + "Delta p_t [p_0 . c]", + "", + ) + + fig.colorbar(scatter_xpx, ax=ax_xpx, fraction=0.046, pad=0.04) + fig.colorbar(scatter_ypy, ax=ax_ypy, fraction=0.046, pad=0.04) + fig.colorbar(scatter_tpt, ax=ax_tpt, fraction=0.046, pad=0.04) + + fig.tight_layout() + + else: + ax_xpx.text( + 0.5, + 0.5, + "No data available", + horizontalalignment="center", + verticalalignment="center", + ) + ax_ypy.text( + 0.5, + 0.5, + "No data available", + horizontalalignment="center", + verticalalignment="center", + ) + ax_tpt.text( + 0.5, + 0.5, + "No data available", + horizontalalignment="center", + verticalalignment="center", + ) + + fig.canvas.manager.set_window_title("Phase Space") + + return fig diff --git a/src/python/impactx/dashboard/Analyze/plotsMain.py b/src/python/impactx/dashboard/Analyze/plotsMain.py new file mode 100644 index 000000000..4d71ffb86 --- /dev/null +++ b/src/python/impactx/dashboard/Analyze/plotsMain.py @@ -0,0 +1,252 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +import asyncio +import contextlib +import glob +import io +import os + +from trame.widgets import matplotlib, plotly, vuetify + +from ..trame_setup import setup_server +from .analyzeFunctions import AnalyzeFunctions +from .plot_ParameterEvolutionOverS.overS import line_plot_1d +from .plot_PhaseSpaceProjections.phaseSpace import run_simulation + +server, state, ctrl = setup_server() + +# ----------------------------------------------------------------------------- +# Plotting +# ----------------------------------------------------------------------------- + + +# Call plot_over_s +def plot_over_s(): + """ + Generates a plot. + """ + + fig = line_plot_1d(state.selected_headers, state.filtered_data) + ctrl.plotly_figure_update(fig) + + +PLOTS = { + "Plot Over S": plot_over_s, + "Phase Space Plots": None, +} + +# ----------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- + + +def available_plot_options(simulationClicked): + """ + Displays plot_options for users based on status of simulation. + :param simulationClicked (bool): status of simulation status + :return: list of plot_options for users + """ + + if simulationClicked: + return list(PLOTS.keys()) + else: + return ["Run Simulation To See Options"] + + +def load_dataTable_data(): + """ + Loads and processes data from combined beam and reference particle files. + """ + + CURRENT_DIR = os.getcwd() + DIAGS_DIR = os.path.join(CURRENT_DIR, "diags") + + base_path = DIAGS_DIR + "/" + REDUCED_BEAM_DATA = glob.glob(base_path + "reduced_beam_characteristics.*")[0] + REF_PARTICLE_DATA = glob.glob(base_path + "ref_particle.*")[0] + + if not os.path.exists(REDUCED_BEAM_DATA) or not os.path.exists(REF_PARTICLE_DATA): + ctrl.terminal_print( + "Diagnostics files are missing. Please ensure they are in the correct directory." + ) + return + + combined_files = AnalyzeFunctions.combine_files( + REDUCED_BEAM_DATA, REF_PARTICLE_DATA + ) + combined_files_data_converted_to_dictionary_format = ( + AnalyzeFunctions.convert_to_dict(combined_files) + ) + data, headers = combined_files_data_converted_to_dictionary_format + state.all_data = data + state.all_headers = headers + state.headers_without_step_or_s = state.all_headers[2:] + + +# ----------------------------------------------------------------------------- +# Defaults +# ----------------------------------------------------------------------------- + +DEFAULT_HEADERS = ["s", "beta_x", "beta_y"] + +state.selected_headers = DEFAULT_HEADERS +state.plot_options = available_plot_options(simulationClicked=False) +state.show_table = False +state.active_plot = None +state.filtered_data = [] +state.all_data = [] +state.all_headers = [] + +# ----------------------------------------------------------------------------- +# Functions to update table/plot +# ----------------------------------------------------------------------------- + + +def update_data_table(): + """ + Combines reducedBeam and refParticle files + and updates data table upon column selection by user + """ + + load_dataTable_data() + state.filtered_data = AnalyzeFunctions.filter_data( + state.all_data, state.selected_headers + ) + state.filtered_headers = AnalyzeFunctions.filter_headers( + state.all_headers, state.selected_headers + ) + + +def update_plot(): + """ + Performs actions to display correct information, + based on the plot optin selected by the user + """ + + if state.active_plot == "Plot Over S": + update_data_table() + plot_over_s() + state.show_table = True + elif state.active_plot == "Phase Space Plots": + state.show_table = False + ctrl.matplotlib_figure_update(state.simulation_data) + + +def run_simulation_impactX(): + buf = io.StringIO() + + with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf): + state.simulation_data = run_simulation() + + buf.seek(0) + lines = [line.strip() for line in buf] + + # Use $nextTick to ensure the terminal is fully rendered before printing + async def print_lines(): + for line in lines: + ctrl.terminal_print(line) + ctrl.terminal_print("Simulation complete.") + + asyncio.create_task(print_lines()) + + +# ----------------------------------------------------------------------------- +# State changes +# ----------------------------------------------------------------------------- + + +@state.change("selected_headers") +def on_header_selection_change(selected_headers, **kwargs): + state.filtered_headers = AnalyzeFunctions.filter_headers( + state.all_headers, selected_headers + ) + state.filtered_data = AnalyzeFunctions.filter_data(state.all_data, selected_headers) + + +@state.change("filtered_data", "active_plot") +def on_filtered_data_change(**kwargs): + update_plot() + + +@ctrl.add("run_simulation") +def run_simulation_and_store(): + state.plot_options = available_plot_options(simulationClicked=True) + run_simulation_impactX() + update_plot() + load_dataTable_data() + + +# ----------------------------------------------------------------------------- +# GUI +# ----------------------------------------------------------------------------- + + +class AnalyzeSimulation: + """ + Prepares contents for the 'Analyze' page. + """ + + @staticmethod + def card(): + """ + Displays any non-plot content for 'Analyze' page. + """ + + with vuetify.VContainer(): + with vuetify.VCard(v_if=("show_table")): + with vuetify.VCol(style="width: 500px;"): + vuetify.VSelect( + v_model=("selected_headers",), + items=("headers_without_step_or_s",), + label="Select data to view", + multiple=True, + ) + vuetify.VDivider() + vuetify.VDataTable( + headers=("filtered_headers",), + items=("filtered_data",), + header_class="centered-header", + dense=True, + height="325px", + ) + + @staticmethod + def plot(): + """ + Displays any plot content for 'Analyze' page. + """ + + with vuetify.VContainer(fluid=True): + with vuetify.VContainer( + v_if="active_plot === 'Phase Space Plots'", fluid=True + ): + with vuetify.VCard(style="height: 50vh; width: 150vh;"): + with vuetify.VTabs(v_model=("active_tab", 0)): + vuetify.VTab("Plot") + vuetify.VTab("Interact") + vuetify.VDivider() + with vuetify.VTabsItems(v_model="active_tab"): + with vuetify.VTabItem(): + vuetify.VImg(v_if=("image_data",), src=("image_data",)) + + with vuetify.VTabItem(): + with vuetify.VContainer( + style="height: 37vh; width: 147vh;" + ): + matplotlib_figure = matplotlib.Figure( + style="position: absolute" + ) + ctrl.matplotlib_figure_update = matplotlib_figure.update + + with vuetify.VContainer( + v_if="active_plot === 'Plot Over S'", style="height: 50vh; width: 90vh;" + ): + plotly_figure = plotly.Figure( + display_mode_bar="true", config={"responsive": True} + ) + ctrl.plotly_figure_update = plotly_figure.update diff --git a/src/python/impactx/dashboard/Input/__init__.py b/src/python/impactx/dashboard/Input/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/python/impactx/dashboard/Input/distributionParameters/__init__.py b/src/python/impactx/dashboard/Input/distributionParameters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/python/impactx/dashboard/Input/distributionParameters/distributionMain.py b/src/python/impactx/dashboard/Input/distributionParameters/distributionMain.py new file mode 100644 index 000000000..b57e16fe7 --- /dev/null +++ b/src/python/impactx/dashboard/Input/distributionParameters/distributionMain.py @@ -0,0 +1,212 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +from trame.widgets import vuetify + +from impactx import distribution + +from ...trame_setup import setup_server +from ..generalFunctions import generalFunctions + +server, state, ctrl = setup_server() + +# ----------------------------------------------------------------------------- +# Helpful +# ----------------------------------------------------------------------------- + +DISTRIBUTIONS_MODULE_NAME = distribution + +state.listOfDistributions = generalFunctions.select_classes(DISTRIBUTIONS_MODULE_NAME) +state.listOfDistributionsAndParametersAndDefault = ( + generalFunctions.class_parameters_with_defaults(DISTRIBUTIONS_MODULE_NAME) +) + +# ----------------------------------------------------------------------------- +# Default +# ----------------------------------------------------------------------------- + +state.selectedDistribution = "Waterbag" +state.selectedDistributionType = "Native" +state.selectedDistributionParameters = [] + +# ----------------------------------------------------------------------------- +# Main Functions +# ----------------------------------------------------------------------------- + + +def populate_distribution_parameters(selectedDistribution): + """ + Populates distribution parameters based on the selected distribution. + :param selectedDistribution (str): The name of the selected distribution + whos parameters need to be populated. + """ + + selectedDistributionParameters = ( + state.listOfDistributionsAndParametersAndDefault.get(selectedDistribution, []) + ) + + state.selectedDistributionParameters = [ + { + "parameter_name": parameter[0], + "parameter_default_value": parameter[1], + "parameter_type": parameter[2], + "parameter_error_message": generalFunctions.validate_against( + parameter[1], parameter[2] + ), + } + for parameter in selectedDistributionParameters + ] + + generalFunctions.update_simulation_validation_status() + return selectedDistributionParameters + + +def update_distribution_parameters( + parameterName, parameterValue, parameterErrorMessage +): + """ + Updates the value of a distribution parameter and its error message. + + :param parameterName (str): The name of the parameter to update. + :param parameterValue: The new value for the parameter. + :param parameterErrorMessage: The error message related to the parameter's value. + """ + + for param in state.selectedDistributionParameters: + if param["parameter_name"] == parameterName: + param["parameter_default_value"] = parameterValue + param["parameter_error_message"] = parameterErrorMessage + + generalFunctions.update_simulation_validation_status() + state.dirty("selectedDistributionParameters") + + +# ----------------------------------------------------------------------------- +# Write to file functions +# ----------------------------------------------------------------------------- + + +def parameter_input_checker(): + """ + Helper function to check if user input is valid. + :return: A dictionary with parameter names as keys and their validated values. + """ + + parameter_input = {} + for param in state.selectedDistributionParameters: + if param["parameter_error_message"] == []: + parameter_input[param["parameter_name"]] = float( + param["parameter_default_value"] + ) + else: + parameter_input[param["parameter_name"]] = 0.0 + + return parameter_input + + +def distribution_parameters(): + """ + Writes user input for distribution parameters in suitable format for simulation code. + :return: An instance of the selected distribution class, initialized with user-provided parameters. + """ + + distribution_name = state.selectedDistribution + parameters = parameter_input_checker() + + distr = getattr(distribution, distribution_name)(**parameters) + return distr + + +# ----------------------------------------------------------------------------- +# Callbacks +# ----------------------------------------------------------------------------- + + +@state.change("selectedDistribution") +def on_distribution_name_change(selectedDistribution, **kwargs): + populate_distribution_parameters(selectedDistribution) + + +@state.change("selectedDistributionType") +def on_distribution_type_change(**kwargs): + populate_distribution_parameters(state.selectedDistribution) + + +@ctrl.add("updateDistributionParameters") +def on_distribution_parameter_change(parameter_name, parameter_value, parameter_type): + parameter_value, input_type = generalFunctions.determine_input_type(parameter_value) + error_message = generalFunctions.validate_against(parameter_value, parameter_type) + + update_distribution_parameters(parameter_name, parameter_value, error_message) + + +# ----------------------------------------------------------------------------- +# Content +# ----------------------------------------------------------------------------- + + +class DistributionParameters: + """ + User-Input section for beam distribution. + """ + + @staticmethod + def card(): + """ + Creates UI content for beam distribution. + """ + + with vuetify.VCard(style="width: 340px; height: 300px"): + with vuetify.VCardTitle("Distribution Parameters"): + vuetify.VSpacer() + vuetify.VIcon( + "mdi-information", + style="color: #00313C;", + click=lambda: generalFunctions.documentation("BeamDistributions"), + ) + vuetify.VDivider() + with vuetify.VCardText(): + with vuetify.VRow(): + with vuetify.VCol(cols=8): + vuetify.VCombobox( + label="Select Distribution", + v_model=("selectedDistribution",), + items=("listOfDistributions",), + dense=True, + ) + with vuetify.VCol(cols=4): + vuetify.VSelect( + v_model=("selectedDistributionType",), + label="Type", + items=(["Native", "Twiss"],), + # change=(ctrl.kin_energy_unit_change, "[$event]"), + dense=True, + disabled=True, + ) + with vuetify.VRow(classes="my-2"): + for i in range(3): + with vuetify.VCol(cols=4, classes="py-0"): + with vuetify.VRow( + v_for="(parameter, index) in selectedDistributionParameters" + ): + with vuetify.VCol( + v_if=f"index % 3 == {i}", classes="py-1" + ): + vuetify.VTextField( + label=("parameter.parameter_name",), + v_model=("parameter.parameter_default_value",), + change=( + ctrl.updateDistributionParameters, + "[parameter.parameter_name, $event, parameter.parameter_type]", + ), + error_messages=( + "parameter.parameter_error_message", + ), + type="number", + dense=True, + ) diff --git a/src/python/impactx/dashboard/Input/generalFunctions.py b/src/python/impactx/dashboard/Input/generalFunctions.py new file mode 100644 index 000000000..60164bb62 --- /dev/null +++ b/src/python/impactx/dashboard/Input/generalFunctions.py @@ -0,0 +1,263 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +import inspect +import os +import re +import subprocess +import webbrowser + +from ..trame_setup import setup_server + +server, state, ctrl = setup_server() + +# ----------------------------------------------------------------------------- +# Code +# ----------------------------------------------------------------------------- + + +class generalFunctions: + + @staticmethod + def documentation(section_name): + """ + Opens a tab to the specified section link in the documentation. + :param section_name (str): The name of the documentation section to open. + """ + + if section_name == "LatticeElements": + url = "https://impactx.readthedocs.io/en/latest/usage/python.html#lattice-elements" + elif section_name == "BeamDistributions": + url = "https://impactx.readthedocs.io/en/latest/usage/python.html#initial-beam-distributions" + elif section_name == "pythonParameters": + url = "https://impactx.readthedocs.io/en/latest/usage/python.html#general" + else: + raise ValueError(f"Invalid section name: {section_name}") + + if "WSL_DISTRO_NAME" in os.environ: + subprocess.run(["explorer.exe", url]) + else: + webbrowser.open_new_tab(url) + + # ----------------------------------------------------------------------------- + # Validation functions + # ----------------------------------------------------------------------------- + + @staticmethod + def determine_input_type(value): + """ + Determines the type of the input value. + :param value: The input value whose type needs to be determined. + :return: A tuple containing the value converted to its determined type and the type itself. + """ + + try: + return int(value), int + except ValueError: + try: + return float(value), float + except ValueError: + return value, str + + @staticmethod + def validate_against(input_value, value_type): + """ + Returns an error message if the input value type does not match the desired type. + :param input_value: The value to validate. + :param value_type: The desired type ('int', 'float', 'str'). + :return: A list of error messages. An empty list if there are no errors. + """ + + if value_type == "int": + if input_value is None: + return ["Must be an integer"] + try: + int(input_value) + return [] + except ValueError: + return ["Must be an integer"] + + elif value_type == "float": + if input_value is None: + return ["Must be a float"] + try: + float(input_value) + return [] + except ValueError: + return ["Must be a float"] + + elif value_type == "str": + if input_value is None: + return ["Must be a string"] + return [] + else: + return ["Unknown type"] + + @staticmethod + def update_simulation_validation_status(): + """ + Checks if any input fields are not provided with the correct input type. + Updates the state to enable or disable the run simulation button. + """ + + error_details = [] + + # Check for errors in distribution parameters + for param in state.selectedDistributionParameters: + if param["parameter_error_message"]: + error_details.append( + f"{param['parameter_name']}: {param['parameter_error_message']}" + ) + + # Check for errors in lattice parameters + for lattice in state.selectedLatticeList: + for param in lattice["parameters"]: + if param["parameter_error_message"]: + error_details.append( + f"Lattice {lattice['name']} - {param['parameter_name']}: {param['parameter_error_message']}" + ) + + # Check for errors in input card + if state.npart_validation: + error_details.append(f"Number of Particles: {state.npart_validation}") + if state.kin_energy_validation: + error_details.append(f"Kinetic Energy: {state.kin_energy_validation}") + if state.bunch_charge_C_validation: + error_details.append(f"Bunch Charge: {state.bunch_charge_C_validation}") + if state.selectedLatticeList == []: + error_details.append("LatticeListIsEmpty") + + state.disableRunSimulationButton = bool(error_details) + + # ----------------------------------------------------------------------------- + # Class, parameter, default value, and default type retrievals + # ----------------------------------------------------------------------------- + + @staticmethod + def find_classes(module_name): + """ + Returns a list of all classes in the given module. + :param module_name: The module to inspect. + :return: A list of tuples containing class names. + """ + + results = [] + for name in dir(module_name): + attr = getattr(module_name, name) + if inspect.isclass(attr): + results.append((name, attr)) + return results + + @staticmethod + def find_init_docstring_for_classes(classes): + """ + Retrieves the __init__ docstring of the given classes. + :param classes: A list of typles containing class names. + :return: A dictionary with class names as keys and their __init__ docstrings as values. + """ + + if not isinstance(classes, (list, tuple)): + raise TypeError("The 'classes' argument must be a list or tuple.") + + docstrings = {} + for name, cls in classes: + init_method = getattr(cls, "__init__", None) + if init_method: + docstring = cls.__init__.__doc__ + docstrings[name] = docstring + return docstrings + + @staticmethod + def extract_parameters(docstring): + """ + Parses specific information from docstrings. + Aimed to retrieve parameter names, values, and types. + :param docstring: The docstring to parse. + :return: A list of tuples containing parameter names, default values, and types. + """ + + parameters = [] + docstring = re.search(r"\((.*?)\)", docstring).group( + 1 + ) # Return class name and init signature + docstring = docstring.split(",") + + for parameter in docstring: + if parameter.startswith("self"): + continue + + name = parameter + default = None + parameter_type = "Any" + + if ":" in parameter: + split_by_semicolon = parameter.split(":", 1) + name = split_by_semicolon[0].strip() + type_and_default = split_by_semicolon[1].strip() + if "=" in type_and_default: + split_by_equals = type_and_default.split("=", 1) + parameter_type = split_by_equals[0].strip() + default = split_by_equals[1].strip() + if default.startswith("'") and default.endswith("'"): + default = default[1:-1] + else: + parameter_type = type_and_default + + parameters.append((name, default, parameter_type)) + + return parameters + + @staticmethod + def class_parameters_with_defaults(module_name): + """ + Given a module name, outputs a dictionary of class names and their parameters. + Keys are class names, and values are lists of parameter information (name, default value, type). + :param module_name: The module to inspect. + :return: A dictionary with class names as keys and parameter information as values. + """ + + classes = generalFunctions.find_classes(module_name) + docstrings = generalFunctions.find_init_docstring_for_classes(classes) + + result = {} + + for class_name, docstring in docstrings.items(): + parameters = generalFunctions.extract_parameters(docstring) + result[class_name] = parameters + + return result + + @staticmethod + def select_classes(module_name): + """ + Given a module name, outputs a list of all class names in the module. + :param module_name: The module to inspect. + :return: A list of class names. + """ + + return list(generalFunctions.class_parameters_with_defaults(module_name)) + + @staticmethod + def convert_to_correct_type(value, desired_type): + """ + Converts the given value to the desired type. + :param value: The value to convert. + :param desired_type: The type to convert the value to ('int', 'float', 'str'). + :return: The value converted to the desired type. + """ + + if value is None: + raise ValueError("Cannot convert to desired type") + if desired_type == "int": + return int(value) + elif desired_type == "float": + return float(value) + elif desired_type == "str": + return str(value) + else: + raise ValueError("Unknown type") diff --git a/src/python/impactx/dashboard/Input/inputParameters/__init__.py b/src/python/impactx/dashboard/Input/inputParameters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/python/impactx/dashboard/Input/inputParameters/inputFunctions.py b/src/python/impactx/dashboard/Input/inputParameters/inputFunctions.py new file mode 100644 index 000000000..0624ef7b0 --- /dev/null +++ b/src/python/impactx/dashboard/Input/inputParameters/inputFunctions.py @@ -0,0 +1,66 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +from ...trame_setup import setup_server + +server, state, ctrl = setup_server() + +# ----------------------------------------------------------------------------- +# Functions +# ----------------------------------------------------------------------------- + +CONVERSION_FACTORS = { + "meV": 1.0e-9, + "eV": 1.0e-6, + "keV": 1.0e-3, + "MeV": 1.0, + "GeV": 1.0e3, + "TeV": 1.0e6, +} + + +class InputFunctions: + """ + Helper functions for the + User-Input section for beam properties. + """ + + @staticmethod + def value_of_kin_energy_MeV(kineticEnergyOnDisplayValue, OldUnit): + """ + Converts the kinetic energy to MeV. + :param kineticEnergyOnDisplayValue: The kinetic energy value that is displayed in the UI. + :param OldUnit: The previous unit of kin_energy prior to change. + :return: The kinetic energy value converted to MeV. + """ + + state.kin_energy_MeV = ( + kineticEnergyOnDisplayValue + * CONVERSION_FACTORS[OldUnit] + / CONVERSION_FACTORS["MeV"] + ) + return state.kin_energy_MeV + + @staticmethod + def update_kin_energy_on_display(old_unit, new_unit, kin_energy_value): + """ + Updates the kinetic energy value in the UI. + :param old_unit: The previous unit of kin_energy prior to change. + :param new_unit: The new unit of kin_energy changed to by user. + :param kin_energy_value: The kinetic energy value in the current unit. + :return: The kinetic energy value converted to the new unit. + """ + + value_in_mev = InputFunctions.value_of_kin_energy_MeV( + kin_energy_value, old_unit + ) + kin_energy_value_on_display = ( + value_in_mev * CONVERSION_FACTORS["MeV"] / CONVERSION_FACTORS[new_unit] + ) + + return kin_energy_value_on_display diff --git a/src/python/impactx/dashboard/Input/inputParameters/inputMain.py b/src/python/impactx/dashboard/Input/inputParameters/inputMain.py new file mode 100644 index 000000000..52e7e63ba --- /dev/null +++ b/src/python/impactx/dashboard/Input/inputParameters/inputMain.py @@ -0,0 +1,153 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +from trame.widgets import vuetify + +from ...trame_setup import setup_server +from ..generalFunctions import generalFunctions +from .inputFunctions import InputFunctions + +server, state, ctrl = setup_server() + +# ----------------------------------------------------------------------------- +# Callbacks +# ----------------------------------------------------------------------------- + + +@ctrl.add("on_input_change") +def validate_and_convert_to_correct_type( + value, desired_type, state_name, validation_name +): + validation_result = generalFunctions.validate_against(value, desired_type) + setattr(state, validation_name, validation_result) + generalFunctions.update_simulation_validation_status() + + if validation_result == []: + converted_value = generalFunctions.convert_to_correct_type(value, desired_type) + + if getattr(state, state_name) != converted_value: + setattr(state, state_name, converted_value) + if state_name == "kin_energy": + state.kin_energy_MeV = InputFunctions.value_of_kin_energy_MeV( + converted_value, state.kin_energy_unit + ) + + +@ctrl.add("kin_energy_unit_change") +def on_convert_kin_energy_change(new_unit): + old_unit = state.old_kin_energy_unit + if old_unit != new_unit and float(state.kin_energy) > 0: + state.kin_energy = InputFunctions.update_kin_energy_on_display( + old_unit, new_unit, state.kin_energy + ) + state.kin_energy_unit = new_unit + state.old_kin_energy_unit = new_unit + state.kin_energy_MeV = InputFunctions.value_of_kin_energy_MeV( + float(state.kin_energy), new_unit + ) + + +# ----------------------------------------------------------------------------- +# Content +# ----------------------------------------------------------------------------- + + +class InputParameters: + """ + User-Input section for beam properties. + """ + + def __init__(self): + state.particle_shape = 2 + state.npart = 1000 + state.kin_energy = 2.0e3 + state.kin_energy_MeV = state.kin_energy + state.bunch_charge_C = 1.0e-9 + state.kin_energy_unit = "MeV" + state.old_kin_energy_unit = "MeV" + + state.npart_validation = [] + state.kin_energy_validation = [] + state.bunch_charge_C_validation = [] + + def card(self): + """ + Creates UI content for beam properties. + """ + + with vuetify.VCard(style="width: 340px; height: 300px"): + with vuetify.VCardTitle("Input Parameters"): + vuetify.VSpacer() + vuetify.VIcon( + "mdi-information", + style="color: #00313C;", + click=lambda: generalFunctions.documentation("pythonParameters"), + ) + vuetify.VDivider() + with vuetify.VCardText(): + vuetify.VCombobox( + v_model=("particle_shape",), + label="Particle Shape", + items=([1, 2, 3],), + dense=True, + ) + with vuetify.VRow(classes="my-0"): + with vuetify.VCol(cols=12, classes="py-0"): + vuetify.VTextField( + v_model=("npart",), + label="Number of Particles", + error_messages=("npart_validation",), + change=( + ctrl.on_input_change, + "[$event, 'int','npart','npart_validation']", + ), + type="number", + dense=True, + ) + with vuetify.VRow(classes="my-2"): + with vuetify.VCol(cols=8, classes="py-0"): + vuetify.VTextField( + v_model=("kin_energy",), + label="Kinetic Energy", + error_messages=("kin_energy_validation",), + change=( + ctrl.on_input_change, + "[$event, 'float','kin_energy','kin_energy_validation']", + ), + type="number", + dense=True, + classes="mr-2", + ) + with vuetify.VCol(cols=4, classes="py-0"): + vuetify.VSelect( + v_model=("kin_energy_unit",), + label="Unit", + items=(["meV", "eV", "keV", "MeV", "GeV", "TeV"],), + change=(ctrl.kin_energy_unit_change, "[$event]"), + dense=True, + ) + with vuetify.VRow(classes="my-2"): + with vuetify.VCol(cols=8, classes="py-0"): + vuetify.VTextField( + label="Bunch Charge", + v_model=("bunch_charge_C",), + error_messages=("bunch_charge_C_validation",), + change=( + ctrl.on_input_change, + "[$event, 'float','bunch_charge_C','bunch_charge_C_validation']", + ), + type="number", + dense=True, + ) + with vuetify.VCol(cols=4, classes="py-0"): + vuetify.VTextField( + label="Unit", + value="C", + dense=True, + disabled=True, + ) diff --git a/src/python/impactx/dashboard/Input/latticeConfiguration/__init__.py b/src/python/impactx/dashboard/Input/latticeConfiguration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/python/impactx/dashboard/Input/latticeConfiguration/latticeMain.py b/src/python/impactx/dashboard/Input/latticeConfiguration/latticeMain.py new file mode 100644 index 000000000..5371042c2 --- /dev/null +++ b/src/python/impactx/dashboard/Input/latticeConfiguration/latticeMain.py @@ -0,0 +1,433 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +from trame.widgets import vuetify + +from impactx import elements + +from ...trame_setup import setup_server +from ..generalFunctions import generalFunctions + +server, state, ctrl = setup_server() + +# ----------------------------------------------------------------------------- +# Helpful +# ----------------------------------------------------------------------------- + +LATTICE_ELEMENTS_MODULE_NAME = elements + +state.listOfLatticeElements = generalFunctions.select_classes( + LATTICE_ELEMENTS_MODULE_NAME +) +state.listOfLatticeElementParametersAndDefault = ( + generalFunctions.class_parameters_with_defaults(LATTICE_ELEMENTS_MODULE_NAME) +) + +# ----------------------------------------------------------------------------- +# Default +# ----------------------------------------------------------------------------- + +state.selectedLattice = None +state.selectedLatticeList = [] +state.nsliceDefaultValue = None + +# ----------------------------------------------------------------------------- +# Main Functions +# ----------------------------------------------------------------------------- + + +def add_lattice_element(): + """ + Adds the selected lattice element to the list of selected + lattice elements along with its default parameters. + :return: dictionary representing the added lattice element with its parameters. + """ + + selectedLattice = state.selectedLattice + selectedLatticeParameters = state.listOfLatticeElementParametersAndDefault.get( + selectedLattice, [] + ) + + selectedLatticeElement = { + "name": selectedLattice, + "parameters": [ + { + "parameter_name": parameter[0], + "parameter_default_value": parameter[1], + "parameter_type": parameter[2], + "parameter_error_message": generalFunctions.validate_against( + parameter[1], parameter[2] + ), + } + for parameter in selectedLatticeParameters + ], + } + + state.selectedLatticeList.append(selectedLatticeElement) + generalFunctions.update_simulation_validation_status() + return selectedLatticeElement + + +def update_latticeElement_parameters( + index, parameterName, parameterValue, parameterErrorMessage +): + """ + Updates parameter value and includes error message if user input is not valid + """ + + for param in state.selectedLatticeList[index]["parameters"]: + if param["parameter_name"] == parameterName: + param["parameter_default_value"] = parameterValue + param["parameter_error_message"] = parameterErrorMessage + + generalFunctions.update_simulation_validation_status() + state.dirty("selectedLatticeList") + + +# ----------------------------------------------------------------------------- +# Write to file functions +# ----------------------------------------------------------------------------- + + +def parameter_input_checker_for_lattice(latticeElement): + """ + Helper function to check if user input is valid. + :return: A dictionary with parameter names as keys and their validated values. + """ + + parameter_input = {} + for parameter in latticeElement["parameters"]: + if parameter["parameter_error_message"] == []: + if parameter["parameter_type"] == "str": + parameter_input[parameter["parameter_name"]] = ( + f"'{parameter['parameter_default_value']}'" + ) + else: + parameter_input[parameter["parameter_name"]] = parameter[ + "parameter_default_value" + ] + else: + parameter_input[parameter["parameter_name"]] = 0 + + return parameter_input + + +def lattice_elements(): + """ + Writes user input for lattice element parameters parameters in suitable format for simulation code. + :return: A list in the suitable format. + """ + + elements_list = [] + for latticeElement in state.selectedLatticeList: + latticeElement_name = latticeElement["name"] + parameters = parameter_input_checker_for_lattice(latticeElement) + + param_values = ", ".join(f"{value}" for value in parameters.values()) + elements_list.append(eval(f"elements.{latticeElement_name}({param_values})")) + + return elements_list + + +# ----------------------------------------------------------------------------- +# Callbacks +# ----------------------------------------------------------------------------- + + +@state.change("selectedLatticeList") +def on_selectedLatticeList_change(selectedLatticeList, **kwargs): + if selectedLatticeList == []: + state.isSelectedLatticeListEmpty = "Please select a lattice element" + generalFunctions.update_simulation_validation_status() + else: + state.isSelectedLatticeListEmpty = "" + + +@state.change("selectedLattice") +def on_lattice_element_name_change(selectedLattice, **kwargs): + return + + +@ctrl.add("add_latticeElement") +def on_add_lattice_element_click(): + selectedLattice = state.selectedLattice + if selectedLattice: + add_lattice_element() + state.dirty("selectedLatticeList") + + +@ctrl.add("updateLatticeElementParameters") +def on_lattice_element_parameter_change( + index, parameter_name, parameter_value, parameter_type +): + parameter_value, input_type = generalFunctions.determine_input_type(parameter_value) + error_message = generalFunctions.validate_against(parameter_value, parameter_type) + + update_latticeElement_parameters( + index, parameter_name, parameter_value, error_message + ) + + +@ctrl.add("clear_latticeElements") +def on_clear_lattice_element_click(): + state.selectedLatticeList = [] + + +@ctrl.add("deleteLatticeElement") +def on_delete_LatticeElement_click(index): + state.selectedLatticeList.pop(index) + state.dirty("selectedLatticeList") + + +@ctrl.add("move_latticeElementIndex_up") +def on_move_latticeElementIndex_up_click(index): + if index > 0: + state.selectedLatticeList[index], state.selectedLatticeList[index - 1] = ( + state.selectedLatticeList[index - 1], + state.selectedLatticeList[index], + ) + state.dirty("selectedLatticeList") + + +@ctrl.add("move_latticeElementIndex_down") +def on_move_latticeElementIndex_down_click(index): + if index < len(state.selectedLatticeList) - 1: + state.selectedLatticeList[index], state.selectedLatticeList[index + 1] = ( + state.selectedLatticeList[index + 1], + state.selectedLatticeList[index], + ) + state.dirty("selectedLatticeList") + + +@ctrl.add("nsliceDefaultChange") +def update_default_value(parameter_name, new_value): + data = generalFunctions.class_parameters_with_defaults(elements) + + for key, parameters in data.items(): + for i, param in enumerate(parameters): + if param[0] == parameter_name: + parameters[i] = (param[0], new_value, param[2]) + + state.listOfLatticeElementParametersAndDefault = data + + +# ----------------------------------------------------------------------------- +# ContentSetup +# ----------------------------------------------------------------------------- + + +class LatticeConfiguration: + """ + User-Input section for configuring lattice elements. + """ + + @staticmethod + def card(): + """ + Creates UI content for lattice configuration. + """ + + with vuetify.VDialog(v_model=("showDialog", False), width="1200px"): + LatticeConfiguration.dialog_lattice_elementList() + + with vuetify.VDialog(v_model=("showDialog_settings", False), width="500px"): + LatticeConfiguration.dialog_lattice_settings() + + with vuetify.VCard(style="width: 696px;"): + with vuetify.VCardTitle("Lattice Configuration"): + vuetify.VSpacer() + vuetify.VIcon( + "mdi-information", + classes="ml-2", + click=lambda: generalFunctions.documentation("LatticeElements"), + style="color: #00313C;", + ) + vuetify.VDivider() + with vuetify.VCardText(): + with vuetify.VRow(align="center", no_gutters=True): + with vuetify.VCol(cols=8): + vuetify.VCombobox( + label="Select Accelerator Lattice", + v_model=("selectedLattice", None), + items=("listOfLatticeElements",), + error_messages=("isSelectedLatticeListEmpty",), + dense=True, + classes="mr-2 pt-6", + ) + with vuetify.VCol(cols="auto"): + vuetify.VBtn( + "ADD", + color="primary", + dense=True, + classes="mr-2", + click=ctrl.add_latticeElement, + ) + with vuetify.VCol(cols="auto"): + vuetify.VBtn( + "CLEAR", + color="secondary", + dense=True, + classes="mr-2", + click=ctrl.clear_latticeElements, + ) + with vuetify.VCol(cols="auto"): + vuetify.VIcon( + "mdi-cog", + click="showDialog_settings = true", + ) + with vuetify.VRow(): + with vuetify.VCol(): + with vuetify.VCard( + style="height: 300px; width: 700px; overflow-y: auto;" + ): + with vuetify.VCardTitle( + "Elements", classes="text-subtitle-2 pa-3" + ): + vuetify.VSpacer() + vuetify.VIcon( + "mdi-arrow-expand", + color="primary", + click="showDialog = true", + ) + vuetify.VDivider() + with vuetify.VContainer(fluid=True): + with vuetify.VRow( + v_for="(latticeElement, index) in selectedLatticeList", + align="center", + no_gutters=True, + style="min-width: 1500px;", + ): + with vuetify.VCol(cols="auto", classes="pa-2"): + vuetify.VIcon( + "mdi-menu-up", + click=( + ctrl.move_latticeElementIndex_up, + "[index]", + ), + ) + vuetify.VIcon( + "mdi-menu-down", + click=( + ctrl.move_latticeElementIndex_down, + "[index]", + ), + ) + vuetify.VIcon( + "mdi-delete", + click=( + ctrl.deleteLatticeElement, + "[index]", + ), + ) + vuetify.VChip( + v_text=("latticeElement.name",), + dense=True, + classes="mr-2", + style="justify-content: center", + ) + with vuetify.VCol( + v_for="(parameter, parameterIndex) in latticeElement.parameters", + cols="auto", + classes="pa-2", + ): + vuetify.VTextField( + label=("parameter.parameter_name",), + v_model=( + "parameter.parameter_default_value", + ), + change=( + ctrl.updateLatticeElementParameters, + "[index, parameter.parameter_name, $event, parameter.parameter_type]", + ), + error_messages=( + "parameter.parameter_error_message", + ), + dense=True, + style="width: 100px;", + ) + + @staticmethod + def dialog_lattice_elementList(): + """ + Displays the content shown on the dialog + box for lattice configuration. + """ + + with vuetify.VCard(): + with vuetify.VCardTitle("Elements", classes="text-subtitle-2 pa-3"): + vuetify.VSpacer() + vuetify.VDivider() + with vuetify.VContainer(fluid=True): + with vuetify.VRow( + v_for="(latticeElement, index) in selectedLatticeList", + align="center", + no_gutters=True, + style="min-width: 1500px;", + ): + with vuetify.VCol(cols="auto", classes="pa-2"): + vuetify.VIcon( + "mdi-delete", + click=(ctrl.deleteLatticeElement, "[index]"), + ) + vuetify.VChip( + v_text=("latticeElement.name",), + dense=True, + classes="mr-2", + style="justify-content: center", + ) + with vuetify.VCol( + v_for="(parameter, parameterIndex) in latticeElement.parameters", + cols="auto", + classes="pa-2", + ): + vuetify.VTextField( + label=("parameter.parameter_name",), + v_model=("parameter.parameter_default_value",), + change=( + ctrl.updateLatticeElementParameters, + "[index, parameter.parameter_name, $event, parameter.parameter_type]", + ), + error_messages=("parameter.parameter_error_message",), + dense=True, + style="width: 100px;", + ) + + @staticmethod + def dialog_lattice_settings(): + """ + Creates UI content for lattice configuration + settings. + """ + + with vuetify.VCard(): + with vuetify.VTabs(v_model=("tab", "Settings")): + vuetify.VTab("Settings") + # vuetify.VTab("Variable Referencing") + vuetify.VDivider() + with vuetify.VTabsItems(v_model="tab"): + with vuetify.VTabItem(): + with vuetify.VContainer(fluid=True): + with vuetify.VRow(no_gutters=True, align="center"): + with vuetify.VCol(no_gutters=True, cols="auto"): + vuetify.VListItem( + "nslice", classes="ma-0 pl-0 font-weight-bold" + ) + with vuetify.VCol(no_gutters=True): + vuetify.VTextField( + v_model=("nsliceDefaultValue",), + change=( + ctrl.nsliceDefaultChange, + "['nslice', $event]", + ), + placeholder="Value", + dense=True, + outlined=True, + hide_details=True, + style="max-width: 75px", + classes="ma-0 pa-0", + ) diff --git a/src/python/impactx/dashboard/Input/trameFunctions.py b/src/python/impactx/dashboard/Input/trameFunctions.py new file mode 100644 index 000000000..599e00c03 --- /dev/null +++ b/src/python/impactx/dashboard/Input/trameFunctions.py @@ -0,0 +1,43 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +from trame.widgets import vuetify + +from ..trame_setup import setup_server + +server, state, ctrl = setup_server() + +# ----------------------------------------------------------------------------- +# Code +# ----------------------------------------------------------------------------- + + +class TrameFunctions: + """ + Contains functions containing Vuetify + components. + """ + + @staticmethod + def create_route(route_title, mdi_icon): + """ + Creates a route with a specified title and icon. + :param route_title: The title of the route. + :param mdi_icon: The icon to be used for the route. + """ + + state[route_title] = False # Does not display route by default + + to = f"/{route_title}" + click = f"{route_title} = true" + + with vuetify.VListItem(to=to, click=click): + with vuetify.VListItemIcon(): + vuetify.VIcon(mdi_icon) + with vuetify.VListItemContent(): + vuetify.VListItemTitle(route_title) diff --git a/src/python/impactx/dashboard/Toolbar/__init__.py b/src/python/impactx/dashboard/Toolbar/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/python/impactx/dashboard/Toolbar/toolbarMain.py b/src/python/impactx/dashboard/Toolbar/toolbarMain.py new file mode 100644 index 000000000..ff5894770 --- /dev/null +++ b/src/python/impactx/dashboard/Toolbar/toolbarMain.py @@ -0,0 +1,74 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +from trame.widgets import vuetify + +from ..trame_setup import setup_server + +server, state, ctrl = setup_server() + +# ----------------------------------------------------------------------------- +# Common toolbar elements +# ----------------------------------------------------------------------------- + + +class ToolbarElements: + """ + Helper functions to create + Vuetify UI elements for toolbar. + """ + + @staticmethod + def plot_options(): + vuetify.VSelect( + v_model=("active_plot", "1D plots over s"), + items=("plot_options",), + label="Select plot to view", + hide_details=True, + dense=True, + style="max-width: 250px", + disabled=("disableRunSimulationButton", True), + ) + + @staticmethod + def run_simulation_button(): + vuetify.VBtn( + "Run Simulation", + style="background-color: #00313C; color: white; margin: 0 20px;", + click=ctrl.run_simulation, + disabled=("disableRunSimulationButton", True), + ) + + +# ----------------------------------------------------------------------------- +# Content +# ----------------------------------------------------------------------------- + + +class Toolbars: + """ + Builds section toolbars for various pages. + """ + + @staticmethod + def run_toolbar(): + """ + Builds toolbar for the 'Run' page. + """ + + vuetify.VSpacer(), + ToolbarElements.run_simulation_button(), + + @staticmethod + def analyze_toolbar(): + """ + Builds toolbar for the 'Analyze' page. + """ + + vuetify.VSpacer() + ToolbarElements.plot_options() diff --git a/src/python/impactx/dashboard/__init__.py b/src/python/impactx/dashboard/__init__.py new file mode 100644 index 000000000..ba8d0fa04 --- /dev/null +++ b/src/python/impactx/dashboard/__init__.py @@ -0,0 +1,5 @@ +from .jupyterApplication import JupyterMainApplication as JupyterApp + +__all__ = [ + "JupyterApp", +] diff --git a/src/python/impactx/dashboard/__main__.py b/src/python/impactx/dashboard/__main__.py new file mode 100644 index 000000000..285c3fc18 --- /dev/null +++ b/src/python/impactx/dashboard/__main__.py @@ -0,0 +1,92 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +import sys + +from trame.ui.router import RouterViewLayout +from trame.ui.vuetify import SinglePageWithDrawerLayout +from trame.widgets import router, vuetify, xterm + +from .Analyze.plotsMain import AnalyzeSimulation +from .Input.distributionParameters.distributionMain import DistributionParameters +from .Input.inputParameters.inputMain import InputParameters +from .Input.latticeConfiguration.latticeMain import LatticeConfiguration +from .Input.trameFunctions import TrameFunctions +from .start import main +from .Toolbar.toolbarMain import Toolbars +from .trame_setup import setup_server + +server, state, ctrl = setup_server() + +# ----------------------------------------------------------------------------- +# Router Views +# ----------------------------------------------------------------------------- + +inputParameters = InputParameters() + +with RouterViewLayout(server, "/Input"): + with vuetify.VContainer(fluid=True): + with vuetify.VRow(): + with vuetify.VCol(cols="auto", classes="pa-2"): + with vuetify.VRow(no_gutters=True): + with vuetify.VCol(cols="auto", classes="pa-2"): + inputParameters.card() + with vuetify.VCol(cols="auto", classes="pa-2"): + DistributionParameters.card() + with vuetify.VRow(no_gutters=True): + with vuetify.VCol(cols="auto", classes="pa-2"): + LatticeConfiguration.card() + +with RouterViewLayout(server, "/Analyze"): + with vuetify.VContainer(fluid=True): + with vuetify.VRow(no_gutters=True, classes="fill-height"): + with vuetify.VCol(cols="auto", classes="pa-2 fill-height"): + AnalyzeSimulation.card() + with vuetify.VCol(cols="auto", classes="pa-2 fill-height"): + AnalyzeSimulation.plot() + + +# ----------------------------------------------------------------------------- +# GUI +# ----------------------------------------------------------------------------- +def init_terminal(): + with xterm.XTerm(v_if="$route.path == '/Run'") as term: + ctrl.terminal_print = term.writeln + + +def application(): + init_terminal() + with SinglePageWithDrawerLayout(server) as layout: + layout.title.hide() + with layout.toolbar: + with vuetify.Template(v_if="$route.path == '/Analyze'"): + Toolbars.analyze_toolbar() + with vuetify.Template(v_if="$route.path == '/Run'"): + Toolbars.run_toolbar() + + with layout.drawer as drawer: + drawer.width = 200 + with vuetify.VList(): + vuetify.VSubheader("Simulation") + TrameFunctions.create_route("Input", "mdi-file-edit") + TrameFunctions.create_route("Run", "mdi-play") + TrameFunctions.create_route("Analyze", "mdi-chart-box-multiple") + + with layout.content: + router.RouterView() + init_terminal() + return layout + + +application() +# ----------------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------------- + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/python/impactx/dashboard/jupyterApplication.py b/src/python/impactx/dashboard/jupyterApplication.py new file mode 100644 index 000000000..3d0fa3e8c --- /dev/null +++ b/src/python/impactx/dashboard/jupyterApplication.py @@ -0,0 +1,22 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +from .__main__ import application + + +class JupyterMainApplication: + """ + Creates specific components from + Trame Application for Jupyter Notebook. + """ + + def __init__(self): + self.ui = self.generate_ui() + + def generate_ui(self): + return application() diff --git a/src/python/impactx/dashboard/requirements.txt b/src/python/impactx/dashboard/requirements.txt new file mode 100644 index 000000000..7eaac07dc --- /dev/null +++ b/src/python/impactx/dashboard/requirements.txt @@ -0,0 +1,8 @@ +pandas>=2.2.0 +plotly>=5.23.0 +trame>=3.6.2 +trame-matplotlib>=2.0.3 +trame-plotly>=3.0.2 +trame-router>=2.2.0 +trame-vuetify>=2.6.2 +trame-xterm>=0.2.1 diff --git a/src/python/impactx/dashboard/start.py b/src/python/impactx/dashboard/start.py new file mode 100644 index 000000000..54740cd9f --- /dev/null +++ b/src/python/impactx/dashboard/start.py @@ -0,0 +1,24 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +from .trame_setup import setup_server + +server, state, ctrl = setup_server() + + +# ----------------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------------- + + +def main(): + """ + Launches Trame application server + """ + server.start() + return 0 diff --git a/src/python/impactx/dashboard/trame_setup.py b/src/python/impactx/dashboard/trame_setup.py new file mode 100644 index 000000000..fee62e48f --- /dev/null +++ b/src/python/impactx/dashboard/trame_setup.py @@ -0,0 +1,15 @@ +""" +This file is part of ImpactX + +Copyright 2024 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +from trame.app import get_server + + +def setup_server(client_type="vue2"): + server = get_server(client_type=client_type) + state, ctrl = server.state, server.controller + return server, state, ctrl