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