diff --git a/crystal_toolkit/apps/examples/reverse_pourbaix_example.py b/crystal_toolkit/apps/examples/reverse_pourbaix_example.py
new file mode 100755
index 00000000..edec3bd8
--- /dev/null
+++ b/crystal_toolkit/apps/examples/reverse_pourbaix_example.py
@@ -0,0 +1,48 @@
+"""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 json
+from pathlib import Path
+
+import dash
+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
+DATA_PATH = Path(__file__).parent / "reverse_pourbaix_heatmap.json"
+
+with open(DATA_PATH) as f:
+ heatmap_data = json.load(f)
+
+# 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/apps/examples/reverse_pourbaix_heatmap.json b/crystal_toolkit/apps/examples/reverse_pourbaix_heatmap.json
new file mode 100644
index 00000000..423330a7
--- /dev/null
+++ b/crystal_toolkit/apps/examples/reverse_pourbaix_heatmap.json
@@ -0,0 +1,1524 @@
+{
+ "ph_values": [
+ 0.0,
+ 1.0,
+ 2.0,
+ 3.0,
+ 4.0,
+ 5.0,
+ 6.0,
+ 7.0,
+ 8.0,
+ 9.0,
+ 10.0,
+ 11.0,
+ 12.0,
+ 13.0,
+ 14.0
+ ],
+ "v_values": [
+ 2.0,
+ 1.5,
+ 1.0,
+ 0.5,
+ 0.0,
+ -0.5,
+ -1.0,
+ -1.5,
+ -2.0
+ ],
+ "cutoffs": [
+ 0.1,
+ 0.2,
+ 0.3,
+ 0.4,
+ 0.5
+ ],
+ "grid": [
+ {
+ "pH": 0.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4434,
+ "0.2": 5621,
+ "0.3": 6819,
+ "0.4": 8044,
+ "0.5": 9464
+ }
+ },
+ {
+ "pH": 0.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 3824,
+ "0.2": 5137,
+ "0.3": 6644,
+ "0.4": 8830,
+ "0.5": 11695
+ }
+ },
+ {
+ "pH": 0.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 3039,
+ "0.2": 4665,
+ "0.3": 7275,
+ "0.4": 11410,
+ "0.5": 17082
+ }
+ },
+ {
+ "pH": 0.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 2325,
+ "0.2": 4948,
+ "0.3": 9897,
+ "0.4": 16961,
+ "0.5": 26452
+ }
+ },
+ {
+ "pH": 0.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 2070,
+ "0.2": 7209,
+ "0.3": 18500,
+ "0.4": 31128,
+ "0.5": 43152
+ }
+ },
+ {
+ "pH": 0.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 1770,
+ "0.2": 8887,
+ "0.3": 22572,
+ "0.4": 35679,
+ "0.5": 45744
+ }
+ },
+ {
+ "pH": 0.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 2093,
+ "0.2": 9633,
+ "0.3": 23973,
+ "0.4": 37177,
+ "0.5": 47804
+ }
+ },
+ {
+ "pH": 0.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 2070,
+ "0.2": 9465,
+ "0.3": 22426,
+ "0.4": 34763,
+ "0.5": 46155
+ }
+ },
+ {
+ "pH": 0.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 2382,
+ "0.2": 8651,
+ "0.3": 19244,
+ "0.4": 28641,
+ "0.5": 37909
+ }
+ },
+ {
+ "pH": 1.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4444,
+ "0.2": 5654,
+ "0.3": 6941,
+ "0.4": 8188,
+ "0.5": 9710
+ }
+ },
+ {
+ "pH": 1.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 3856,
+ "0.2": 5278,
+ "0.3": 6967,
+ "0.4": 9410,
+ "0.5": 12625
+ }
+ },
+ {
+ "pH": 1.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 3011,
+ "0.2": 4928,
+ "0.3": 8041,
+ "0.4": 12735,
+ "0.5": 19038
+ }
+ },
+ {
+ "pH": 1.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 2616,
+ "0.2": 6011,
+ "0.3": 12098,
+ "0.4": 21184,
+ "0.5": 32751
+ }
+ },
+ {
+ "pH": 1.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 2457,
+ "0.2": 9155,
+ "0.3": 22229,
+ "0.4": 36190,
+ "0.5": 47890
+ }
+ },
+ {
+ "pH": 1.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 2489,
+ "0.2": 11351,
+ "0.3": 26402,
+ "0.4": 38536,
+ "0.5": 48721
+ }
+ },
+ {
+ "pH": 1.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 2810,
+ "0.2": 11963,
+ "0.3": 27730,
+ "0.4": 40567,
+ "0.5": 52057
+ }
+ },
+ {
+ "pH": 1.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 2728,
+ "0.2": 11736,
+ "0.3": 24895,
+ "0.4": 37581,
+ "0.5": 47677
+ }
+ },
+ {
+ "pH": 1.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 2949,
+ "0.2": 9952,
+ "0.3": 20382,
+ "0.4": 29538,
+ "0.5": 38398
+ }
+ },
+ {
+ "pH": 2.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4448,
+ "0.2": 5714,
+ "0.3": 7049,
+ "0.4": 8366,
+ "0.5": 9998
+ }
+ },
+ {
+ "pH": 2.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 3901,
+ "0.2": 5498,
+ "0.3": 7448,
+ "0.4": 10201,
+ "0.5": 13802
+ }
+ },
+ {
+ "pH": 2.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 3041,
+ "0.2": 5345,
+ "0.3": 9127,
+ "0.4": 14563,
+ "0.5": 21440
+ }
+ },
+ {
+ "pH": 2.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 3065,
+ "0.2": 7315,
+ "0.3": 15525,
+ "0.4": 27192,
+ "0.5": 38176
+ }
+ },
+ {
+ "pH": 2.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 2788,
+ "0.2": 11462,
+ "0.3": 26986,
+ "0.4": 40966,
+ "0.5": 51160
+ }
+ },
+ {
+ "pH": 2.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 3396,
+ "0.2": 14374,
+ "0.3": 29943,
+ "0.4": 41860,
+ "0.5": 51693
+ }
+ },
+ {
+ "pH": 2.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 3514,
+ "0.2": 14728,
+ "0.3": 30979,
+ "0.4": 43740,
+ "0.5": 54564
+ }
+ },
+ {
+ "pH": 2.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 3440,
+ "0.2": 13291,
+ "0.3": 26116,
+ "0.4": 38464,
+ "0.5": 48167
+ }
+ },
+ {
+ "pH": 2.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 3193,
+ "0.2": 10378,
+ "0.3": 20903,
+ "0.4": 29631,
+ "0.5": 38169
+ }
+ },
+ {
+ "pH": 3.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4457,
+ "0.2": 5788,
+ "0.3": 7164,
+ "0.4": 8549,
+ "0.5": 10412
+ }
+ },
+ {
+ "pH": 3.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 3999,
+ "0.2": 5801,
+ "0.3": 8031,
+ "0.4": 11390,
+ "0.5": 15041
+ }
+ },
+ {
+ "pH": 3.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 3166,
+ "0.2": 5941,
+ "0.3": 10468,
+ "0.4": 16483,
+ "0.5": 23578
+ }
+ },
+ {
+ "pH": 3.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 3752,
+ "0.2": 10017,
+ "0.3": 21299,
+ "0.4": 32549,
+ "0.5": 43230
+ }
+ },
+ {
+ "pH": 3.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 3402,
+ "0.2": 15000,
+ "0.3": 32180,
+ "0.4": 44577,
+ "0.5": 54304
+ }
+ },
+ {
+ "pH": 3.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 4414,
+ "0.2": 17534,
+ "0.3": 32935,
+ "0.4": 45167,
+ "0.5": 55494
+ }
+ },
+ {
+ "pH": 3.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 4273,
+ "0.2": 17440,
+ "0.3": 33527,
+ "0.4": 46955,
+ "0.5": 56300
+ }
+ },
+ {
+ "pH": 3.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 4089,
+ "0.2": 14434,
+ "0.3": 26821,
+ "0.4": 38658,
+ "0.5": 48094
+ }
+ },
+ {
+ "pH": 3.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 3356,
+ "0.2": 10538,
+ "0.3": 20461,
+ "0.4": 28871,
+ "0.5": 36950
+ }
+ },
+ {
+ "pH": 4.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4473,
+ "0.2": 5853,
+ "0.3": 7252,
+ "0.4": 8826,
+ "0.5": 10748
+ }
+ },
+ {
+ "pH": 4.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 4107,
+ "0.2": 6128,
+ "0.3": 8876,
+ "0.4": 12383,
+ "0.5": 16490
+ }
+ },
+ {
+ "pH": 4.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 3331,
+ "0.2": 6781,
+ "0.3": 11893,
+ "0.4": 18219,
+ "0.5": 25547
+ }
+ },
+ {
+ "pH": 4.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 4373,
+ "0.2": 12899,
+ "0.3": 25038,
+ "0.4": 36203,
+ "0.5": 47186
+ }
+ },
+ {
+ "pH": 4.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 4417,
+ "0.2": 18849,
+ "0.3": 35651,
+ "0.4": 47418,
+ "0.5": 57138
+ }
+ },
+ {
+ "pH": 4.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 5191,
+ "0.2": 19302,
+ "0.3": 35387,
+ "0.4": 47680,
+ "0.5": 58432
+ }
+ },
+ {
+ "pH": 4.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 4920,
+ "0.2": 18614,
+ "0.3": 34981,
+ "0.4": 47806,
+ "0.5": 56958
+ }
+ },
+ {
+ "pH": 4.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 4478,
+ "0.2": 15282,
+ "0.3": 27222,
+ "0.4": 38435,
+ "0.5": 47260
+ }
+ },
+ {
+ "pH": 4.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 3811,
+ "0.2": 10773,
+ "0.3": 20317,
+ "0.4": 27828,
+ "0.5": 35626
+ }
+ },
+ {
+ "pH": 5.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4481,
+ "0.2": 5907,
+ "0.3": 7381,
+ "0.4": 9102,
+ "0.5": 11139
+ }
+ },
+ {
+ "pH": 5.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 4268,
+ "0.2": 6612,
+ "0.3": 9683,
+ "0.4": 13612,
+ "0.5": 17957
+ }
+ },
+ {
+ "pH": 5.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 3691,
+ "0.2": 7765,
+ "0.3": 13204,
+ "0.4": 19764,
+ "0.5": 27770
+ }
+ },
+ {
+ "pH": 5.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 4971,
+ "0.2": 15318,
+ "0.3": 28201,
+ "0.4": 40012,
+ "0.5": 50513
+ }
+ },
+ {
+ "pH": 5.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 5396,
+ "0.2": 21107,
+ "0.3": 37210,
+ "0.4": 48797,
+ "0.5": 58902
+ }
+ },
+ {
+ "pH": 5.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 5912,
+ "0.2": 20599,
+ "0.3": 37490,
+ "0.4": 50965,
+ "0.5": 60314
+ }
+ },
+ {
+ "pH": 5.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 5427,
+ "0.2": 19246,
+ "0.3": 35257,
+ "0.4": 48041,
+ "0.5": 56646
+ }
+ },
+ {
+ "pH": 5.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 4947,
+ "0.2": 16042,
+ "0.3": 27544,
+ "0.4": 38033,
+ "0.5": 46617
+ }
+ },
+ {
+ "pH": 5.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 4251,
+ "0.2": 10958,
+ "0.3": 19878,
+ "0.4": 26876,
+ "0.5": 34206
+ }
+ },
+ {
+ "pH": 6.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4496,
+ "0.2": 5986,
+ "0.3": 7579,
+ "0.4": 9418,
+ "0.5": 11585
+ }
+ },
+ {
+ "pH": 6.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 4367,
+ "0.2": 7195,
+ "0.3": 10575,
+ "0.4": 14757,
+ "0.5": 19609
+ }
+ },
+ {
+ "pH": 6.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 3923,
+ "0.2": 8525,
+ "0.3": 14283,
+ "0.4": 21201,
+ "0.5": 30302
+ }
+ },
+ {
+ "pH": 6.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 5539,
+ "0.2": 17405,
+ "0.3": 31174,
+ "0.4": 43205,
+ "0.5": 53913
+ }
+ },
+ {
+ "pH": 6.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 6474,
+ "0.2": 23257,
+ "0.3": 38880,
+ "0.4": 50492,
+ "0.5": 61779
+ }
+ },
+ {
+ "pH": 6.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 6573,
+ "0.2": 22273,
+ "0.3": 39682,
+ "0.4": 52941,
+ "0.5": 61782
+ }
+ },
+ {
+ "pH": 6.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 5676,
+ "0.2": 19627,
+ "0.3": 35338,
+ "0.4": 47839,
+ "0.5": 56304
+ }
+ },
+ {
+ "pH": 6.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 5384,
+ "0.2": 16855,
+ "0.3": 27837,
+ "0.4": 37295,
+ "0.5": 45879
+ }
+ },
+ {
+ "pH": 6.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 4641,
+ "0.2": 11107,
+ "0.3": 19189,
+ "0.4": 25878,
+ "0.5": 32723
+ }
+ },
+ {
+ "pH": 7.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4499,
+ "0.2": 6109,
+ "0.3": 7756,
+ "0.4": 9779,
+ "0.5": 12261
+ }
+ },
+ {
+ "pH": 7.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 4666,
+ "0.2": 7774,
+ "0.3": 11546,
+ "0.4": 16131,
+ "0.5": 22026
+ }
+ },
+ {
+ "pH": 7.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 4131,
+ "0.2": 9019,
+ "0.3": 15362,
+ "0.4": 23215,
+ "0.5": 34101
+ }
+ },
+ {
+ "pH": 7.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 6144,
+ "0.2": 19193,
+ "0.3": 33651,
+ "0.4": 45864,
+ "0.5": 57640
+ }
+ },
+ {
+ "pH": 7.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 7303,
+ "0.2": 24199,
+ "0.3": 40524,
+ "0.4": 53152,
+ "0.5": 63607
+ }
+ },
+ {
+ "pH": 7.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 7285,
+ "0.2": 23849,
+ "0.3": 42204,
+ "0.4": 54134,
+ "0.5": 62400
+ }
+ },
+ {
+ "pH": 7.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 5902,
+ "0.2": 19737,
+ "0.3": 35434,
+ "0.4": 47270,
+ "0.5": 55896
+ }
+ },
+ {
+ "pH": 7.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 5250,
+ "0.2": 16200,
+ "0.3": 26836,
+ "0.4": 35836,
+ "0.5": 44649
+ }
+ },
+ {
+ "pH": 7.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 5008,
+ "0.2": 11205,
+ "0.3": 18593,
+ "0.4": 24962,
+ "0.5": 31291
+ }
+ },
+ {
+ "pH": 8.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4539,
+ "0.2": 6246,
+ "0.3": 7995,
+ "0.4": 10188,
+ "0.5": 13197
+ }
+ },
+ {
+ "pH": 8.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 4926,
+ "0.2": 8407,
+ "0.3": 12667,
+ "0.4": 17896,
+ "0.5": 24829
+ }
+ },
+ {
+ "pH": 8.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 4467,
+ "0.2": 9767,
+ "0.3": 16868,
+ "0.4": 26875,
+ "0.5": 39381
+ }
+ },
+ {
+ "pH": 8.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 6508,
+ "0.2": 20353,
+ "0.3": 35568,
+ "0.4": 48979,
+ "0.5": 60974
+ }
+ },
+ {
+ "pH": 8.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 7422,
+ "0.2": 24215,
+ "0.3": 41154,
+ "0.4": 55140,
+ "0.5": 64149
+ }
+ },
+ {
+ "pH": 8.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 7469,
+ "0.2": 24045,
+ "0.3": 42511,
+ "0.4": 54536,
+ "0.5": 62384
+ }
+ },
+ {
+ "pH": 8.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 5912,
+ "0.2": 19461,
+ "0.3": 34562,
+ "0.4": 46161,
+ "0.5": 54917
+ }
+ },
+ {
+ "pH": 8.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 5256,
+ "0.2": 15441,
+ "0.3": 25529,
+ "0.4": 34422,
+ "0.5": 43096
+ }
+ },
+ {
+ "pH": 8.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 5220,
+ "0.2": 11106,
+ "0.3": 17829,
+ "0.4": 24008,
+ "0.5": 29894
+ }
+ },
+ {
+ "pH": 9.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4612,
+ "0.2": 6375,
+ "0.3": 8269,
+ "0.4": 10974,
+ "0.5": 14120
+ }
+ },
+ {
+ "pH": 9.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 5241,
+ "0.2": 9070,
+ "0.3": 13705,
+ "0.4": 20173,
+ "0.5": 27182
+ }
+ },
+ {
+ "pH": 9.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 4831,
+ "0.2": 10819,
+ "0.3": 19805,
+ "0.4": 31816,
+ "0.5": 44589
+ }
+ },
+ {
+ "pH": 9.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 7147,
+ "0.2": 21451,
+ "0.3": 37950,
+ "0.4": 52029,
+ "0.5": 63819
+ }
+ },
+ {
+ "pH": 9.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 7755,
+ "0.2": 24168,
+ "0.3": 42100,
+ "0.4": 56021,
+ "0.5": 64244
+ }
+ },
+ {
+ "pH": 9.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 7739,
+ "0.2": 24019,
+ "0.3": 42235,
+ "0.4": 54209,
+ "0.5": 61974
+ }
+ },
+ {
+ "pH": 9.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 6232,
+ "0.2": 19226,
+ "0.3": 33558,
+ "0.4": 44680,
+ "0.5": 53625
+ }
+ },
+ {
+ "pH": 9.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 5446,
+ "0.2": 14887,
+ "0.3": 24415,
+ "0.4": 33039,
+ "0.5": 41295
+ }
+ },
+ {
+ "pH": 9.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 5385,
+ "0.2": 10934,
+ "0.3": 17085,
+ "0.4": 23093,
+ "0.5": 28637
+ }
+ },
+ {
+ "pH": 10.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4575,
+ "0.2": 6345,
+ "0.3": 8570,
+ "0.4": 11635,
+ "0.5": 15160
+ }
+ },
+ {
+ "pH": 10.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 5379,
+ "0.2": 9419,
+ "0.3": 15092,
+ "0.4": 22116,
+ "0.5": 29091
+ }
+ },
+ {
+ "pH": 10.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 5407,
+ "0.2": 12969,
+ "0.3": 24438,
+ "0.4": 36887,
+ "0.5": 49449
+ }
+ },
+ {
+ "pH": 10.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 7682,
+ "0.2": 22789,
+ "0.3": 40533,
+ "0.4": 54499,
+ "0.5": 65750
+ }
+ },
+ {
+ "pH": 10.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 7972,
+ "0.2": 24237,
+ "0.3": 42595,
+ "0.4": 55815,
+ "0.5": 63884
+ }
+ },
+ {
+ "pH": 10.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 7720,
+ "0.2": 23773,
+ "0.3": 41540,
+ "0.4": 53350,
+ "0.5": 61264
+ }
+ },
+ {
+ "pH": 10.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 6383,
+ "0.2": 18824,
+ "0.3": 32649,
+ "0.4": 43132,
+ "0.5": 52024
+ }
+ },
+ {
+ "pH": 10.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 5453,
+ "0.2": 14025,
+ "0.3": 23111,
+ "0.4": 31410,
+ "0.5": 39010
+ }
+ },
+ {
+ "pH": 10.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 5511,
+ "0.2": 10692,
+ "0.3": 16419,
+ "0.4": 22239,
+ "0.5": 27526
+ }
+ },
+ {
+ "pH": 11.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4508,
+ "0.2": 6380,
+ "0.3": 9120,
+ "0.4": 12465,
+ "0.5": 16102
+ }
+ },
+ {
+ "pH": 11.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 5268,
+ "0.2": 10006,
+ "0.3": 16470,
+ "0.4": 23612,
+ "0.5": 30754
+ }
+ },
+ {
+ "pH": 11.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 5582,
+ "0.2": 14117,
+ "0.3": 26859,
+ "0.4": 39691,
+ "0.5": 52220
+ }
+ },
+ {
+ "pH": 11.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 7869,
+ "0.2": 24217,
+ "0.3": 42027,
+ "0.4": 55797,
+ "0.5": 66566
+ }
+ },
+ {
+ "pH": 11.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 7641,
+ "0.2": 23809,
+ "0.3": 42512,
+ "0.4": 55325,
+ "0.5": 63201
+ }
+ },
+ {
+ "pH": 11.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 7281,
+ "0.2": 23342,
+ "0.3": 40593,
+ "0.4": 51993,
+ "0.5": 60245
+ }
+ },
+ {
+ "pH": 11.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 6260,
+ "0.2": 18346,
+ "0.3": 31744,
+ "0.4": 41734,
+ "0.5": 50320
+ }
+ },
+ {
+ "pH": 11.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 5221,
+ "0.2": 13045,
+ "0.3": 21845,
+ "0.4": 29597,
+ "0.5": 37176
+ }
+ },
+ {
+ "pH": 11.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 5327,
+ "0.2": 10221,
+ "0.3": 15672,
+ "0.4": 21346,
+ "0.5": 26531
+ }
+ },
+ {
+ "pH": 12.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4479,
+ "0.2": 6624,
+ "0.3": 9659,
+ "0.4": 13262,
+ "0.5": 17159
+ }
+ },
+ {
+ "pH": 12.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 5306,
+ "0.2": 10923,
+ "0.3": 17407,
+ "0.4": 24797,
+ "0.5": 32214
+ }
+ },
+ {
+ "pH": 12.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 5753,
+ "0.2": 15520,
+ "0.3": 28977,
+ "0.4": 42028,
+ "0.5": 54440
+ }
+ },
+ {
+ "pH": 12.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 7229,
+ "0.2": 23428,
+ "0.3": 41080,
+ "0.4": 55171,
+ "0.5": 65287
+ }
+ },
+ {
+ "pH": 12.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 7248,
+ "0.2": 23348,
+ "0.3": 41763,
+ "0.4": 54329,
+ "0.5": 62388
+ }
+ },
+ {
+ "pH": 12.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 6853,
+ "0.2": 22856,
+ "0.3": 39215,
+ "0.4": 50321,
+ "0.5": 58870
+ }
+ },
+ {
+ "pH": 12.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 5936,
+ "0.2": 17245,
+ "0.3": 29954,
+ "0.4": 39968,
+ "0.5": 48205
+ }
+ },
+ {
+ "pH": 12.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 5106,
+ "0.2": 12146,
+ "0.3": 20624,
+ "0.4": 27920,
+ "0.5": 35333
+ }
+ },
+ {
+ "pH": 12.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 5156,
+ "0.2": 9489,
+ "0.3": 14728,
+ "0.4": 20340,
+ "0.5": 25380
+ }
+ },
+ {
+ "pH": 13.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4501,
+ "0.2": 7042,
+ "0.3": 10465,
+ "0.4": 14305,
+ "0.5": 18358
+ }
+ },
+ {
+ "pH": 13.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 5628,
+ "0.2": 11474,
+ "0.3": 18087,
+ "0.4": 25546,
+ "0.5": 33826
+ }
+ },
+ {
+ "pH": 13.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 5614,
+ "0.2": 16402,
+ "0.3": 30728,
+ "0.4": 43535,
+ "0.5": 55510
+ }
+ },
+ {
+ "pH": 13.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 6499,
+ "0.2": 21639,
+ "0.3": 39549,
+ "0.4": 54048,
+ "0.5": 63601
+ }
+ },
+ {
+ "pH": 13.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 6937,
+ "0.2": 22251,
+ "0.3": 40514,
+ "0.4": 52931,
+ "0.5": 61330
+ }
+ },
+ {
+ "pH": 13.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 6550,
+ "0.2": 21634,
+ "0.3": 37526,
+ "0.4": 48177,
+ "0.5": 56671
+ }
+ },
+ {
+ "pH": 13.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 5434,
+ "0.2": 15533,
+ "0.3": 27707,
+ "0.4": 37362,
+ "0.5": 45339
+ }
+ },
+ {
+ "pH": 13.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 4884,
+ "0.2": 11052,
+ "0.3": 19103,
+ "0.4": 25866,
+ "0.5": 33147
+ }
+ },
+ {
+ "pH": 13.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 4998,
+ "0.2": 8678,
+ "0.3": 13741,
+ "0.4": 19263,
+ "0.5": 24133
+ }
+ },
+ {
+ "pH": 14.0,
+ "V": -2.0,
+ "counts": {
+ "0.1": 4537,
+ "0.2": 7770,
+ "0.3": 11448,
+ "0.4": 15576,
+ "0.5": 20015
+ }
+ },
+ {
+ "pH": 14.0,
+ "V": -1.5,
+ "counts": {
+ "0.1": 5416,
+ "0.2": 11385,
+ "0.3": 18043,
+ "0.4": 25791,
+ "0.5": 35107
+ }
+ },
+ {
+ "pH": 14.0,
+ "V": -1.0,
+ "counts": {
+ "0.1": 5528,
+ "0.2": 16391,
+ "0.3": 30722,
+ "0.4": 43706,
+ "0.5": 56402
+ }
+ },
+ {
+ "pH": 14.0,
+ "V": -0.5,
+ "counts": {
+ "0.1": 5999,
+ "0.2": 19884,
+ "0.3": 37429,
+ "0.4": 52302,
+ "0.5": 62172
+ }
+ },
+ {
+ "pH": 14.0,
+ "V": 0.0,
+ "counts": {
+ "0.1": 6412,
+ "0.2": 20627,
+ "0.3": 38600,
+ "0.4": 50844,
+ "0.5": 59654
+ }
+ },
+ {
+ "pH": 14.0,
+ "V": 0.5,
+ "counts": {
+ "0.1": 5971,
+ "0.2": 20014,
+ "0.3": 35275,
+ "0.4": 45868,
+ "0.5": 54328
+ }
+ },
+ {
+ "pH": 14.0,
+ "V": 1.0,
+ "counts": {
+ "0.1": 4888,
+ "0.2": 13583,
+ "0.3": 25291,
+ "0.4": 34560,
+ "0.5": 42304
+ }
+ },
+ {
+ "pH": 14.0,
+ "V": 1.5,
+ "counts": {
+ "0.1": 4681,
+ "0.2": 10060,
+ "0.3": 17537,
+ "0.4": 24170,
+ "0.5": 30658
+ }
+ },
+ {
+ "pH": 14.0,
+ "V": 2.0,
+ "counts": {
+ "0.1": 4801,
+ "0.2": 8005,
+ "0.3": 12732,
+ "0.4": 17965,
+ "0.5": 22797
+ }
+ }
+ ]
+}
\ No newline at end of file
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/reverse_pourbaix.py b/crystal_toolkit/components/reverse_pourbaix.py
new file mode 100755
index 00000000..caaa6f27
--- /dev/null
+++ b/crystal_toolkit/components/reverse_pourbaix.py
@@ -0,0 +1,356 @@
+"""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 pathlib import Path
+from typing import Any
+
+import pandas as pd
+import plotly.graph_objects as go
+import pyarrow.parquet as pq
+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
+
+logger = logging.getLogger(__name__)
+
+__author__ = "Leo Karlsson"
+
+# Grid and display constants
+HEIGHT = 550
+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={
+ "title": "pH",
+ "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={
+ "title": "Potential (V vs. SHE)",
+ "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)",
+ height=HEIGHT,
+ width=WIDTH,
+ 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, parquet_path: str | Path | None = None, *args, **kwargs):
+ """
+ Args:
+ parquet_path: path to the precomputed (pH, V, mp_id, decomposition_energy)
+ parquet file. Loaded once at component construction. If None, the
+ click-to-list functionality is disabled but the heatmap still works.
+ Current parquet data is computed with solid filter and default
+ ion concentrations.
+ """
+ # TODO: in future, it would be nice if user can disable solid filter and
+ # specify ion concentrations.
+ super().__init__(*args, **kwargs)
+ self._stability_df: pd.DataFrame | None = None
+ if parquet_path is not None:
+ logger.info("Loading reverse-Pourbaix stability data from %s", parquet_path)
+ df = pq.read_table(parquet_path).to_pandas()
+ # Index by (pH, V) for fast cell-click lookups.
+ self._stability_df = df.set_index(["pH", "V"]).sort_index()
+ logger.info(
+ "Loaded %d stability rows across %d cells",
+ len(df),
+ self._stability_df.index.nunique(),
+ )
+
+ @staticmethod
+ def _resolve_cutoff(value: float | list[float] | None) -> float:
+ """Unwrap a slider value (which may be a list) to a float, falling back
+ to the default cutoff."""
+ if isinstance(value, list):
+ value = value[0] if value else DEFAULT_CUTOFF
+ if value is None:
+ return DEFAULT_CUTOFF
+ return float(value)
+
+ @staticmethod
+ def _snap_to_grid(ph: float, v: float) -> tuple[int, float]:
+ """Snap a clicked (pH, V) point to the precomputed grid keys."""
+ return int(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}"
+
+ def get_stable_mp_ids(self, ph: float, v: float, cutoff: float) -> list[str]:
+ """Return mp_ids stable at (pH, V) below the given decomposition-energy cutoff.
+
+ Returns an empty list if the parquet data is not loaded.
+ """
+ if self._stability_df is None:
+ return []
+ ph_key, v_key = self._snap_to_grid(ph, v)
+ try:
+ cell = self._stability_df.loc[(ph_key, v_key)]
+ except KeyError:
+ logger.debug("No stability data for (pH=%s, V=%s)", ph_key, v_key)
+ return []
+ stable = cell[cell["decomposition_energy"] <= cutoff]
+ return stable["mp_id"].tolist()
+
+ @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},
+ ),
+ ],
+ # style={"minHeight": "500px"},
+ 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):
+ 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
+
+ figure = self.get_heatmap_figure(
+ heatmap_data,
+ stability_cutoff=self._resolve_cutoff(stability_cutoff),
+ show_water_lines=bool(show_water_lines),
+ )
+
+ return figure