battery-ion-sim / utils /plots.py
shrut27's picture
Upload utils/plots.py with huggingface_hub
d766e11 verified
Raw
History Blame Contribute Delete
34.4 kB
"""Plotly figure factory for the LiB Simulation AI Engine dashboard."""
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
FF_COLORS = {
"Yun et al.": "#3A86FF",
"Wang et al.": "#4CAF50",
"This Work": "#FF4757",
"This Work (De Angelis 2024)": "#FF4757",
"Yun et al. (2017)": "#3A86FF",
"Wang et al. (2020)": "#4CAF50",
"DFT (reference)": "#222",
"DFT – Vacancy": "#8B5CF6",
"DFT – Knock-off": "#F59E0B",
"DFT – Direct-hopping": "#06B6D4",
"DFT reference": "#999",
}
_DARK = dict(
plot_bgcolor="rgba(0,0,0,0)",
paper_bgcolor="rgba(0,0,0,0)",
font=dict(color="#EEE"),
legend=dict(bgcolor="rgba(0,0,0,0)"),
)
_GRID = dict(gridcolor="rgba(255,255,255,0.1)")
def _apply_dark(fig: go.Figure, height: int = 400) -> go.Figure:
fig.update_layout(height=height, **_DARK, margin=dict(t=55, b=40))
fig.update_xaxes(**_GRID)
fig.update_yaxes(**_GRID)
return fig
# ── Force Field Performance ────────────────────────────────────────────────────
def ff_performance_chart(df_perf: dict) -> go.Figure:
ffs = df_perf["Force Field"]
fig = make_subplots(rows=1, cols=2,
subplot_titles=["R² (higher is better)", "RMSE (lower is better)"],
horizontal_spacing=0.12)
colors = [FF_COLORS.get(f, "#888") for f in ffs]
for col, (label, key) in enumerate([("Energy R²", "Energy R²"), ("Force R²", "Force R²")]):
for i, (ff, val) in enumerate(zip(ffs, df_perf[key])):
fig.add_trace(go.Bar(name=ff, x=[label], y=[val], marker_color=colors[i],
showlegend=(col == 0), legendgroup=ff,
text=[f"{val:.3f}"], textposition="outside"), row=1, col=1)
for label, key, scale in [("Energy RMSE (eV)", "Energy RMSE (eV)", 1),
("Force RMSE ×10⁻³ (eV/Å)", "Force RMSE (eV/Å)", 1000)]:
for i, (ff, val) in enumerate(zip(ffs, df_perf[key])):
fig.add_trace(go.Bar(name=ff, x=[label], y=[val * scale], marker_color=colors[i],
showlegend=False, legendgroup=ff,
text=[f"{val*scale:.4f}"], textposition="outside"), row=1, col=2)
fig.update_layout(barmode="group", height=440, **_DARK, margin=dict(t=60, b=40))
fig.update_xaxes(showgrid=False)
fig.update_yaxes(**_GRID)
return fig
def loss_curve_chart(df: pd.DataFrame) -> go.Figure:
ma = df["Loss (SSE)"].rolling(100, min_periods=1).mean()
fig = go.Figure()
fig.add_trace(go.Scatter(x=df["Iteration"], y=df["Loss (SSE)"], mode="lines",
name="Loss (SSE)", line=dict(color="#3A86FF", width=0.6), opacity=0.4))
fig.add_trace(go.Scatter(x=df["Iteration"], y=ma, mode="lines",
name="Moving Avg (100 iter)", line=dict(color="#FF4757", width=2)))
fig.add_vline(x=5000, line_dash="dash", line_color="#F59E0B",
annotation_text="bond → vdW phase", annotation_font_color="#F59E0B")
fig.update_layout(xaxis_title="Optimization Iteration", yaxis_title="Loss (SSE)", **_DARK,
height=360, margin=dict(t=20, b=40))
fig.update_xaxes(**_GRID)
fig.update_yaxes(**_GRID)
return fig
# ── Diffusion & Arrhenius ──────────────────────────────────────────────────────
def arrhenius_plot(df: pd.DataFrame, selected_system: str) -> go.Figure:
fig = go.Figure()
subset = df[df["System"] == selected_system]
for ff in subset["Force Field"].unique():
s = subset[subset["Force Field"] == ff]
fig.add_trace(go.Scatter(x=s["1000/T (K⁻¹)"], y=s["log₁₀(D)"], mode="lines",
name=ff, line=dict(color=FF_COLORS.get(ff, "#888"), width=2.5)))
dft_df = df[df["Force Field"] == "DFT reference"]
for mech_full in dft_df["System"].unique():
s = dft_df[dft_df["System"] == mech_full]
fig.add_trace(go.Scatter(x=s["1000/T (K⁻¹)"], y=s["log₁₀(D)"], mode="lines",
name=mech_full, line=dict(color=FF_COLORS.get(mech_full, "#aaa"),
width=1.5, dash="dot")))
for T_mark, label in [(300, "300 K"), (400, "400 K"), (500, "500 K")]:
fig.add_vline(x=1000 / T_mark, line_color="rgba(255,255,255,0.15)", line_dash="dot",
annotation_text=label, annotation_font_color="rgba(255,255,255,0.5)",
annotation_font_size=10)
fig.update_layout(xaxis_title="1000/T (K⁻¹)", yaxis_title="log₁₀[D (cm²/s)]",
title=f"Arrhenius Plot — {selected_system}", **_DARK,
height=420, margin=dict(t=50, b=40))
fig.update_xaxes(**_GRID)
fig.update_yaxes(**_GRID)
return fig
def diffusion_bar_chart(df: pd.DataFrame, temperature: int) -> go.Figure:
sub = df[df["Temperature (K)"] == temperature].copy()
fig = px.bar(sub, x="System", y="D (cm²/s)", color="Force Field", barmode="group",
color_discrete_map={k: v for k, v in FF_COLORS.items()}, log_y=True,
labels={"D (cm²/s)": "Diffusion Coefficient (cm²/s)"},
title=f"Li Diffusion Coefficient at {temperature} K")
return _apply_dark(fig, 420)
def activation_energy_comparison() -> go.Figure:
from utils.data import ARRHENIUS_PARAMS
systems = list(ARRHENIUS_PARAMS.keys())
ffs = ["Yun et al.", "Wang et al.", "This Work"]
fig = go.Figure()
for ff in ffs:
eas = [ARRHENIUS_PARAMS[s][ff]["Ea"] for s in systems]
ea_errs = [ARRHENIUS_PARAMS[s][ff]["Ea_err"] for s in systems]
fig.add_trace(go.Bar(name=ff, x=systems, y=eas,
error_y=dict(type="data", array=ea_errs, visible=True),
marker_color=FF_COLORS.get(ff, "#888")))
fig.add_hline(y=24.1, line_dash="dot", line_color="#F59E0B",
annotation_text="CI-NEB knock-off (24.1 kJ/mol)", annotation_font_color="#F59E0B")
fig.add_hline(y=63.7, line_dash="dot", line_color="#8B5CF6",
annotation_text="CI-NEB vacancy (63.7 kJ/mol)", annotation_font_color="#8B5CF6")
fig.update_layout(barmode="group", xaxis_title="System", yaxis_title="Eₐ (kJ/mol)",
title="Activation Energy — All Systems & Force Fields", **_DARK,
height=450, margin=dict(t=60, b=60))
fig.update_xaxes(**_GRID, tickangle=-10)
fig.update_yaxes(**_GRID)
return fig
def msd_plot(df: pd.DataFrame) -> go.Figure:
fig = go.Figure()
palette = px.colors.qualitative.Plotly
for i, label in enumerate(df["Label"].unique()):
s = df[df["Label"] == label]
fig.add_trace(go.Scatter(x=s["Time (ps)"], y=s["MSD (Ų)"], mode="lines",
name=label, line=dict(color=palette[i % len(palette)], width=1.8)))
fig.update_layout(xaxis_title="Time (ps)", yaxis_title="MSD (Ų)",
title="Mean Square Displacement — Li Atoms", **_DARK,
height=400, margin=dict(t=50, b=40))
fig.update_xaxes(**_GRID)
fig.update_yaxes(**_GRID)
return fig
# ── Crystal stability ──────────────────────────────────────────────────────────
def strain_energy_plot(df: pd.DataFrame) -> go.Figure:
fig = go.Figure()
dash_map = {"DFT (reference)": "solid", "Yun et al.": "dash",
"Wang et al.": "dot", "This Work": "solid"}
for method in df["Method"].unique():
s = df[df["Method"] == method]
fig.add_trace(go.Scatter(x=s["Strain ε₁₂"], y=s["Energy (eV/atom)"], mode="lines",
name=method,
line=dict(color=FF_COLORS.get(method, "#888"),
dash=dash_map.get(method, "solid"), width=2)))
fig.add_vline(x=0, line_color="rgba(255,255,255,0.3)", line_dash="dot")
fig.update_layout(xaxis_title="Shear Strain ε₁₂", yaxis_title="Energy (eV/atom)",
title="LiF Energy–Strain (Shear Deformation)", **_DARK,
height=400, margin=dict(t=50, b=40))
fig.update_xaxes(**_GRID)
fig.update_yaxes(**_GRID)
return fig
def eos_plot(df: pd.DataFrame) -> go.Figure:
fig = go.Figure()
for method in df["Method"].unique():
s = df[df["Method"] == method]
fig.add_trace(go.Scatter(x=s["V/V₀"], y=s["Energy (eV/atom)"], mode="lines",
name=method,
line=dict(color=FF_COLORS.get(method, "#888"), width=2)))
fig.add_vline(x=1.0, line_color="rgba(255,255,255,0.3)", line_dash="dot",
annotation_text="V₀", annotation_font_color="rgba(255,255,255,0.5)")
fig.update_layout(xaxis_title="V/V₀", yaxis_title="Energy (eV/atom)",
title="Murnaghan Equation of State — LiF", **_DARK,
height=400, margin=dict(t=50, b=40))
fig.update_xaxes(**_GRID)
fig.update_yaxes(**_GRID)
return fig
def rdf_plot(df: pd.DataFrame, ff_label: str) -> go.Figure:
times = sorted(df["Time (ps)"].unique())
colorscale = px.colors.sequential.Plasma
fig = go.Figure()
for i, t in enumerate(times):
s = df[df["Time (ps)"] == t]
frac = i / max(len(times) - 1, 1)
idx = int(frac * (len(colorscale) - 1))
fig.add_trace(go.Scatter(x=s["r (Å)"], y=s["g(r)"], mode="lines", name=f"{t} ps",
line=dict(color=colorscale[idx], width=1.8)))
fig.add_vline(x=2.01, line_color="rgba(255,255,255,0.4)", line_dash="dash",
annotation_text="d(Li–F)=2.01 Å",
annotation_font_color="rgba(255,255,255,0.5)")
fig.update_layout(xaxis_title="r (Å)", yaxis_title="g(r)",
title=f"Li–F Radial Distribution Function — {ff_label}", **_DARK,
height=400, margin=dict(t=50, b=40))
fig.update_xaxes(**_GRID)
fig.update_yaxes(**_GRID)
return fig
# ── SEI Analysis ───────────────────────────────────────────────────────────────
def sei_ranking_chart(df: pd.DataFrame) -> go.Figure:
df_sorted = df.sort_values("Overall Score", ascending=True)
colors = ["#FF4757" if c == "LiF (FEC-derived)" else "#3A86FF" for c in df_sorted["Component"]]
fig = go.Figure(go.Bar(x=df_sorted["Overall Score"], y=df_sorted["Component"],
orientation="h", marker_color=colors,
text=df_sorted["Overall Score"].apply(lambda v: f"{v:.2f}"),
textposition="outside"))
fig.update_layout(xaxis_title="Overall Score", title="SEI Component Ranking (Weighted Score)",
**_DARK, height=400, margin=dict(t=50, b=40, l=180))
fig.update_xaxes(range=[0, 11], **_GRID)
fig.update_yaxes(gridcolor=None)
return fig
def sei_radar(df: pd.DataFrame, components: list) -> go.Figure:
cats = ["Ionic Conductivity Score", "Electronic Insulation Score",
"Mechanical Stability Score", "Thermal Stability Score"]
fig = go.Figure()
palette = px.colors.qualitative.Plotly
for i, comp in enumerate(components):
row = df[df["Component"] == comp].iloc[0]
vals = [row[c] for c in cats]
vals_closed = vals + [vals[0]]
cats_closed = cats + [cats[0]]
fig.add_trace(go.Scatterpolar(r=vals_closed, theta=cats_closed, fill="toself",
name=comp, line=dict(color=palette[i % len(palette)]),
opacity=0.7))
fig.update_layout(polar=dict(radialaxis=dict(visible=True, range=[0, 10], color="#aaa"),
bgcolor="rgba(0,0,0,0)"),
**_DARK, height=420, title="SEI Component Property Radar",
margin=dict(t=60, b=20))
return fig
# ── ML model benchmark ─────────────────────────────────────────────────────────
def ml_model_scatter(df: pd.DataFrame) -> go.Figure:
fig = px.scatter(df, x="Energy MAE (meV/atom)", y="Force MAE (meV/Å)",
size="Inference Speed (rel.)", color="Model",
hover_data=["R² Energy", "Training Data (DFT pts)"],
text="Model", size_max=40,
title="ML Force Field Accuracy vs Computational Speed",
color_discrete_sequence=px.colors.qualitative.Plotly)
fig.update_traces(textposition="top center", textfont_size=10)
return _apply_dark(fig, 440)
# ── Battery Property Predictor charts ─────────────────────────────────────────
def cycle_life_plot(df: pd.DataFrame) -> go.Figure:
fig = px.line(df, x="Cycle", y="Capacity Retention (%)", color="SEI Type",
title="Capacity Retention vs. Cycle Number",
color_discrete_sequence=px.colors.qualitative.Plotly)
fig.add_hline(y=80, line_dash="dash", line_color="rgba(255,200,0,0.6)",
annotation_text="80% EOL threshold",
annotation_font_color="rgba(255,200,0,0.8)")
return _apply_dark(fig, 420)
def rate_capability_plot(df: pd.DataFrame) -> go.Figure:
fig = px.line(df, x="C-rate", y="Discharge Capacity (mAh/g)", color="Anode / SEI",
log_x=True, title="Rate Capability — Discharge Capacity vs. C-Rate",
color_discrete_sequence=px.colors.qualitative.Plotly,
markers=True)
return _apply_dark(fig, 420)
def thermal_safety_plot(df: pd.DataFrame) -> go.Figure:
risk_color = {"Safe": "#4ade80", "Moderate": "#F59E0B", "High": "#f87171", "Critical": "#dc2626"}
colors = [risk_color.get(r, "#888") for r in df["Risk Level"]]
fig = go.Figure(go.Bar(
x=df["Temperature (°C)"],
y=df["Component / Event"],
orientation="h",
marker_color=colors,
text=df["Temperature (°C)"].apply(lambda v: f"{v} °C"),
textposition="outside",
hovertext=df["Notes"],
))
fig.add_vline(x=130, line_dash="dash", line_color="#dc2626",
annotation_text="Thermal runaway onset (130 °C)",
annotation_font_color="#dc2626")
fig.update_layout(xaxis_title="Temperature (°C)",
title="Thermal Safety — Component Decomposition / Event Temperatures",
**_DARK, height=420, margin=dict(t=55, b=40, l=260))
fig.update_xaxes(**_GRID)
fig.update_yaxes(gridcolor=None)
return fig
def electrolyte_ranking_chart(df: pd.DataFrame) -> go.Figure:
df_s = df.sort_values("Overall Score", ascending=True)
fig = go.Figure(go.Bar(
x=df_s["Overall Score"], y=df_s["Electrolyte"], orientation="h",
marker_color=["#FF4757" if "FEC" in e else "#3A86FF" for e in df_s["Electrolyte"]],
text=df_s["Overall Score"].apply(lambda v: f"{v:.2f}"), textposition="outside",
))
fig.update_layout(xaxis_title="Overall Score",
title="Electrolyte Ranking — Ionic Conductivity, Stability, Decomp. Risk",
**_DARK, height=400, margin=dict(t=55, b=40, l=240))
fig.update_xaxes(**_GRID)
fig.update_yaxes(gridcolor=None)
return fig
def anode_ranking_chart(df: pd.DataFrame) -> go.Figure:
df_s = df.sort_values("Overall Score", ascending=True)
fig = go.Figure(go.Bar(
x=df_s["Overall Score"], y=df_s["Anode Material"], orientation="h",
marker_color=px.colors.qualitative.Plotly[:len(df_s)],
text=df_s["Overall Score"].apply(lambda v: f"{v:.2f}"), textposition="outside",
))
fig.update_layout(xaxis_title="Overall Score",
title="Anode Material Ranking — Capacity, Stability, SEI Compatibility",
**_DARK, height=380, margin=dict(t=55, b=40, l=200))
fig.update_xaxes(**_GRID)
fig.update_yaxes(gridcolor=None)
return fig
def additive_ranking_chart(df: pd.DataFrame) -> go.Figure:
df_s = df.sort_values("Overall Score", ascending=True)
colors = ["#FF4757" if a == "FEC (fluoroethylene carbonate)" else "#3A86FF"
for a in df_s["Additive"]]
fig = go.Figure(go.Bar(
x=df_s["Overall Score"], y=df_s["Additive"], orientation="h",
marker_color=colors,
text=df_s["Overall Score"].apply(lambda v: f"{v:.2f}"), textposition="outside",
))
fig.update_layout(xaxis_title="Overall Score",
title="Electrolyte Additive Ranking — LiF SEI Enhancement & Cycle Life",
**_DARK, height=380, margin=dict(t=55, b=40, l=240))
fig.update_xaxes(**_GRID)
fig.update_yaxes(gridcolor=None)
return fig
def multi_objective_pareto(df_anode: pd.DataFrame) -> go.Figure:
fig = px.scatter(
df_anode,
x="Volume Expansion (%)",
y="Practical Capacity (mAh/g)",
color="Anode Material",
size="Cycle Stability Score",
size_max=40,
text="Anode Material",
title="Multi-Objective Trade-off: Capacity vs. Volume Expansion (bubble = cycle stability)",
color_discrete_sequence=px.colors.qualitative.Plotly,
)
fig.update_traces(textposition="top center", textfont_size=10)
return _apply_dark(fig, 440)
def ion_mobility_plot(df: pd.DataFrame) -> go.Figure:
colors = {"Inorganic (LiF-rich)": "#FF4757",
"Mixed inorganic/organic": "#F59E0B",
"Organic outer layer": "#3A86FF"}
fig = go.Figure()
for layer in df["SEI Layer"].unique():
s = df[df["SEI Layer"] == layer]
fig.add_trace(go.Scatter(
x=s["SEI Depth (nm)"], y=s["Li⁺ Diffusivity (cm²/s)"],
mode="lines", name=layer,
line=dict(color=colors.get(layer, "#888"), width=2.5),
fill="tozeroy", fillcolor=colors.get(layer, "#888").replace(")", ",0.08)").replace("rgb", "rgba"),
))
fig.add_hline(y=3.44e-8, line_dash="dot", line_color="#FF4757",
annotation_text="LiF bulk D (paper: 3.44×10⁻⁸ cm²/s)",
annotation_font_color="#FF4757")
fig.update_layout(
xaxis_title="Depth from Anode Surface (nm)",
yaxis_title="Li⁺ Diffusivity (cm²/s)",
title="Ion Mobility Map — Li⁺ Diffusivity Through SEI Layers",
yaxis_type="log",
**_DARK, height=400, margin=dict(t=55, b=40),
)
fig.update_xaxes(**_GRID)
fig.update_yaxes(**_GRID)
return fig
def dendrite_risk_plot(df: pd.DataFrame) -> go.Figure:
fig = px.line(df, x="Current Density (mA/cm²)", y="Overpotential (mV)",
color="SEI Condition", title="Dendrite Nucleation Risk — Overpotential vs. Current Density",
color_discrete_sequence=["#4ade80", "#F59E0B", "#f87171", "#dc2626"])
fig.add_hline(y=50, line_dash="dot", line_color="rgba(255,255,255,0.3)",
annotation_text="Dendrite nucleation threshold (~50 mV)",
annotation_font_color="rgba(255,255,255,0.5)")
return _apply_dark(fig, 400)
def reaction_pathway_plot(df: pd.DataFrame) -> go.Figure:
fig = make_subplots(specs=[[{"secondary_y": True}]])
fig.add_trace(go.Bar(x=df["Step"], y=df["ΔG (eV)"], name="ΔG (eV)",
marker_color=["#4ade80" if v < 0 else "#f87171" for v in df["ΔG (eV)"]],
text=df["Reaction"].apply(lambda r: r[:30] + "…" if len(r) > 30 else r),
textposition="outside"), secondary_y=False)
fig.add_trace(go.Scatter(x=df["Step"], y=df["Barrier Ea (eV)"], mode="lines+markers",
name="Barrier Eₐ (eV)", line=dict(color="#F59E0B", width=2),
marker=dict(size=8)), secondary_y=True)
fig.update_layout(title="FEC Decomposition → LiF: Reaction Pathway Energetics",
xaxis_title="Reaction Step", **_DARK, height=420, margin=dict(t=55, b=80))
fig.update_yaxes(title_text="ΔG (eV)", **_GRID, secondary_y=False)
fig.update_yaxes(title_text="Barrier Eₐ (eV)", secondary_y=True)
fig.update_xaxes(**_GRID)
return fig
def mechanical_spider(df: pd.DataFrame, materials: list) -> go.Figure:
cats = ["Bulk Modulus (GPa)", "Shear Modulus (GPa)", "Young's Modulus (GPa)", "Fracture Toughness (MPa√m)"]
fig = go.Figure()
palette = px.colors.qualitative.Plotly
for i, mat in enumerate(materials):
row = df[df["Material"] == mat].iloc[0]
# Normalise each column to 0-10
norms = [row["Bulk Modulus (GPa)"] / 120 * 10,
row["Shear Modulus (GPa)"] / 60 * 10,
row["Young's Modulus (GPa)"] / 130 * 10,
row["Fracture Toughness (MPa√m)"] / 1.0 * 10]
norms_c = norms + [norms[0]]
cats_c = cats + [cats[0]]
fig.add_trace(go.Scatterpolar(r=norms_c, theta=cats_c, fill="toself", name=mat,
line=dict(color=palette[i % len(palette)]), opacity=0.7))
fig.update_layout(polar=dict(radialaxis=dict(visible=True, range=[0, 10], color="#aaa"),
bgcolor="rgba(0,0,0,0)"),
title="Mechanical Properties Radar (normalised)", **_DARK,
height=420, margin=dict(t=60, b=20))
return fig
# ════════════════════════════════════════════════════════════════════════════════
# SODIUM-ION BATTERY (SIB) PLOTS
# ════════════════════════════════════════════════════════════════════════════════
_SIB_MAT_COLORS = {
"NaFePO₄": "#3A86FF",
"Na₂MnNiO₄": "#FF4757",
"NaFe0.5Mn0.5PO₄ (predicted)": "#4ade80",
"Na₂Mn0.5Co0.5O₄ (predicted)": "#F59E0B",
"NaFe0.25Ni0.75PO₄ (predicted)": "#8B5CF6",
"NaCoO₂ (reference)": "#aaa",
"NaMnO₂ (reference)": "#ccc",
"Na₃V₂(PO₄)₃ (reference)": "#999",
}
_ATOM_COLORS = {
"Na": "#FFD700",
"Fe": "#B7410E",
"P": "#FF6B35",
"Mn": "#7B2D8B",
"Ni": "#2E8B57",
"O": "#4FC3F7",
}
def sib_structure_overview(df: pd.DataFrame) -> go.Figure:
"""Grouped bar comparing total energy and formation energy per structure."""
fig = make_subplots(rows=1, cols=2,
subplot_titles=["Total Energy (eV)", "Formation Energy (eV/atom)"],
horizontal_spacing=0.12)
colors = [_SIB_MAT_COLORS.get(c, "#888") for c in df["Composition"]]
fig.add_trace(go.Bar(x=df["Material"], y=df["Total Energy (eV)"],
marker_color=colors, name="Total Energy",
text=df["Total Energy (eV)"].apply(lambda v: f"{v:.2f}"),
textposition="outside"), row=1, col=1)
fig.add_trace(go.Bar(x=df["Material"], y=df["Formation Energy (eV/atom)"],
marker_color=colors, name="Formation Energy",
text=df["Formation Energy (eV/atom)"].apply(lambda v: f"{v:.3f}"),
textposition="outside", showlegend=False), row=1, col=2)
fig.update_layout(barmode="group", showlegend=False, **_DARK,
height=430, margin=dict(t=55, b=80))
fig.update_xaxes(tickangle=-20, **_GRID)
fig.update_yaxes(**_GRID)
return fig
def sib_lattice_radar(df: pd.DataFrame, materials: list) -> go.Figure:
"""Radar of normalised lattice parameters for selected structures."""
cats = ["a (Å)", "b (Å)", "c (Å)"]
fig = go.Figure()
palette = px.colors.qualitative.Plotly
for i, mat in enumerate(materials):
row = df[df["Material"] == mat].iloc[0]
vals = [row["a (Å)"], row["b (Å)"], row["c (Å)"]]
# normalise 0–40 range
norms = [v / 40 * 10 for v in vals]
norms_c = norms + [norms[0]]
cats_c = cats + [cats[0]]
fig.add_trace(go.Scatterpolar(r=norms_c, theta=cats_c, fill="toself", name=mat,
line=dict(color=palette[i % len(palette)]),
opacity=0.75))
fig.update_layout(
polar=dict(radialaxis=dict(visible=True, range=[0, 10], color="#aaa"),
bgcolor="rgba(0,0,0,0)"),
title="Lattice Parameter Comparison (normalised to 0–10)",
**_DARK, height=400, margin=dict(t=60, b=20),
)
return fig
def sib_bader_box(df: pd.DataFrame, material: str) -> go.Figure:
"""Box/strip plot of Bader charges per atom type for one material."""
sub = df[df["Material"] == material]
atoms = sub["Atom"].unique()
fig = go.Figure()
for atom in atoms:
s = sub[sub["Atom"] == atom]
fig.add_trace(go.Box(
y=s["Charge (e)"], name=atom,
marker_color=_ATOM_COLORS.get(atom, "#888"),
boxpoints="all", jitter=0.4, pointpos=0,
line=dict(width=1.5),
))
fig.update_layout(
yaxis_title="Bader Charge (e)",
title=f"Bader Charge Distribution — {material}",
**_DARK, height=420, margin=dict(t=55, b=40),
)
fig.update_xaxes(showgrid=False)
fig.update_yaxes(**_GRID)
return fig
def sib_bader_heatmap(df: pd.DataFrame) -> go.Figure:
"""Heatmap of average Bader charges: materials × atom types."""
pivot = df.groupby(["Material", "Atom"])["Charge (e)"].mean().reset_index()
pivot_wide = pivot.pivot(index="Material", columns="Atom", values="Charge (e)")
# Order atoms sensibly
atom_order = [a for a in ["Na", "Fe", "Mn", "Ni", "P", "O"] if a in pivot_wide.columns]
pivot_wide = pivot_wide[atom_order]
fig = go.Figure(go.Heatmap(
z=pivot_wide.values,
x=pivot_wide.columns.tolist(),
y=pivot_wide.index.tolist(),
colorscale="RdBu",
zmid=0,
text=[[f"{v:.3f}" for v in row] for row in pivot_wide.values],
texttemplate="%{text}",
textfont=dict(size=11),
colorbar=dict(title="Charge (e)"),
))
fig.update_layout(
title="Average Bader Charges — All Structures (e)",
xaxis_title="Atom Type",
yaxis_title="Material",
**_DARK, height=320, margin=dict(t=55, b=40),
)
return fig
def sib_charge_transfer_bar(df: pd.DataFrame) -> go.Figure:
"""Bar chart showing charge transfer from each cation to O for each structure."""
# Compute charge neutrality check: sum of charges per structure
summary = df.groupby(["Material", "Structure", "Atom"]).agg(
total_charge=("Charge (e)", lambda x: x.sum()),
n=("Charge (e)", "count"),
avg_charge=("Charge (e)", "mean"),
).reset_index()
cations = summary[summary["avg_charge"] > 0].copy()
cations["label"] = cations["Material"] + "\n" + cations["Atom"]
fig = px.bar(cations, x="Atom", y="avg_charge", color="Material",
barmode="group", text="avg_charge",
title="Average Cation Charges — Bader Analysis",
color_discrete_map=_SIB_MAT_COLORS,
labels={"avg_charge": "Average Charge (e)", "Atom": "Atom Type"})
fig.update_traces(texttemplate="%{y:.3f}", textposition="outside")
fig.update_layout(**_DARK, height=400, margin=dict(t=55, b=40))
fig.update_xaxes(showgrid=False)
fig.update_yaxes(**_GRID)
return fig
def sib_formation_energy_chart(df: pd.DataFrame) -> go.Figure:
"""Bar chart of formation energies — DFT values + AI screened."""
color_map = {"DFT": "#FF4757", "Literature": "#3A86FF",
"AI prediction": "#4ade80"}
fig = go.Figure()
for src in df["Source"].unique():
sub = df[df["Source"] == src]
fig.add_trace(go.Bar(
x=sub["Material"], y=sub["Formation Energy (eV/atom)"],
name=src, marker_color=color_map.get(src, "#888"),
text=sub["Formation Energy (eV/atom)"].apply(lambda v: f"{v:.3f}"),
textposition="outside",
))
fig.add_hline(y=-2.38, line_dash="dot", line_color="#FF4757",
annotation_text="NaFePO₄: −2.38 eV/atom",
annotation_font_color="#FF4757")
fig.add_hline(y=-1.542, line_dash="dot", line_color="#3A86FF",
annotation_text="Na₂MnNiO₄: −1.542 eV/atom",
annotation_font_color="#3A86FF")
fig.update_layout(barmode="group",
title="Formation Energy — DFT vs AI-Screened Cathodes",
yaxis_title="Formation Energy (eV/atom)",
**_DARK, height=450, margin=dict(t=55, b=80))
fig.update_xaxes(tickangle=-25, showgrid=False)
fig.update_yaxes(**_GRID)
return fig
def sib_cathode_ranking_chart(df: pd.DataFrame) -> go.Figure:
"""Horizontal bar of AI Score for all cathodes."""
df_s = df.sort_values("AI Score (0–100)", ascending=True)
colors = [
"#FF4757" if s == "DFT" else
"#4ade80" if s == "AI predicted" else
"#3A86FF"
for s in df_s["Source"]
]
fig = go.Figure(go.Bar(
x=df_s["AI Score (0–100)"],
y=df_s["Material"],
orientation="h",
marker_color=colors,
text=df_s["AI Score (0–100)"].apply(lambda v: f"{v:.1f}"),
textposition="outside",
))
fig.update_layout(
title="AI Cathode Ranking (Stability 25% · Voltage 25% · Diffusion 20% · Capacity 20% · Safety 10%)",
xaxis_title="AI Score (0–100)",
**_DARK, height=430, margin=dict(t=60, b=40, l=240),
)
fig.update_xaxes(range=[0, 110], **_GRID)
fig.update_yaxes(gridcolor=None)
return fig
def sib_cathode_radar(df: pd.DataFrame, materials: list) -> go.Figure:
"""Radar of 5-property scores for selected cathodes."""
cats = ["Stability Score", "Voltage Score", "Diffusion Score", "Capacity Score", "Safety Score"]
fig = go.Figure()
palette = px.colors.qualitative.Plotly
for i, mat in enumerate(materials):
row = df[df["Material"] == mat].iloc[0]
vals = [row[c] for c in cats]
fig.add_trace(go.Scatterpolar(
r=vals + [vals[0]], theta=cats + [cats[0]],
fill="toself", name=mat,
line=dict(color=palette[i % len(palette)]), opacity=0.75,
))
fig.update_layout(
polar=dict(radialaxis=dict(visible=True, range=[0, 10], color="#aaa"),
bgcolor="rgba(0,0,0,0)"),
title="Cathode Property Radar",
**_DARK, height=430, margin=dict(t=60, b=20),
)
return fig
def sib_diffusion_bar(df: pd.DataFrame) -> go.Figure:
"""Migration barrier and diffusivity per mechanism."""
fig = make_subplots(rows=1, cols=2,
subplot_titles=["Migration Barrier Eₐ (eV)", "Diffusivity D at 300K (cm²/s)"],
horizontal_spacing=0.14)
colors = [_SIB_MAT_COLORS.get(m, "#888") for m in df["Material"]]
fig.add_trace(go.Bar(
x=df["Mechanism"], y=df["Migration Barrier Ea (eV)"],
marker_color=colors, name="Eₐ",
text=df["Migration Barrier Ea (eV)"].apply(lambda v: f"{v:.2f}"),
textposition="outside",
), row=1, col=1)
fig.add_trace(go.Bar(
x=df["Mechanism"], y=np.log10(df["D at 300K (cm²/s)"]),
marker_color=colors, name="log₁₀D",
text=df["D at 300K (cm²/s)"].apply(lambda v: f"{v:.1e}"),
textposition="outside", showlegend=False,
), row=1, col=2)
fig.update_layout(**_DARK, height=430, margin=dict(t=55, b=80))
fig.update_xaxes(tickangle=-20, showgrid=False)
fig.update_yaxes(**_GRID)
return fig
def sib_screened_bubble(df: pd.DataFrame) -> go.Figure:
"""Bubble chart: predicted voltage vs capacity, sized by |Eform|."""
fig = px.scatter(
df, x="Predicted Voltage (V)", y="Predicted Capacity (mAh/g)",
size=df["Predicted Eform (eV/atom)"].abs(),
color="Source",
text="Material",
size_max=40,
title="AI-Screened Cathodes: Voltage vs Capacity (bubble = |Eform|)",
color_discrete_map={"DFT": "#FF4757", "AI": "#4ade80"},
)
fig.update_traces(textposition="top center", textfont_size=9)
return _apply_dark(fig, 460)
def sib_pipeline_status(df: pd.DataFrame) -> go.Figure:
"""Funnel / gantt-style view of the 8 AI pipeline stages."""
status_colors = {"Done": "#4ade80", "Ready": "#3A86FF",
"Trained": "#F59E0B", "Predicted": "#8B5CF6", "Training": "#f87171"}
colors = [status_colors.get(s, "#888") for s in df["Status"]]
fig = go.Figure(go.Bar(
x=df["Stage"], y=[1] * len(df),
marker_color=colors, name="Stage",
text=df["Name"], textposition="inside",
textfont=dict(size=10, color="white"),
))
for i, row in df.iterrows():
fig.add_annotation(x=row["Stage"], y=1.05,
text=f"<b>{row['Status']}</b>",
showarrow=False,
font=dict(size=9, color=status_colors.get(row["Status"], "#888")))
fig.update_layout(
title="AI Pipeline Stages — Status",
xaxis_title="Stage", yaxis=dict(showticklabels=False, showgrid=False),
**_DARK, height=280, margin=dict(t=55, b=40),
)
fig.update_xaxes(tickvals=list(range(1, 9)),
ticktext=[f"S{i}" for i in range(1, 9)], showgrid=False)
return fig