diff --git a/crystal_toolkit/apps/examples/reverse_pourbaix_example.py b/crystal_toolkit/apps/examples/reverse_pourbaix_example.py new file mode 100644 index 00000000..0f3902d8 --- /dev/null +++ b/crystal_toolkit/apps/examples/reverse_pourbaix_example.py @@ -0,0 +1,49 @@ +"""Example app for the Reverse Pourbaix Diagram Component. + +Renders the heatmap statically using the component's `get_heatmap_figure` +staticmethod. No interactivity. See the Materials Project web integration. +""" + +from __future__ import annotations + +import dash +import pyarrow.parquet as pq +from dash import dcc, html + +import crystal_toolkit.components as ctc +from crystal_toolkit.components.reverse_pourbaix import ReversePourbaixDiagramComponent +from crystal_toolkit.settings import SETTINGS + +app = dash.Dash(assets_folder=SETTINGS.ASSETS_PATH) + +# Load pre-computed heatmap data from the versioned, hive-partitioned S3 +# dataset; filter to one version so multiple versions in the bucket don't +# get mixed together. +DATA_PATH = "s3://materialsproject-build/collections/reverse-pourbaix-heatmap/" +DATA_FILTERS = [("version", "=", "2026-04-13")] + +heatmap_df = pq.read_table(DATA_PATH, filters=DATA_FILTERS).to_pandas() +heatmap_data = ReversePourbaixDiagramComponent.load_heatmap_data(heatmap_df) + +# Build the heatmap figure directly — no component, no callbacks. +figure = ReversePourbaixDiagramComponent.get_heatmap_figure(heatmap_data) + +layout = html.Div( + [ + html.H1("Reverse Pourbaix Diagram"), + html.P( + "Number of thermodynamically stable materials at each pH/potential " + "combination (decomposition energy < 0.2 eV/atom)." + ), + dcc.Graph( + figure=figure, + config={"displayModeBar": False, "displaylogo": False}, + ), + ], + style=dict(maxWidth="900px", margin="2em auto"), +) + +ctc.register_crystal_toolkit(app=app, layout=layout) + +if __name__ == "__main__": + app.run(debug=True, port=8050) diff --git a/crystal_toolkit/components/__init__.py b/crystal_toolkit/components/__init__.py index fc1fef6c..1d3447cc 100644 --- a/crystal_toolkit/components/__init__.py +++ b/crystal_toolkit/components/__init__.py @@ -18,6 +18,7 @@ PhononBandstructureAndDosPanelComponent, ) from crystal_toolkit.components.pourbaix import PourbaixDiagramComponent +from crystal_toolkit.components.reverse_pourbaix import ReversePourbaixDiagramComponent from crystal_toolkit.components.search import SearchComponent from crystal_toolkit.components.structure import StructureMoleculeComponent diff --git a/crystal_toolkit/components/pourbaix.py b/crystal_toolkit/components/pourbaix.py index 42a3d010..527512c1 100644 --- a/crystal_toolkit/components/pourbaix.py +++ b/crystal_toolkit/components/pourbaix.py @@ -688,10 +688,15 @@ def update_heatmap_choices(entries, mat_detials, filter_solids): Output(self.id("comp-conc-btn"), "children"), Output(self.id("comp-conc-btn"), "style"), Input(self.id(), "data"), + # Optional: prefilled ratio from a parent app linking via + # `links={"prefill-ratio": parent_app.id("prefill-ratio")}`. + # If not linked, falls back to equal ratios. + State(self.id("prefill-ratio"), "data"), prevent_initial_call=True, ) def update_element_specific_sliders( entries, + prefill_ratio, ): """ When pourbaix entries input, add concentration and composition options @@ -711,7 +716,7 @@ def update_element_specific_sliders( conc_inputs = [] - for element in sorted(elements): + for element in sorted(elements, key=lambda e: e.symbol): conc_input = PourbaixDiagramComponent.create_centered_object( self.get_numerical_input( f"conc-{element}", @@ -760,10 +765,20 @@ def update_element_specific_sliders( } # elements store - elements = [element.symbol for element in elements] - - # default_comp - default_comp = ":".join(["1" for _ in elements]) + # Sort alphabetically for deterministic ordering across page loads. + # This order is the contract between the composition title display, + # the comp-text positional parsing in make_figure, and any URL-based + # ratio prefill from the reverse-Pourbaix app. + elements = sorted(element.symbol for element in elements) + + # default_comp — use prefilled ratio from URL if supplied and length + # matches the resolved element list, otherwise fall back to all-equal. + if prefill_ratio and len(prefill_ratio) == len(elements): + default_comp = ":".join( + str(int(r)) if r == int(r) else str(r) for r in prefill_ratio + ) + else: + default_comp = ":".join(["1" for _ in elements]) # composition title title = "💡 Composition of " + ":".join(elements) diff --git a/crystal_toolkit/components/reverse_pourbaix.py b/crystal_toolkit/components/reverse_pourbaix.py new file mode 100644 index 00000000..826c6f40 --- /dev/null +++ b/crystal_toolkit/components/reverse_pourbaix.py @@ -0,0 +1,375 @@ +"""Reverse Pourbaix Diagram Component. + +Displays a heatmap of the number of thermodynamically stable materials +across pH and potential (V_SHE) space, based on pre-computed Pourbaix +stability data from the Materials Project database. + +This is the "reverse" of the standard Pourbaix diagram: instead of showing +stability domains for a single material, it shows how many materials are +stable at each electrochemical condition. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +import plotly.graph_objects as go +from dash import dcc, html +from dash.dependencies import Component, Input, Output +from dash.exceptions import PreventUpdate +from frozendict import frozendict +from pymatgen.analysis.pourbaix_diagram import PREFAC + +from crystal_toolkit.core.mpcomponent import MPComponent + +if TYPE_CHECKING: + import pandas as pd + +logger = logging.getLogger(__name__) + +__author__ = "Leo Karlsson" + +HEATMAP_HEIGHT = "70vh" +# WIDTH = 700 +MIN_PH = 0 +MAX_PH = 14 +MIN_V = -2 +MAX_V = 2 + +# Stability cutoff (eV/atom) — the recommended practical value is 0.2, +# up to 0.5 also makes sense +DEFAULT_CUTOFF = 0.2 +CUTOFF_RANGE = [0.1, 0.5] +CUTOFF_STEP = 0.1 + + +class ReversePourbaixDiagramComponent(MPComponent): + """Component for displaying a reverse Pourbaix diagram. + + Shows a heatmap of the number of stable materials at each pH/V + combination, where stability is defined by a user-tunable + decomposition energy cutoff (eV/atom). + """ + + default_state = frozendict( + show_water_lines=True, + stability_cutoff=DEFAULT_CUTOFF, + ) + + default_plot_style = frozendict( + xaxis={ + "anchor": "y", + "mirror": "ticks", + "showgrid": False, + "showline": True, + "side": "bottom", + "tickfont": {"size": 16.0}, + "ticks": "inside", + "title": {"font": {"color": "#000000", "size": 24.0}, "text": "pH"}, + "type": "linear", + "zeroline": False, + "range": [MIN_PH, MAX_PH], + }, + yaxis={ + "anchor": "x", + "mirror": "ticks", + "range": [MIN_V, MAX_V], + "showgrid": False, + "showline": True, + "side": "left", + "tickfont": {"size": 16.0}, + "ticks": "inside", + "title": { + "font": {"color": "#000000", "size": 24.0}, + "text": "Potential (V vs. SHE)", + }, + "type": "linear", + "zeroline": False, + }, + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + autosize=True, + hovermode="closest", + showlegend=False, + margin=dict(l=80, b=70, t=10, r=20), + ) + + empty_plot_style = frozendict( + xaxis={"visible": False}, + yaxis={"visible": False}, + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + ) + + def __init__( + self, + stability_data: pd.DataFrame | None = None, + *args, + **kwargs, + ): + """ + Args: + stability_data: precomputed (pH, V, mp_id, decomposition_energy) + data, e.g. read from a parquet dataset by the caller. Indexed + by (pH, V) once at component construction for fast cell-click + lookups. If None, the click-to-list functionality is disabled + but the heatmap still works. Data is expected to be computed + with solid filter and default ion concentrations. + """ + super().__init__(*args, **kwargs) + self._stability_df: pd.DataFrame | None = None + if stability_data is not None: + # Index by (pH, V) for fast cell-click lookups. + self._stability_df = stability_data.set_index(["pH", "V"]).sort_index() + logger.info( + "Indexed %d stability rows across %d cells", + len(stability_data), + self._stability_df.index.nunique(), + ) + + @staticmethod + def _resolve_cutoff(li: list | None) -> float: + """Unwrap MPComponent's get_slider_input output list to a float, falling back to the default cutoff.""" + if not li: + return DEFAULT_CUTOFF + return float(li[0]) + + @staticmethod + def _snap_to_grid(ph: float, v: float) -> tuple[int, float]: + """Snap a clicked (pH, V) point to the precomputed grid keys.""" + return round(ph), round(v * 2) / 2 + + @staticmethod + def _format_cutoff_key(cutoff: float) -> str: + """Format a cutoff float to match the JSON key convention. + + JSON keys are stored as e.g. "0.1", "0.2" — i.e. one decimal. + """ + return f"{cutoff:.1f}" + + @staticmethod + def load_heatmap_data(df: pd.DataFrame) -> dict[str, Any]: + """Reshape tidy, long-format heatmap counts (one row per pH/V/cutoff + combination, columns "pH", "V", "cutoff", "count") into the dict + `get_heatmap_figure` expects: {"ph_values", "v_values", "cutoffs", + "grid"}, where `grid` is a list of {"pH", "V", "counts": {cutoff_str: + count}}. + + :param df: tidy, long-format heatmap-count data, e.g. read from a + parquet dataset by the caller. + """ + ph_values = sorted(df["pH"].unique().tolist()) + v_values = sorted(df["V"].unique().tolist(), reverse=True) + cutoffs = sorted(df["cutoff"].unique().tolist()) + + grid = [ + { + "pH": ph, + "V": v, + "counts": { + ReversePourbaixDiagramComponent._format_cutoff_key( + float(cutoff) + ): int(count) + for cutoff, count in zip(group["cutoff"], group["count"]) + }, + } + for (ph, v), group in df.groupby(["pH", "V"], sort=True) + ] + + return { + "ph_values": ph_values, + "v_values": v_values, + "cutoffs": cutoffs, + "grid": grid, + } + + @staticmethod + def get_heatmap_figure( + heatmap_data: dict[str, Any], + stability_cutoff: float = DEFAULT_CUTOFF, + show_water_lines: bool = True, + selected_ph: float | None = None, + selected_v: float | None = None, + ) -> go.Figure: + """Generate a Plotly heatmap figure from pre-computed data.""" + ph_values = heatmap_data["ph_values"] + v_values = heatmap_data["v_values"] + grid = heatmap_data["grid"] + + cutoff_key = ReversePourbaixDiagramComponent._format_cutoff_key( + stability_cutoff + ) + + lookup: dict[tuple[float, float], int] = { + (point["pH"], point["V"]): point["counts"][cutoff_key] for point in grid + } + + z_matrix = [[lookup.get((ph, v), 0) for ph in ph_values] for v in v_values] + + data: list[go.BaseTraceType] = [] + + heatmap_trace = go.Heatmap( + z=z_matrix, + x=ph_values, + y=v_values, + colorscale="Viridis", + colorbar={"title": "Number of
Materials"}, + hovertemplate=( + "pH: %{x}
" + "V: %{y} VSHE
" + "Stable materials: %{z}" + "" + ), + ) + data.append(heatmap_trace) + + if show_water_lines: + ph_range = [MIN_PH, MAX_PH] + data.append( + go.Scatter( + x=ph_range, + y=[-ph_range[0] * PREFAC, -ph_range[1] * PREFAC], + mode="lines", + line={"color": "white", "dash": "dash", "width": 2}, + name="Hâ‚‚/Hâ‚‚O", + hoverinfo="skip", + showlegend=False, + ) + ) + data.append( + go.Scatter( + x=ph_range, + y=[-ph_range[0] * PREFAC + 1.23, -ph_range[1] * PREFAC + 1.23], + mode="lines", + line={"color": "white", "dash": "dash", "width": 2}, + name="Oâ‚‚/Hâ‚‚O", + hoverinfo="skip", + showlegend=False, + ) + ) + + layout = {**ReversePourbaixDiagramComponent.default_plot_style} + + if selected_ph is not None and selected_v is not None: + ph_step = ph_values[1] - ph_values[0] if len(ph_values) > 1 else 1 + v_step = abs(v_values[0] - v_values[1]) if len(v_values) > 1 else 0.5 + layout["shapes"] = [ + { + "type": "rect", + "x0": selected_ph - ph_step / 2, + "x1": selected_ph + ph_step / 2, + "y0": selected_v - v_step / 2, + "y1": selected_v + v_step / 2, + "line": {"color": "white", "width": 3}, + "fillcolor": "rgba(0,0,0,0)", + } + ] + + return go.Figure(data=data, layout=layout) + + @property + def _sub_layouts(self) -> dict[str, Component]: + graph = html.Div( + [ + dcc.Graph( + id=self.id("heatmap"), + figure=go.Figure( + layout={**ReversePourbaixDiagramComponent.empty_plot_style} + ), + responsive=True, + config={ + "displayModeBar": False, + "displaylogo": False, + "responsive": True, + }, + style={"height": HEATMAP_HEIGHT, "width": "100%"}, + ), + ], + id=self.id("graph-panel"), + ) + + # Holds the list of mp_ids stable at the most recently clicked cell. + # Downstream callbacks (filtering, table rendering, etc.) can read this. + mp_id_store = dcc.Store(id=self.id("stable-mp-ids"), data=[]) + + # Selection panel + info = html.Div( + [ + html.Div("Selected conditions", className="panel-heading"), + html.Div( + "Click on the heatmap to see the list of stable materials " + "at those conditions.", + id=self.id("click-info"), + className="panel-block is-block", + ), + ], + className="panel", + ) + + options = html.Div( + [ + self.get_bool_input( + "show_water_lines", + default=self.default_state["show_water_lines"], + label="Show Water Stability Lines", + help_str=( + "Show the hydrogen and oxygen evolution reaction lines. " + "Potential scale is SHE." + ), + ), + self.get_slider_input( + kwarg_label="stability_cutoff", + default=self.default_state["stability_cutoff"], + domain=CUTOFF_RANGE, + step=CUTOFF_STEP, + label="Stability Cutoff (eV/atom)", + help_str=( + "Materials with a decomposition energy (G_pbx, distance " + "from the Pourbaix hull) below this cutoff are counted as " + "stable. The recommended value is 0.2 eV/atom, the " + "practical metastability threshold used in Karlsson et al. " + "Higher cutoffs include progressively more metastable phases." + ), + ), + ] + ) + + return {"graph": graph, "info": info, "options": options, "store": mp_id_store} + + def layout(self) -> html.Div: + """Return the full component layout.""" + return html.Div( + children=[ + self._sub_layouts["options"], + self._sub_layouts["graph"], + self._sub_layouts["store"], + self._sub_layouts["info"], + ] + ) + + def generate_callbacks(self, app, cache) -> None: + """Register Dash callbacks for interactivity.""" + + @app.callback( + Output(self.id("heatmap"), "figure"), + Input(self.id(), "data"), + Input(self.get_kwarg_id("show_water_lines"), "value"), + Input(self.get_kwarg_id("stability_cutoff"), "value"), + ) + def update_figure( + heatmap_json, show_water_lines, stability_cutoff + ): # some options ignored for now + if not heatmap_json: + raise PreventUpdate + + heatmap_data = self.from_data(heatmap_json) + + if isinstance(show_water_lines, list): + show_water_lines = show_water_lines[0] if show_water_lines else True + + return self.get_heatmap_figure( + heatmap_data, + stability_cutoff=self._resolve_cutoff(stability_cutoff), + show_water_lines=bool(show_water_lines), + )