"""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"{row['Status']}", 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