"""Plotly visualization helpers for Crash Intelligence.""" from __future__ import annotations import pandas as pd import plotly.express as px import plotly.graph_objects as go COLOR_SEQUENCE = [ "#0B6E4F", "#08A045", "#1B4965", "#5FA8D3", "#C44536", "#E8871E", "#6B4C9A", "#2A9D8F", "#E76F51", "#264653", "#F4A261", "#457B9D", "#9B2226", "#005F73", "#CA6702", ] LAYOUT_DEFAULTS = dict( paper_bgcolor="rgba(255,255,255,1)", plot_bgcolor="rgba(248,250,252,1)", font=dict(family="Source Sans 3, Segoe UI, sans-serif", color="#1a1a1a", size=13), margin=dict(l=50, r=30, t=50, b=50), legend=dict(bgcolor="rgba(255,255,255,0.9)", bordercolor="#ddd", borderwidth=1), ) def _apply_layout(fig: go.Figure, title: str, height: int = 420) -> go.Figure: fig.update_layout(title=title, height=height, **LAYOUT_DEFAULTS) fig.update_xaxes(showgrid=True, gridcolor="#e5e7eb", zeroline=False) fig.update_yaxes(showgrid=True, gridcolor="#e5e7eb", zeroline=False) return fig def family_bar(summary: pd.DataFrame, metric: str, title: str) -> go.Figure: fig = px.bar( summary.sort_values(metric, ascending=True), x=metric, y="family", orientation="h", color=metric, color_continuous_scale=["#D8F3DC", "#0B6E4F"], labels={"family": "Material Family", metric: metric.replace("_", " ").title()}, ) return _apply_layout(fig, title, height=480) def scatter_crash_vs_weight(materials: pd.DataFrame) -> go.Figure: fig = px.scatter( materials.sample(n=min(2000, len(materials)), random_state=3), x="lightweighting_score", y="crashworthiness_index", color="family", size="uts_mpa", hover_data=["material_name", "cost_usd_kg", "sustainability_score"], color_discrete_sequence=COLOR_SEQUENCE, labels={ "lightweighting_score": "Lightweighting Score", "crashworthiness_index": "Crashworthiness Index", }, ) return _apply_layout(fig, "Crashworthiness vs Lightweighting", height=480) def radar_materials(top: pd.DataFrame) -> go.Figure: categories = [ "crashworthiness_index", "lightweighting_score", "cost_performance_score", "sustainability_score", "energy_absorption_potential", ] labels = ["Crash", "Weight", "Cost-Perf", "Sustainability", "Energy Abs."] fig = go.Figure() for i, (_, row) in enumerate(top.head(5).iterrows()): values = [] for c in categories: v = float(row[c]) if c == "energy_absorption_potential": v = min(v * 2.0, 100) if c == "cost_performance_score": v = min(v * 1.5, 100) values.append(v) values.append(values[0]) fig.add_trace( go.Scatterpolar( r=values, theta=labels + [labels[0]], name=str(row.get("material_name", row.get("family", f"M{i}"))), line=dict(color=COLOR_SEQUENCE[i % len(COLOR_SEQUENCE)], width=2), fill="toself", opacity=0.55, ) ) fig.update_layout( polar=dict( bgcolor="#f8fafc", radialaxis=dict(visible=True, range=[0, 100], gridcolor="#e5e7eb"), angularaxis=dict(gridcolor="#e5e7eb"), ), title="Multi-Objective Material Comparison", height=480, **{k: v for k, v in LAYOUT_DEFAULTS.items() if k != "margin"}, margin=dict(l=60, r=60, t=50, b=40), ) return fig def stress_strain_curves(curves: pd.DataFrame, material_ids: list[str]) -> go.Figure: fig = go.Figure() subset = curves[curves["material_id"].isin(material_ids)] for i, mid in enumerate(material_ids): mdf = subset[subset["material_id"] == mid] if mdf.empty: continue name = mdf["material_name"].iloc[0] fig.add_trace( go.Scatter( x=mdf["strain"], y=mdf["stress_mpa"], mode="lines", name=f"{name} (quasi-static)", line=dict(color=COLOR_SEQUENCE[i % len(COLOR_SEQUENCE)], width=2.5), ) ) fig.add_trace( go.Scatter( x=mdf["strain"], y=mdf["stress_high_rate_mpa"], mode="lines", name=f"{name} (high-rate)", line=dict( color=COLOR_SEQUENCE[i % len(COLOR_SEQUENCE)], width=2, dash="dash", ), ) ) fig.update_layout( xaxis_title="True Strain", yaxis_title="True Stress (MPa)", ) return _apply_layout(fig, "Stress–Strain Curves (Strain-Rate Sensitive)", height=460) def scenario_heatmap(recommendations: pd.DataFrame) -> go.Figure: pivot = ( recommendations.groupby(["crash_scenario", "family"])["crash_score"] .mean() .reset_index() .pivot(index="crash_scenario", columns="family", values="crash_score") ) fig = px.imshow( pivot, color_continuous_scale=["#F1FAEE", "#1B4965", "#0B6E4F"], aspect="auto", labels=dict(color="Crash Score"), ) return _apply_layout(fig, "Avg Crash Score by Scenario × Family", height=520) def energy_intrusion_scatter(recommendations: pd.DataFrame) -> go.Figure: sample = recommendations.sample(n=min(1500, len(recommendations)), random_state=5) fig = px.scatter( sample, x="intrusion_mm", y="energy_absorption_kj", color="crash_scenario", symbol="family", hover_data=["material_name", "component", "crash_score"], color_discrete_sequence=COLOR_SEQUENCE, labels={ "intrusion_mm": "Intrusion (mm)", "energy_absorption_kj": "Energy Absorption (kJ)", }, ) return _apply_layout(fig, "Energy Absorption vs Intrusion", height=460) def validation_parity(validation: pd.DataFrame) -> go.Figure: fig = go.Figure() fig.add_trace( go.Scatter( x=validation["cae_crash_score"], y=validation["ai_crash_score"], mode="markers", name="AI vs CAE", marker=dict(color="#1B4965", size=7, opacity=0.55), ) ) lims = [0, 100] fig.add_trace( go.Scatter( x=lims, y=lims, mode="lines", name="Ideal", line=dict(color="#C44536", dash="dash", width=2), ) ) fig.update_layout(xaxis_title="CAE Crash Score", yaxis_title="AI Crash Score") return _apply_layout(fig, "AI Prediction vs CAE Validation", height=440) def validation_error_hist(validation: pd.DataFrame) -> go.Figure: fig = px.histogram( validation, x="ai_cae_error_pct", nbins=30, color="pass_fail", color_discrete_map={"Pass": "#0B6E4F", "Review": "#C44536"}, labels={"ai_cae_error_pct": "AI–CAE Error (%)"}, ) return _apply_layout(fig, "AI–CAE Error Distribution", height=400) def cost_sustain_bubble(materials: pd.DataFrame) -> go.Figure: sample = materials.sample(n=min(1500, len(materials)), random_state=9) fig = px.scatter( sample, x="cost_usd_kg", y="sustainability_score", size="crashworthiness_index", color="family", hover_data=["material_name", "density_g_cm3", "uts_mpa"], color_discrete_sequence=COLOR_SEQUENCE, labels={ "cost_usd_kg": "Cost (USD/kg)", "sustainability_score": "Sustainability Score", }, ) return _apply_layout(fig, "Cost vs Sustainability (bubble = crash score)", height=460) def top_recommendations_bar(top: pd.DataFrame) -> go.Figure: plot_df = top.copy() name_col = "material_name" if "material_name" in plot_df.columns else "family" fig = px.bar( plot_df.sort_values("crash_score", ascending=True), x="crash_score", y=name_col, color="family" if "family" in plot_df.columns else None, orientation="h", color_discrete_sequence=COLOR_SEQUENCE, labels={"crash_score": "Crash Score", name_col: "Material"}, ) return _apply_layout(fig, "Top Recommended Materials", height=420) def kpi_gauge(value: float, title: str, color: str = "#0B6E4F") -> go.Figure: fig = go.Figure( go.Indicator( mode="gauge+number", value=value, title={"text": title, "font": {"size": 14, "color": "#1a1a1a"}}, number={"font": {"color": "#1a1a1a"}}, gauge={ "axis": {"range": [0, 100], "tickcolor": "#1a1a1a"}, "bar": {"color": color}, "bgcolor": "#f1f5f9", "bordercolor": "#cbd5e1", "steps": [ {"range": [0, 40], "color": "#fee2e2"}, {"range": [40, 70], "color": "#fef3c7"}, {"range": [70, 100], "color": "#dcfce7"}, ], }, ) ) fig.update_layout( height=220, margin=dict(l=20, r=20, t=40, b=10), paper_bgcolor="white", font=dict(color="#1a1a1a"), ) return fig