diff --git a/pyproject.toml b/pyproject.toml index c8381cb..d049066 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,12 +20,11 @@ requires-python = ">=3.8" dependencies = [ "aiida-vibroscopy>=1.0.2", "aiida-phonopy>=1.1.3", - "phonopy", - #"euphonic==1.1.0", + "phonopy=2.25.0", "pre-commit", "euphonic", "kaleido", - "weas-widget==0.1.15", + "weas-widget==0.1.19", ] [tool.ruff.lint] diff --git a/src/aiidalab_qe_vibroscopy/app/__init__.py b/src/aiidalab_qe_vibroscopy/app/__init__.py index d4e2de5..361de09 100644 --- a/src/aiidalab_qe_vibroscopy/app/__init__.py +++ b/src/aiidalab_qe_vibroscopy/app/__init__.py @@ -1,43 +1,34 @@ -from aiidalab_qe_vibroscopy.app.settings import Setting -from aiidalab_qe_vibroscopy.app.workchain import workchain_and_builder -from aiidalab_qe_vibroscopy.app.result import Result -from aiidalab_qe.common.panel import OutlinePanel +from aiidalab_qe.common.panel import PluginOutline -from aiidalab_qe.common.widgets import ( - QEAppComputationalResourcesWidget, - PwCodeResourceSetupWidget, +from aiidalab_qe_vibroscopy.app.model import VibroConfigurationSettingsModel +from aiidalab_qe_vibroscopy.app.settings import VibroConfigurationSettingPanel +from aiidalab_qe_vibroscopy.app.code import ( + VibroResourceSettingsModel, + VibroResourcesSettingsPanel, ) +from aiidalab_qe_vibroscopy.app.result.result import VibroResultsPanel +from aiidalab_qe_vibroscopy.app.result.model import VibroResultsModel +from aiidalab_qe_vibroscopy.app.workchain import workchain_and_builder -class Outline(OutlinePanel): - title = "Vibrational properties" - # description = "IR and Raman spectra; you may also select phononic and dielectric properties" +class VibroPluginOutline(PluginOutline): + title = "Vibrational Spectroscopy (VIBRO)" -PhononWorkChainPwCode = PwCodeResourceSetupWidget( - description="pw.x for phonons", # code for the PhononWorkChain workflow", - default_calc_job_plugin="quantumespresso.pw", -) - -# The finite electric field does not support npools (does not work with npools>1), so we just set it as QEAppComputationalResourcesWidget -DielectricWorkChainPwCode = QEAppComputationalResourcesWidget( - description="pw.x for dielectric", # code for the DielectricWorChain workflow", - default_calc_job_plugin="quantumespresso.pw", -) - -PhonopyCalculationCode = QEAppComputationalResourcesWidget( - description="phonopy", # code for the PhonopyCalculation calcjob", - default_calc_job_plugin="phonopy.phonopy", -) property = { - "outline": Outline, + "outline": VibroPluginOutline, + "configuration": { + "panel": VibroConfigurationSettingPanel, + "model": VibroConfigurationSettingsModel, + }, "code": { - "phonon": PhononWorkChainPwCode, - "dielectric": DielectricWorkChainPwCode, - "phonopy": PhonopyCalculationCode, + "panel": VibroResourcesSettingsPanel, + "model": VibroResourceSettingsModel, + }, + "result": { + "panel": VibroResultsPanel, + "model": VibroResultsModel, }, - "setting": Setting, "workchain": workchain_and_builder, - "result": Result, } diff --git a/src/aiidalab_qe_vibroscopy/app/code.py b/src/aiidalab_qe_vibroscopy/app/code.py new file mode 100644 index 0000000..53574d6 --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/code.py @@ -0,0 +1,29 @@ +from aiidalab_qe.common.code.model import CodeModel, PwCodeModel +from aiidalab_qe.common.panel import ResourceSettingsModel, ResourceSettingsPanel + + +class VibroResourceSettingsModel(ResourceSettingsModel): + """Resource settings for the vibroscopy calculations.""" + + codes = { + "phonon": PwCodeModel( + description="pw.x for phonons", + default_calc_job_plugin="quantumespresso.pw", + ), + "dielectric": PwCodeModel( + description="pw.x for dielectric", + default_calc_job_plugin="quantumespresso.pw", + ), + "phonopy": CodeModel( + name="phonopy", + description="phonopy", + default_calc_job_plugin="phonopy.phonopy", + ), + } + + +class VibroResourcesSettingsPanel(ResourceSettingsPanel[VibroResourceSettingsModel]): + """Panel for the resource settings for the vibroscopy calculations.""" + + title = "Vibronic" + identifier = identifier = "vibronic" diff --git a/src/aiidalab_qe_vibroscopy/app/model.py b/src/aiidalab_qe_vibroscopy/app/model.py new file mode 100644 index 0000000..86b7d3a --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/model.py @@ -0,0 +1,221 @@ +import traitlets as tl + +import numpy as np +from aiidalab_qe.common.mixins import HasInputStructure +from aiidalab_qe.common.panel import ConfigurationSettingsModel + +from aiida_phonopy.data.preprocess import PreProcessData +from aiida.plugins import DataFactory +import sys +import os + +HubbardStructureData = DataFactory("quantumespresso.hubbard_structure") +from aiida_vibroscopy.calculations.spectra_utils import get_supercells_for_hubbard +from aiida_vibroscopy.workflows.phonons.base import get_supercell_hubbard_structure + +# spinner for waiting time (supercell estimations) +spinner_html = """ + +
+
+
+""" + + +def disable_print(func): + def wrapper(*args, **kwargs): + # Save the current standard output + original_stdout = sys.stdout + # Redirect standard output to os.devnull + sys.stdout = open(os.devnull, "w") + try: + # Call the function + result = func(*args, **kwargs) + finally: + # Restore the original standard output + sys.stdout.close() + sys.stdout = original_stdout + return result + + return wrapper + + +class VibroConfigurationSettingsModel(ConfigurationSettingsModel, HasInputStructure): + dependencies = [ + "input_structure", + ] + + simulation_type_options = tl.List( + trait=tl.List(tl.Union([tl.Unicode(), tl.Int()])), + default_value=[ + ("IR/Raman, Phonon, Dielectric, INS properties", 1), + ("IR/Raman and Dielectric in Primitive Cell Approach", 2), + ("Phonons for non-polar materials and INS", 3), + ("Dielectric properties", 4), + ], + ) + simulation_type = tl.Int(1) + + symmetry_symprec = tl.Float(1e-5) + supercell_x = tl.Int(2) + supercell_y = tl.Int(2) + supercell_z = tl.Int(2) + + # Control for disable the supercell widget + + disable_x = tl.Bool(False) + disable_y = tl.Bool(False) + disable_z = tl.Bool(False) + + supercell = tl.List( + trait=tl.Int(), + default_value=[2, 2, 2], + ) + supercell_number_estimator = tl.Unicode( + "Click the button to estimate the supercell size." + ) + + def get_model_state(self): + return { + "simulation_type": self.simulation_type, + "symmetry_symprec": self.symmetry_symprec, + "supercell": self.supercell, + } + + def set_model_state(self, parameters: dict): + self.simulation_type = parameters.get("simulation_type", 1) + self.symmetry_symprec = parameters.get("symmetry_symprec", 1e-5) + self.supercell = parameters.get("supercell", [2, 2, 2]) + self.supercell_x, self.supercell_y, self.supercell_z = self.supercell + + def reset(self): + with self.hold_trait_notifications(): + self.simulation_type = 1 + self.symmetry_symprec = self._get_default("symmetry_symprec") + self.supercell = [2, 2, 2] + self.supercell_x, self.supercell_y, self.supercell_z = self.supercell + self.supercell_number_estimator = self._get_default( + "supercell_number_estimator" + ) + + def _get_default(self, trait): + return self._defaults.get(trait, self.traits()[trait].default_value) + + def on_input_structure_change(self, _=None): + if not self.input_structure: + self.reset() + + else: + self.disable_x, self.disable_y, self.disable_z = True, True, True + pbc = self.input_structure.pbc + + if pbc == (False, False, False): + # No periodicity; fully disable and reset supercell + self.supercell_x = self.supercell_y = self.supercell_z = 1 + elif pbc == (True, False, False): + self.supercell_y = self.supercell_z = 1 + self.disable_x = False + self.symmetry_symprec = 1e-3 + elif pbc == (True, True, False): + self.supercell_z = 1 + self.disable_x = self.disable_y = False + elif pbc == (True, True, True): + self.disable_x = self.disable_y = self.disable_z = False + + self.supercell = [self.supercell_x, self.supercell_y, self.supercell_z] + + def suggest_supercell(self, _=None): + """ + minimal supercell size for phonons, imposing a minimum lattice parameter of 15 A. + """ + if self.input_structure and self.input_structure.pbc != (False, False, False): + ase_structure = self.input_structure.get_ase() + suggested_3D = 15 // np.array(ase_structure.cell.cellpar()[:3]) + 1 + + # Update only dimensions that are not disabled + if not self.disable_x: + self.supercell_x = int(suggested_3D[0]) + if not self.disable_y: + self.supercell_y = int(suggested_3D[1]) + if not self.disable_z: + self.supercell_z = int(suggested_3D[2]) + + # Sync the updated values to the supercell list + self.supercell = [self.supercell_x, self.supercell_y, self.supercell_z] + + else: + return + + def supercell_reset(self, _=None): + if not self.disable_x: + self.supercell_x = self._get_default("supercell_x") + if not self.disable_y: + self.supercell_y = self._get_default("supercell_x") + if not self.disable_z: + self.supercell_z = self._get_default("supercell_x") + self.supercell = [self.supercell_x, self.supercell_y, self.supercell_z] + + def reset_symprec(self, _=None): + self.symmetry_symprec = ( + self._get_default("symmetry_symprec") + if self.input_structure.pbc != (True, False, False) + else 1e-3 + ) + self.supercell_number_estimator = self._get_default( + "supercell_number_estimator" + ) + + @disable_print + def _estimate_supercells(self, _=None): + if self.input_structure: + self.supercell_number_estimator = spinner_html + + preprocess_data = PreProcessData( + structure=self.input_structure, + supercell_matrix=[ + [self.supercell_x, 0, 0], + [0, self.supercell_y, 0], + [0, 0, self.supercell_z], + ], + symprec=self.symmetry_symprec, + distinguish_kinds=False, + is_symmetry=True, + ) + + if isinstance(self.input_structure, HubbardStructureData): + supercell = get_supercell_hubbard_structure( + self.input_structure, + self.input_structure, + metadata={"store_provenance": False}, + ) + supercells = get_supercells_for_hubbard( + preprocess_data=preprocess_data, + ref_structure=supercell, + metadata={"store_provenance": False}, + ) + else: + supercells = preprocess_data.get_supercells_with_displacements() + + self.supercell_number_estimator = f"{len(supercells)}" + + return diff --git a/src/aiidalab_qe_vibroscopy/app/result.py b/src/aiidalab_qe_vibroscopy/app/result.py index 4b1d19e..af78e20 100644 --- a/src/aiidalab_qe_vibroscopy/app/result.py +++ b/src/aiidalab_qe_vibroscopy/app/result.py @@ -4,65 +4,14 @@ from aiidalab_qe.common.panel import ResultPanel -from aiidalab_qe.common.bandpdoswidget import BandPdosPlotly -from IPython.display import display -import numpy as np -from ..utils.raman.result import export_iramanworkchain_data -from ..utils.dielectric.result import export_dielectric_data, DielectricResults -from ..utils.phonons.result import export_phononworkchain_data from ..utils.euphonic import ( export_euphonic_data, EuphonicSuperWidget, - DownloadYamlHdf5Widget, ) -import plotly.graph_objects as go -import ipywidgets as ipw - -from ..utils.raman.result import SpectrumPlotWidget, ActiveModesWidget - -class PhononBandPdosPlotly(BandPdosPlotly): - def __init__(self, bands_data=None, pdos_data=None): - super().__init__(bands_data, pdos_data) - self._bands_yaxis = go.layout.YAxis( - title=dict(text="Phonon Bands (THz)", standoff=1), - side="left", - showgrid=True, - showline=True, - zeroline=True, - range=self.SETTINGS["vertical_range_bands"], - fixedrange=False, - automargin=True, - ticks="inside", - linewidth=2, - linecolor=self.SETTINGS["axis_linecolor"], - tickwidth=2, - zerolinewidth=2, - ) - - paths = self.bands_data.get("paths") - slider_bands = go.layout.xaxis.Rangeslider( - thickness=0.08, - range=[0, paths[-1]["x"][-1]], - ) - self._bands_xaxis = go.layout.XAxis( - title="q-points", - range=[0, paths[-1]["x"][-1]], - showgrid=True, - showline=True, - tickmode="array", - rangeslider=slider_bands, - fixedrange=False, - tickvals=self.bands_data["pathlabels"][1], # ,self.band_labels[1], - ticktext=self.bands_data["pathlabels"][0], # self.band_labels[0], - showticklabels=True, - linecolor=self.SETTINGS["axis_linecolor"], - mirror=True, - linewidth=2, - type="linear", - ) +import ipywidgets as ipw class Result(ResultPanel): @@ -83,135 +32,7 @@ def _update_view(self): children_result_widget = () tab_titles = [] # this is needed to name the sub panels - spectra_data = export_iramanworkchain_data(self.node) - phonon_data = export_phononworkchain_data(self.node) ins_data = export_euphonic_data(self.node) - dielectric_data = export_dielectric_data(self.node) - - if phonon_data: - phonon_children = () - if phonon_data["bands"] or phonon_data["pdos"]: - _bands_plot_view_class = PhononBandPdosPlotly( - bands_data=phonon_data["bands"][0], - pdos_data=phonon_data["pdos"][0], - ).bandspdosfigure - _bands_plot_view_class.update_layout( - yaxis=dict(autorange=True), # Automatically scale the y-axis - ) - - # the data (bands and pdos) are the first element of the lists phonon_data["bands"] and phonon_data["pdos"]! - downloadBandsPdos_widget = DownloadBandsPdosWidget( - data=phonon_data, - ) - downloadYamlHdf5_widget = DownloadYamlHdf5Widget( - phonopy_node=self.node.outputs.vibronic.phonon_pdos.creator - ) - - phonon_children += ( - _bands_plot_view_class, - ipw.HBox( - children=[ - downloadBandsPdos_widget, - downloadYamlHdf5_widget, - ] - ), - ) - - if phonon_data["thermo"]: - import plotly.graph_objects as go - - T = phonon_data["thermo"][0][0] - F = phonon_data["thermo"][0][1] - F_units = phonon_data["thermo"][0][2] - E = phonon_data["thermo"][0][3] - E_units = phonon_data["thermo"][0][4] - Cv = phonon_data["thermo"][0][5] - Cv_units = phonon_data["thermo"][0][6] - - g = go.FigureWidget( - layout=go.Layout( - title=dict(text="Thermal properties"), - barmode="overlay", - ) - ) - g.update_layout( - xaxis=dict( - title="Temperature (K)", - linecolor="black", - linewidth=2, - showline=True, - ), - yaxis=dict(linecolor="black", linewidth=2, showline=True), - plot_bgcolor="white", - ) - g.add_scatter(x=T, y=F, name=f"Helmoltz Free Energy ({F_units})") - g.add_scatter(x=T, y=E, name=f"Entropy ({E_units})") - g.add_scatter(x=T, y=Cv, name=f"Specific Heat-V=const ({Cv_units})") - - downloadThermo_widget = DownloadThermoWidget(T, F, E, Cv) - - phonon_children += ( - g, - downloadThermo_widget, - ) - - tab_titles.append("Phonon properties") - - children_result_widget += ( - ipw.VBox( - children=phonon_children, - layout=ipw.Layout( - width="100%", - ), - ), - ) # the comma is required! otherwise the tuple is not detected. - - if spectra_data: - # Here we should provide the possibility to have both IR and Raman, - # as the new logic can provide both at the same time. - # We are gonna use the same widget, providing the correct spectrum_type: "Raman" or "Ir". - children_spectra = () - for spectrum, data in spectra_data.items(): - if not data: - continue - - elif isinstance(data, str): - # No Modes are detected. So we explain why - no_mode_widget = ipw.HTML(data) - explanation_widget = ipw.HTML( - "This may be due to the fact that the current implementation of aiida-vibroscopy plugin only considers first-order effects." - ) - - children_spectra += ( - ipw.VBox([no_mode_widget, explanation_widget]), - ) - - else: - subwidget_title = ipw.HTML(f"

{spectrum} spectroscopy

") - spectrum_widget = SpectrumPlotWidget( - node=self.node, output_node=data, spectrum_type=spectrum - ) - modes_animation = ActiveModesWidget( - node=self.node, output_node=data, spectrum_type=spectrum - ) - - children_spectra += ( - ipw.VBox([subwidget_title, spectrum_widget, modes_animation]), - ) - children_result_widget += ( - ipw.VBox( - children=children_spectra, - layout=ipw.Layout( - width="100%", - ), - ), - ) - tab_titles.append("Raman/IR spectra") - - if dielectric_data: - dielectric_results = DielectricResults(dielectric_data) - children_result_widget += (dielectric_results,) - tab_titles.append("Dielectric properties") # euphonic if ins_data: @@ -227,127 +48,3 @@ def _update_view(self): self.result_tabs.set_title(title_index, tab_titles[title_index]) self.children = [self.result_tabs] - - -class DownloadBandsPdosWidget(ipw.HBox): - def __init__(self, data, **kwargs): - self.download_button = ipw.Button( - description="Download phonon bands and pdos data", - icon="pencil", - button_style="primary", - disabled=False, - layout=ipw.Layout(width="auto"), - ) - self.download_button.on_click(self.download_data) - self.bands_data = data["bands"] - self.pdos_data = data["pdos"] - - super().__init__( - children=[ - self.download_button, - ], - ) - - def download_data(self, _=None): - """Function to download the phonon data.""" - import json - from monty.json import jsanitize - import base64 - - file_name_bands = "phonon_bands_data.json" - file_name_pdos = "phonon_dos_data.json" - if self.bands_data[0]: - bands_data_export = {} - for key, value in self.bands_data[0].items(): - if isinstance(value, np.ndarray): - bands_data_export[key] = value.tolist() - else: - bands_data_export[key] = value - - json_str = json.dumps(jsanitize(bands_data_export)) - b64_str = base64.b64encode(json_str.encode()).decode() - self._download(payload=b64_str, filename=file_name_bands) - if self.pdos_data: - json_str = json.dumps(jsanitize(self.pdos_data[0])) - b64_str = base64.b64encode(json_str.encode()).decode() - self._download(payload=b64_str, filename=file_name_pdos) - - @staticmethod - def _download(payload, filename): - from IPython.display import Javascript - - javas = Javascript( - """ - var link = document.createElement('a'); - link.href = 'data:text/json;charset=utf-8;base64,{payload}' - link.download = "{filename}" - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - """.format(payload=payload, filename=filename) - ) - display(javas) - - -class DownloadThermoWidget(ipw.HBox): - def __init__(self, T, F, E, Cv, **kwargs): - self.download_button = ipw.Button( - description="Download thermal properties data", - icon="pencil", - button_style="primary", - disabled=False, - layout=ipw.Layout(width="auto"), - ) - self.download_button.on_click(self.download_data) - - self.temperature = T - self.free_E = F - self.entropy = E - self.Cv = Cv - - super().__init__( - children=[ - self.download_button, - ], - ) - - def download_data(self, _=None): - """Function to download the phonon data.""" - import json - import base64 - - file_name = "phonon_thermo_data.json" - data_export = {} - for key, value in zip( - [ - "Temperature (K)", - "Helmoltz Free Energy (kJ/mol)", - "Entropy (J/K/mol)", - "Specific Heat-V=const (J/K/mol)", - ], - [self.temperature, self.free_E, self.entropy, self.Cv], - ): - if isinstance(value, np.ndarray): - data_export[key] = value.tolist() - else: - data_export[key] = value - - json_str = json.dumps(data_export) - b64_str = base64.b64encode(json_str.encode()).decode() - self._download(payload=b64_str, filename=file_name) - - @staticmethod - def _download(payload, filename): - from IPython.display import Javascript - - javas = Javascript( - """ - var link = document.createElement('a'); - link.href = 'data:text/json;charset=utf-8;base64,{payload}' - link.download = "{filename}" - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - """.format(payload=payload, filename=filename) - ) - display(javas) diff --git a/src/aiidalab_qe_vibroscopy/app/result/__init__.py b/src/aiidalab_qe_vibroscopy/app/result/__init__.py new file mode 100644 index 0000000..6dba6bb --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/result/__init__.py @@ -0,0 +1,7 @@ +from aiidalab_qe_vibroscopy.app.result.model import VibroResultsModel +from aiidalab_qe_vibroscopy.app.result.result import VibroResultsPanel + +__all__ = [ + "VibroResultsModel", + "VibroResultsPanel", +] diff --git a/src/aiidalab_qe_vibroscopy/app/result/model.py b/src/aiidalab_qe_vibroscopy/app/result/model.py new file mode 100644 index 0000000..a723014 --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/result/model.py @@ -0,0 +1,41 @@ +from aiidalab_qe.common.panel import ResultsModel +import traitlets as tl + + +class VibroResultsModel(ResultsModel): + identifier = "vibronic" + + _this_process_label = "VibroWorkChain" + + tab_titles = tl.List([]) + + def get_vibro_node(self): + return self._get_child_outputs() + + def needs_dielectric_tab(self): + node = self.get_vibro_node() + if not any(key in node for key in ["iraman", "dielectric", "harmonic"]): + return False + return True + + def needs_raman_tab(self): + node = self.get_vibro_node() + if not any(key in node for key in ["iraman", "harmonic"]): + return False + return True + + # Here we use _fetch_child_process_node() since the function needs the input_structure in inputs + def needs_phonons_tab(self): + node = self.get_vibro_node() + if not any( + key in node for key in ["phonon_bands", "phonon_thermo", "phonon_pdos"] + ): + return False + return True + + # Here we use _fetch_child_process_node() since the function needs the input_structure in inputs + def needs_euphonic_tab(self): + node = self.get_vibro_node() + if not any(key in node for key in ["phonon_bands"]): + return False + return True diff --git a/src/aiidalab_qe_vibroscopy/app/result/result.py b/src/aiidalab_qe_vibroscopy/app/result/result.py new file mode 100644 index 0000000..8ad8d69 --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/result/result.py @@ -0,0 +1,101 @@ +"""Vibronic results view widgets""" + +from aiidalab_qe_vibroscopy.app.result.model import VibroResultsModel +from aiidalab_qe.common.panel import ResultsPanel + +from aiidalab_qe_vibroscopy.app.widgets.dielectricwidget import DielectricWidget +from aiidalab_qe_vibroscopy.app.widgets.dielectricmodel import DielectricModel +import ipywidgets as ipw + +from aiidalab_qe_vibroscopy.app.widgets.ir_ramanwidget import IRRamanWidget +from aiidalab_qe_vibroscopy.app.widgets.ir_ramanmodel import IRRamanModel + +from aiidalab_qe_vibroscopy.app.widgets.phononwidget import PhononWidget +from aiidalab_qe_vibroscopy.app.widgets.phononmodel import PhononModel + +from aiidalab_qe_vibroscopy.app.widgets.euphonicwidget import ( + EuphonicSuperWidget as EuphonicWidget, +) +from aiidalab_qe_vibroscopy.app.widgets.euphonicmodel import ( + EuphonicBaseResultsModel as EuphonicModel, +) +# from aiidalab_qe_vibroscopy.app.widgets.euphonic.model import EuphonicModel +# from aiidalab_qe_vibroscopy.app.widgets.euphonic.widget import EuphonicWidget + + +class VibroResultsPanel(ResultsPanel[VibroResultsModel]): + title = "Vibronic" + identifier = "vibronic" + workchain_labels = ["vibro"] + + def render(self): + if self.rendered: + return + + self.tabs = ipw.Tab( + layout=ipw.Layout(min_height="250px"), + selected_index=None, + ) + self.tabs.observe( + self._on_tab_change, + "selected_index", + ) + + tab_data = [] + vibro_node = self._model.get_vibro_node() + + needs_phonons_tab = self._model.needs_phonons_tab() + if needs_phonons_tab: + vibroscopy_node = self._model._fetch_child_process_node() + phonon_model = PhononModel() + phonon_widget = PhononWidget( + model=phonon_model, + node=vibroscopy_node, + ) + tab_data.append(("Phonons", phonon_widget)) + + needs_raman_tab = self._model.needs_raman_tab() + if needs_raman_tab: + vibroscopy_node = self._model._fetch_child_process_node() + input_structure = vibroscopy_node.inputs.structure.get_ase() + irraman_model = IRRamanModel() + irraman_widget = IRRamanWidget( + model=irraman_model, + node=vibro_node, + input_structure=input_structure, + ) + + tab_data.append(("Raman/IR spectra", irraman_widget)) + + needs_dielectric_tab = self._model.needs_dielectric_tab() + if needs_dielectric_tab: + dielectric_model = DielectricModel() + dielectric_widget = DielectricWidget( + model=dielectric_model, + node=vibro_node, + ) + tab_data.append(("Dielectric Properties", dielectric_widget)) + + needs_euphonic_tab = False # self._model.needs_euphonic_tab() + if needs_euphonic_tab: + euphonic_model = EuphonicModel() + euphonic_widget = EuphonicWidget( + model=euphonic_model, + node=vibro_node, + ) + tab_data.append(("Neutron scattering", euphonic_widget)) + + # Assign children and titles dynamically + self.tabs.children = [content for _, content in tab_data] + + for index, (title, _) in enumerate(tab_data): + self.tabs.set_title(index, title) + + self.children = [self.tabs] + self.rendered = True + self.tabs.selected_index = 0 + + def _on_tab_change(self, change): + if (tab_index := change["new"]) is None: + return + self.tabs.children[tab_index].render() # type: ignore diff --git a/src/aiidalab_qe_vibroscopy/app/settings.py b/src/aiidalab_qe_vibroscopy/app/settings.py index f4992a5..47ef5a4 100644 --- a/src/aiidalab_qe_vibroscopy/app/settings.py +++ b/src/aiidalab_qe_vibroscopy/app/settings.py @@ -8,80 +8,33 @@ """ import ipywidgets as ipw -import traitlets as tl -import numpy as np -from aiida import orm -from aiidalab_qe.common.panel import Panel - - -import sys -import os +from aiidalab_qe.common.panel import ConfigurationSettingsPanel +from aiidalab_qe_vibroscopy.app.model import VibroConfigurationSettingsModel from aiida.plugins import DataFactory HubbardStructureData = DataFactory("quantumespresso.hubbard_structure") -# spinner for waiting time (supercell estimations) -spinner_html = """ - -
-
-
-""" - - -def disable_print(func): - def wrapper(*args, **kwargs): - # Save the current standard output - original_stdout = sys.stdout - # Redirect standard output to os.devnull - sys.stdout = open(os.devnull, "w") - try: - # Call the function - result = func(*args, **kwargs) - finally: - # Restore the original standard output - sys.stdout.close() - sys.stdout = original_stdout - return result - - return wrapper - -class Setting(Panel): +class VibroConfigurationSettingPanel( + ConfigurationSettingsPanel[VibroConfigurationSettingsModel], +): title = "Vibrational Settings" + identifier = "vibronic" - simulation_mode = [ - ("IR/Raman, Phonon, Dielectric, INS properties", 1), - ("IR/Raman and Dielectric in Primitive Cell Approach", 2), - ("Phonons for non-polar materials and INS", 3), - ("Dielectric properties", 4), - ] + def __init__(self, model: VibroConfigurationSettingsModel, **kwargs): + super().__init__(model, **kwargs) - input_structure = tl.Instance(orm.StructureData, allow_none=True) + self._model.observe( + self._on_input_structure_change, + "input_structure", + ) + + def render(self): + if self.rendered: + return - def __init__(self, **kwargs): self.settings_title = ipw.HTML( """

Vibrational Settings

""" @@ -118,46 +71,71 @@ def __init__(self, **kwargs): """, ) - self.calc_options_description = ipw.HTML("Select calculation:") - self.calc_options = ipw.Dropdown( - options=self.simulation_mode, + self.simulation_type = ipw.Dropdown( layout=ipw.Layout(width="450px"), - value=self.simulation_mode[0][1], + ) + ipw.dlink( + (self._model, "simulation_type_options"), + (self.simulation_type, "options"), + ) + ipw.link( + (self._model, "simulation_type"), + (self.simulation_type, "value"), + ) + self.simulation_type.observe( + self._on_change_simulation_type, + "value", ) - self.calc_options.observe(self._display_supercell, names="value") - - # start Supercell - - self.supercell = [2, 2, 2] - - def change_supercell(_=None): - self.supercell = [ - self._sc_x.value, - self._sc_y.value, - self._sc_z.value, - ] + self.symmetry_symprec = ipw.BoundedFloatText( + max=1, + min=1e-7, + step=1e-4, + description="Symmetry tolerance (symprec):", + style={"description_width": "initial"}, + layout={"width": "300px"}, + ) + ipw.link( + (self._model, "symmetry_symprec"), + (self.symmetry_symprec, "value"), + ) - if self.input_structure: - pbc = self.input_structure.pbc - else: - pbc = (True, True, True) - - for elem, periodic in zip(["x", "y", "z"], pbc): - # periodic allows support of hints also for 2D, 1D. - setattr( - self, - "_sc_" + elem, - ipw.BoundedIntText( - value=2 if periodic else 1, - min=1, - layout={"width": "40px"}, - disabled=False if periodic else True, - ), - ) - for elem in [self._sc_x, self._sc_y, self._sc_z]: - elem.observe(change_supercell, names="value") - elem.observe(self._activate_estimate_supercells, names="value") + self.supercell_x = ipw.BoundedIntText( + min=1, + layout={"width": "40px"}, + ) + self.supercell_y = ipw.BoundedIntText( + min=1, + layout={"width": "40px"}, + ) + self.supercell_z = ipw.BoundedIntText( + min=1, + layout={"width": "40px"}, + ) + ipw.link( + (self._model, "supercell_x"), + (self.supercell_x, "value"), + ) + ipw.link( + (self._model, "disable_x"), + (self.supercell_x, "disabled"), + ) + ipw.link( + (self._model, "supercell_y"), + (self.supercell_y, "value"), + ) + ipw.link( + (self._model, "disable_y"), + (self.supercell_y, "disabled"), + ) + ipw.link( + (self._model, "supercell_z"), + (self.supercell_z, "value"), + ) + ipw.link( + (self._model, "disable_z"), + (self.supercell_z, "disabled"), + ) self.supercell_selector = ipw.HBox( children=[ @@ -167,9 +145,9 @@ def change_supercell(_=None): ) ] + [ - self._sc_x, - self._sc_y, - self._sc_z, + self.supercell_x, + self.supercell_y, + self.supercell_z, ], ) @@ -182,8 +160,9 @@ def change_supercell(_=None): layout=ipw.Layout(width="100px"), button_style="info", ) + # supercell hint (15A lattice params) - self.supercell_hint_button.on_click(self._suggest_supercell) + self.supercell_hint_button.on_click(self._model.suggest_supercell) # reset supercell self.supercell_reset_button = ipw.Button( @@ -193,26 +172,38 @@ def change_supercell(_=None): button_style="warning", ) # supercell reset reaction - self.supercell_reset_button.on_click(self._reset_supercell) + self.supercell_reset_button.on_click(self._model.supercell_reset) - # Estimate supercell button - self.supercell_estimate_button = ipw.Button( - description="Estimate number of supercells ➡", + self.symmetry_symprec_reset_button = ipw.Button( + description="Reset symprec", disabled=False, - layout=ipw.Layout(width="240px", display="none"), - button_style="info", - tooltip="Number of supercells for phonons calculations;\nwarning: for large systems, this may take some time.", + layout=ipw.Layout(width="125px"), + button_style="warning", ) # supercell reset reaction - self.supercell_estimate_button.on_click(self._estimate_supercells) + self.symmetry_symprec_reset_button.on_click(self._model.reset_symprec) # Estimate the number of supercells for frozen phonons. self.supercell_number_estimator = ipw.HTML( - # description="Number of supercells:", - value="?", style={"description_width": "initial"}, layout=ipw.Layout(display="none"), ) + ipw.link( + (self._model, "supercell_number_estimator"), + (self.supercell_number_estimator, "value"), + ) + + # Estimate supercell button + self.supercell_estimate_button = ipw.Button( + description="Estimate number of supercells ➡", + disabled=False, + layout=ipw.Layout(width="240px", display="none"), + button_style="info", + tooltip="Number of supercells for phonons calculations;\nwarning: for large systems, this may take some time.", + ) + + # supercell reset reaction + self.supercell_estimate_button.on_click(self._model._estimate_supercells) ## end supercell hint. @@ -233,26 +224,9 @@ def change_supercell(_=None): self.supercell_widget.layout.display = "block" # end Supercell. - self.symmetry_symprec = ipw.FloatText( - value=1e-5, - max=1, - min=1e-7, # Ensure the value is always positive - step=1e-4, # Step value of 1e-4 - description="Symmetry tolerance (symprec):", - style={"description_width": "initial"}, - layout={"width": "300px"}, - ) - self.symmetry_symprec.observe(self._activate_estimate_supercells, "value") + # self.symmetry_symprec.observe(self._activate_estimate_supercells, "value") # reset supercell - self.symmetry_symprec_reset_button = ipw.Button( - description="Reset symprec", - disabled=False, - layout=ipw.Layout(width="125px"), - button_style="warning", - ) - # supercell reset reaction - self.symmetry_symprec_reset_button.on_click(self._reset_symprec) self.children = [ ipw.VBox( @@ -273,8 +247,8 @@ def change_supercell(_=None): ), ipw.HBox( [ - self.calc_options_description, - self.calc_options, + ipw.HTML("Select calculation:"), + self.simulation_type, ], ), self.supercell_widget, @@ -286,157 +260,26 @@ def change_supercell(_=None): ), ] - super().__init__(**kwargs) - - # we define a block for the estimation of the supercell if we ask for hint, - # so that we call the estimator only at the end of the supercell hint generator, - # and now each time after the x, y, z generation (i.e., we don't lose time). - # see the methods below. - self.block = False - - @tl.observe("input_structure") - def _update_input_structure(self, change): - if self.input_structure: - for direction, periodic in zip( - [self._sc_x, self._sc_y, self._sc_z], self.input_structure.pbc - ): - direction.value = 2 if periodic else 1 - direction.disabled = False if periodic else True - - self.supercell_number_estimator.layout.display = ( - "block" if len(self.input_structure.sites) <= 30 else "none" - ) - self.supercell_estimate_button.layout.display = ( - "block" if len(self.input_structure.sites) <= 30 else "none" - ) - else: - self.supercell_number_estimator.layout.display = "none" - self.supercell_estimate_button.layout.display = "none" - - def _display_supercell(self, change): - selected = change["new"] - if selected in [1, 3]: - self.supercell_widget.layout.display = "block" - else: - self.supercell_widget.layout.display = "none" - - def _suggest_supercell(self, _=None): - """ - minimal supercell size for phonons, imposing a minimum lattice parameter of 15 A. - """ - if self.input_structure: - s = self.input_structure.get_ase() - suggested_3D = 15 // np.array(s.cell.cellpar()[:3]) + 1 - - # if disabled, it means that it is a non-periodic direction. - # here we manually unobserve the `_activate_estimate_supercells`, so it is faster - # and only compute when all the three directions are updated - self.block = True - for direction, suggested, original in zip( - [self._sc_x, self._sc_y, self._sc_z], suggested_3D, s.cell.cellpar()[:3] - ): - direction.value = suggested if not direction.disabled else 1 - self.block = False - self._activate_estimate_supercells() - else: - return - - def _activate_estimate_supercells(self, _=None): - self.supercell_estimate_button.disabled = False - self.supercell_number_estimator.value = "?" - - # @tl.observe("input_structure") - @disable_print - def _estimate_supercells(self, _=None): - """_summary_ - - Estimate the number of supercells to be computed for frozen phonon calculation. - """ - if self.block: - return - - symprec_value = self.symmetry_symprec.value - - self.symmetry_symprec.value = max(1e-5, min(symprec_value, 1)) - - self.supercell_number_estimator.value = spinner_html + self.rendered = True + self._on_change_simulation_type({"new": 1}) - from aiida_phonopy.data.preprocess import PreProcessData - - if self.input_structure: - preprocess_data = PreProcessData( - structure=self.input_structure, - supercell_matrix=[ - [self._sc_x.value, 0, 0], - [0, self._sc_y.value, 0], - [0, 0, self._sc_z.value], - ], - symprec=self.symmetry_symprec.value, - distinguish_kinds=False, - is_symmetry=True, - ) - - supercells = preprocess_data.get_supercells_with_displacements() - - # for now, we comment the following part, as the HubbardSD is generated in the submission step. - """if isinstance(self.input_structure, HubbardStructureData): - from aiida_vibroscopy.calculations.spectra_utils import get_supercells_for_hubbard - from aiida_vibroscopy.workflows.phonons.base import get_supercell_hubbard_structure - supercell = get_supercell_hubbard_structure( - self.input_structure, - self.input_structure, - metadata={"store_provenance": False}, - ) - supercells = get_supercells_for_hubbard( - preprocess_data=preprocess_data, - ref_structure=supercell, - metadata={"store_provenance": False}, - ) + def _on_input_structure_change(self, _): + self.refresh(specific="structure") + self._model.on_input_structure_change() - else: - supercells = preprocess_data.get_supercells_with_displacements() - """ - self.supercell_number_estimator.value = f"{len(supercells)}" - self.supercell_estimate_button.disabled = True - - return - - def _reset_supercell(self, _=None): - if self.input_structure is not None: - reset_supercell = [] - self.block = True - for direction, periodic in zip( - [self._sc_x, self._sc_y, self._sc_z], self.input_structure.pbc - ): - reset_supercell.append(2 if periodic else 1) - (self._sc_x.value, self._sc_y.value, self._sc_z.value) = reset_supercell - self.block = False - self._activate_estimate_supercells() - return - - def _reset_symprec(self, _=None): - self.symmetry_symprec.value = 1e-5 - self._activate_estimate_supercells() - return - - def get_panel_value(self): - """Return a dictionary with the input parameters for the plugin.""" - return { - "simulation_mode": self.calc_options.value, - "supercell_selector": self.supercell, - "symmetry_symprec": self.symmetry_symprec.value, - } - - def set_panel_value(self, input_dict): - """Load a dictionary with the input parameters for the plugin.""" - self.calc_options.value = input_dict.get("simulation_mode", 1) - self.supercell = input_dict.get("supercell_selector", [2, 2, 2]) - self.symmetry_symprec.value = input_dict.get("symmetry_symprec", 1e-5) - self._sc_x.value, self._sc_y.value, self._sc_z.value = self.supercell - - def reset(self): - """Reset the panel""" - self.calc_options.value = 1 - self.supercell = [2, 2, 2] - self.symmetry_symprec.value = 1e-5 - self._sc_x.value, self._sc_y.value, self._sc_z.value = self.supercell + def _on_change_simulation_type(self, _): + self.supercell_widget.layout.display = ( + "block" if self._model.simulation_type in [1, 3] else "none" + ) + self.supercell_number_estimator.layout.display = ( + "block" + if self._model.simulation_type in [1, 3] + and len(self._model.input_structure.sites) <= 30 + else "none" + ) + self.supercell_estimate_button.layout.display = ( + "block" + if self._model.simulation_type in [1, 3] + and len(self._model.input_structure.sites) <= 30 + else "none" + ) diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/dielectricmodel.py b/src/aiidalab_qe_vibroscopy/app/widgets/dielectricmodel.py new file mode 100644 index 0000000..95f376f --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/dielectricmodel.py @@ -0,0 +1,162 @@ +from aiidalab_qe.common.mvc import Model +import traitlets as tl +from aiida.common.extendeddicts import AttributeDict +from aiidalab_qe_vibroscopy.utils.dielectric.result import NumpyEncoder +import numpy as np +import base64 +import json +from IPython.display import display +from aiidalab_qe_vibroscopy.utils.dielectric.result import export_dielectric_data + + +class DielectricModel(Model): + vibro = tl.Instance(AttributeDict, allow_none=True) + + site_selector_options = tl.List( + trait=tl.Tuple((tl.Unicode(), tl.Int())), + ) + dielectric_data = {} + + dielectric_tensor_table = tl.Unicode("") + born_charges_table = tl.Unicode("") + raman_tensors_table = tl.Unicode("") + site = tl.Int() + + def fetch_data(self): + """Fetch the dielectric data from the VibroWorkChain""" + self.dielectric_data = export_dielectric_data(self.vibro) + + def set_initial_values(self): + """Set the initial values for the model.""" + + self.dielectric_tensor_table = self._create_dielectric_tensor_table() + self.born_charges_table = self._create_born_charges_table(0) + self.raman_tensors_table = self._create_raman_tensors_table(0) + self.site_selector_options = self._get_site_selector_options() + + def _get_site_selector_options(self): + """Get the site selector options.""" + if not self.dielectric_data: + return [] + + unit_cell_sites = self.dielectric_data["unit_cell"] + decimal_places = 5 + # Create the options with rounded positions + site_selector_options = [ + ( + f"{site.kind_name} @ ({', '.join(f'{coord:.{decimal_places}f}' for coord in site.position)})", + index, + ) + for index, site in enumerate(unit_cell_sites) + ] + return site_selector_options + + def _create_dielectric_tensor_table(self): + """Create the HTML table for the dielectric tensor.""" + if not self.dielectric_data: + return "" + + dielectric_tensor = self.dielectric_data["dielectric_tensor"] + table_data = self._generate_table(dielectric_tensor) + return table_data + + def _create_born_charges_table(self, site_index): + """Create the HTML table for the Born charges.""" + if not self.dielectric_data: + return "" + + born_charges = self.dielectric_data["born_charges"] + round_data = born_charges[site_index].round(6) + table_data = self._generate_table(round_data) + return table_data + + def _create_raman_tensors_table(self, site_index): + """Create the HTML table for the Raman tensors.""" + if not self.dielectric_data: + return "" + + raman_tensors = self.dielectric_data["raman_tensors"] + round_data = raman_tensors[site_index].round(6) + table_data = self._generate_table(round_data, cell_width="200px") + return table_data + + def download_data(self, _=None): + """Function to download the data.""" + if self.dielectric_data: + data_to_print = { + key: value + for key, value in self.dielectric_data.items() + if key != "unit_cell" + } + file_name = "dielectric_data.json" + json_str = json.dumps(data_to_print, cls=NumpyEncoder) + b64_str = base64.b64encode(json_str.encode()).decode() + self._download(payload=b64_str, filename=file_name) + + @staticmethod + def _download(payload, filename): + """Download payload as a file named as filename.""" + from IPython.display import Javascript + + javas = Javascript( + f""" + var link = document.createElement('a'); + link.href = 'data:text/json;charset=utf-8;base64,{payload}' + link.download = "{filename}" + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + """ + ) + display(javas) + + def on_site_selection_change(self, site): + self.site = site + self.born_charges_table = self._create_born_charges_table(site) + self.raman_tensors_table = self._create_raman_tensors_table(site) + + def _generate_table(self, data, cell_width="50px"): + rows = [] + for row in data: + cells = [] + for value in row: + # Check if value is a numpy array + if isinstance(value, np.ndarray): + # Format the numpy array as a string, e.g., "[0, 0, 1]" + value_str = np.array2string( + value, separator=", ", formatter={"all": lambda x: f"{x:.6g}"} + ) + cell = f"{value_str}" + elif isinstance(value, str) and value == "special": + # Handle the "special" keyword + cell = f'{value}' + else: + # Handle other types (numbers, strings, etc.) + cell = f"{value}" + cells.append(cell) + rows.append(f"{''.join(cells)}") + + # Define the HTML with styles, using the dynamic cell width + table_html = f""" + + + {''.join(rows)} +
+ """ + return table_html diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/dielectricwidget.py b/src/aiidalab_qe_vibroscopy/app/widgets/dielectricwidget.py new file mode 100644 index 0000000..ac6c889 --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/dielectricwidget.py @@ -0,0 +1,110 @@ +import ipywidgets as ipw +from aiidalab_qe_vibroscopy.app.widgets.dielectricmodel import DielectricModel +from aiidalab_qe.common.widgets import LoadingWidget + + +class DielectricWidget(ipw.VBox): + """ + Widget for displaying dielectric properties results + """ + + def __init__(self, model: DielectricModel, node: None, **kwargs): + super().__init__( + children=[LoadingWidget("Loading widgets")], + **kwargs, + ) + self._model = model + + self.rendered = False + self._model.vibro = node + + def render(self): + if self.rendered: + return + + self.dielectric_results_help = ipw.HTML( + """
+ The DielectricWorkchain computes different properties:
+ -High Freq. Dielectric Tensor
+ -Born Charges
+ -Raman Tensors
+ -The non-linear optical susceptibility tensor
+ All information can be downloaded as a JSON file.
+ +
""" + ) + + self.site_selector = ipw.Dropdown( + layout=ipw.Layout(width="450px"), + description="Select atom site:", + style={"description_width": "initial"}, + ) + ipw.dlink( + (self._model, "site_selector_options"), + (self.site_selector, "options"), + ) + self.site_selector.observe(self._on_site_change, names="value") + + self.download_button = ipw.Button( + description="Download Data", icon="download", button_style="primary" + ) + + self.download_button.on_click(self._model.download_data) + + # HTML table with the dielectric tensor + self.dielectric_tensor_table = ipw.HTML() + ipw.link( + (self._model, "dielectric_tensor_table"), + (self.dielectric_tensor_table, "value"), + ) + + # HTML table with the Born charges @ site + self.born_charges_table = ipw.HTML() + ipw.link( + (self._model, "born_charges_table"), + (self.born_charges_table, "value"), + ) + + # HTML table with the Raman tensors @ site + self.raman_tensors_table = ipw.HTML() + ipw.link( + (self._model, "raman_tensors_table"), + (self.raman_tensors_table, "value"), + ) + + self.children = [ + self.dielectric_results_help, + ipw.HTML("

Dielectric tensor

"), + self.dielectric_tensor_table, + self.site_selector, + ipw.HBox( + [ + ipw.VBox( + [ + ipw.HTML("

Born effective charges

"), + self.born_charges_table, + ] + ), + ipw.VBox( + [ + ipw.HTML("

Raman Tensor

"), + self.raman_tensors_table, + ] + ), + ] + ), + self.download_button, + ] + + self.rendered = True + self._initial_view() + + def _initial_view(self): + self._model.fetch_data() + self._model.set_initial_values() + self.dielectric_tensor_table.layout = ipw.Layout(width="300px", height="auto") + self.born_charges_table.layout = ipw.Layout(width="300px", height="auto") + # self.raman_tensors_table.layout = ipw.Layout(width="auto", height="auto") + + def _on_site_change(self, change): + self._model.on_site_selection_change(change["new"]) diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/model.py b/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/model.py new file mode 100644 index 0000000..264ca43 --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/model.py @@ -0,0 +1,15 @@ +from aiidalab_qe.common.panel import ResultsModel +from aiida.common.extendeddicts import AttributeDict +import traitlets as tl +from aiidalab_qe_vibroscopy.utils.euphonic.data_manipulation.intensity_maps import ( + export_euphonic_data, +) + + +class EuphonicModel(ResultsModel): + node = tl.Instance(AttributeDict, allow_none=True) + + def fetch_data(self): + ins_data = export_euphonic_data(self.node) + self.fc = ins_data["fc"] + self.q_path = ins_data["q_path"] diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/powder_full_model.py b/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/powder_full_model.py new file mode 100644 index 0000000..1f1a1a3 --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/powder_full_model.py @@ -0,0 +1,8 @@ +from __future__ import annotations +from aiidalab_qe.common.mvc import Model +import traitlets as tl +from aiida.common.extendeddicts import AttributeDict + + +class PowderFullModel(Model): + vibro = tl.Instance(AttributeDict, allow_none=True) diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/powder_full_widget.py b/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/powder_full_widget.py new file mode 100644 index 0000000..839fe69 --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/powder_full_widget.py @@ -0,0 +1,40 @@ +import ipywidgets as ipw +from aiidalab_qe_vibroscopy.app.widgets.euphonic.powder_full_model import ( + PowderFullModel, +) + + +class PowderFullWidget(ipw.VBox): + def __init__(self, model: PowderFullModel, node: None, **kwargs): + super().__init__( + children=[ipw.HTML("Loading Powder data...")], + **kwargs, + ) + self._model = model + self._model.vibro = node + self.rendered = False + + def render(self): + if self.rendered: + return + + self.children = [ipw.HTML("Here goes widgets for Powder data")] + + self.rendered = True + + # self._model.fetch_data() + # self._needs_powder_widget() + # self.render_widgets() + + # def _needs_powder_widget(self): + # if self._model.needs_powder_tab: + # self.powder_model = PowderModel() + # self.powder_widget = PowderWidget( + # model=self.powder_model, + # node=self._model.vibro, + # ) + # self.children = (*self.children, self.powder_widget) + + # def render_widgets(self): + # if self._model.needs_powder_tab: + # self.powder_widget.render() diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/single_crystal_model.py b/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/single_crystal_model.py new file mode 100644 index 0000000..308aedb --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/single_crystal_model.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from aiidalab_qe.common.mvc import Model +import traitlets as tl +from aiida.common.extendeddicts import AttributeDict +from IPython.display import display +from euphonic import ForceConstants + + +class SingleCrystalFullModel(Model): + node = tl.Instance(AttributeDict, allow_none=True) + + fc = tl.Instance(ForceConstants, allow_none=True) + q_path = tl.Dict(allow_none=True) + + custom_kpath = tl.Unicode("") + q_spacing = tl.Float(0.01) + energy_broadening = tl.Float(0.05) + energy_bins = tl.Int(200) + temperature = tl.Float(0) + weighting = tl.Unicode("coherent") + + E_units_button_options = tl.List( + trait=tl.List(tl.Unicode()), + default_value=[ + ("meV", "meV"), + ("THz", "THz"), + ], + ) + E_units = tl.Unicode("meV") + + slider_intensity = tl.List( + trait=tl.Float(), + default_value=[1, 10], + ) + + parameters = tl.Dict() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.update_parameters() + + # Observe changes in dependent trailets + self.observe( + self.update_parameters, + names=[ + "weighting", + "E_units", + "temperature", + "q_spacing", + "energy_broadening", + "energy_bins", + ], + ) + + def update_parameters(self): + """Update the parameters dictionary dynamically.""" + self.parameters = { + "weighting": self.weighting, + "grid": None, + "grid_spacing": 0.1, + "energy_units": self.E_units, + "temperature": self.temperature, + "shape": "gauss", + "length_unit": "angstrom", + "q_spacing": self.q_spacing, + "energy_broadening": self.energy_broadening, + "q_broadening": None, + "ebins": self.energy_bins, + "e_min": 0, + "e_max": None, + "title": None, + "ylabel": "THz", + "xlabel": "", + "save_json": False, + "no_base_style": False, + "style": False, + "vmin": None, + "vmax": None, + "save_to": None, + "asr": None, + "dipole_parameter": 1.0, + "use_c": None, + "n_threads": None, + } + + def _update_spectra(self): + q_path = self.q_path + if self.custom_kpath: + q_path = self.q_path + q_path["coordinates"], q_path["labels"] = self.curate_path_and_labels( + self.custom_kpath + ) + q_path["delta_q"] = self.q_spacing + + # spectra, parameters = produce_bands_weigthed_data( + # params=self.parameters, + # fc=self.fc, + # linear_path=q_path, + # plot=False, + # ) + + # if self.custom_path: + # self.x, self.y = np.meshgrid( + # spectra[0].x_data.magnitude, spectra[0].y_data.magnitude + # ) + # ( + # self.final_xspectra, + # self.final_zspectra, + # self.ticks_positions, + # self.ticks_labels, + # ) = generated_curated_data(spectra) + # else: + # # Spectrum2D as output of the powder data + # self.x, self.y = np.meshgrid( + # spectra.x_data.magnitude, spectra.y_data.magnitude + # ) + + # # we don't need to curate the powder data, + # # we can directly use them: + # self.final_xspectra = spectra.x_data.magnitude + # self.final_zspectra = spectra.z_data.magnitude + + def curate_path_and_labels(self, path): + # This is used to curate the path and labels of the spectra if custom kpath is provided. + # I do not like this implementation (MB) + coordinates = [] + labels = [] + linear_paths = path.split("|") + for i in linear_paths: + scoords = [] + s = i.split( + " - " + ) # not i.split("-"), otherwise also the minus of the negative numbers are used for the splitting. + for k in s: + labels.append(k.strip()) + # AAA missing support for fractions. + l = tuple(map(float, [kk for kk in k.strip().split(" ")])) # noqa: E741 + scoords.append(l) + coordinates.append(scoords) + return coordinates, labels + + @staticmethod + def _download(payload, filename): + from IPython.display import Javascript + + javas = Javascript( + """ + var link = document.createElement('a'); + link.href = 'data:text/json;charset=utf-8;base64,{payload}' + link.download = "{filename}" + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + """.format(payload=payload, filename=filename) + ) + display(javas) diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/single_crystal_widget.py b/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/single_crystal_widget.py new file mode 100644 index 0000000..37d313e --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/single_crystal_widget.py @@ -0,0 +1,218 @@ +import ipywidgets as ipw +from aiidalab_qe_vibroscopy.app.widgets.euphonic.single_crystal_model import ( + SingleCrystalFullModel, +) +import plotly.graph_objects as go + + +class SingleCrystalFullWidget(ipw.VBox): + def __init__(self, model: SingleCrystalFullModel, node: None, **kwargs): + super().__init__( + children=[ipw.HTML("Loading Single Crystal data...")], + **kwargs, + ) + self._model = model + self._model.vibro = node + self.rendered = False + + def render(self): + if self.rendered: + return + + self.custom_kpath_description = ipw.HTML( + """ +
+ Custom q-points path for the structure factor:
+ we can provide it via a specific format:
+ (1) each linear path should be divided by '|';
+ (2) each path is composed of 'qxi qyi qzi - qxf qyf qzf' where qxi and qxf are, respectively, + the start and end x-coordinate of the q direction, in reciprocal lattice units (rlu).
+ An example path is: '0 0 0 - 1 1 1 | 1 1 1 - 0.5 0.5 0.5'.
+ For now, we do not support fractions (i.e. we accept 0.5 but not 1/2). +
+ """ + ) + + self.fig = go.FigureWidget() + + self.slider_intensity = ipw.FloatRangeSlider( + min=1, + max=100, + step=1, + orientation="horizontal", + readout=True, + readout_format=".0f", + layout=ipw.Layout( + width="400px", + ), + ) + ipw.link( + (self._model, "slider_intensity"), + (self.slider_intensity, "value"), + ) + + self.E_units_button = ipw.ToggleButtons( + description="Energy units:", + layout=ipw.Layout( + width="auto", + ), + ) + ipw.dlink( + (self._model, "E_units_button_options"), + (self.E_units_button, "options"), + ) + + ipw.link( + (self._model, "E_units"), + (self.E_units_button, "value"), + ) + + self.plot_button = ipw.Button( + description="Replot", + icon="pencil", + button_style="primary", + disabled=True, + layout=ipw.Layout(width="auto"), + ) + + self.reset_button = ipw.Button( + description="Reset", + icon="recycle", + button_style="primary", + disabled=False, + layout=ipw.Layout(width="auto"), + ) + + self.download_button = ipw.Button( + description="Download Data and Plot", + icon="download", + button_style="primary", + disabled=False, # Large files... + layout=ipw.Layout(width="auto"), + ) + + self.q_spacing = ipw.FloatText( + step=0.001, + description="q step (1/A)", + tooltip="q spacing in 1/A", + ) + ipw.link( + (self._model, "q_spacing"), + (self.q_spacing, "value"), + ) + self.energy_broadening = ipw.FloatText( + step=0.01, + description="ΔE (meV)", + tooltip="Energy broadening in meV", + ) + ipw.link( + (self._model, "energy_broadening"), + (self.energy_broadening, "value"), + ) + + self.energy_bins = ipw.IntText( + description="#E bins", + tooltip="Number of energy bins", + ) + ipw.link( + (self._model, "energy_bins"), + (self.energy_bins, "value"), + ) + + self.temperature = ipw.FloatText( + step=0.01, + description="T (K)", + disabled=False, + ) + ipw.link( + (self._model, "temperature"), + (self.temperature, "value"), + ) + + self.weight_button = ipw.ToggleButtons( + options=[ + ("Coherent", "coherent"), + ("DOS", "dos"), + ], + description="weight:", + disabled=False, + style={"description_width": "initial"}, + ) + ipw.link( + (self._model, "weighting"), + (self.weight_button, "value"), + ) + + self.custom_kpath = ipw.Text( + description="Custom path (rlu):", + style={"description_width": "initial"}, + ) + + ipw.link( + (self._model, "custom_kpath"), + (self.custom_kpath, "value"), + ) + + self.children = [ + ipw.HTML("

Neutron dynamic structure factor - Single Crystal

"), + self.fig, + self.slider_intensity, + ipw.HTML("(Intensity is relative to the maximum intensity at T=0K)"), + self.E_units_button, + ipw.HBox( + [ + ipw.VBox( + [ + ipw.HBox( + [ + self.reset_button, + self.plot_button, + self.download_button, + ] + ), + self.q_spacing, + self.energy_broadening, + self.energy_bins, + self.temperature, + self.weight_button, + ], + layout=ipw.Layout( + width="60%", + ), + ), + ipw.VBox( + [ + self.custom_kpath_description, + self.custom_kpath, + ], + layout=ipw.Layout( + width="70%", + ), + ), + ], # end of HBox children + ), + ] + + self.rendered = True + self._init_view() + + def _init_view(self, _=None): + print("Init view") + # self._model._update_spectra() + + # self._model.fetch_data() + # self._needs_single_crystal_widget() + # self.render_widgets() + + # def _needs_single_crystal_widget(self): + # if self._model.needs_single_crystal_tab: + # self.single_crystal_model = SingleCrystalModel() + # self.single_crystal_widget = SingleCrystalWidget( + # model=self.single_crystal_model, + # node=self._model.vibro, + # ) + # self.children = (*self.children, self.single_crystal_widget) + + # def render_widgets(self): + # if self._model.needs_single_crystal_tab: + # self.single_crystal_widget.render() diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/widget.py b/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/widget.py new file mode 100644 index 0000000..68ece6c --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/euphonic/widget.py @@ -0,0 +1,98 @@ +import ipywidgets as ipw +from aiidalab_qe_vibroscopy.app.widgets.euphonic.model import EuphonicModel +from aiidalab_qe_vibroscopy.app.widgets.euphonic.single_crystal_widget import ( + SingleCrystalFullWidget, +) +from aiidalab_qe_vibroscopy.app.widgets.euphonic.single_crystal_model import ( + SingleCrystalFullModel, +) +from aiidalab_qe_vibroscopy.app.widgets.euphonic.powder_full_widget import ( + PowderFullWidget, +) +from aiidalab_qe_vibroscopy.app.widgets.euphonic.powder_full_model import ( + PowderFullModel, +) + + +class EuphonicWidget(ipw.VBox): + """ + Widget for the Euphonic Results + """ + + def __init__(self, model: EuphonicModel, node: None, **kwargs): + super().__init__(children=[ipw.HTML("Loading Euphonic data...")], **kwargs) + self._model = model + self._model.node = node + self.rendered = False + + def render(self): + if self.rendered: + return + + self.rendering_results_button = ipw.Button( + description="Initialise INS data", + icon="pencil", + button_style="primary", + layout=ipw.Layout(width="auto"), + ) + self.rendering_results_button.on_click( + self._on_rendering_results_button_clicked + ) + + self.tabs = ipw.Tab( + layout=ipw.Layout(min_height="250px"), + selected_index=None, + ) + self.tabs.observe( + self._on_tab_change, + "selected_index", + ) + + self.children = [ + ipw.HBox( + [ + ipw.HTML("Click the button to initialise the INS data."), + self.rendering_results_button, + ] + ) + ] + + self.rendered = True + self._model.fetch_data() + # self.render_widgets() + + def _on_rendering_results_button_clicked(self, _): + self.children = [] + tab_data = [] + + single_crystal_model = SingleCrystalFullModel() + single_crystal_widget = SingleCrystalFullWidget( + model=single_crystal_model, + node=self._model.node, + ) + # We need to link the q_path and fc from the EuphonicModel to the SingleCrystalModel + single_crystal_widget._model.q_path = self._model.q_path + single_crystal_widget._model.fc = self._model.fc + + powder_model = PowderFullModel() + powder_widget = PowderFullWidget( + model=powder_model, + node=self._model.node, + ) + qplane_widget = ipw.HTML("Q-plane view data") + tab_data.append(("Single crystal", single_crystal_widget)) + tab_data.append(("Powder", powder_widget)) + tab_data.append(("Q-plane view", qplane_widget)) + # Assign children and titles dynamically + self.tabs.children = [content for _, content in tab_data] + + for index, (title, _) in enumerate(tab_data): + self.tabs.set_title(index, title) + + self.children = [self.tabs] + self.tabs.selected_index = 0 + + def _on_tab_change(self, change): + if (tab_index := change["new"]) is None: + return + self.tabs.children[tab_index].render() # type: ignore diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/euphonicmodel.py b/src/aiidalab_qe_vibroscopy/app/widgets/euphonicmodel.py new file mode 100644 index 0000000..687b838 --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/euphonicmodel.py @@ -0,0 +1,252 @@ +import numpy as np +import traitlets as tl +import copy + +from aiidalab_qe_vibroscopy.utils.euphonic.data_manipulation.intensity_maps import ( + AttrDict, + produce_bands_weigthed_data, + produce_powder_data, + generated_curated_data, + par_dict, + par_dict_powder, + export_euphonic_data, + generate_force_constant_instance, +) + +from aiidalab_qe_vibroscopy.utils.euphonic.tab_widgets.euphonic_q_planes_widgets import ( + produce_Q_section_modes, + produce_Q_section_spectrum, +) + +from aiidalab_qe.common.mvc import Model + + +class EuphonicBaseResultsModel(Model): + """Model for the neutron scattering results panel.""" + + # Here we mode all the model and data-controller, i.e. all the data and their + # manipulation to produce new spectra. + # plot-controller, i.e. scales, colors and so on, should be attached to the widget, I think. + # the above last point should be discussed more. + + # For now, we do only the first of the following: + # 1. single crystal data: sc + # 2. powder average: pa + # 3. Q planes: qp + # TRY it on the detached app. + + # AAA TOBE defined with respect to the type + spectra = {} + path = [] + q_path = None + + # Settings for single crystal and powder average + q_spacing = tl.Float(0.01) + energy_broadening = tl.Float(0.05) + energy_bins = tl.Int(200) + temperature = tl.Float(0) + weighting = tl.Unicode("coherent") + + def fetch_data(self): + """Fetch the data from the database or from the uploaded files.""" + # 1. from aiida, so we have the node + if self.node: + ins_data = export_euphonic_data(self.node) + self.fc = ins_data["fc"] + self.q_path = ins_data["q_path"] + # 2. from uploaded files... + else: + self.fc = self.upload_widget._read_phonopy_files( + fname=self.fname, + phonopy_yaml_content=self._model.phonopy_yaml_content, + fc_hdf5_content=self._model.fc_hdf5_content, + ) + + def _inject_single_crystal_settings( + self, + ): + self.parameters = copy.deepcopy( + par_dict + ) # need to be different if powder or q section. + self._callback_spectra_generation = produce_bands_weigthed_data + # Dynamically add a trait for single crystal settings + self.add_traits(custom_kpath=tl.Unicode("")) + + def _inject_powder_settings( + self, + ): + self.parameters = copy.deepcopy(par_dict_powder) + self._callback_spectra_generation = produce_powder_data + # Dynamically add a trait for powder settings + self.add_traits(q_min=tl.Float(0.0)) + self.add_traits(q_max=tl.Float(1)) + self.add_traits(npts=tl.Int(100)) + + def _inject_qsection_settings( + self, + ): + # self._callback_spectra_generation = produce_Q_section_modes + # Dynamically add a trait for q section settings + self.add_traits(center_e=tl.Float(0.0)) + self.add_traits(Q0_vec=tl.List(trait=tl.Float(), default_value=[0.0, 0.0, 0.0])) + self.add_traits( + h_vec=tl.List(trait=tl.Float(), default_value=[1, 1, 1, 100, 1]) + ) + self.add_traits( + k_vec=tl.List(trait=tl.Float(), default_value=[1, 1, 1, 100, 1]) + ) + + def set_model_state(self, parameters: dict): + for k, v in parameters.items(): + setattr(self, k, v) + + def _get_default(self, trait): + if trait in ["h_vec", "k_vec"]: + return [1, 1, 1, 100, 1] + elif trait == "Q0_vec": + return [0.0, 0.0, 0.0] + return self.traits()[trait].default_value + + def get_model_state(self): + return {trait: getattr(self, trait) for trait in self.traits()} + + def reset( + self, + ): + with self.hold_trait_notifications(): + for trait in self.traits(): + setattr(self, trait, self._get_default(trait)) + + def _update_spectra( + self, + ): + # This is used to update the spectra when the parameters are changed + # and the + if not hasattr(self, "parameters"): + self._inject_single_crystal_settings() + + self.parameters.update(self.get_model_state()) + parameters_ = AttrDict(self.parameters) + + # custom linear path + custom_kpath = self.custom_kpath if hasattr(self, "custom_kpath") else "" + if len(custom_kpath) > 1: + coordinates, labels = self.curate_path_and_labels() + qpath = { + "coordinates": coordinates, + "labels": labels, # ["$\Gamma$","X","X","(1,1,1)"], + "delta_q": parameters_["q_spacing"], + } + else: + qpath = copy.deepcopy(self.q_path) + if qpath: + qpath["delta_q"] = parameters_["q_spacing"] + + spectra, parameters = self._callback_spectra_generation( + params=parameters_, + fc=self.fc, + linear_path=qpath, + plot=False, + ) + + # curated spectra (labels and so on...) + if hasattr(self, "custom_kpath"): # single crystal case + self.x, self.y = np.meshgrid( + spectra[0].x_data.magnitude, spectra[0].y_data.magnitude + ) + ( + self.final_xspectra, + self.final_zspectra, + self.ticks_positions, + self.ticks_labels, + ) = generated_curated_data(spectra) + else: + # Spectrum2D as output of the powder data + self.x, self.y = np.meshgrid( + spectra.x_data.magnitude, spectra.y_data.magnitude + ) + + # we don't need to curate the powder data, + # we can directly use them: + self.final_xspectra = spectra.x_data.magnitude + self.final_zspectra = spectra.z_data.magnitude + + def _update_qsection_spectra( + self, + ): + parameters_ = AttrDict( + { + "h": np.array([i for i in self.h_vec[:-2]]), + "k": np.array([i for i in self.k_vec[:-2]]), + "n_h": int(self.h_vec[-2]), + "n_k": int(self.k_vec[-2]), + "h_extension": self.h_vec[-1], + "k_extension": self.k_vec[-1], + "Q0": np.array([i for i in self.Q0_vec[:]]), + "ecenter": self.center_e, + "deltaE": self.energy_broadening, + "bins": self.energy_bins, + "spectrum_type": self.weighting, + "temperature": self.temperature, + } + ) + + modes, q_array, h_array, k_array, labels, dw = produce_Q_section_modes( + self.fc, + h=parameters_.h, + k=parameters_.k, + Q0=parameters_.Q0, + n_h=parameters_.n_h, + n_k=parameters_.n_k, + h_extension=parameters_.h_extension, + k_extension=parameters_.k_extension, + temperature=parameters_.temperature, + ) + + self.av_spec, self.q_array, self.h_array, self.k_array, self.labels = ( + produce_Q_section_spectrum( + modes, + q_array, + h_array, + k_array, + ecenter=parameters_.ecenter, + deltaE=parameters_.deltaE, + bins=parameters_.bins, + spectrum_type=parameters_.spectrum_type, + dw=dw, + labels=labels, + ) + ) + + def curate_path_and_labels( + self, + ): + # This is used to curate the path and labels of the spectra if custom kpath is provided. + # I do not like this implementation (MB) + coordinates = [] + labels = [] + path = self.custom_kpath + linear_paths = path.split("|") + for i in linear_paths: + scoords = [] + s = i.split( + " - " + ) # not i.split("-"), otherwise also the minus of the negative numbers are used for the splitting. + for k in s: + labels.append(k.strip()) + # AAA missing support for fractions. + l = tuple(map(float, [kk for kk in k.strip().split(" ")])) # noqa: E741 + scoords.append(l) + coordinates.append(scoords) + return coordinates, labels + + def _clone(self): + return copy.deepcopy(self) + + def produce_phonopy_files(self): + # This is used to produce the phonopy files from + # PhonopyCalculation data. The files are phonopy.yaml and force_constants.hdf5 + phonopy_yaml, fc_hdf5 = generate_force_constant_instance( + self.node.phonon_bands.creator, mode="download" + ) + return phonopy_yaml, fc_hdf5 diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/euphonicwidget.py b/src/aiidalab_qe_vibroscopy/app/widgets/euphonicwidget.py new file mode 100644 index 0000000..2a3ad09 --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/euphonicwidget.py @@ -0,0 +1,312 @@ +import pathlib +import tempfile + + +from IPython.display import display + +import ipywidgets as ipw + +# from ..euphonic.bands_pdos import * +from aiidalab_qe_vibroscopy.utils.euphonic.data_manipulation.intensity_maps import ( + generate_force_constant_instance, + export_euphonic_data, # noqa: F401 +) +from aiidalab_qe_vibroscopy.utils.euphonic.tab_widgets.euphonic_single_crystal_widgets import ( + SingleCrystalFullWidget, +) +from aiidalab_qe_vibroscopy.utils.euphonic.tab_widgets.euphonic_powder_widgets import ( + PowderFullWidget, +) +from aiidalab_qe_vibroscopy.utils.euphonic.tab_widgets.euphonic_q_planes_widgets import ( + QSectionFullWidget, +) + + +from aiidalab_qe.common.widgets import LoadingWidget +###### START for detached app: + + +# Upload buttons +class UploadPhonopyYamlWidget(ipw.FileUpload): + def __init__(self, **kwargs): + super().__init__( + description="upload phonopy YAML file", + multiple=False, + layout={"width": "initial"}, + ) + + +class UploadForceConstantsHdf5Widget(ipw.FileUpload): + def __init__(self, **kwargs): + super().__init__( + description="upload force constants HDF5 file", + multiple=False, + layout={"width": "initial"}, + ) + + +class UploadPhonopyWidget(ipw.HBox): + def __init__(self, **kwargs): + self.upload_phonopy_yaml = UploadPhonopyYamlWidget(**kwargs) + self.upload_phonopy_hdf5 = UploadForceConstantsHdf5Widget(**kwargs) + + self.reset_uploads = ipw.Button( + description="Discard uploaded files", + icon="pencil", + button_style="warning", + disabled=False, + layout=ipw.Layout(width="auto"), + ) + + super().__init__( + children=[ + self.upload_phonopy_yaml, + self.upload_phonopy_hdf5, + self.reset_uploads, + ], + **kwargs, + ) + + def _read_phonopy_files(self, fname, phonopy_yaml_content, fc_hdf5_content=None): + suffix = "".join(pathlib.Path(fname).suffixes) + + with tempfile.NamedTemporaryFile(suffix=suffix) as temp_yaml: + temp_yaml.write(phonopy_yaml_content) + temp_yaml.flush() + + if fc_hdf5_content: + with tempfile.NamedTemporaryFile(suffix=".hdf5") as temp_file: + temp_file.write(fc_hdf5_content) + temp_file.flush() + temp_hdf5_name = temp_file.name + + try: + fc = generate_force_constant_instance( + path=pathlib.Path(fname), + summary_name=temp_yaml.name, + fc_name=temp_hdf5_name, + ) + except ValueError: + return None + + return fc + else: + temp_hdf5_name = None + + try: + fc = generate_force_constant_instance( + path=pathlib.Path(fname), + summary_name=temp_yaml.name, + # fc_name=temp_hdf5_name, + ) + except ValueError: + return None + + return fc + + +#### END for detached app + + +##### START OVERALL WIDGET TO DISPLAY EVERYTHING: + + +class EuphonicSuperWidget(ipw.VBox): + """ + Widget that will include everything, + from the upload widget to the tabs with single crystal and powder predictions. + In between, we trigger the initialization of plots via a button. + """ + + def __init__( + self, mode="aiidalab-qe app plugin", model=None, node=None, fc=None, q_path=None + ): + """ + Initialize the Euphonic utility class. + Parameters: + ----------- + mode : str, optional + The mode of operation, default is "aiidalab-qe app plugin". + fc : optional + Force constants, default is None. + q_path : optional + Q-path for phonon calculations, default is None. If Low-D system, this can be provided. + It is the same path obtained for the PhonopyCalculation of the phonopy_bands. + Attributes: + ----------- + mode : str + The mode of operation. + upload_widget : UploadPhonopyWidget + Widget for uploading phonopy files. + fc_hdf5_content : None + Content of the force constants HDF5 file. + tab_widget : ipw.Tab + Tab widget for different views. + plot_button : ipw.Button + Button to initialize INS data. + fc : optional + Force constants if provided. + """ + + self.mode = mode + self._model = model # this is the single crystal model. + self._model.node = node + self._model.fc_hdf5_content = None + + self.rendered = False + + super().__init__() + + def render(self): + if self.rendered: + return + + self.upload_widget = UploadPhonopyWidget() + self.upload_widget.reset_uploads.on_click(self._on_reset_uploads_button_clicked) + + self.tab_widget = ipw.Tab() + self.tab_widget.layout.display = "none" + self.tab_widget.set_title(0, "Single crystal") + self.tab_widget.set_title(1, "Powder sample") + self.tab_widget.set_title(2, "Q-plane view") + self.tab_widget.children = () + + self.plot_button = ipw.Button( + description="Initialise INS data", + icon="pencil", + button_style="primary", + disabled=True, + layout=ipw.Layout(width="auto"), + ) + self.plot_button.on_click(self._on_first_plot_button_clicked) + + self.loading_widget = LoadingWidget("Loading INS data") + self.loading_widget.layout.display = "none" + + if self.mode == "aiidalab-qe app plugin": + self.upload_widget.layout.display = "none" + self.plot_button.disabled = False + else: + self.upload_widget.children[0].observe(self._on_upload_yaml, "_counter") + self.upload_widget.children[1].observe(self._on_upload_hdf5, "_counter") + + self.download_widget = DownloadYamlHdf5Widget(model=self._model) + self.download_widget.layout.display = "none" + + self.children = [ + self.upload_widget, + self.plot_button, + self.loading_widget, + self.tab_widget, + self.download_widget, + ] + + self.rendered = True + + def _on_reset_uploads_button_clicked(self, change): + self.upload_widget.upload_phonopy_yaml.value.clear() + self.upload_widget.upload_phonopy_yaml._counter = 0 + self.upload_widget.upload_phonopy_hdf5.value.clear() + self.upload_widget.upload_phonopy_hdf5._counter = 0 + + self.plot_button.layout.display = "block" + self.plot_button.disabled = True + + self.tab_widget.children = () + + self.tab_widget.layout.display = "none" + + def _on_upload_yaml(self, change): + if change["new"] != change["old"]: + for fname in self.upload_widget.children[ + 0 + ].value.keys(): # always one key because I allow only one file at the time. + self.fname = fname + self._model.phonopy_yaml_content = self.upload_widget.children[0].value[ + fname + ]["content"] + + if self.plot_button.disabled: + self.plot_button.disabled = False + + def _on_upload_hdf5(self, change): + if change["new"] != change["old"]: + for fname in self.upload_widget.children[1].value.keys(): + self._model.fc_hdf5_content = self.upload_widget.children[1].value[ + fname + ]["content"] + + def _on_first_plot_button_clicked(self, change=None): # basically the render. + # It creates the widgets + self.plot_button.layout.display = "none" + self.loading_widget.layout.display = "block" + + self._model.fetch_data() # should be in the model. + powder_model = self._model._clone() + qsection_model = self._model._clone() + + # I first initialise this widget, to then have the 0K ref for the other two. + # the model is passed to the widget. For the other two, I need to generate the model. + singlecrystalwidget = SingleCrystalFullWidget(model=self._model) + + # I need to generate the models for the other two widgets. + self._model._inject_single_crystal_settings() + powder_model._inject_powder_settings() + qsection_model._inject_qsection_settings() + + self.tab_widget.children = ( + singlecrystalwidget, + PowderFullWidget(model=powder_model), + QSectionFullWidget(model=qsection_model), + ) + + for widget in self.tab_widget.children: + widget.render() # this is the render method of the widget. + + self.loading_widget.layout.display = "none" + self.tab_widget.layout.display = "block" + self.download_widget.layout.display = "block" + + +class DownloadYamlHdf5Widget(ipw.HBox): + def __init__(self, model): + self._model = model + + self.download_button = ipw.Button( + description="Download phonopy data", + icon="pencil", + button_style="primary", + disabled=False, + layout=ipw.Layout(width="auto"), + ) + self.download_button.on_click(self._download_data) + + super().__init__( + children=[ + self.download_button, + ], + ) + + def _download_data(self, _=None): + """ + Download both the phonopy.yaml and fc.hdf5 files. + """ + phonopy_yaml, fc_hdf5 = self._model.produce_phonopy_files() + self._download(payload=phonopy_yaml, filename="phonopy" + ".yaml") + self._download(payload=fc_hdf5, filename="fc" + ".hdf5") + + @staticmethod + def _download(payload, filename): + from IPython.display import Javascript + + javas = Javascript( + """ + var link = document.createElement('a'); + link.href = 'data:text/json;charset=utf-8;base64,{payload}' + link.download = "{filename}" + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + """.format(payload=payload, filename=filename) + ) + display(javas) diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/ir_ramanmodel.py b/src/aiidalab_qe_vibroscopy/app/widgets/ir_ramanmodel.py new file mode 100644 index 0000000..8d9e604 --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/ir_ramanmodel.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from aiidalab_qe.common.mvc import Model +from aiida.common.extendeddicts import AttributeDict +from ase.atoms import Atoms +import traitlets as tl + +from aiidalab_qe_vibroscopy.utils.raman.result import export_iramanworkchain_data + + +class IRRamanModel(Model): + vibro = tl.Instance(AttributeDict, allow_none=True) + input_structure = tl.Instance(Atoms, allow_none=True) + + needs_raman_tab = tl.Bool() + needs_ir_tab = tl.Bool() + + def fetch_data(self): + spectra_data = export_iramanworkchain_data(self.vibro) + if spectra_data["Ir"] == "No IR modes detected.": + self.needs_ir_tab = False + else: + self.needs_ir_tab = True + if spectra_data["Raman"] == "No Raman modes detected.": + self.needs_raman_tab = False + else: + self.needs_raman_tab = True diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/ir_ramanwidget.py b/src/aiidalab_qe_vibroscopy/app/widgets/ir_ramanwidget.py new file mode 100644 index 0000000..3666076 --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/ir_ramanwidget.py @@ -0,0 +1,58 @@ +from aiidalab_qe_vibroscopy.app.widgets.ir_ramanmodel import IRRamanModel +from aiidalab_qe_vibroscopy.app.widgets.ramanwidget import RamanWidget +from aiidalab_qe_vibroscopy.app.widgets.ramanmodel import RamanModel +import ipywidgets as ipw + + +class IRRamanWidget(ipw.VBox): + def __init__(self, model: IRRamanModel, node: None, input_structure, **kwargs): + super().__init__( + children=[ipw.HTML("Loading Raman data...")], + **kwargs, + ) + self._model = model + self._model.vibro = node + self._model.input_structure = input_structure + self.rendered = False + + def render(self): + if self.rendered: + return + + self.children = [] + + self.rendered = True + self._model.fetch_data() + self._needs_raman_widget() + self._needs_ir_widget() + self.render_widgets() + + def _needs_raman_widget(self): + if self._model.needs_raman_tab: + self.raman_model = RamanModel() + self.raman_widget = RamanWidget( + model=self.raman_model, + node=self._model.vibro, + input_structure=self._model.input_structure, + spectrum_type="Raman", + ) + self.children = (*self.children, self.raman_widget) + + def _needs_ir_widget(self): + if self._model.needs_ir_tab: + self.ir_model = RamanModel() + self.ir_widget = RamanWidget( + model=self.ir_model, + node=self._model.vibro, + input_structure=self._model.input_structure, + spectrum_type="IR", + ) + self.children = (*self.children, self.ir_widget) + else: + self.children = (*self.children, ipw.HTML("No IR modes detected.")) + + def render_widgets(self): + if self._model.needs_raman_tab: + self.raman_widget.render() + if self._model.needs_ir_tab: + self.ir_widget.render() diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/phononmodel.py b/src/aiidalab_qe_vibroscopy/app/widgets/phononmodel.py new file mode 100644 index 0000000..41010a4 --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/phononmodel.py @@ -0,0 +1,114 @@ +from aiidalab_qe.common.mvc import Model +import traitlets as tl + +from aiida.orm.nodes.process.workflow.workchain import WorkChainNode +from aiidalab_qe_vibroscopy.utils.phonons.result import export_phononworkchain_data +from IPython.display import display +import numpy as np + + +class PhononModel(Model): + vibro = tl.Instance(WorkChainNode, allow_none=True) + + pdos_data = {} + bands_data = {} + thermo_data = {} + + def fetch_data(self): + """Fetch the phonon data from the VibroWorkChain""" + phonon_data = export_phononworkchain_data(self.vibro) + self.pdos_data = phonon_data["pdos"][0] + self.bands_data = phonon_data["bands"][0] + self.thermo_data = phonon_data["thermo"][0] + + def update_thermo_plot(self, fig): + """Update the thermal properties plot.""" + self.temperature = self.thermo_data[0] + self.free_E = self.thermo_data[1] + F_units = self.thermo_data[2] + self.entropy = self.thermo_data[3] + E_units = self.thermo_data[4] + self.Cv = self.thermo_data[5] + Cv_units = self.thermo_data[6] + fig.update_layout( + xaxis=dict( + title="Temperature (K)", + linecolor="black", + linewidth=2, + showline=True, + ), + yaxis=dict(linecolor="black", linewidth=2, showline=True), + plot_bgcolor="white", + ) + fig.add_scatter( + x=self.temperature, y=self.free_E, name=f"Helmoltz Free Energy ({F_units})" + ) + fig.add_scatter(x=self.temperature, y=self.entropy, name=f"Entropy ({E_units})") + fig.add_scatter( + x=self.temperature, y=self.Cv, name=f"Specific Heat-V=const ({Cv_units})" + ) + + def download_thermo_data(self, _=None): + """Function to download the phonon data.""" + import json + import base64 + + file_name = "phonon_thermo_data.json" + data_export = {} + for key, value in zip( + [ + "Temperature (K)", + "Helmoltz Free Energy (kJ/mol)", + "Entropy (J/K/mol)", + "Specific Heat-V=const (J/K/mol)", + ], + [self.temperature, self.free_E, self.entropy, self.Cv], + ): + if isinstance(value, np.ndarray): + data_export[key] = value.tolist() + else: + data_export[key] = value + + json_str = json.dumps(data_export) + b64_str = base64.b64encode(json_str.encode()).decode() + self._download(payload=b64_str, filename=file_name) + + def download_bandspdos_data(self, _=None): + """Function to download the phonon data.""" + import json + from monty.json import jsanitize + import base64 + + file_name_bands = "phonon_bands_data.json" + file_name_pdos = "phonon_dos_data.json" + if self.bands_data: + bands_data_export = {} + for key, value in self.bands_data.items(): + if isinstance(value, np.ndarray): + bands_data_export[key] = value.tolist() + else: + bands_data_export[key] = value + + json_str = json.dumps(jsanitize(bands_data_export)) + b64_str = base64.b64encode(json_str.encode()).decode() + self._download(payload=b64_str, filename=file_name_bands) + if self.pdos_data: + json_str = json.dumps(jsanitize(self.pdos_data)) + b64_str = base64.b64encode(json_str.encode()).decode() + self._download(payload=b64_str, filename=file_name_pdos) + + @staticmethod + def _download(payload, filename): + from IPython.display import Javascript + + javas = Javascript( + """ + var link = document.createElement('a'); + link.href = 'data:text/json;charset=utf-8;base64,{payload}' + link.download = "{filename}" + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + """.format(payload=payload, filename=filename) + ) + display(javas) diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/phononwidget.py b/src/aiidalab_qe_vibroscopy/app/widgets/phononwidget.py new file mode 100644 index 0000000..c5df792 --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/phononwidget.py @@ -0,0 +1,74 @@ +import ipywidgets as ipw + +from aiidalab_qe.common.widgets import LoadingWidget +from aiidalab_qe_vibroscopy.app.widgets.phononmodel import PhononModel + +import plotly.graph_objects as go +from aiidalab_qe.common.bands_pdos.bandpdosplotly import BandsPdosPlotly + + +class PhononWidget(ipw.VBox): + """ + Widget for displaying phonon properties results + """ + + def __init__(self, model: PhononModel, node: None, **kwargs): + super().__init__( + children=[LoadingWidget("Loading widgets")], + **kwargs, + ) + self._model = model + self._model.vibro = node + self.rendered = False + + def render(self): + if self.rendered: + return + + self.bandspdos_download_button = ipw.Button( + description="Download phonon bands and dos data", + icon="pencil", + button_style="primary", + layout=ipw.Layout(width="300px"), + ) + self.bandspdos_download_button.on_click(self._model.download_bandspdos_data) + + self.thermal_plot = go.FigureWidget( + layout=go.Layout( + title=dict(text="Thermal properties"), + barmode="overlay", + ) + ) + + self.thermo_download_button = ipw.Button( + description="Download thermal properties data", + icon="pencil", + button_style="primary", + layout=ipw.Layout(width="300px"), + ) + self.thermo_download_button.on_click(self._model.download_thermo_data) + + self.children = [ + self.bandspdos_download_button, + self.thermal_plot, + self.thermo_download_button, + ] + + self.rendered = True + self._init_view() + + def _init_view(self): + self._model.fetch_data() + self.bands_pdos = BandsPdosPlotly( + self._model.bands_data, self._model.pdos_data + ).bandspdosfigure + y_max = max(self.bands_pdos.data[0].y) + y_min = min(self.bands_pdos.data[0].y) + x_max = max(self.bands_pdos.data[1].x) + self.bands_pdos.update_layout( + xaxis=dict(title="q-points"), + yaxis=dict(title="Phonon Bands (THz)", range=[y_min - 0.1, y_max + 0.1]), + xaxis2=dict(range=[0, x_max + 0.1]), + ) + self.children = (self.bands_pdos, *self.children) + self._model.update_thermo_plot(self.thermal_plot) diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/ramanmodel.py b/src/aiidalab_qe_vibroscopy/app/widgets/ramanmodel.py new file mode 100644 index 0000000..c1bebfa --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/ramanmodel.py @@ -0,0 +1,399 @@ +from __future__ import annotations +from aiidalab_qe.common.mvc import Model +import traitlets as tl +from aiida.common.extendeddicts import AttributeDict +from ase.atoms import Atoms +from IPython.display import display +import numpy as np +from aiida_vibroscopy.utils.broadenings import multilorentz +import plotly.graph_objects as go +import base64 +import json + + +class RamanModel(Model): + vibro = tl.Instance(AttributeDict, allow_none=True) + input_structure = tl.Instance(Atoms, allow_none=True) + spectrum_type = tl.Unicode() + + plot_type_options = tl.List( + trait=tl.List(tl.Unicode()), + default_value=[ + ("Powder", "powder"), + ("Single Crystal", "single_crystal"), + ], + ) + + plot_type = tl.Unicode("powder") + temperature = tl.Float(300) + frequency_laser = tl.Float(532) + pol_incoming = tl.Unicode("0 0 1") + pol_outgoing = tl.Unicode("0 0 1") + broadening = tl.Float(10.0) + separate_polarizations = tl.Bool(False) + + frequencies = [] + intensities = [] + + frequencies_depolarized = [] + intensities_depolarized = [] + + # Active modes + active_modes_options = tl.List( + trait=tl.Tuple((tl.Unicode(), tl.Int())), + ) + active_mode = tl.Int(0) + amplitude = tl.Float(3.0) + + supercell_0 = tl.Int(1) + supercell_1 = tl.Int(1) + supercell_2 = tl.Int(1) + + def fetch_data(self): + """Fetch the Raman data from the VibroWorkChain""" + self.raman_data = self.get_vibrational_data(self.vibro) + self.raw_frequencies, self.eigenvectors, self.labels = ( + self.raman_data.run_active_modes( + selection_rule=self.spectrum_type.lower(), + ) + ) + self.rounded_frequencies = [ + round(frequency, 3) for frequency in self.raw_frequencies + ] + self.active_modes_options = self._get_active_modes_options() + + def _get_active_modes_options(self): + active_modes_options = [ + (f"{index + 1}: {value}", index) + for index, value in enumerate(self.rounded_frequencies) + ] + + return active_modes_options + + def update_data(self): + """ + Update the plot data based on the selected spectrum type, plot type, and configuration. + """ + if self.plot_type == "powder": + self._update_powder_data() + else: + self._update_single_crystal_data() + + def _update_powder_data(self): + """ + Update data for the powder plot, handling both Raman and IR spectra. + """ + if self.spectrum_type == "Raman": + ( + polarized_intensities, + depolarized_intensities, + frequencies, + _, + ) = self.raman_data.run_powder_raman_intensities( + frequencies=self.frequency_laser, + temperature=self.temperature, + ) + + if self.separate_polarizations: + self.frequencies, self.intensities = self.generate_plot_data( + frequencies, + polarized_intensities, + self.broadening, + ) + self.frequencies_depolarized, self.intensities_depolarized = ( + self.generate_plot_data( + frequencies, + depolarized_intensities, + self.broadening, + ) + ) + else: + combined_intensities = polarized_intensities + depolarized_intensities + self.frequencies, self.intensities = self.generate_plot_data( + frequencies, + combined_intensities, + self.broadening, + ) + self.frequencies_depolarized, self.intensities_depolarized = [], [] + + elif self.spectrum_type == "IR": + ( + intensities, + frequencies, + _, + ) = self.raman_data.run_powder_ir_intensities() + self.frequencies, self.intensities = self.generate_plot_data( + frequencies, + intensities, + self.broadening, + ) + self.frequencies_depolarized, self.intensities_depolarized = [], [] + + def _update_single_crystal_data(self): + """ + Update data for the single crystal plot, handling both Raman and IR spectra. + """ + dir_incoming, _ = self._check_inputs_correct(self.pol_incoming) + + if self.spectrum_type == "Raman": + dir_outgoing, _ = self._check_inputs_correct(self.pol_outgoing) + ( + intensities, + frequencies, + _, + ) = self.raman_data.run_single_crystal_raman_intensities( + pol_incoming=dir_incoming, + pol_outgoing=dir_outgoing, + frequencies=self.frequency_laser, + temperature=self.temperature, + ) + elif self.spectrum_type == "IR": + ( + intensities, + frequencies, + _, + ) = self.raman_data.run_single_crystal_ir_intensities( + pol_incoming=dir_incoming + ) + + self.frequencies, self.intensities = self.generate_plot_data( + frequencies, intensities + ) + self.frequencies_depolarized, self.intensities_depolarized = [], [] + + def update_plot(self, plot): + """ + Update the Raman plot based on the selected plot type and configuration. + + Parameters: + plot: The plotly.graph_objs.Figure widget to update. + """ + update_function = ( + self._update_powder_plot + if self.plot_type == "powder" + else self._update_single_crystal_plot + ) + update_function(plot) + + def _update_powder_plot(self, plot): + """ + Update the powder Raman plot. + + Parameters: + plot: The plotly.graph_objs.Figure widget to update. + """ + if self.separate_polarizations: + self._update_polarized_and_depolarized(plot) + else: + self._clear_depolarized_and_update(plot) + + def _update_polarized_and_depolarized(self, plot): + """ + Update the plot when polarized and depolarized data are separate. + + Parameters: + plot: The plotly.graph_objs.Figure widget to update. + """ + if len(plot.data) == 1: + self._update_trace( + plot.data[0], self.frequencies, self.intensities, "Polarized" + ) + plot.add_trace( + go.Scatter( + x=self.frequencies_depolarized, + y=self.intensities_depolarized, + name="Depolarized", + ) + ) + plot.layout.title.text = f"Powder {self.spectrum_type} Spectrum" + elif len(plot.data) == 2: + self._update_trace( + plot.data[0], self.frequencies, self.intensities, "Polarized" + ) + self._update_trace( + plot.data[1], + self.frequencies_depolarized, + self.intensities_depolarized, + "Depolarized", + ) + plot.layout.title.text = f"Powder {self.spectrum_type} Spectrum" + + def _clear_depolarized_and_update(self, plot): + """ + Clear depolarized data and update the plot. + + Parameters: + plot: The plotly.graph_objs.Figure widget to update. + """ + if len(plot.data) == 2: + self._update_trace(plot.data[0], self.frequencies, self.intensities, "") + plot.data[1].x = [] + plot.data[1].y = [] + plot.layout.title.text = f"Powder{self.spectrum_type} Spectrum" + elif len(plot.data) == 1: + self._update_trace(plot.data[0], self.frequencies, self.intensities, "") + plot.layout.title.text = f"Powder{self.spectrum_type} Spectrum" + + def _update_single_crystal_plot(self, plot): + """ + Update the single crystal Raman plot. + + Parameters: + plot: The plotly.graph_objs.Figure widget to update. + """ + if len(plot.data) == 2: + self._update_trace(plot.data[0], self.frequencies, self.intensities, "") + plot.data[1].x = [] + plot.data[1].y = [] + plot.layout.title.text = f"Single Crystal {self.spectrum_type} Spectrum" + elif len(plot.data) == 1: + self._update_trace(plot.data[0], self.frequencies, self.intensities, "") + plot.layout.title.text = f"Single Crystal {self.spectrum_type} Spectrum" + + def _update_trace(self, trace, x_data, y_data, name): + """ + Helper function to update a single trace in the plot. + + Parameters: + trace: The trace to update. + x_data: The new x-axis data. + y_data: The new y-axis data. + name: The name of the trace. + """ + trace.x = x_data + trace.y = y_data + trace.name = name + + def get_vibrational_data(self, node): + """ + Extract vibrational data from an IRamanWorkChain or HarmonicWorkChain node. + + Parameters: + node: The workchain node containing IRaman or Harmonic data. + + Returns: + The vibrational accuracy data (vibro) or None if not available. + """ + # Determine the output node + output_node = getattr(node, "iraman", None) or getattr(node, "harmonic", None) + if not output_node: + return None + + # Check for vibrational data and extract accuracy + vibrational_data = getattr(output_node, "vibrational_data", None) + if not vibrational_data: + return None + + # Extract vibrational accuracy (prefer numerical_accuracy_4 if available) + vibro = getattr(vibrational_data, "numerical_accuracy_4", None) or getattr( + vibrational_data, "numerical_accuracy_2", None + ) + + return vibro + + def _check_inputs_correct(self, polarization): + # Check if the polarization vectors are correct + input_text = polarization + input_values = input_text.split() + dir_values = [] + if len(input_values) == 3: + try: + dir_values = [float(i) for i in input_values] + return dir_values, True + except: # noqa: E722 + return dir_values, False + else: + return dir_values, False + + def generate_plot_data( + self, + frequencies: list[float], + intensities: list[float], + broadening: float = 10.0, + x_range: list[float] | str = "auto", + broadening_function=multilorentz, + normalize: bool = True, + ): + frequencies = np.array(frequencies) + intensities = np.array(intensities) + + if x_range == "auto": + xi = max(0, frequencies.min() - 200) + xf = frequencies.max() + 200 + x_range = np.arange(xi, xf, 1.0) + + y_range = broadening_function(x_range, frequencies, intensities, broadening) + + if normalize: + y_range /= y_range.max() + + return x_range, y_range + + def modes_table(self): + """Display table with the active modes.""" + # Create an HTML table with the active modes + table_data = [list(x) for x in zip(self.rounded_frequencies, self.labels)] + table_html = "" + table_html += "" + for row in table_data: + table_html += "" + for cell in row: + table_html += "".format(cell) + table_html += "" + table_html += "
Frequencies (cm-1) Label
{}
" + + return table_html + + def set_vibrational_mode_animation(self, weas): + eigenvector = self.eigenvectors[self.active_mode] + phonon_setting = { + "eigenvectors": np.array( + [[[real_part, 0] for real_part in row] for row in eigenvector] + ), + "kpoint": [0, 0, 0], # optional + "amplitude": self.amplitude, + "factor": self.amplitude * 0.6, + "nframes": 20, + "repeat": [ + self.supercell_0, + self.supercell_1, + self.supercell_2, + ], + "color": "black", + "radius": 0.1, + } + weas.avr.phonon_setting = phonon_setting + return weas + + def download_data(self, _=None): + filename = "spectra.json" + if self.separate_polarizations: + my_dict = { + "Frequencies cm-1": self.frequencies.tolist(), + "Polarized intensities": self.intensities.tolist(), + "Depolarized intensities": self.intensities_depolarized.tolist(), + } + else: + my_dict = { + "Frequencies cm-1": self.frequencies.tolist(), + "Intensities": self.intensities.tolist(), + } + json_str = json.dumps(my_dict) + b64_str = base64.b64encode(json_str.encode()).decode() + self._download(payload=b64_str, filename=filename) + + @staticmethod + def _download(payload, filename): + from IPython.display import Javascript + + javas = Javascript( + """ + var link = document.createElement('a'); + link.href = 'data:text/json;charset=utf-8;base64,{payload}' + link.download = "{filename}" + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + """.format(payload=payload, filename=filename) + ) + display(javas) diff --git a/src/aiidalab_qe_vibroscopy/app/widgets/ramanwidget.py b/src/aiidalab_qe_vibroscopy/app/widgets/ramanwidget.py new file mode 100644 index 0000000..503ead8 --- /dev/null +++ b/src/aiidalab_qe_vibroscopy/app/widgets/ramanwidget.py @@ -0,0 +1,300 @@ +import ipywidgets as ipw +from aiidalab_qe_vibroscopy.app.widgets.ramanmodel import RamanModel +import plotly.graph_objects as go +from aiidalab_widgets_base.utils import StatusHTML +from IPython.display import HTML, clear_output, display +from weas_widget import WeasWidget + + +class RamanWidget(ipw.VBox): + """ + Widget to display Raman properties Tab + """ + + def __init__( + self, model: RamanModel, node: None, input_structure, spectrum_type, **kwargs + ): + super().__init__( + children=[ipw.HTML("Loading Raman data...")], + **kwargs, + ) + self._model = model + self._model.spectrum_type = spectrum_type + self._model.vibro = node + self._model.input_structure = input_structure + self.rendered = False + + def render(self): + if self.rendered: + return + + self.guiConfig = { + "enabled": True, + "components": { + "atomsControl": True, + "buttons": True, + "cameraControls": True, + }, + "buttons": { + "fullscreen": True, + "download": True, + "measurement": True, + }, + } + + self.plot_type = ipw.ToggleButtons( + description="Spectrum type:", + style={"description_width": "initial"}, + ) + ipw.dlink( + (self._model, "plot_type_options"), + (self.plot_type, "options"), + ) + ipw.link( + (self._model, "plot_type"), + (self.plot_type, "value"), + ) + self.plot_type.observe(self._on_plot_type_change, names="value") + self.temperature = ipw.FloatText( + description="Temperature (K):", + style={"description_width": "initial"}, + ) + ipw.link( + (self._model, "temperature"), + (self.temperature, "value"), + ) + self.frequency_laser = ipw.FloatText( + description="Laser frequency (nm):", + style={"description_width": "initial"}, + ) + ipw.link( + (self._model, "frequency_laser"), + (self.frequency_laser, "value"), + ) + self.pol_incoming = ipw.Text( + description="Incoming polarization:", + style={"description_width": "initial"}, + layout=ipw.Layout(visibility="hidden"), + ) + ipw.link( + (self._model, "pol_incoming"), + (self.pol_incoming, "value"), + ) + self.pol_outgoing = ipw.Text( + description="Outgoing polarization:", + style={"description_width": "initial"}, + layout=ipw.Layout(visibility="hidden"), + ) + ipw.link( + (self._model, "pol_outgoing"), + (self.pol_outgoing, "value"), + ) + self.plot_button = ipw.Button( + description="Update Plot", + icon="pencil", + button_style="primary", + layout=ipw.Layout(width="auto"), + ) + self.plot_button.on_click(self._on_plot_button_click) + self.download_button = ipw.Button( + description="Download Data", + icon="download", + button_style="primary", + layout=ipw.Layout(width="auto"), + ) + self.download_button.on_click(self._model.download_data) + self._wrong_syntax = StatusHTML(clear_after=8) + + self.broadening = ipw.FloatText( + description="Broadening (cm-1):", + style={"description_width": "initial"}, + ) + ipw.link( + (self._model, "broadening"), + (self.broadening, "value"), + ) + + self.separate_polarizations = ipw.Checkbox( + description="Separate polarized and depolarized intensities", + style={"description_width": "initial"}, + ) + ipw.link( + (self._model, "separate_polarizations"), + (self.separate_polarizations, "value"), + ) + self.spectrum = go.FigureWidget( + layout=go.Layout( + title=dict(text="Powder Raman spectrum"), + barmode="overlay", + xaxis=dict( + title="Wavenumber (cm-1)", + nticks=0, + ), + yaxis=dict( + title="Intensity (arb. units)", + ), + height=500, + width=700, + plot_bgcolor="white", + ) + ) + + # Active Modes + self.modes_table = ipw.Output() + self.animation = ipw.Output() + + self.active_modes = ipw.Dropdown( + description="Select mode:", + style={"description_width": "initial"}, + ) + ipw.dlink( + (self._model, "active_modes_options"), + (self.active_modes, "options"), + ) + self.amplitude = ipw.FloatText( + description="Amplitude :", + style={"description_width": "initial"}, + ) + ipw.link( + (self._model, "amplitude"), + (self.amplitude, "value"), + ) + self._supercell = [ + ipw.BoundedIntText(min=1, layout={"width": "40px"}), + ipw.BoundedIntText(min=1, layout={"width": "40px"}), + ipw.BoundedIntText(min=1, layout={"width": "40px"}), + ] + for i, widget in enumerate(self._supercell): + ipw.link( + (self._model, f"supercell_{i}"), + (widget, "value"), + ) + + self.supercell_selector = ipw.HBox( + [ + ipw.HTML( + description="Super cell:", style={"description_width": "initial"} + ) + ] + + self._supercell + ) + # WeasWidget Setting + self.weas = WeasWidget(guiConfig=self.guiConfig) + self.weas.from_ase(self._model.input_structure) + self.weas.avr.model_style = 1 + self.weas.avr.color_type = "JMOL" + + widget_list = [ + self.active_modes, + self.amplitude, + self._supercell[0], + self._supercell[1], + self._supercell[2], + ] + for elem in widget_list: + elem.observe(self._select_active_mode, names="value") + + self.children = [ + ipw.HTML(f"

{self._model.spectrum_type} spectroscopy

"), + ipw.HTML( + """
+ Select the type of Raman spectrum to plot. +
""" + ), + self.plot_type, + self.temperature, + self.frequency_laser, + self.broadening, + self.separate_polarizations, + self.pol_incoming, + self.pol_outgoing, + ipw.HBox([self.plot_button, self.download_button]), + self.spectrum, + ipw.HBox( + [ + ipw.VBox( + [ + ipw.HTML( + value=f"{self._model.spectrum_type} Active Modes" + ), + self.modes_table, + ] + ), + ipw.VBox( + [ + self.active_modes, + self.amplitude, + self.supercell_selector, + self.animation, + ], + ), + ] + ), + ] + + self.rendered = True + + self._initial_view() + + def _initial_view(self): + self._model.fetch_data() + self._model.update_data() + if self._model.spectrum_type == "IR": + self.temperature.layout.display = "none" + self.frequency_laser.layout.display = "none" + self.pol_outgoing.layout.display == "none" + self.separate_polarizations.layout.display = "none" + + self.spectrum.add_scatter( + x=self._model.frequencies, y=self._model.intensities, name="" + ) + self.spectrum.layout.title.text = f"Powder {self._model.spectrum_type} Spectrum" + self.modes_table.layout = { + "overflow": "auto", + "height": "200px", + "width": "150px", + } + self.weas = self._model.set_vibrational_mode_animation(self.weas) + with self.animation: + clear_output() + display(self.weas) + + with self.modes_table: + clear_output() + display(HTML(self._model.modes_table())) + + def _on_plot_type_change(self, change): + if change["new"] == "single_crystal": + self.pol_incoming.layout.visibility = "visible" + if self._model.spectrum_type == "Raman": + self.pol_outgoing.layout.visibility = "visible" + else: + self.separate_polarizations.layout.display = "none" + self.separate_polarizations.layout.visibility = "hidden" + else: + self.pol_incoming.layout.visibility = "hidden" + self.pol_outgoing.layout.visibility = "hidden" + self.separate_polarizations.layout.visibility = "visible" + + def _on_plot_button_click(self, _): + _, incoming_syntax_ok = self._model._check_inputs_correct( + self.pol_incoming.value + ) + _, outgoing_syntax_ok = self._model._check_inputs_correct( + self.pol_outgoing.value + ) + if not (incoming_syntax_ok and outgoing_syntax_ok): + self._wrong_syntax.message = """ +
+ ERROR: Invalid syntax for polarization directions. +
+ """ + return + self._model.update_data() + self._model.update_plot(self.spectrum) + + def _select_active_mode(self, _): + self.weas = self._model.set_vibrational_mode_animation(self.weas) + with self.animation: + clear_output() + display(self.weas) diff --git a/src/aiidalab_qe_vibroscopy/app/workchain.py b/src/aiidalab_qe_vibroscopy/app/workchain.py index 41cd05c..83fcdff 100644 --- a/src/aiidalab_qe_vibroscopy/app/workchain.py +++ b/src/aiidalab_qe_vibroscopy/app/workchain.py @@ -44,10 +44,10 @@ def get_builder(codes, structure, parameters): pw_dielectric_code = codes.get("dielectric")["code"] phonopy_code = codes.get("phonopy")["code"] - simulation_mode = parameters["vibronic"].pop("simulation_mode", 1) + simulation_mode = parameters["vibronic"].pop("simulation_type", 1) # Define the supercell matrix - supercell_matrix = parameters["vibronic"].pop("supercell_selector", None) + supercell_matrix = parameters["vibronic"].pop("supercell", None) # The following include_all is needed to have forces written overrides = { diff --git a/src/aiidalab_qe_vibroscopy/utils/dielectric/result.py b/src/aiidalab_qe_vibroscopy/utils/dielectric/result.py index ecccf70..2806e9c 100644 --- a/src/aiidalab_qe_vibroscopy/utils/dielectric/result.py +++ b/src/aiidalab_qe_vibroscopy/utils/dielectric/result.py @@ -1,8 +1,5 @@ # -*- coding: utf-8 -*- -import ipywidgets as ipw -from IPython.display import HTML, display -import base64 import json import numpy as np @@ -59,23 +56,18 @@ def get_priority_tensor(filtered_node): def export_dielectric_data(node): - if "vibronic" not in node.outputs: - return None - - if not any( - key in node.outputs.vibronic for key in ["iraman", "dielectric", "harmonic"] - ): + if not any(key in node for key in ["iraman", "dielectric", "harmonic"]): return None else: - if "iraman" in node.outputs.vibronic: - vibrational_data = node.outputs.vibronic.iraman.vibrational_data + if "iraman" in node: + vibrational_data = node.iraman.vibrational_data - elif "harmonic" in node.outputs.vibronic: - vibrational_data = node.outputs.vibronic.harmonic.vibrational_data + elif "harmonic" in node: + vibrational_data = node.harmonic.vibrational_data - elif "dielectric" in node.outputs.vibronic: - tensor_data = node.outputs.vibronic.dielectric + elif "dielectric" in node: + tensor_data = node.dielectric output_data = get_priority_tensor(tensor_data) dielectric_tensor = output_data.get_array("dielectric").round( 6 @@ -118,172 +110,3 @@ def export_dielectric_data(node): "nlo_susceptibility": nlo_susceptibility, "unit_cell": unit_cell, } - - -class DielectricResults(ipw.VBox): - def __init__(self, dielectric_data): - # Helper - self.dielectric_results_help = ipw.HTML( - """
- The DielectricWorkchain computes different properties:
- -High Freq. Dielectric Tensor
- -Born Charges
- -Raman Tensors
- -The non-linear optical susceptibility tensor
- All information can be downloaded as a JSON file.
- -
""" - ) - - self.unit_cell_sites = dielectric_data.pop("unit_cell") - self.dielectric_data = dielectric_data - self.dielectric_tensor = dielectric_data["dielectric_tensor"] - self.born_charges = dielectric_data["born_charges"] - self.volume = dielectric_data["volume"] - self.raman_tensors = dielectric_data["raman_tensors"] - self.nlo_susceptibility = dielectric_data["nlo_susceptibility"] - - # HTML table with the dielectric tensor - self.dielectric_tensor_table = ipw.Output() - - # HTML table with the Born charges @ site - self.born_charges_table = ipw.Output() - - # HTML table with the Raman tensors @ site - self.raman_tensors_table = ipw.Output() - - decimal_places = 6 - # Create the options with rounded positions - site_selector_options = [ - ( - f"{site.kind_name} @ ({', '.join(f'{coord:.{decimal_places}f}' for coord in site.position)})", - index, - ) - for index, site in enumerate(self.unit_cell_sites) - ] - - self.site_selector = ipw.Dropdown( - options=site_selector_options, - value=site_selector_options[0][1], - layout=ipw.Layout(width="450px"), - description="Select atom site:", - style={"description_width": "initial"}, - ) - # Download button - self.download_button = ipw.Button( - description="Download Data", icon="download", button_style="primary" - ) - self.download_button.on_click(self.download_data) - - # Initialize the HTML table - self._create_dielectric_tensor_table() - # Initialize Born Charges Table - self._create_born_charges_table(self.site_selector.value) - # Initialize Raman Tensors Table - self._create_raman_tensors_table(self.site_selector.value) - - self.site_selector.observe(self._on_site_selection_change, names="value") - super().__init__( - children=( - self.dielectric_results_help, - ipw.HTML("

Dielectric tensor

"), - self.dielectric_tensor_table, - self.site_selector, - ipw.HBox( - [ - ipw.VBox( - [ - ipw.HTML("

Born effective charges

"), - self.born_charges_table, - ] - ), - ipw.VBox( - [ - ipw.HTML("

Raman Tensor

"), - self.raman_tensors_table, - ] - ), - ] - ), - self.download_button, - ) - ) - - def download_data(self, _=None): - """Function to download the data.""" - file_name = "dielectric_data.json" - - json_str = json.dumps(self.dielectric_data, cls=NumpyEncoder) - b64_str = base64.b64encode(json_str.encode()).decode() - self._download(payload=b64_str, filename=file_name) - - @staticmethod - def _download(payload, filename): - """Download payload as a file named as filename.""" - from IPython.display import Javascript - - javas = Javascript( - f""" - var link = document.createElement('a'); - link.href = 'data:text/json;charset=utf-8;base64,{payload}' - link.download = "{filename}" - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - """ - ) - display(javas) - - def _create_html_table(self, matrix): - """ - Create an HTML table representation of a 3x3 matrix. - - :param matrix: List of lists representing a 3x3 matrix - :return: HTML table string - """ - html = '' - for row in matrix: - html += "" - for cell in row: - html += f'' - html += "" - html += "
{cell}
" - return html - - def _create_dielectric_tensor_table(self): - table_data = self._create_html_table(self.dielectric_tensor) - self.dielectric_tensor_table.layout = { - "overflow": "auto", - "height": "100px", - "width": "300px", - } - with self.dielectric_tensor_table: - display(HTML(table_data)) - - def _create_born_charges_table(self, site_index): - round_data = self.born_charges[site_index].round(6) - table_data = self._create_html_table(round_data) - self.born_charges_table.layout = { - "overflow": "auto", - "height": "150px", - "width": "300px", - } - with self.born_charges_table: - display(HTML(table_data)) - - def _create_raman_tensors_table(self, site_index): - round_data = self.raman_tensors[site_index].round(6) - table_data = self._create_html_table(round_data) - self.raman_tensors_table.layout = { - "overflow": "auto", - "height": "150px", - "width": "500px", - } - with self.raman_tensors_table: - display(HTML(table_data)) - - def _on_site_selection_change(self, change): - self.born_charges_table.clear_output() - self.raman_tensors_table.clear_output() - self._create_born_charges_table(change["new"]) - self._create_raman_tensors_table(change["new"]) diff --git a/src/aiidalab_qe_vibroscopy/utils/euphonic/Detached_app.ipynb b/src/aiidalab_qe_vibroscopy/utils/euphonic/Detached_app.ipynb index 98a0cfc..e66b899 100644 --- a/src/aiidalab_qe_vibroscopy/utils/euphonic/Detached_app.ipynb +++ b/src/aiidalab_qe_vibroscopy/utils/euphonic/Detached_app.ipynb @@ -8,7 +8,12 @@ "outputs": [ { "data": { - "application/javascript": "IPython.OutputArea.prototype._should_scroll = function(lines) {\n return false;\n}\ndocument.title='aiidalab-qe-vibroscopy detached app'\n", + "application/javascript": [ + "IPython.OutputArea.prototype._should_scroll = function(lines) {\n", + " return false;\n", + "}\n", + "document.title='aiidalab-qe-vibroscopy detached app'\n" + ], "text/plain": [ "" ] @@ -30,7 +35,56 @@ "execution_count": 2, "id": "a8549863", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "a2abe40204ac4a478c99cb6f8ba683b4", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "\n", + "
\n", + "

Warning:

\n", + "

The default profile 'default' was loaded automatically. This behavior will be removed in the v3.0.0. Please load the profile manually before loading modules from aiidalab-widgets-base by adding the following code at the beginning cell of the notebook:

\n", + "
\n",
+       "from aiida import load_profile\n",
+       "load_profile();
\n", + "
\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/javascript": [ + "\n", + " var style = document.createElement('style');\n", + " style.type = 'text/css';\n", + " style.innerHTML = ``;\n", + " document.head.appendChild(style);\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "# Fix pybel import path\n", "try:\n", @@ -52,10 +106,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "586bd730", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "aec117638bca4fb998fcec2ce07f9d0d", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Output()" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "env = Environment()\n", "\n", @@ -66,7 +135,9 @@ " '

Copyright (c) 2024 Miki Bonacci (PSI), miki.bonacci@psi.ch;  Version: 0.1.1

'\n", ")\n", "\n", - "widget = EuphonicSuperWidget(mode=\"detached\")\n", + "from aiidalab_qe_vibroscopy.app.widgets.euphonicmodel import EuphonicBaseResultsModel\n", + "\n", + "widget = EuphonicSuperWidget(mode=\"detached\", model=EuphonicBaseResultsModel())\n", "\n", "output = ipw.Output()\n", "\n", @@ -75,6 +146,14 @@ "\n", "display(output)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22387f54", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/src/aiidalab_qe_vibroscopy/utils/euphonic/__init__.py b/src/aiidalab_qe_vibroscopy/utils/euphonic/__init__.py index 0d60410..e69de29 100644 --- a/src/aiidalab_qe_vibroscopy/utils/euphonic/__init__.py +++ b/src/aiidalab_qe_vibroscopy/utils/euphonic/__init__.py @@ -1,334 +0,0 @@ -import pathlib -import tempfile - - -from IPython.display import display - -import ipywidgets as ipw - -# from ..euphonic.bands_pdos import * -from .intensity_maps import ( - generate_force_constant_instance, - export_euphonic_data, # noqa: F401 -) -from .euphonic_single_crystal_widgets import SingleCrystalFullWidget -from .euphonic_powder_widgets import PowderFullWidget -from .euphonic_q_planes_widgets import QSectionFullWidget - - -###### START for detached app: - -# spinner for waiting time (supercell estimations) -spinner_html = """ - -
-
-
-""" - - -# Upload buttons -class UploadPhonopyYamlWidget(ipw.FileUpload): - def __init__(self, **kwargs): - super().__init__( - description="upload phonopy YAML file", - multiple=False, - layout={"width": "initial"}, - ) - - -class UploadForceConstantsHdf5Widget(ipw.FileUpload): - def __init__(self, **kwargs): - super().__init__( - description="upload force constants HDF5 file", - multiple=False, - layout={"width": "initial"}, - ) - - -class UploadPhonopyWidget(ipw.HBox): - def __init__(self, **kwargs): - self.upload_phonopy_yaml = UploadPhonopyYamlWidget(**kwargs) - self.upload_phonopy_hdf5 = UploadForceConstantsHdf5Widget(**kwargs) - - self.reset_uploads = ipw.Button( - description="Discard uploaded files", - icon="pencil", - button_style="warning", - disabled=False, - layout=ipw.Layout(width="auto"), - ) - - super().__init__( - children=[ - self.upload_phonopy_yaml, - self.upload_phonopy_hdf5, - self.reset_uploads, - ], - **kwargs, - ) - - def _read_phonopy_files(self, fname, phonopy_yaml_content, fc_hdf5_content=None): - suffix = "".join(pathlib.Path(fname).suffixes) - - with tempfile.NamedTemporaryFile(suffix=suffix) as temp_yaml: - temp_yaml.write(phonopy_yaml_content) - temp_yaml.flush() - - if fc_hdf5_content: - with tempfile.NamedTemporaryFile(suffix=".hdf5") as temp_file: - temp_file.write(fc_hdf5_content) - temp_file.flush() - temp_hdf5_name = temp_file.name - - try: - fc = generate_force_constant_instance( - path=pathlib.Path(fname), - summary_name=temp_yaml.name, - fc_name=temp_hdf5_name, - ) - except ValueError: - return None - - return fc - else: - temp_hdf5_name = None - - try: - fc = generate_force_constant_instance( - path=pathlib.Path(fname), - summary_name=temp_yaml.name, - # fc_name=temp_hdf5_name, - ) - except ValueError: - return None - - return fc - - -#### END for detached app - - -##### START OVERALL WIDGET TO DISPLAY EVERYTHING: - - -class EuphonicSuperWidget(ipw.VBox): - """ - Widget that will include everything, - from the upload widget to the tabs with single crystal and powder predictions. - In between, we trigger the initialization of plots via a button. - """ - - def __init__(self, mode="aiidalab-qe app plugin", fc=None, q_path=None): - """ - Initialize the Euphonic utility class. - Parameters: - ----------- - mode : str, optional - The mode of operation, default is "aiidalab-qe app plugin". - fc : optional - Force constants, default is None. - q_path : optional - Q-path for phonon calculations, default is None. If Low-D system, this can be provided. - It is the same path obtained for the PhonopyCalculation of the phonopy_bands. - Attributes: - ----------- - mode : str - The mode of operation. - upload_widget : UploadPhonopyWidget - Widget for uploading phonopy files. - fc_hdf5_content : None - Content of the force constants HDF5 file. - tab_widget : ipw.Tab - Tab widget for different views. - plot_button : ipw.Button - Button to initialize INS data. - fc : optional - Force constants if provided. - """ - - self.mode = mode - - self.upload_widget = UploadPhonopyWidget() - self.upload_widget.reset_uploads.on_click(self._on_reset_uploads_button_clicked) - self.fc_hdf5_content = None - - self.tab_widget = ipw.Tab() - self.tab_widget.layout.display = "none" - self.tab_widget.set_title(0, "Single crystal") - self.tab_widget.set_title(1, "Powder sample") - self.tab_widget.set_title(2, "Q-plane view") - self.tab_widget.children = () - - if fc: - self.fc = fc - - self.q_path = q_path - - self.plot_button = ipw.Button( - description="Initialise INS data", - icon="pencil", - button_style="primary", - disabled=True, - layout=ipw.Layout(width="auto"), - ) - self.plot_button.on_click(self._on_first_plot_button_clicked) - - self.loading_widget = ipw.HTML( - value=spinner_html, - ) - self.loading_widget.layout.display = "none" - - if self.mode == "aiidalab-qe app plugin": - self.upload_widget.layout.display = "none" - self.plot_button.disabled = False - else: - self.upload_widget.children[0].observe(self._on_upload_yaml, "_counter") - self.upload_widget.children[1].observe(self._on_upload_hdf5, "_counter") - - super().__init__( - children=[ - self.upload_widget, - self.plot_button, - self.loading_widget, - self.tab_widget, - ], - ) - - def _on_reset_uploads_button_clicked(self, change): - self.upload_widget.upload_phonopy_yaml.value.clear() - self.upload_widget.upload_phonopy_yaml._counter = 0 - self.upload_widget.upload_phonopy_hdf5.value.clear() - self.upload_widget.upload_phonopy_hdf5._counter = 0 - - self.plot_button.layout.display = "block" - self.plot_button.disabled = True - - self.tab_widget.children = () - - self.tab_widget.layout.display = "none" - - def _on_upload_yaml(self, change): - if change["new"] != change["old"]: - for fname in self.upload_widget.children[ - 0 - ].value.keys(): # always one key because I allow only one file at the time. - self.fname = fname - self.phonopy_yaml_content = self.upload_widget.children[0].value[fname][ - "content" - ] - - if self.plot_button.disabled: - self.plot_button.disabled = False - - def _on_upload_hdf5(self, change): - if change["new"] != change["old"]: - for fname in self.upload_widget.children[1].value.keys(): - self.fc_hdf5_content = self.upload_widget.children[1].value[fname][ - "content" - ] - - def _generate_force_constants( - self, - ): - if self.mode == "aiidalab-qe app plugin": - return self.fc - - else: - fc = self.upload_widget._read_phonopy_files( - fname=self.fname, - phonopy_yaml_content=self.phonopy_yaml_content, - fc_hdf5_content=self.fc_hdf5_content, - ) - - return fc - - def _on_first_plot_button_clicked(self, change=None): - # It creates the widgets - self.plot_button.layout.display = "none" - - self.loading_widget.layout.display = "block" - - self.fc = self._generate_force_constants() - - # I first initialise this widget, to then have the 0K ref for the other two. - singlecrystalwidget = SingleCrystalFullWidget(self.fc, self.q_path) - - self.tab_widget.children = ( - singlecrystalwidget, - PowderFullWidget( - self.fc, intensity_ref_0K=singlecrystalwidget.intensity_ref_0K - ), - QSectionFullWidget( - self.fc, intensity_ref_0K=singlecrystalwidget.intensity_ref_0K - ), - ) - - self.loading_widget.layout.display = "none" - - self.tab_widget.layout.display = "block" - - -class DownloadYamlHdf5Widget(ipw.HBox): - def __init__(self, phonopy_node, **kwargs): - self.download_button = ipw.Button( - description="Download phonopy data", - icon="pencil", - button_style="primary", - disabled=False, - layout=ipw.Layout(width="auto"), - ) - self.download_button.on_click(self.download_data) - self.node = phonopy_node - - super().__init__( - children=[ - self.download_button, - ], - ) - - def download_data(self, _=None): - """ - Download both the phonopy.yaml and fc.hdf5 files. - """ - phonopy_yaml, fc_hdf5 = generate_force_constant_instance( - self.node, mode="download" - ) - self._download(payload=phonopy_yaml, filename="phonopy" + ".yaml") - self._download(payload=fc_hdf5, filename="fc" + ".hdf5") - - @staticmethod - def _download(payload, filename): - from IPython.display import Javascript - - javas = Javascript( - """ - var link = document.createElement('a'); - link.href = 'data:text/json;charset=utf-8;base64,{payload}' - link.download = "{filename}" - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - """.format(payload=payload, filename=filename) - ) - display(javas) diff --git a/src/aiidalab_qe_vibroscopy/utils/euphonic/euphonic_base_widgets.py b/src/aiidalab_qe_vibroscopy/utils/euphonic/base_widgets/euphonic_base_widgets.py similarity index 50% rename from src/aiidalab_qe_vibroscopy/utils/euphonic/euphonic_base_widgets.py rename to src/aiidalab_qe_vibroscopy/utils/euphonic/base_widgets/euphonic_base_widgets.py index 1958068..b0daf63 100644 --- a/src/aiidalab_qe_vibroscopy/utils/euphonic/euphonic_base_widgets.py +++ b/src/aiidalab_qe_vibroscopy/utils/euphonic/base_widgets/euphonic_base_widgets.py @@ -1,10 +1,9 @@ -from IPython.display import display - import numpy as np import ipywidgets as ipw +import plotly.graph_objects as go # from ..euphonic.bands_pdos import * -from .intensity_maps import * # noqa: F403 +from aiidalab_qe_vibroscopy.utils.euphonic.data_manipulation.intensity_maps import * # noqa: F403 # sys and os used to prevent euphonic to print in the stdout. @@ -43,38 +42,27 @@ class StructureFactorBasePlotWidget(ipw.VBox): """ THz_to_meV = 4.13566553853599 # conversion factor. + THz_to_cm1 = 33.3564095198155 # conversion factor. - def __init__(self, final_xspectra, **kwargs): - """ + def __init__(self, model): + super().__init__() + self._model = model + self.rendered = False - Args: - final_xspectra (_type_): + def render(self): + """Render the widget. + This is the generic render method which can be overwritten by the subwidgets. + However, it is important to call this method at the start of the subwidgets.render() in order to have the go.FigureWidget. """ - self.message_fig = ipw.HTML("") - self.message_fig.layout.display = "none" - - if self.fig.layout.images: - for image in self.fig.layout.images: - image["scl"] = 2 # Set the scale for each image - - self.fig["layout"]["xaxis"].update( - range=[min(final_xspectra), max(final_xspectra)] - ) - - self.fig["layout"]["yaxis"].update(title="meV") + if self.rendered: + return - self.fig.update_layout( - height=500, - width=700, - margin=dict(l=15, r=15, t=15, b=15), - ) - # Update x-axis and y-axis to enable autoscaling - self.fig.update_xaxes(autorange=True) - self.fig.update_yaxes(autorange=True) + if not hasattr(self._model, "fc"): + self._model.fetch_data() + self._model._update_spectra() - # Update the layout to enable autoscaling - self.fig.update_layout(autosize=True) + self.fig = go.FigureWidget() self.slider_intensity = ipw.FloatRangeSlider( value=[1, 100], # Default selected interval @@ -108,52 +96,30 @@ def __init__(self, final_xspectra, **kwargs): ), ) self.E_units_button.observe(self._update_energy_units, "value") + # MAYBE WE LINK ALSO THIS TO THE MODEL? so we can download the data with the preferred units. # Create and show figure - super().__init__( - children=[ - self.message_fig, - self.fig, - ipw.HBox([ipw.HTML("Intensity window (%):"), self.slider_intensity]), - self.specification_intensity, - self.E_units_button, - ], - layout=ipw.Layout( - width="100%", - ), - ) + self.children = [ + self.fig, + ipw.HBox([ipw.HTML("Intensity window (%):"), self.slider_intensity]), + self.specification_intensity, + self.E_units_button, + ] - def _update_spectra( - self, - final_zspectra, - ): - # this will be called in the _update_spectra method of SingleCrystalPlotWidget and PowderPlotWidget + def _update_plot(self): + """This is the generic update_plot method which can be overwritten by the subwidgets. + However, it is important to call this method at the end of the subwidgets._update_plot() in order to update the intensity window. + """ - # Update the layout to enable autoscaling self.fig.update_layout(autosize=True) - # We should do a check, if we have few points (<200?) provide like a warning.. - # Also decise less than what, 30%, 50%...? - - """ - visible_points = len( - np.where(self.fig.data[0].z > 0.5)[0] - ) - if visible_points < 1000: - message = f"Only {visible_points}/{len(final_zspectra.T)} points have intensity higher than 50%" - self.message_fig.value = message - self.message_fig.layout.display = "block" - else: - self.message_fig.layout.display = "none" - """ - # I have also to update the energy window. or better, to set the intensity to respect the current intensity window selected: self.fig.data[0].zmax = ( self.slider_intensity.value[1] * np.max(self.fig.data[0].z) / 100 - ) # above this, it is all yellow, i.e. max intensity. + ) # above this, it is all yellow, i.e. this is the max detachable intensity. self.fig.data[0].zmin = ( self.slider_intensity.value[0] * np.max(self.fig.data[0].z) / 100 - ) # above this, it is all yellow, i.e. max intensity. + ) # below this, it is all dark blue, i.e. this is the min detachable intensity. def _update_intensity_filter(self, change): # the value of the intensity slider is in fractions of the max. @@ -186,50 +152,78 @@ class StructureFactorSettingsBaseWidget(ipw.VBox): both single crystal or powder. """ - def __init__(self, **kwargs): + def __init__(self, model, **kwargs): super().__init__() + self._model = model + self.rendered = False - self.float_q_spacing = ipw.FloatText( - value=0.01, + def render(self): + """Render the widget.""" + + if self.rendered: + return + + self.q_spacing = ipw.FloatText( + value=self._model.q_spacing, step=0.001, description="q step (1/A)", tooltip="q spacing in 1/A", ) - self.float_q_spacing.observe(self._on_setting_changed, names="value") + ipw.link( + (self._model, "q_spacing"), + (self.q_spacing, "value"), + ) + self.q_spacing.observe(self._on_setting_changed, names="value") - self.float_energy_broadening = ipw.FloatText( - value=0.5, + self.energy_broadening = ipw.FloatText( + value=self._model.energy_broadening, step=0.01, description="ΔE (meV)", tooltip="Energy broadening in meV", ) - self.float_energy_broadening.observe(self._on_setting_changed, names="value") + ipw.link( + (self._model, "energy_broadening"), + (self.energy_broadening, "value"), + ) + self.energy_broadening.observe(self._on_setting_changed, names="value") - self.int_energy_bins = ipw.IntText( - value=200, + self.energy_bins = ipw.IntText( + value=self._model.energy_bins, description="#E bins", tooltip="Number of energy bins", ) - self.int_energy_bins.observe(self._on_setting_changed, names="value") + ipw.link( + (self._model, "energy_bins"), + (self.energy_bins, "value"), + ) + self.energy_bins.observe(self._on_setting_changed, names="value") - self.float_T = ipw.FloatText( - value=0, + self.temperature = ipw.FloatText( + value=self._model.temperature, step=0.01, description="T (K)", disabled=False, ) - self.float_T.observe(self._on_setting_changed, names="value") + ipw.link( + (self._model, "temperature"), + (self.temperature, "value"), + ) + self.temperature.observe(self._on_setting_changed, names="value") self.weight_button = ipw.ToggleButtons( options=[ ("Coherent", "coherent"), ("DOS", "dos"), ], - value="coherent", + value=self._model.weighting, description="weight:", disabled=False, style={"description_width": "initial"}, ) + ipw.link( + (self._model, "weighting"), + (self.weight_button, "value"), + ) self.weight_button.observe(self._on_weight_button_change, names="value") self.plot_button = ipw.Button( @@ -248,6 +242,7 @@ def __init__(self, **kwargs): disabled=False, layout=ipw.Layout(width="auto"), ) + self.reset_button.on_click(self._reset_settings) self.download_button = ipw.Button( description="Download Data and Plot", @@ -257,97 +252,20 @@ def __init__(self, **kwargs): layout=ipw.Layout(width="auto"), ) - self.reset_button.on_click(self._reset_settings) - - def _reset_settings(self, _): - self.float_q_spacing.value = 0.01 - self.float_energy_broadening.value = 0.5 - self.int_energy_bins.value = 200 - self.float_T.value = 0 - self.weight_button.value = "coherent" - def _on_plot_button_changed(self, change): if change["new"] != change["old"]: self.download_button.disabled = not change["new"] def _on_weight_button_change(self, change): if change["new"] != change["old"]: - self.float_T.value = 0 - self.float_T.disabled = True if change["new"] == "dos" else False + self._model.temperature = 0 + self.temperature.disabled = True if change["new"] == "dos" else False self.plot_button.disabled = False - def _on_setting_changed(self, change): + def _on_setting_changed( + self, change + ): # think if we want to do something more evident... self.plot_button.disabled = False - -class SingleCrystalSettingsWidget(StructureFactorSettingsBaseWidget): - def __init__(self, **kwargs): - self.custom_kpath_description = ipw.HTML( - """ -
- Custom q-points path for the structure factor:
- we can provide it via a specific format:
- (1) each linear path should be divided by '|';
- (2) each path is composed of 'qxi qyi qzi - qxf qyf qzf' where qxi and qxf are, respectively, - the start and end x-coordinate of the q direction, in reciprocal lattice units (rlu).
- An example path is: '0 0 0 - 1 1 1 | 1 1 1 - 0.5 0.5 0.5'.
- For now, we do not support fractions (i.e. we accept 0.5 but not 1/2). -
- """ - ) - - self.custom_kpath_text = ipw.Text( - value="", - description="Custom path (rlu):", - style={"description_width": "initial"}, - ) - custom_style = '' - display(ipw.HTML(custom_style)) - self.custom_kpath_text.add_class("custom-font") - - self.custom_kpath_text.observe(self._on_setting_changed, names="value") - - # Please note: if you change the order of the widgets below, it will - # affect the usage of the children[0] below in the full widget. - - super().__init__() - - self.children = [ - ipw.HBox( - [ - ipw.VBox( - [ - ipw.HBox( - [ - self.reset_button, - self.plot_button, - self.download_button, - ] - ), - self.specification_intensity, - self.float_q_spacing, - self.float_energy_broadening, - self.int_energy_bins, - self.float_T, - self.weight_button, - ], - layout=ipw.Layout( - width="50%", - ), - ), - ipw.VBox( - [ - self.custom_kpath_description, - self.custom_kpath_text, - ], - layout=ipw.Layout( - width="80%", - ), - ), - ], # end of HBox children - ), - ] - def _reset_settings(self, _): - self.custom_kpath_text.value = "" - super()._reset_settings(_) + self._model.reset() diff --git a/src/aiidalab_qe_vibroscopy/utils/euphonic/bands_pdos.py b/src/aiidalab_qe_vibroscopy/utils/euphonic/data_manipulation/bands_pdos.py similarity index 100% rename from src/aiidalab_qe_vibroscopy/utils/euphonic/bands_pdos.py rename to src/aiidalab_qe_vibroscopy/utils/euphonic/data_manipulation/bands_pdos.py diff --git a/src/aiidalab_qe_vibroscopy/utils/euphonic/intensity_maps.py b/src/aiidalab_qe_vibroscopy/utils/euphonic/data_manipulation/intensity_maps.py similarity index 98% rename from src/aiidalab_qe_vibroscopy/utils/euphonic/intensity_maps.py rename to src/aiidalab_qe_vibroscopy/utils/euphonic/data_manipulation/intensity_maps.py index 6a7490b..66095ca 100644 --- a/src/aiidalab_qe_vibroscopy/utils/euphonic/intensity_maps.py +++ b/src/aiidalab_qe_vibroscopy/utils/euphonic/data_manipulation/intensity_maps.py @@ -231,7 +231,9 @@ def produce_bands_weigthed_data( if not params: args = AttrDict(copy.deepcopy(parameters)) else: - args = AttrDict(copy.deepcopy(params)) + args = copy.deepcopy(parameters) + args.update(params) + args = AttrDict(args) # redundancy with args... calc_modes_kwargs = _calc_modes_kwargs(args) @@ -431,6 +433,7 @@ def produce_powder_data( params: Optional[List[str]] = parameters_powder, fc: ForceConstants = None, plot=False, + linear_path=None, ) -> None: blockPrint() @@ -758,18 +761,14 @@ def generate_force_constant_instance( return fc -def export_euphonic_data(node, fermi_energy=None): - if "vibronic" not in node.outputs: - # Not a phonon calculation +def export_euphonic_data(output_vibronic, fermi_energy=None): + if "phonon_bands" not in output_vibronic: return None - else: - if "phonon_bands" not in node.outputs.vibronic: - return None - output_set = node.outputs.vibronic.phonon_bands + output_set = output_vibronic.phonon_bands - if any(not element for element in node.inputs.structure.pbc): - vibro_bands = node.inputs.vibronic.phonopy_bands_dict.get_dict() + if any(not element for element in output_set.creator.caller.inputs.structure.pbc): + vibro_bands = output_set.creator.caller.inputs.phonopy_bands_dict.get_dict() # Group the band and band_labels band = vibro_bands["band"] band_labels = vibro_bands["band_labels"] diff --git a/src/aiidalab_qe_vibroscopy/utils/euphonic/euphonic_powder_widgets.py b/src/aiidalab_qe_vibroscopy/utils/euphonic/tab_widgets/euphonic_powder_widgets.py similarity index 51% rename from src/aiidalab_qe_vibroscopy/utils/euphonic/euphonic_powder_widgets.py rename to src/aiidalab_qe_vibroscopy/utils/euphonic/tab_widgets/euphonic_powder_widgets.py index 8543482..cf218f1 100644 --- a/src/aiidalab_qe_vibroscopy/utils/euphonic/euphonic_powder_widgets.py +++ b/src/aiidalab_qe_vibroscopy/utils/euphonic/tab_widgets/euphonic_powder_widgets.py @@ -5,17 +5,15 @@ import ipywidgets as ipw import plotly.graph_objects as go import plotly.io as pio -import numpy as np # from ..euphonic.bands_pdos import * -from ..euphonic.intensity_maps import produce_powder_data, parameters_powder, AttrDict import json from monty.json import jsanitize # sys and os used to prevent euphonic to print in the stdout. -from aiidalab_qe_vibroscopy.utils.euphonic.euphonic_base_widgets import ( +from aiidalab_qe_vibroscopy.utils.euphonic.base_widgets.euphonic_base_widgets import ( StructureFactorBasePlotWidget, StructureFactorSettingsBaseWidget, COLORBAR_DICT, @@ -24,98 +22,119 @@ class PowderPlotWidget(StructureFactorBasePlotWidget): - def __init__(self, spectra, intensity_ref_0K=1, **kwargs): - final_zspectra = spectra.z_data.magnitude - final_xspectra = spectra.x_data.magnitude - # Data to contour is the sum of two Gaussian functions. - x, y = np.meshgrid(spectra.x_data.magnitude, spectra.y_data.magnitude) + def render(self): + if self.rendered: + return + + super().render() + + heatmap_trace = go.Heatmap( + z=self._model.final_zspectra.T, + y=self._model.y[:, 0] * self.THz_to_meV, + x=self._model.x[0], + colorbar=COLORBAR_DICT, + colorscale=COLORSCALE, # imported from euphonic_base_widgets + ) - self.intensity_ref_0K = intensity_ref_0K + # Add colorbar + colorbar = heatmap_trace.colorbar + colorbar.x = 1.05 # Move colorbar to the right + colorbar.y = 0.5 # Center colorbar vertically - self.fig = go.FigureWidget() + # Add heatmap trace to figure + self.fig.add_trace(heatmap_trace) - self.fig.add_trace( - go.Heatmap( - z=final_zspectra.T, - y=y[:, 0] * self.THz_to_meV, - x=x[0], - colorbar=COLORBAR_DICT, - colorscale=COLORSCALE, # imported from euphonic_base_widgets - ) + # Layout settings + self.fig["layout"]["xaxis"].update( + title="|q| (1/A)", + range=[min(self._model.final_xspectra), max(self._model.final_xspectra)], + ) + self.fig["layout"]["yaxis"].update( + title="meV", + range=[min(self._model.y[:, 0]), max(self._model.y[:, 0])], ) - self.fig["layout"]["xaxis"].update(title="|q| (1/A)") - self.fig["layout"]["yaxis"].update(range=[min(y[:, 0]), max(y[:, 0])]) + if self.fig.layout.images: + for image in self.fig.layout.images: + image["scl"] = 2 # Set the scale for each image - # Create and show figure - super().__init__( - final_xspectra, - **kwargs, + self.fig.update_layout( + height=500, + width=700, + margin=dict(l=15, r=15, t=15, b=15), ) + # Update x-axis and y-axis to enable autoscaling + self.fig.update_xaxes(autorange=True) + self.fig.update_yaxes(autorange=True) - def _update_spectra(self, spectra): - final_zspectra = spectra.z_data.magnitude - final_xspectra = spectra.x_data.magnitude # noqa: F841 - # Data to contour is the sum of two Gaussian functions. - x, y = np.meshgrid(spectra.x_data.magnitude, spectra.y_data.magnitude) + # Update the layout to enable autoscaling + self.fig.update_layout(autosize=True) - # If I do this - # self.data = () - # I have a delay in the plotting, I have blank plot while it - # is adding the new trace (see below); So, I will instead do the - # re-assignement of the self.data = [self.data[1]] afterwards. + def _update_plot(self): + # update the spectra, i.e. the data contained in the _model. + self._model._update_spectra() - x = x[0] self.fig.add_trace( go.Heatmap( - z=final_zspectra.T, + z=self._model.final_zspectra.T, y=( - y[:, 0] * self.THz_to_meV + self._model.y[:, 0] * self.THz_to_meV if self.E_units_button.value == "meV" - else y[:, 0] + else self._model.y[:, 0] ), - x=x, + x=self._model.x[0], colorbar=COLORBAR_DICT, colorscale=COLORSCALE, # imported from euphonic_base_widgets ) ) - # change the path wants also a change in the labels. - # this is delays things - self.fig["layout"]["xaxis"].update(title="|q| (1/A)") - self.fig.data = [self.fig.data[1]] - super()._update_spectra(final_zspectra) + super()._update_plot() class PowderSettingsWidget(StructureFactorSettingsBaseWidget): - def __init__(self, **kwargs): - self.float_qmin = ipw.FloatText( + def render(self): + if self.rendered: + return + + super().render() + + self.qmin = ipw.FloatText( value=0, description="|q|min (1/A)", ) - self.float_qmin.observe(self._on_setting_changed, names="value") + ipw.link( + (self._model, "q_min"), + (self.qmin, "value"), + ) + self.qmin.observe(self._on_setting_changed, names="value") - self.float_qmax = ipw.FloatText( + self.qmax = ipw.FloatText( step=0.01, value=1, description="|q|max (1/A)", ) - self.float_qmax.observe(self._on_setting_changed, names="value") + ipw.link( + (self._model, "q_max"), + (self.qmax, "value"), + ) + self.qmax.observe(self._on_setting_changed, names="value") self.int_npts = ipw.IntText( value=100, description="npts", tooltip="Number of points to be used in the average sphere.", ) + ipw.link( + (self._model, "npts"), + (self.int_npts, "value"), + ) self.int_npts.observe(self._on_setting_changed, names="value") # Please note: if you change the order of the widgets below, it will # affect the usage of the children[0] below in the full widget. - super().__init__() - self.children = [ ipw.HBox( [ @@ -128,9 +147,9 @@ def __init__(self, **kwargs): self.download_button, ] ), - self.float_q_spacing, - self.float_qmin, - self.float_qmax, + self.q_spacing, + self.qmin, + self.qmax, # self.int_npts, ], layout=ipw.Layout( @@ -139,9 +158,9 @@ def __init__(self, **kwargs): ), ipw.VBox( [ - self.float_energy_broadening, - self.int_energy_bins, - self.float_T, + self.energy_broadening, + self.energy_bins, + self.temperature, self.weight_button, ], layout=ipw.Layout( @@ -152,12 +171,6 @@ def __init__(self, **kwargs): ), ] - def _reset_settings(self, _): - self.float_qmin.value = 0 - self.float_qmax.value = 1 - self.int_npts.value = 100 - super()._reset_settings(_) - class PowderFullWidget(ipw.VBox): """ @@ -169,27 +182,30 @@ class PowderFullWidget(ipw.VBox): and are from Sears (1992) Neutron News 3(3) pp26--37. """ - def __init__(self, fc, intensity_ref_0K=1, **kwargs): - self.fc = fc + def __init__(self, model): + self._model = model + self.rendered = False + super().__init__() - self.spectra, self.parameters = produce_powder_data( - params=parameters_powder, fc=self.fc - ) + def render(self): + if self.rendered: + return self.title_intensity = ipw.HTML( "

Neutron dynamic structure factor - Powder sample

" ) - self.settings_intensity = PowderSettingsWidget(mode="powder") - self.settings_intensity.plot_button.on_click(self._on_plot_button_clicked) - self.settings_intensity.download_button.on_click(self.download_data) + # we initialize and inject the model here. + self.settings_intensity = PowderSettingsWidget(model=self._model) + self.map_widget = PowderPlotWidget(model=self._model) - # This is used in order to have an overall intensity scale. Inherithed from the SingleCrystal - self.intensity_ref_0K = intensity_ref_0K # CHANGED + # rendering the widgets + self.settings_intensity.render() + self.map_widget.render() - self.map_widget = PowderPlotWidget( - self.spectra, mode="powder", intensity_ref_0K=self.intensity_ref_0K - ) # CHANGED + # specific for the powder + self.settings_intensity.plot_button.on_click(self._on_plot_button_clicked) + self.settings_intensity.download_button.on_click(self.download_data) super().__init__( children=[ @@ -199,27 +215,11 @@ def __init__(self, fc, intensity_ref_0K=1, **kwargs): ], ) - def _on_plot_button_clicked(self, change=None): - self.parameters.update( - { - "weighting": self.settings_intensity.weight_button.value, - "q_spacing": self.settings_intensity.float_q_spacing.value, - "energy_broadening": self.settings_intensity.float_energy_broadening.value, - "ebins": self.settings_intensity.int_energy_bins.value, - "temperature": self.settings_intensity.float_T.value, - "q_min": self.settings_intensity.float_qmin.value, - "q_max": self.settings_intensity.float_qmax.value, - "npts": self.settings_intensity.int_npts.value, - } - ) - parameters_powder = AttrDict(self.parameters) - - self.spectra, self.parameters = produce_powder_data( - params=parameters_powder, fc=self.fc, plot=False - ) + self.rendered = True + def _on_plot_button_clicked(self, change=None): self.settings_intensity.plot_button.disabled = True - self.map_widget._update_spectra(self.spectra) # CHANGED + self.map_widget._update_plot() def download_data(self, _=None): """ @@ -229,18 +229,7 @@ def download_data(self, _=None): filename = "powder" my_dict = self.spectra.to_dict() - my_dict.update( - { - "weighting": self.settings_intensity.weight_button.value, - "q_spacing": self.settings_intensity.float_q_spacing.value, - "energy_broadening": self.settings_intensity.float_energy_broadening.value, - "ebins": self.settings_intensity.int_energy_bins.value, - "temperature": self.settings_intensity.float_T.value, - "q_min": self.settings_intensity.float_qmin.value, - "q_max": self.settings_intensity.float_qmax.value, - # "npts": self.settings_intensity.int_npts.value, - } - ) + my_dict.update(self._model.get_model_state()) for k in ["weighting", "q_spacing", "temperature"]: filename += "_" + k + "_" + str(my_dict[k]) diff --git a/src/aiidalab_qe_vibroscopy/utils/euphonic/euphonic_q_planes_widgets.py b/src/aiidalab_qe_vibroscopy/utils/euphonic/tab_widgets/euphonic_q_planes_widgets.py similarity index 70% rename from src/aiidalab_qe_vibroscopy/utils/euphonic/euphonic_q_planes_widgets.py rename to src/aiidalab_qe_vibroscopy/utils/euphonic/tab_widgets/euphonic_q_planes_widgets.py index c54199c..6fa8a5f 100644 --- a/src/aiidalab_qe_vibroscopy/utils/euphonic/euphonic_q_planes_widgets.py +++ b/src/aiidalab_qe_vibroscopy/utils/euphonic/tab_widgets/euphonic_q_planes_widgets.py @@ -11,7 +11,7 @@ _get_energy_bins, ) -from aiidalab_qe_vibroscopy.utils.euphonic.euphonic_base_widgets import ( +from aiidalab_qe_vibroscopy.utils.euphonic.base_widgets.euphonic_base_widgets import ( StructureFactorSettingsBaseWidget, COLORSCALE, COLORBAR_DICT, @@ -21,10 +21,9 @@ from monty.json import jsanitize import plotly.graph_objects as go -from aiidalab_qe_vibroscopy.utils.euphonic.intensity_maps import ( +from aiidalab_qe_vibroscopy.utils.euphonic.data_manipulation.intensity_maps import ( blockPrint, enablePrint, - AttrDict, ) @@ -102,7 +101,7 @@ def produce_Q_section_spectrum( dw=None, labels=None, ): - from aiidalab_qe_vibroscopy.utils.euphonic.intensity_maps import ( + from aiidalab_qe_vibroscopy.utils.euphonic.data_manipulation.intensity_maps import ( blockPrint, enablePrint, ) @@ -134,27 +133,24 @@ def produce_Q_section_spectrum( class QSectionPlotWidget(StructureFactorBasePlotWidget): - def __init__(self, h_array, k_array, av_spec, labels, intensity_ref_0K=1, **kwargs): - self.intensity_ref_0K = intensity_ref_0K + def render( + self, + ): + if self.rendered: + return - self.fig = go.FigureWidget() + super().render() self.fig.add_trace( go.Heatmap( - z=av_spec, - x=h_array, - y=k_array, + z=self._model.av_spec, + x=self._model.h_array, + y=self._model.k_array, colorbar=COLORBAR_DICT, colorscale=COLORSCALE, # imported from euphonic_base_widgets ) ) - # Create and show figure - super().__init__( - h_array, - **kwargs, - ) - self.fig.update_layout( height=625, width=650, @@ -165,32 +161,20 @@ def __init__(self, h_array, k_array, av_spec, labels, intensity_ref_0K=1, **kwar self.E_units_button.layout.display = "none" # self.fig.update_layout(title=labels["q"]) self.fig["layout"]["xaxis"].update( - range=[np.min(h_array), np.max(h_array)], + range=[np.min(self._model.h_array), np.max(self._model.h_array)], title=r"$\alpha \text{ in } \vec{Q_0} + \alpha \vec{h}$", ) self.fig["layout"]["yaxis"].update( - range=[np.min(k_array), np.max(k_array)], + range=[np.min(self._model.k_array), np.max(self._model.k_array)], title=r"$\beta \text{ in } \vec{Q_0} + \beta \vec{k}$", ) - def _update_spectra( - self, - h_array, - k_array, - av_spec, - labels, - ): - # If I do this - # self.data = () - # I have a delay in the plotting, I have blank plot while it - # is adding the new trace (see below); So, I will instead do the - # re-assignement of the self.data = [self.data[1]] afterwards. - + def _update_plot(self): self.fig.add_trace( go.Heatmap( - z=av_spec, - x=h_array, - y=k_array, + z=self._model.av_spec, + x=self._model.h_array, + y=self._model.k_array, colorbar=COLORBAR_DICT, colorscale=COLORSCALE, # imported from euphonic_base_widgets ) @@ -203,28 +187,33 @@ def _update_spectra( ) # self.fig.update_layout(title=labels["q"]) self.fig["layout"]["xaxis"].update( - range=[np.min(h_array), np.max(h_array)], + range=[np.min(self._model.h_array), np.max(self._model.h_array)], title=r"$\alpha \text{ in } \vec{Q_0} + \alpha \vec{h}$", ) self.fig["layout"]["yaxis"].update( - range=[np.min(k_array), np.max(k_array)], + range=[np.min(self._model.k_array), np.max(self._model.k_array)], title=r"$\beta \text{ in } \vec{Q_0} + \beta \vec{k}$", ) self.fig.data = [self.fig.data[1]] - # super()._update_spectra(av_spec, intensity_ref_0K=intensity_ref_0K) - class QSectionSettingsWidget(StructureFactorSettingsBaseWidget): - def __init__(self, **kwargs): - super().__init__() + def render(self): + if self.rendered: + return + + super().render() - self.float_ecenter = ipw.FloatText( + self.ecenter = ipw.FloatText( value=0, description="E (meV)", ) - self.float_ecenter.observe(self._on_setting_changed, names="value") + ipw.link( + (self._model, "center_e"), + (self.ecenter, "value"), + ) + self.ecenter.observe(self._on_setting_changed, names="value") self.plane_description_widget = ipw.HTML( """ @@ -271,6 +260,7 @@ def __init__(self, **kwargs): for vec in [self.Q0_vec, self.h_vec, self.k_vec]: for child in vec.children: child.observe(self._on_setting_changed, names="value") + child.observe(self._on_vector_changed, names="value") self.Q0_widget = ipw.HBox( [ipw.HTML("Q0: ", layout={"width": "20px"}), self.Q0_vec] @@ -282,13 +272,16 @@ def __init__(self, **kwargs): [ipw.HTML("k: ", layout={"width": "20px"}), self.k_vec] ) - self.float_energy_broadening = ipw.FloatText( + self.energy_broadening = ipw.FloatText( value=0.5, description="ΔE (meV)", tooltip="Energy window in eV", ) - - self.float_energy_broadening.observe(self._on_setting_changed, names="value") + ipw.link( + (self._model, "energy_broadening"), + (self.energy_broadening, "value"), + ) + self.energy_broadening.observe(self._on_setting_changed, names="value") self.plot_button.disabled = False self.plot_button.description = "Plot" @@ -310,10 +303,10 @@ def __init__(self, **kwargs): ] ), # self.specification_intensity, - self.float_ecenter, - self.float_energy_broadening, + self.ecenter, + self.energy_broadening, # self.int_energy_bins, # does not change anything here. - self.float_T, + self.temperature, self.weight_button, ], layout=ipw.Layout( @@ -335,13 +328,13 @@ def __init__(self, **kwargs): ), ] - def _reset_settings(self, _): - #### - super()._reset_settings(_) - self.h_vec.children[-2].value = 100 # n_h - self.k_vec.children[-2].value = 100 # n_h - self.h_vec.children[-1].value = 1 # alpha, or h_extension - self.k_vec.children[-1].value = 1 # beta, or k_extension + def _on_vector_changed(self, change=None): + """ + Update the model. + """ + self._model.Q0_vec = [i.value for i in self.Q0_vec.children[:-2]] + self._model.h_vec = [i.value for i in self.h_vec.children] + self._model.k_vec = [i.value for i in self.k_vec.children] class QSectionFullWidget(ipw.VBox): @@ -354,94 +347,50 @@ class QSectionFullWidget(ipw.VBox): and are from Sears (1992) Neutron News 3(3) pp26--37. """ - def __init__(self, fc, intensity_ref_0K=1, **kwargs): - self.fc = fc + def __init__(self, model): + self._model = model + self.rendered = False + super().__init__() + + def render(self): + if self.rendered: + return self.title_intensity = ipw.HTML( "

Neutron dynamic structure factor in a Q-section visualization

" ) - self.settings_intensity = QSectionSettingsWidget() + self.settings_intensity = QSectionSettingsWidget(model=self._model) + + # rendering the widget. + self.settings_intensity.render() + + # specific for the q section self.settings_intensity.plot_button.on_click(self._on_plot_button_clicked) self.settings_intensity.download_button.on_click(self.download_data) - # This is used in order to have an overall intensity scale. Inherithed from the SingleCrystal - self.intensity_ref_0K = intensity_ref_0K # CHANGED + self.children = [ + self.title_intensity, + # self.map_widget, + self.settings_intensity, + ] - super().__init__( - children=[ - self.title_intensity, - # self.map_widget, - self.settings_intensity, - ], - ) + self.rendered = True def _on_plot_button_clicked(self, change=None): - self.parameters = { - "h": np.array( - [i.value for i in self.settings_intensity.h_vec.children[:-2]] - ), - "k": np.array( - [i.value for i in self.settings_intensity.k_vec.children[:-2]] - ), - "n_h": self.settings_intensity.h_vec.children[-2].value, - "n_k": self.settings_intensity.k_vec.children[-2].value, - "h_extension": self.settings_intensity.h_vec.children[-1].value, - "k_extension": self.settings_intensity.k_vec.children[-1].value, - "Q0": np.array( - [i.value for i in self.settings_intensity.Q0_vec.children[:-2]] - ), - "ecenter": self.settings_intensity.float_ecenter.value, - "deltaE": self.settings_intensity.float_energy_broadening.value, - "bins": self.settings_intensity.int_energy_bins.value, - "spectrum_type": self.settings_intensity.weight_button.value, - "temperature": self.settings_intensity.float_T.value, - } - - parameters_ = AttrDict(self.parameters) # CHANGED - - modes, q_array, h_array, k_array, labels, dw = produce_Q_section_modes( - self.fc, - h=parameters_.h, - k=parameters_.k, - Q0=parameters_.Q0, - n_h=parameters_.n_h, - n_k=parameters_.n_k, - h_extension=parameters_.h_extension, - k_extension=parameters_.k_extension, - temperature=parameters_.temperature, - ) + self._model._update_qsection_spectra() - av_spec, q_array, h_array, k_array, labels = produce_Q_section_spectrum( - modes, - q_array, - h_array, - k_array, - ecenter=parameters_.ecenter, - deltaE=parameters_.deltaE, - bins=parameters_.bins, - spectrum_type=parameters_.spectrum_type, - dw=dw, - labels=labels, - ) - - self.settings_intensity.plot_button.disabled = True - self.settings_intensity.plot_button.description = "Replot" - - if hasattr(self, "map_widget"): - self.map_widget._update_spectra( - h_array, k_array, av_spec, labels - ) # CHANGED - else: - self.map_widget = QSectionPlotWidget( - h_array, k_array, av_spec, labels - ) # CHANGED + if not hasattr(self, "map_widget"): + self.map_widget = QSectionPlotWidget(self._model) # CHANGED + self.map_widget.render() self.children = [ self.title_intensity, self.map_widget, self.settings_intensity, ] + self.map_widget._update_plot() + def download_data(self, _=None): """ Download both the ForceConstants and the spectra json files. @@ -465,9 +414,9 @@ def download_data(self, _=None): [i.value for i in self.settings_intensity.Q0_vec.children[:-2]] ), "weight": self.settings_intensity.weight_button.value, - "ecenter": self.settings_intensity.float_ecenter.value, - "deltaE": self.settings_intensity.float_energy_broadening.value, - "temperature": self.settings_intensity.float_T.value, + "ecenter": self.settings_intensity.ecenter.value, + "deltaE": self.settings_intensity.energy_broadening.value, + "temperature": self.settings_intensity.temperature.value, } ) for k in ["h", "k", "Q0", "weight", "ecenter", "deltaE", "temperature"]: diff --git a/src/aiidalab_qe_vibroscopy/utils/euphonic/euphonic_single_crystal_widgets.py b/src/aiidalab_qe_vibroscopy/utils/euphonic/tab_widgets/euphonic_single_crystal_widgets.py similarity index 51% rename from src/aiidalab_qe_vibroscopy/utils/euphonic/euphonic_single_crystal_widgets.py rename to src/aiidalab_qe_vibroscopy/utils/euphonic/tab_widgets/euphonic_single_crystal_widgets.py index f03df86..47e7e85 100644 --- a/src/aiidalab_qe_vibroscopy/utils/euphonic/euphonic_single_crystal_widgets.py +++ b/src/aiidalab_qe_vibroscopy/utils/euphonic/tab_widgets/euphonic_single_crystal_widgets.py @@ -1,25 +1,18 @@ import base64 from IPython.display import display -import numpy as np -import copy import ipywidgets as ipw import plotly.graph_objects as go import plotly.io as pio # from ..euphonic.bands_pdos import * -from ..euphonic.intensity_maps import ( - AttrDict, - produce_bands_weigthed_data, - generated_curated_data, -) import json from monty.json import jsanitize # sys and os used to prevent euphonic to print in the stdout. -from aiidalab_qe_vibroscopy.utils.euphonic.euphonic_base_widgets import ( +from aiidalab_qe_vibroscopy.utils.euphonic.base_widgets.euphonic_base_widgets import ( StructureFactorSettingsBaseWidget, COLORSCALE, COLORBAR_DICT, @@ -28,23 +21,15 @@ class SingleCrystalPlotWidget(StructureFactorBasePlotWidget): - def __init__(self, spectra, intensity_ref_0K=1, **kwargs): - ( - final_xspectra, - final_zspectra, - ticks_positions, - ticks_labels, - ) = generated_curated_data(spectra) - # Data to contour is the sum of two Gaussian functions. - x, y = np.meshgrid(spectra[0].x_data.magnitude, spectra[0].y_data.magnitude) - - self.intensity_ref_0K = intensity_ref_0K + def render(self): + if self.rendered: + return - self.fig = go.FigureWidget() + super().render() heatmap_trace = go.Heatmap( - z=final_zspectra.T, - y=y[:, 0] * self.THz_to_meV, + z=self._model.final_zspectra.T, + y=self._model.y[:, 0] * self.THz_to_meV, x=None, colorbar=COLORBAR_DICT, colorscale=COLORSCALE, # imported from euphonic_base_widgets @@ -58,45 +43,50 @@ def __init__(self, spectra, intensity_ref_0K=1, **kwargs): # Add heatmap trace to figure self.fig.add_trace(heatmap_trace) + # Layout settings self.fig.update_layout( xaxis=dict( - tickmode="array", tickvals=ticks_positions, ticktext=ticks_labels + tickmode="array", + tickvals=self._model.ticks_positions, + ticktext=self._model.ticks_labels, ) ) - self.fig["layout"]["yaxis"].update(range=[min(y[:, 0]), max(y[:, 0])]) - # Create and show figure - super().__init__( - final_xspectra, - **kwargs, + self.fig["layout"]["yaxis"].update( + title="meV", + range=[min(self._model.y[:, 0]), max(self._model.y[:, 0])], + ) + + if self.fig.layout.images: + for image in self.fig.layout.images: + image["scl"] = 2 # Set the scale for each image + + self.fig.update_layout( + height=500, + width=700, + margin=dict(l=15, r=15, t=15, b=15), ) + # Update x-axis and y-axis to enable autoscaling + self.fig.update_xaxes(autorange=True) + self.fig.update_yaxes(autorange=True) - def _update_spectra(self, spectra): - ( - final_xspectra, - final_zspectra, - ticks_positions, - ticks_labels, - ) = generated_curated_data(spectra) - # Data to contour is the sum of two Gaussian functions. - x, y = np.meshgrid(spectra[0].x_data.magnitude, spectra[0].y_data.magnitude) - - # If I do this - # self.data = () - # I have a delay in the plotting, I have blank plot while it - # is adding the new trace (see below); So, I will instead do the - # re-assignement of the self.data = [self.data[1]] afterwards. + # Update the layout to enable autoscaling + self.fig.update_layout(autosize=True) + + def _update_plot(self): + # update the spectra, i.e. the data contained in the _model. + self._model._update_spectra() x = None # if mode == "intensity" else x[0] self.fig.add_trace( go.Heatmap( - z=final_zspectra.T, + z=self._model.final_zspectra.T, y=( - y[:, 0] * self.THz_to_meV + self._model.y[:, 0] * self.THz_to_meV if self.E_units_button.value == "meV" - else y[:, 0] + else self._model.y[:, 0] ), - x=x, + x=x, # self._model.x, colorbar=COLORBAR_DICT, colorscale=COLORSCALE, # imported from euphonic_base_widgets ) @@ -106,17 +96,24 @@ def _update_spectra(self, spectra): # this is delays things self.fig.update_layout( xaxis=dict( - tickmode="array", tickvals=ticks_positions, ticktext=ticks_labels + tickmode="array", + tickvals=self._model.ticks_positions, + ticktext=self._model.ticks_labels, ) ) self.fig.data = [self.fig.data[1]] - super()._update_spectra(final_zspectra) + super()._update_plot() class SingleCrystalSettingsWidget(StructureFactorSettingsBaseWidget): - def __init__(self, **kwargs): + def render(self): + if self.rendered: + return + + super().render() + self.custom_kpath_description = ipw.HTML( """
@@ -124,7 +121,7 @@ def __init__(self, **kwargs): we can provide it via a specific format:
(1) each linear path should be divided by '|';
(2) each path is composed of 'qxi qyi qzi - qxf qyf qzf' where qxi and qxf are, respectively, - the start and end x-coordinate of the q direction, in crystal coordinates.
+ the start and end x-coordinate of the q direction, in reciprocal lattice units (rlu).
An example path is: '0 0 0 - 1 1 1 | 1 1 1 - 0.5 0.5 0.5'.
For now, we do not support fractions (i.e. we accept 0.5 but not 1/2).
@@ -139,14 +136,12 @@ def __init__(self, **kwargs): custom_style = '' display(ipw.HTML(custom_style)) self.custom_kpath_text.add_class("custom-font") - + ipw.link( + (self._model, "custom_kpath"), + (self.custom_kpath_text, "value"), + ) self.custom_kpath_text.observe(self._on_setting_changed, names="value") - # Please note: if you change the order of the widgets below, it will - # affect the usage of the children[0] below in the full widget. - - super().__init__() - self.children = [ ipw.HBox( [ @@ -159,10 +154,10 @@ def __init__(self, **kwargs): self.download_button, ] ), - self.float_q_spacing, - self.float_energy_broadening, - self.int_energy_bins, - self.float_T, + self.q_spacing, + self.energy_broadening, + self.energy_bins, + self.temperature, self.weight_button, ], layout=ipw.Layout( @@ -182,108 +177,73 @@ def __init__(self, **kwargs): ), ] - def _reset_settings(self, _): - self.custom_kpath_text.value = "" - super()._reset_settings(_) - class SingleCrystalFullWidget(ipw.VBox): + # I need to put the model also HERE! Think how we can so all of this in simpler way. """ The Widget to display specifically the Intensity map of Dynamical structure - factor for single crystal. + factor for single crystal. It is composed of the following widgets: + - title_intensity: HTML widget with the title of the widget. + - settings_intensity: SingleCrystalSettingsWidget widget with the settings for the plot. + - map_widget: SingleCrystalPlotWidget widget with the plot of the intensity map. + - download_button: Button widget to download the intensity map. + The scattering lengths used in the `produce_bands_weigthed_data` function are tabulated (Euphonic/euphonic/data/sears-1992.json) and are from Sears (1992) Neutron News 3(3) pp26--37. """ - def __init__(self, fc, q_path, **kwargs): - self.fc = fc - self.q_path = q_path + def __init__(self, model): + self._model = model + self.rendered = False + super().__init__() - self.spectra, self.parameters = produce_bands_weigthed_data( - fc=self.fc, - linear_path=self.q_path, - plot=False, # CHANGED - ) + def render(self): + if self.rendered: + return self.title_intensity = ipw.HTML( "

Neutron dynamic structure factor - Single Crystal

" ) - self.settings_intensity = SingleCrystalSettingsWidget() + # we initialize and inject the model here. + self.settings_intensity = SingleCrystalSettingsWidget(model=self._model) + self.map_widget = SingleCrystalPlotWidget(model=self._model) + + # rendering the widgets + self.settings_intensity.render() + self.map_widget.render() + + # specific for the single crystal self.settings_intensity.plot_button.on_click(self._on_plot_button_clicked) self.settings_intensity.download_button.on_click(self.download_data) - # This is used in order to have an overall intensity scale. - self.intensity_ref_0K = np.max(self.spectra[0].z_data.magnitude) # CHANGED - - self.map_widget = SingleCrystalPlotWidget( - self.spectra, intensity_ref_0K=self.intensity_ref_0K - ) # CHANGED + self.children = [ + self.title_intensity, + self.map_widget, + self.settings_intensity, + ] - super().__init__( - children=[ - self.title_intensity, - self.map_widget, - self.settings_intensity, - ], - ) + self.rendered = True def _on_plot_button_clicked(self, change=None): - self.parameters.update( - { - "weighting": self.settings_intensity.weight_button.value, - "q_spacing": self.settings_intensity.float_q_spacing.value, - "energy_broadening": self.settings_intensity.float_energy_broadening.value, - "ebins": self.settings_intensity.int_energy_bins.value, - "temperature": self.settings_intensity.float_T.value, - } - ) - parameters_ = AttrDict(self.parameters) # CHANGED - - # custom linear path - if len(self.settings_intensity.custom_kpath_text.value) > 1: - coordinates, labels = self.curate_path_and_labels() - linear_path = { - "coordinates": coordinates, - "labels": labels, # ["$\Gamma$","X","X","(1,1,1)"], - "delta_q": parameters_["q_spacing"], - } - else: - linear_path = copy.deepcopy(self.q_path) - if linear_path: - linear_path["delta_q"] = parameters_["q_spacing"] - - self.spectra, self.parameters = produce_bands_weigthed_data( - params=parameters_, - fc=self.fc, - plot=False, - linear_path=linear_path, # CHANGED - ) - self.settings_intensity.plot_button.disabled = True - self.map_widget._update_spectra(self.spectra) # CHANGED + self.map_widget._update_plot() def download_data(self, _=None): """ Download both the ForceConstants and the spectra json files. + TODO: improve the format, should be easy to open somewhere else. """ force_constants_dict = self.fc.to_dict() filename = "single_crystal.json" my_dict = {} - for branch in range(len(self.spectra)): - my_dict[str(branch)] = self.spectra[branch].to_dict() - my_dict.update( - { - "weighting": self.settings_intensity.weight_button.value, - "q_spacing": self.settings_intensity.float_q_spacing.value, - "energy_broadening": self.settings_intensity.float_energy_broadening.value, - "ebins": self.settings_intensity.int_energy_bins.value, - "temperature": self.settings_intensity.float_T.value, - } - ) + my_dict["x"] = self._model.final_xspectra.tolist() + my_dict["y"] = self._model.y.tolist() + my_dict["z"] = self._model.final_zspectra.tolist() + my_dict.update(self._model.get_model_state()) for k in ["weighting", "q_spacing", "temperature"]: filename += "_" + k + "_" + str(my_dict[k]) @@ -305,27 +265,6 @@ def download_data(self, _=None): b64_str = base64.b64encode(image_bytes).decode() self._download(payload=b64_str, filename=filename + ".png") - def curate_path_and_labels( - self, - ): - # I do not like this implementation (MB) - coordinates = [] - labels = [] - path = self.settings_intensity.custom_kpath_text.value - linear_paths = path.split("|") - for i in linear_paths: - scoords = [] - s = i.split( - " - " - ) # not i.split("-"), otherwise also the minus of the negative numbers are used for the splitting. - for k in s: - labels.append(k.strip()) - # AAA missing support for fractions. - l = tuple(map(float, [kk for kk in k.strip().split(" ")])) # noqa: E741 - scoords.append(l) - coordinates.append(scoords) - return coordinates, labels - @staticmethod def _download(payload, filename): from IPython.display import Javascript diff --git a/src/aiidalab_qe_vibroscopy/utils/phonons/result.py b/src/aiidalab_qe_vibroscopy/utils/phonons/result.py index 7b1296c..49a2759 100644 --- a/src/aiidalab_qe_vibroscopy/utils/phonons/result.py +++ b/src/aiidalab_qe_vibroscopy/utils/phonons/result.py @@ -1,6 +1,6 @@ """Bands results view widgets""" -from aiidalab_qe.common.bandpdoswidget import cmap, get_bands_labeling +from aiidalab_qe.common.bands_pdos.utils import _cmap, _get_bands_labeling import numpy as np import json @@ -34,25 +34,22 @@ def export_phononworkchain_data(node, fermi_energy=None): } parameters = {} - if "vibronic" not in node.outputs: - return None - - if "phonon_bands" in node.outputs.vibronic: + if "phonon_bands" in node.outputs: """ copied and pasted from aiidalab_qe.common.bandsplotwidget. adapted for phonon outputs """ data = json.loads( - node.outputs.vibronic.phonon_bands._exportcontent("json", comments=False)[0] + node.outputs.phonon_bands._exportcontent("json", comments=False)[0] ) # The fermi energy from band calculation is not robust. data["fermi_energy"] = 0 - data["pathlabels"] = get_bands_labeling(data) + data["pathlabels"] = _get_bands_labeling(data) replace_symbols_with_uppercase(data["pathlabels"]) data["Y_label"] = "Dispersion (THz)" - bands = node.outputs.vibronic.phonon_bands._get_bandplot_data( + bands = node.outputs.phonon_bands._get_bandplot_data( cartesian=True, prettify_format=None, join_symbol=None, get_segments=True ) parameters["energy_range"] = { @@ -64,8 +61,8 @@ def export_phononworkchain_data(node, fermi_energy=None): data["y"] = bands["y"] full_data["bands"] = [data, parameters] - if "phonon_pdos" in node.outputs.vibronic: - phonopy_calc = node.outputs.vibronic.phonon_pdos.creator + if "phonon_pdos" in node.outputs: + phonopy_calc = node.outputs.phonon_pdos.creator kwargs = {} if "settings" in phonopy_calc.inputs: @@ -75,7 +72,7 @@ def export_phononworkchain_data(node, fermi_energy=None): kwargs.update({key: the_settings[key]}) symbols = node.inputs.structure.get_ase().get_chemical_symbols() - pdos = node.outputs.vibronic.phonon_pdos + pdos = node.outputs.phonon_pdos index_dict, dos_dict = ( {}, @@ -119,8 +116,8 @@ def export_phononworkchain_data(node, fermi_energy=None): "label": atom, "x": pdos.get_x()[1].tolist(), "y": dos_dict[atom].tolist(), - "borderColor": cmap(atom), - "backgroundColor": cmap(atom), + "borderColor": _cmap(atom), + "backgroundColor": _cmap(atom), "backgroundAlpha": "40%", "lineStyle": "solid", } @@ -140,27 +137,27 @@ def export_phononworkchain_data(node, fermi_energy=None): full_data["pdos"] = [json.loads(json.dumps(data_dict)), parameters, "dos"] - if "phonon_thermo" in node.outputs.vibronic: + if "phonon_thermo" in node.outputs: ( what, T, units_k, - ) = node.outputs.vibronic.phonon_thermo.get_x() + ) = node.outputs.phonon_thermo.get_x() ( F_name, F_data, units_F, - ) = node.outputs.vibronic.phonon_thermo.get_y()[0] + ) = node.outputs.phonon_thermo.get_y()[0] ( Entropy_name, Entropy_data, units_entropy, - ) = node.outputs.vibronic.phonon_thermo.get_y()[1] + ) = node.outputs.phonon_thermo.get_y()[1] ( Cv_name, Cv_data, units_Cv, - ) = node.outputs.vibronic.phonon_thermo.get_y()[2] + ) = node.outputs.phonon_thermo.get_y()[2] full_data["thermo"] = ( [T, F_data, units_F, Entropy_data, units_entropy, Cv_data, units_Cv], diff --git a/src/aiidalab_qe_vibroscopy/utils/raman/result.py b/src/aiidalab_qe_vibroscopy/utils/raman/result.py index cb8287d..bc488e6 100644 --- a/src/aiidalab_qe_vibroscopy/utils/raman/result.py +++ b/src/aiidalab_qe_vibroscopy/utils/raman/result.py @@ -3,13 +3,8 @@ from __future__ import annotations -import ipywidgets as ipw import numpy as np -from IPython.display import HTML, clear_output, display -import base64 -import json -from weas_widget import WeasWidget from aiida_vibroscopy.utils.broadenings import multilorentz @@ -43,16 +38,13 @@ def export_iramanworkchain_data(node): We have multiple choices: IR, RAMAN. """ - if "vibronic" not in node.outputs: - return None + if "iraman" in node: + output_node = node.iraman + elif "harmonic" in node: + output_node = node.harmonic else: - if "iraman" in node.outputs.vibronic: - output_node = node.outputs.vibronic.iraman - elif "harmonic" in node.outputs.vibronic: - output_node = node.outputs.vibronic.harmonic - else: - # we have raman and ir only if we run IRamanWorkChain or HarmonicWorkChain - return None + # we have raman and ir only if we run IRamanWorkChain or HarmonicWorkChain + return None if "vibrational_data" in output_node: # We enable the possibility to provide both spectra. @@ -106,490 +98,3 @@ def export_iramanworkchain_data(node): return spectra_data else: return None - - -class SpectrumPlotWidget(ipw.VBox): - """Widget that allows different options for plotting Raman or IR Spectrum.""" - - def __init__(self, node, output_node, spectrum_type, **kwargs): - self.node = node - self.output_node = output_node - self.spectrum_type = spectrum_type - - # VibrationalData - vibrational_data = self.output_node.vibrational_data - self.vibro = ( - vibrational_data.numerical_accuracy_4 - if hasattr(vibrational_data, "numerical_accuracy_4") - else vibrational_data.numerical_accuracy_2 - ) - - self.description = ipw.HTML( - f"""
- Select the type of {self.spectrum_type} spectrum to plot. -
""" - ) - - self._plot_type = ipw.ToggleButtons( - options=[ - ("Powder", "powder"), - ("Single Crystal", "single_crystal"), - ], - value="powder", - description="Spectrum type:", - disabled=False, - style={"description_width": "initial"}, - ) - self.temperature = ipw.FloatText( - value=298.0, - description="Temperature (K):", - disabled=False, - style={"description_width": "initial"}, - ) - self.frequency_laser = ipw.FloatText( - value=532.0, - description="Laser frequency (nm):", - disabled=False, - style={"description_width": "initial"}, - ) - self.pol_incoming = ipw.Text( - value="0 0 1", - description="Incoming polarization:", - disabled=False, - style={"description_width": "initial"}, - ) - self.pol_outgoing = ipw.Text( - value="0 0 1", - description="Outgoing polarization:", - disabled=False, - style={"description_width": "initial"}, - ) - self.plot_button = ipw.Button( - description="Plot", - icon="pencil", - button_style="primary", - disabled=False, - layout=ipw.Layout(width="auto"), - ) - self.download_button = ipw.Button( - description="Download Data", - icon="download", - button_style="primary", - disabled=False, - layout=ipw.Layout(width="auto", visibility="hidden"), - ) - self.wrong_syntax = ipw.HTML( - value=""" wrong syntax""", - layout={"visibility": "hidden"}, - ) - self.broadening = ipw.FloatText( - value=10.0, - description="Broadening (cm-1):", - disabled=False, - style={"description_width": "initial"}, - ) - self.spectrum_widget = ipw.Output() - self.frequencies = [] - self.intensities = [] - self.polarization_out = ipw.Output() - - # Polarized and Unpolirized intensities - self.separate_polarized_display = ipw.Output() - self.separate_polarized = ipw.Checkbox( - value=False, - description="Separate polarized and depolarized intensities", - disabled=False, - style={"description_width": "initial"}, - ) - - def download_data(_=None): - filename = "spectra.json" - if self.separate_polarized.value: - my_dict = { - "Frequencies cm-1": self.frequencies.tolist(), - "Polarized intensities": self.intensities.tolist(), - "Depolarized intensities": self.intensities_depolarized.tolist(), - } - else: - my_dict = { - "Frequencies cm-1": self.frequencies.tolist(), - "Intensities": self.intensities.tolist(), - } - json_str = json.dumps(my_dict) - b64_str = base64.b64encode(json_str.encode()).decode() - self._download(payload=b64_str, filename=filename) - - # hide the outgoing pol, frequency and temperature if ir: - if self.spectrum_type == "IR": - self.temperature.layout.display = "none" - self.frequency_laser.layout.display = "none" - self.pol_outgoing.layout.display == "none" - - self._plot_type.observe(self._on_plot_type_change, names="value") - self.plot_button.on_click(self._on_plot_button_clicked) - self.download_button.on_click(download_data) - super().__init__( - children=[ - self.description, - self._plot_type, - self.temperature, - self.frequency_laser, - self.broadening, - self.separate_polarized_display, - self.polarization_out, - ipw.HBox([self.plot_button, self.download_button]), - self.wrong_syntax, - self.spectrum_widget, - ] - ) - self._update_separate_polarized() - - @staticmethod - def _download(payload, filename): - from IPython.display import Javascript - - javas = Javascript( - """ - var link = document.createElement('a'); - link.href = 'data:text/json;charset=utf-8;base64,{payload}' - link.download = "{filename}" - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - """.format(payload=payload, filename=filename) - ) - display(javas) - - def _on_plot_type_change(self, change): - if change["new"] == "single_crystal": - with self.polarization_out: - clear_output() - display(self.pol_incoming) - if self.spectrum_type == "Raman": - display(self.pol_outgoing) - else: - self.pol_incoming.value = "0 0 1" - self.pol_outgoing.value = "0 0 1" - self.wrong_syntax.layout.visibility = "hidden" - with self.polarization_out: - clear_output() - - self._update_separate_polarized() - - def _update_separate_polarized(self): - with self.separate_polarized_display: - clear_output() - if (self._plot_type.value == "powder") and (self.spectrum_type == "Raman"): - display(self.separate_polarized) - else: - self.separate_polarized.value = False - - def _on_plot_button_clicked(self, change): - if self._plot_type.value == "powder": - # Powder spectrum - if self.spectrum_type == "Raman": - ( - polarized_intensities, - depolarized_intensities, - frequencies, - labels, - ) = self.vibro.run_powder_raman_intensities( - frequencies=self.frequency_laser.value, - temperature=self.temperature.value, - ) - total_intensities = polarized_intensities + depolarized_intensities - else: - ( - polarized_intensities, - frequencies, - labels, - ) = self.vibro.run_powder_ir_intensities() - total_intensities = polarized_intensities - if self.separate_polarized.value: - self.frequencies, self.intensities = plot_powder( - frequencies, - polarized_intensities, - self.broadening.value, - ) - self.frequencies_depolarized, self.intensities_depolarized = ( - plot_powder( - frequencies, - depolarized_intensities, - self.broadening.value, - ) - ) - self._display_figure() - else: - self.frequencies, self.intensities = plot_powder( - frequencies, - total_intensities, - self.broadening.value, - ) - self._display_figure() - - else: - # Single crystal spectrum - dir_incoming, correct_syntax_incoming = self._check_inputs_correct( - self.pol_incoming - ) - dir_outgoing, correct_syntax_outgoing = self._check_inputs_correct( - self.pol_outgoing - ) - if not correct_syntax_incoming or not correct_syntax_outgoing: - self.wrong_syntax.layout.visibility = "visible" - return - else: - self.wrong_syntax.layout.visibility = "hidden" - if self.spectrum_type == "Raman": - ( - intensities, - frequencies, - labels, - ) = self.vibro.run_single_crystal_raman_intensities( - pol_incoming=dir_incoming, - pol_outgoing=dir_incoming, - frequencies=self.frequency_laser.value, - temperature=self.temperature.value, - ) - else: - ( - intensities, - frequencies, - labels, - ) = self.vibro.run_single_crystal_ir_intensities( - pol_incoming=dir_incoming - ) - - self.frequencies, self.intensities = plot_powder( - frequencies, intensities - ) - self._display_figure() - - def _check_inputs_correct(self, polarization): - # Check if the polarization vectors are correct - input_text = polarization.value - input_values = input_text.split() - dir_values = [] - if len(input_values) == 3: - try: - dir_values = [float(i) for i in input_values] - return dir_values, True - except: # noqa: E722 - return dir_values, False - else: - return dir_values, False - - def _spectrum_widget(self): - import plotly.graph_objects as go - - fig = go.FigureWidget( - layout=go.Layout( - title=dict(text=f"{self.spectrum_type} spectrum"), - barmode="overlay", - ) - ) - fig.layout.xaxis.title = "Wavenumber (cm-1)" - fig.layout.yaxis.title = "Intensity (arb. units)" - fig.layout.xaxis.nticks = 0 - if self.separate_polarized.value: - fig.add_scatter(x=self.frequencies, y=self.intensities, name="Polarized") - fig.add_scatter( - x=self.frequencies_depolarized, - y=self.intensities_depolarized, - name="Depolarized", - ) - else: - fig.add_scatter(x=self.frequencies, y=self.intensities, name="") - fig.update_layout( - height=500, - width=700, - plot_bgcolor="white", - ) - return fig - - def _display_figure(self): - with self.spectrum_widget: - clear_output() - display(self._spectrum_widget()) - self.download_button.layout.visibility = "visible" - - -class ActiveModesWidget(ipw.VBox): - """Widget that display an animation (nglview) of the active modes.""" - - def __init__(self, node, output_node, spectrum_type, **kwargs): - self.node = node - self.output_node = output_node - self.spectrum_type = spectrum_type - - # WeasWidget configuration - self.guiConfig = { - "enabled": True, - "components": { - "atomsControl": True, - "buttons": True, - "cameraControls": True, - }, - "buttons": { - "fullscreen": True, - "download": True, - "measurement": True, - }, - } - # VibrationalData - vibrational_data = self.output_node.vibrational_data - self.vibro = ( - vibrational_data.numerical_accuracy_4 - if hasattr(vibrational_data, "numerical_accuracy_4") - else vibrational_data.numerical_accuracy_2 - ) - - # Raman or IR active modes - selection_rule = self.spectrum_type.lower() - frequencies, self.eigenvectors, self.labels = self.vibro.run_active_modes( - selection_rule=selection_rule, - ) - self.rounded_frequencies = [round(frequency, 3) for frequency in frequencies] - - # StructureData - self.structure_ase = self.node.inputs.structure.get_ase() - - modes_values = [ - f"{index + 1}: {value}" - for index, value in enumerate(self.rounded_frequencies) - ] - # Create Raman modes widget - self.active_modes = ipw.Dropdown( - options=modes_values, - value=modes_values[0], # Default value - description="Select mode:", - style={"description_width": "initial"}, - ) - - self.amplitude = ipw.FloatText( - value=3.0, - description="Amplitude :", - disabled=False, - style={"description_width": "initial"}, - ) - - self._supercell = [ - ipw.BoundedIntText(value=1, min=1, layout={"width": "40px"}), - ipw.BoundedIntText(value=1, min=1, layout={"width": "40px"}), - ipw.BoundedIntText(value=1, min=1, layout={"width": "40px"}), - ] - - self.supercell_selector = ipw.HBox( - [ - ipw.HTML( - description="Super cell:", style={"description_width": "initial"} - ) - ] - + self._supercell - ) - - self.modes_table = ipw.Output() - self.animation = ipw.Output() - self._display_table() - self._select_active_mode(None) - widget_list = [ - self.active_modes, - self.amplitude, - self._supercell[0], - self._supercell[1], - self._supercell[2], - ] - for elem in widget_list: - elem.observe(self._select_active_mode, names="value") - - super().__init__( - children=[ - ipw.HBox( - [ - ipw.VBox( - [ - ipw.HTML( - value=f" {self.spectrum_type} Active Modes " - ), - self.modes_table, - ] - ), - ipw.VBox( - [ - self.active_modes, - self.amplitude, - self.supercell_selector, - self.animation, - ], - layout={"justify_content": "center"}, - ), - ] - ), - ] - ) - - def _display_table(self): - """Display table with the active modes.""" - # Create an HTML table with the active modes - table_data = [list(x) for x in zip(self.rounded_frequencies, self.labels)] - table_html = "" - table_html += "" - for row in table_data: - table_html += "" - for cell in row: - table_html += "".format(cell) - table_html += "" - table_html += "
Frequencies (cm-1) Label
{}
" - # Set layout to a fix size - self.modes_table.layout = { - "overflow": "auto", - "height": "200px", - "width": "150px", - } - with self.modes_table: - clear_output() - display(HTML(table_html)) - - def _select_active_mode(self, change): - """Display animation of the selected active mode.""" - self._animation_widget() - with self.animation: - clear_output() - display(self.weas) - - def _animation_widget(self): - """Create animation widget.""" - # Get the index of the selected mode - index_str = self.active_modes.value.split(":")[0] - index = int(index_str) - 1 - # Get the eigenvector of the selected mode - eigenvector = self.eigenvectors[index] - # Get the amplitude of the selected mode - amplitude = self.amplitude.value - # Get the structure of the selected mode - structure = self.structure_ase - - self.weas = WeasWidget(guiConfig=self.guiConfig) - self.weas.from_ase(structure) - - phonon_setting = { - "eigenvectors": np.array( - [[[real_part, 0] for real_part in row] for row in eigenvector] - ), - "kpoint": [0, 0, 0], # optional - "amplitude": amplitude, - "factor": amplitude * 0.6, - "nframes": 20, - "repeat": [ - self._supercell[0].value, - self._supercell[1].value, - self._supercell[2].value, - ], - "color": "black", - "radius": 0.1, - } - self.weas.avr.phonon_setting = phonon_setting - - self.weas.avr.model_style = 1 - self.weas.avr.color_type = "JMOL" - self.weas.avr.vf.show = True