Repository: materialsproject/crystaltoolkit (language: python)

Issue:
`FermiSurface` component
This issue primarily refers to new code within `crystal_toolkit.components.fermi` or similar, and `crystal_toolkit.renderables.fermi_surface` or similar.

- [x] A new `MPComponent` to represent Fermi surfaces via [IFermi](https://fermisurfaces.github.io/IFermi/index.html). This might use features added via [this PR](https://github.com/fermisurfaces/IFermi/pull/7) or build upon existing [Brillouin zone visualization code](https://github.com/materialsproject/crystaltoolkit/blob/50debdea383f2fdaafda44e95db12a4f81851a16/crystal_toolkit/components/bandstructure.py#L212-L328) already in Crystal Toolkit.
- [x] This may include adding new `get_scene()` or `get_plot()` methods for various IFermi classes such as `FermiSurface`, [`WignerSeitzCell`](https://github.com/fermisurfaces/IFermi/blob/c336bbeb26fe888f02dd8d57996214dcb5452813/ifermi/brillouin_zone.py#L192) or similar
- [x] A minimal example app should be added to `crystal_toolkit.apps.examples`, so that this can be added to the test suite and documentation.

Possible issues and decisions:

* Decide between plotly `Graph` rendering vs. Crystal Toolkit `Scene` rendering.
    * The former has the advantage that mesh color coding is already available and is simpler, while the latter would be more consistent with Crystal Toolkit and its design goals as a whole but likely require more effort.
    * The latter has the specific advantage that user interaction options increase, e.g. interaction between a band structure component and a Fermi surface component to click on a high symmetry line in one and see it highlighted in the other, and visual representations between components are also more consistent, e.g. a Brillouin zone can be shown on its own, and then a FermiSurface super-imposed if available.
    * The latter has the disadvantage that it may require new features added on the JavaScript side, e.g. see [here](https://github.com/materialsproject/mp-react-components/blob/99b821dba5895dd43ad6ccd9a5f4d7b886b9691a/src/components/crystal-toolkit/scene/three_builder.ts#L384-L421). Guidance can be given on this if required.
    * A proposal would be to get an initial version working via existing functionality in `IFermi` with plotly `Graph`, and leave `Scene` for future work, unless ambitious!
* Issues with the size of data structure may be encountered, see #256

The user benefit of this feature would be to readily enable the visualization of Fermi surfaces on the Materials Project website and in web apps for suers of Crystal Toolkit.

If you have any questions related to the above, please either ask them in this issue or contact privately.

---

**Notice:** Issues with the "new contributor" label are issues assigned to a specific individual who may either be a new contributor to this repository or who have only have had minimal prior experience. These issues refer to a specific pre-arranged set of action items, and are intended to welcome and onboard new developers. Please get in touch with mkhorton@lbl.gov if you would like to get involved and want a similar "new contributor" issue assigned.


Discussion / hints:

Fix patch:
diff --git a/crystal_toolkit/apps/examples/BaFe2As2_fs.json.gz b/crystal_toolkit/apps/examples/BaFe2As2_fs.json.gz
new file mode 100644
index 00000000..6d0e87d5
Binary files /dev/null and b/crystal_toolkit/apps/examples/BaFe2As2_fs.json.gz differ
diff --git a/crystal_toolkit/apps/examples/fermi_surface.py b/crystal_toolkit/apps/examples/fermi_surface.py
new file mode 100644
index 00000000..66bb9aa9
--- /dev/null
+++ b/crystal_toolkit/apps/examples/fermi_surface.py
@@ -0,0 +1,36 @@
+import os
+
+import dash
+from monty.serialization import loadfn
+
+import crystal_toolkit.components as ctc
+from crystal_toolkit.helpers.layouts import H1, Container
+from crystal_toolkit.settings import SETTINGS
+
+# assets folder set for visual styles only
+app = dash.Dash(assets_folder=SETTINGS.ASSETS_PATH)
+
+# If callbacks created dynamically they cannot be statically checked at app startup.
+# For this simple example this IS a problem and,
+# nested layout this will need to be enabled -- consult Dash documentation
+# for more information.
+# app.config["suppress_callback_exceptions"] = True
+
+path = os.path.dirname(os.path.realpath(__file__))
+fermi_surface = loadfn(f"{path}/BaFe2As2_fs.json.gz")
+
+# # create the Crystal Toolkit component
+fs_component = ctc.FermiSurfaceComponent(fermi_surface, id="fermi_surface")
+
+# example layout to demonstrate capabilities of component
+my_layout = Container([H1("Fermi Surface Example"), fs_component.layout()])
+
+# wrap your app.layout with crystal_toolkit_layout()
+# to ensure all necessary components are loaded into layout
+ctc.register_crystal_toolkit(app, layout=my_layout)
+
+# run this app with "python path/to/this/file.py"
+# in production, deploy behind gunicorn or similar
+# see Dash documentation for more information
+if __name__ == "__main__":
+    app.run_server(debug=True, port=8050)
diff --git a/crystal_toolkit/components/__init__.py b/crystal_toolkit/components/__init__.py
index 18ba761b..c451e2b1 100644
--- a/crystal_toolkit/components/__init__.py
+++ b/crystal_toolkit/components/__init__.py
@@ -14,6 +14,9 @@
 from crystal_toolkit.components.diffraction import (
     XRayDiffractionComponent as XRayDiffractionComponent,
 )
+from crystal_toolkit.components.fermi_surface import (
+    FermiSurfaceComponent as FermiSurfaceComponent,
+)
 from crystal_toolkit.components.phase_diagram import (
     PhaseDiagramComponent as PhaseDiagramComponent,
 )
diff --git a/crystal_toolkit/components/fermi_surface.py b/crystal_toolkit/components/fermi_surface.py
new file mode 100644
index 00000000..6e6d3e6e
--- /dev/null
+++ b/crystal_toolkit/components/fermi_surface.py
@@ -0,0 +1,188 @@
+from __future__ import annotations
+
+import typing
+
+import matplotlib.pyplot as plt
+from dash import Input, Output
+from dash.development.base_component import Component
+
+from crystal_toolkit.core.mpcomponent import MPComponent
+from crystal_toolkit.helpers.layouts import Box, Column, Columns, Loading, dcc
+
+if typing.TYPE_CHECKING:
+    from ifermi.surface import FermiSurface
+    from plotly.graph_objs import Figure
+
+
+class FermiSurfaceComponent(MPComponent):
+    """
+    Component to display FermiSurface objects generated from ifermi.
+
+    Args:
+        fermi_surface: An ifermi FermiSurface object.
+        id: A unique id, required if multiple of the same type of MPComponent are
+            included in an app.
+        kwargs: Keyword arguments that get passed to MPComponent.
+    """
+
+    def __init__(
+        self,
+        fermi_surface: FermiSurface = None,
+        id: str | None = None,
+        **kwargs,
+    ):
+        super().__init__(id=id, default_data=fermi_surface, **kwargs)
+
+    @staticmethod
+    def get_figure(fermi_surface: FermiSurface, **kwargs) -> Figure:
+        """
+        Get a fermi surface figure.
+
+        Args:
+            fermi_surface: An ifermi FermiSurface object.
+            kwargs: Keyword arguments that get passed to FermiSurfacePlotter.get_plot.
+
+        Returns:
+            A plotly Figure object.
+        """
+        from ifermi.plot import FermiSurfacePlotter
+
+        plotter = FermiSurfacePlotter(fermi_surface)
+        fig = plotter.get_plot(plot_type="plotly", **kwargs)
+
+        # ensure the plot has a transparent background
+        fig.layout["paper_bgcolor"] = "rgba(0,0,0,0)"
+        fig.layout["plot_bgcolor"] = "rgba(0,0,0,0)"
+        fig.layout["scene"]["xaxis"]["backgroundcolor"] = "rgba(0,0,0,0)"
+        fig.layout["scene"]["yaxis"]["backgroundcolor"] = "rgba(0,0,0,0)"
+        fig.layout["scene"]["zaxis"]["backgroundcolor"] = "rgba(0,0,0,0)"
+
+        # remove mouseover grid
+        fig.layout["scene"]["xaxis"]["showspikes"] = False
+        fig.layout["scene"]["yaxis"]["showspikes"] = False
+        fig.layout["scene"]["zaxis"]["showspikes"] = False
+        for data in fig.data:
+            data["hoverinfo"] = "skip"
+
+        # remove LaTeX from high-symmetry labels
+        str_replace = {
+            "$": "",
+            "\\mid": "|",
+            "\\Gamma": "Γ",
+            "\\Sigma": "Σ",
+            "GAMMA": "Γ",
+            "_1": "₁",
+            "_2": "₂",
+            "_3": "₃",
+            "_4": "₄",
+            "_{1}": "₁",
+            "_{2}": "₂",
+            "_{3}": "₃",
+            "_{4}": "₄",
+            "^{*}": "*",
+        }
+        for label in fig.layout["scene"]["annotations"]:
+            for find, replace in str_replace.items():
+                label["text"] = label["text"].replace(find, replace)
+
+        return fig
+
+    @property
+    def _sub_layouts(self):
+        if fermi_surface := self.initial_data["default"]:
+            figure = self.get_figure(fermi_surface, color_properties=False)
+        else:
+            figure = None
+
+        state = {
+            "show_cell": True,
+            "show_labels": True,
+            "color_properties": False,
+        }
+
+        graph = Loading(
+            [
+                dcc.Graph(
+                    id=self.id("fermi-surface-graph"),  # id allows for callbacks
+                    figure=figure,
+                    config={"displayModeBar": False},
+                    responsive=True,
+                )
+            ],
+            id=self.id("fermi-surface-div"),
+        )
+
+        show_cell = self.get_bool_input(
+            "show_cell",
+            state=state,
+            label="Show Brillouin zone edges",
+            help_str="Show the edges of the Brillouin zone.",
+        )
+
+        show_labels = self.get_bool_input(
+            "show_labels",
+            state=state,
+            label="Show high-symmetry labels",
+            help_str="Show the labels for high-symmetry points in the Brillouin zone.",
+        )
+
+        options = [{"label": "None", "value": False}]
+        if fermi_surface is not None and fermi_surface.has_properties:
+            options += [{"label": k, "value": k} for k in plt.colormaps()]
+        color_properties = self.get_choice_input(
+            kwarg_label="color_properties",
+            state=state,
+            label="Property colormap",
+            help_str="Colormap to use if the Fermi surface has properties (such as "
+            "group velocity) included",
+            options=options,
+            style={"width": "10rem"},
+        )
+
+        return {
+            "graph": graph,
+            "show_cell": show_cell,
+            "show_labels": show_labels,
+            "color_properties": color_properties,
+        }
+
+    def layout(self) -> Component:
+        """Get a Dash layout for the component."""
+        layouts = self._sub_layouts
+
+        return Columns(
+            [
+                Column(
+                    [Box([layouts["graph"]], style={"height": "480px"})],
+                    size=8,
+                    style={"height": "600px"},
+                ),
+                Column(
+                    [
+                        layouts["show_cell"],
+                        layouts["show_labels"],
+                        layouts["color_properties"],
+                    ],
+                    size=4,
+                ),
+            ]
+        )
+
+    def generate_callbacks(self, app, cache):
+        @app.callback(
+            Output(self.id("fermi-surface-graph"), "figure"),
+            [
+                Input(self.id(), "data"),
+                Input(self.get_all_kwargs_id(), "value"),
+            ],
+        )
+        def update_plot(fermi_surface, *args):
+            # if update_plot is slow, an @cache decorator can be added here
+            fermi_surface = self.from_data(fermi_surface)  # converts back to object
+            kwargs = self.reconstruct_kwargs_from_state()
+
+            # invert show_cell and show_labels
+            kwargs["hide_cell"] = not kwargs.pop("show_cell")
+            kwargs["hide_labels"] = not kwargs.pop("show_labels")
+
+            return self.get_figure(fermi_surface, **kwargs)


Test patch:
diff --git a/crystal_toolkit/apps/examples/tests/test_fermi_surface.py b/crystal_toolkit/apps/examples/tests/test_fermi_surface.py
new file mode 100644
index 00000000..5e7d2099
--- /dev/null
+++ b/crystal_toolkit/apps/examples/tests/test_fermi_surface.py
@@ -0,0 +1,15 @@
+from crystal_toolkit.apps.examples.fermi_surface import app
+from crystal_toolkit.apps.examples.tests.typing import DashDuo
+
+
+def test_diffraction(dash_duo: DashDuo) -> None:
+
+    dash_duo.start_server(app)
+    dash_duo.clear_storage()
+
+    # make sure the FS component was mounted and is a node with class 'dash-graph'
+    node = dash_duo.find_element("#CTfermi_surface_fermi-surface-graph")
+    assert "dash-graph" in node.get_attribute("class")
+
+    logs = dash_duo.get_logs()
+    assert logs == [], f"Browser console should not contain errors: {logs}"