Nexus-Grid / server /dashboard.py
Abineshsdata's picture
Update server/dashboard.py
f33bec9 verified
Raw
History Blame Contribute Delete
67.2 kB
"""
NexusGrid Gradio Dashboard — Pro Command Center
Professional SCADA-style UI: dark glass panels, live KPIs, spacious charts.
Seven panels: topology, frequency, Sankey, threat feed, action trace,
reward breakdown, task radar.
"""
from __future__ import annotations
import gradio as gr
import json
import math
from typing import Dict, Any, List
try:
import plotly.graph_objects as go
from plotly.subplots import make_subplots
PLOTLY_AVAILABLE = True
except ImportError:
go = None # type: ignore[assignment]
make_subplots = None # type: ignore[assignment]
PLOTLY_AVAILABLE = False
# ── Pro command-center palette ────────────────────────────────────
C: Dict[str, str] = {
"bg": "#070b12",
"bg_grad": "#0c1220",
"surface": "#111827",
"surface_hi": "#1a2332",
"glass": "rgba(17,24,39,0.72)",
"border": "#243044",
"border_mid": "#334155",
"border_hi": "#3d4f66",
"text": "#f1f5f9",
"text_mid": "#94a3b8",
"text_soft": "#64748b",
"accent": "#22d3ee",
"accent_soft": "rgba(34,211,238,0.12)",
"indigo": "#818cf8",
"blue": "#3b82f6",
"purple": "#a78bfa",
"green": "#34d399",
"green_soft": "rgba(52,211,153,0.15)",
"amber": "#fbbf24",
"amber_soft": "rgba(251,191,36,0.12)",
"orange": "#fb923c",
"red": "#f87171",
"red_soft": "rgba(248,113,113,0.12)",
"crimson": "#fb7185",
}
FS = {
"title": 15,
"axis": 12,
"label": 11,
"legend": 11,
"value": 36,
"zone": 14,
"hover": 12,
}
TYPE_COL = {
"hydro": "#0284c7", "nuclear": "#16a34a", "gas": "#ea580c",
"wind": "#0891b2", "solar": "#ca8a04", "battery": "#9333ea", "load": "#64748b",
}
TYPE_RING = {
"hydro": 0, "nuclear": 0, "gas": 0,
"wind": 1, "solar": 1, "battery": 1, "load": 2,
}
RING_R = {0: 0.30, 1: 0.62, 2: 0.92}
TASK_META = [
{"id": 0, "name": "Smoke Test", "difficulty": "Trivial", "max": 1.0},
{"id": 1, "name": "Duck Curve", "difficulty": "Easy", "max": 1.0},
{"id": 2, "name": "Cascade Overload", "difficulty": "Medium", "max": 1.0},
{"id": 3, "name": "Phantom Injection", "difficulty": "Hard", "max": 1.0},
{"id": 4, "name": "Stuxnet Resonance", "difficulty": "Very Hard", "max": 1.0},
{"id": 5, "name": "Black Start", "difficulty": "Expert", "max": 1.0},
]
DIFF_COL = {
"Trivial": "#94a3b8", "Easy": "#34d399", "Medium": "#22d3ee",
"Hard": "#fbbf24", "Very Hard": "#fb923c", "Expert": "#fb7185",
}
DIFF_SHORT = {
"Trivial": "Trivial", "Easy": "Easy", "Medium": "Medium",
"Hard": "Hard", "Very Hard": "V.Hard", "Expert": "Expert",
}
PLOT_HEIGHT = 440
PLOT_HEIGHT_TALL = 500
PLOT_HEIGHT_WIDE = 540
def _pro_layout(**kw) -> dict:
"""Plotly layout defaults; merges extra font keys without duplicate kwargs."""
font_extra = kw.pop("font", None)
base_font = dict(family="DM Sans, Inter, system-ui, sans-serif", color=C["text"], size=FS["axis"])
if font_extra:
base_font.update(font_extra)
base = dict(
template="plotly_dark",
paper_bgcolor=C["surface"],
plot_bgcolor=C["surface_hi"],
font=base_font,
margin=dict(l=52, r=52, t=72, b=52),
autosize=True,
hoverlabel=dict(
bgcolor=C["surface_hi"],
bordercolor=C["border_hi"],
font=dict(family="JetBrains Mono, ui-monospace, monospace", size=FS["hover"], color=C["text"]),
),
)
base.update(kw)
return base
def _apply_layout(fig: go.Figure, **kw) -> go.Figure:
"""Apply layout without duplicate keyword errors (e.g. font passed twice)."""
fig.update_layout(**_pro_layout(**kw))
return fig
def _safe_chart(builder, *args, **kwargs):
try:
return builder(*args, **kwargs)
except Exception as exc:
fig = go.Figure()
fig.update_layout(**_pro_layout(height=360))
fig.add_annotation(
text=f"Chart unavailable: {exc}",
xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False,
font=dict(size=13, color=C["text_mid"]),
)
return fig
def _shell(title: str, dot: str = C["accent"]) -> str:
return (
f'<div class="chart-shell">'
f'<div class="chart-head"><span>{title}</span>'
f'<span class="dot" style="background:{dot}"></span></div></div>'
)
def _section(num: str, title: str, subtitle: str) -> str:
return (
f'<div class="ops-section">'
f'<span class="ops-num">{num}</span>'
f'<span class="ops-title">{title}</span>'
f'<span class="ops-sub">{subtitle}</span>'
f'</div>'
)
# ═══════════════════════════════════════════════════════════════════
# PANEL 1 — GRID TOPOLOGY
# ═══════════════════════════════════════════════════════════════════
def create_topology_graph(obs_dict: Dict[str, Any]) -> go.Figure:
topo = obs_dict.get("topology_graph", {})
nodes = topo.get("nodes", [])
edges = topo.get("edges", [])
if not nodes:
fig = go.Figure()
fig.update_layout(
**_pro_layout(height=PLOT_HEIGHT_TALL, margin=dict(l=40, r=40, t=56, b=40)),
xaxis=dict(visible=False), yaxis=dict(visible=False),
)
fig.add_annotation(
text="System offline — reset to initialise the grid",
xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False,
font=dict(size=14, color=C["text_soft"]),
)
return fig
buckets: Dict[int, list] = {0: [], 1: [], 2: []}
for nd in nodes:
ring = TYPE_RING.get(nd.get("node_type", "load"), 2)
buckets[ring].append(nd)
pos: Dict[str, tuple] = {}
for ring_id, bucket in buckets.items():
n = len(bucket)
r = RING_R[ring_id]
angle_offset = 15 * ring_id
for i, nd in enumerate(bucket):
deg = angle_offset + (360 * i / max(n, 1))
rad = math.radians(deg)
pos[nd["id"]] = (r * math.cos(rad), r * math.sin(rad))
ring_traces = []
for r_val in (0.30, 0.62, 0.92):
angles = list(range(0, 361, 4))
xs = [r_val * math.cos(math.radians(a)) for a in angles]
ys = [r_val * math.sin(math.radians(a)) for a in angles]
ring_traces.append(go.Scatter(
x=xs, y=ys, mode="lines",
line=dict(color=C["border"], width=1, dash="dot"),
showlegend=False, hoverinfo="none",
))
edge_traces: List[go.Scatter] = []
for edge in edges:
src, tgt = edge.get("source", ""), edge.get("target", "")
if src not in pos or tgt not in pos:
continue
x0, y0 = pos[src]
x1, y1 = pos[tgt]
cap = max(edge.get("capacity_mw", 1), 1)
load = edge.get("current_load_mw", 0)
status = edge.get("status", "LIVE")
pct = load / cap
if status == "TRIPPED":
col, w, dash = C["text_soft"], 1.5, "dot"
elif pct >= 0.95:
col, w, dash = C["crimson"], 3.5, "solid"
elif pct >= 0.80:
col, w, dash = C["amber"], 2.8, "solid"
elif pct >= 0.50:
col, w, dash = C["accent"], 2.0, "solid"
else:
col, w, dash = "#94a3b8", 1.2, "solid"
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
dist = math.sqrt(mx**2 + my**2)
if dist > 0.01:
bulge = 0.10
cx = mx - bulge * mx / dist
cy = my - bulge * my / dist
else:
cx, cy = mx, my
curve_x, curve_y = [], []
for k in range(9):
t = k / 8
curve_x.append((1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t ** 2 * x1)
curve_y.append((1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t ** 2 * y1)
curve_x.append(None)
curve_y.append(None)
edge_traces.append(go.Scatter(
x=curve_x, y=curve_y, mode="lines",
line=dict(width=w, color=col, dash=dash),
hoverinfo="none", showlegend=False,
))
nx_l, ny_l, nc_l, nb_l, ns_l = [], [], [], [], []
hover_l = []
for nd in nodes:
nid = nd["id"]
t = nd.get("node_type", "load")
crit = nd.get("critical", False)
en = nd.get("energized", True)
cap = nd.get("capacity_mw", 0)
gen = nd.get("generation_mw", 0)
spoof = nd.get("spoofed", False)
x, y = pos.get(nid, (0, 0))
nx_l.append(x)
ny_l.append(y)
base = TYPE_COL.get(t, C["text_soft"])
nc_l.append(base if en else "#334155")
nb_l.append(C["crimson"] if crit else (C["amber"] if spoof else "#e2e8f0"))
ns_l.append(20 + min(int(cap / 300), 12))
tags = []
if crit:
tags.append("CRITICAL")
if spoof:
tags.append("SPOOFED")
tag_str = (" · " + " · ".join(tags)) if tags else ""
hover_l.append(
f"<b>{nid}</b>{tag_str}<br>"
f"Type: {t.upper()}<br>"
f"State: {'Live' if en else 'Dark'}<br>"
f"Capacity: {cap:,.0f} MW<br>"
f"Generation: {gen:,.0f} MW"
)
node_trace = go.Scatter(
x=nx_l, y=ny_l, mode="markers",
hovertext=hover_l,
hoverinfo="text",
marker=dict(size=ns_l, color=nc_l, line=dict(width=2, color=nb_l), opacity=0.96),
showlegend=False,
)
seen: set = set()
legend: List[go.Scatter] = []
for nd in nodes:
t = nd.get("node_type", "load")
if t in seen:
continue
seen.add(t)
legend.append(go.Scatter(
x=[None], y=[None], mode="markers",
marker=dict(size=12, color=TYPE_COL.get(t, C["text_soft"]),
line=dict(width=1, color=C["text"])),
name=t.capitalize(),
showlegend=True,
))
fig = go.Figure(data=ring_traces + edge_traces + [node_trace] + legend)
layout = _pro_layout(height=PLOT_HEIGHT_TALL, margin=dict(l=24, r=24, t=72, b=88))
layout["title"] = dict(
text="Grid Topology — 20-node network",
font=dict(size=FS["title"], color=C["text"]),
x=0.5, xanchor="center",
)
layout["legend"] = dict(
orientation="h", yanchor="top", y=-0.12, xanchor="center", x=0.5,
font=dict(size=FS["legend"], color=C["text_mid"]),
bgcolor="rgba(17,24,39,0.92)", bordercolor=C["border_hi"], borderwidth=1,
)
layout["xaxis"] = dict(visible=False, range=[-1.25, 1.25], scaleanchor="y", scaleratio=1)
layout["yaxis"] = dict(visible=False, range=[-1.25, 1.25])
fig.update_layout(**layout)
return fig
# ═══════════════════════════════════════════════════════════════════
# PANEL 2 — FREQUENCY GAUGE
# ═══════════════════════════════════════════════════════════════════
def _freq_zone(freq_hz: float) -> tuple[str, str]:
"""Return (accent_color, zone_label) for a grid frequency reading."""
f = round(freq_hz, 3)
if f < 59.0:
return C["crimson"], "BLACKOUT"
if f < 59.2:
return C["red"], "CRITICAL"
if f < 59.5:
return C["orange"], "DANGER"
if f < 59.7:
return C["amber"], "WARNING"
if f <= 60.3:
return C["green"], "NOMINAL"
return C["amber"], "ELEVATED"
def format_freq_status(freq_hz: float) -> str:
"""HTML status strip above the gauge — keeps titles out of the Plotly canvas."""
col, zone = _freq_zone(freq_hz)
delta = round(freq_hz - 60.0, 3)
sign = "+" if delta >= 0 else ""
return (
f'<div class="freq-status-bar">'
f'<span class="freq-status-zone" style="color:{col}">Grid Frequency · {zone}</span>'
f'<span class="freq-status-delta">Δ {sign}{delta:.3f} Hz from nominal</span>'
f'<span class="freq-status-val" style="color:{col}">{freq_hz:.3f} Hz</span>'
f'</div>'
)
def create_frequency_gauge(freq_hz: float,
history: List[float] | None = None) -> go.Figure:
freq_hz = round(freq_hz, 3)
col, zone = _freq_zone(freq_hz)
fig = make_subplots(
rows=2, cols=1,
row_heights=[0.52, 0.48],
vertical_spacing=0.26,
specs=[[{"type": "indicator"}], [{"type": "scatter"}]],
)
fig.add_trace(go.Indicator(
mode="gauge+number",
value=freq_hz,
number=dict(
suffix=" Hz",
font=dict(size=28, color=col, family="Inter, sans-serif"),
valueformat=".3f",
),
title=dict(text=""),
gauge=dict(
axis=dict(
range=[58.5, 61.5],
tickvals=[59.0, 59.5, 60.0, 60.5, 61.0],
tickwidth=1,
tickcolor=C["text_mid"],
tickfont=dict(size=10, color=C["text_mid"]),
),
bar=dict(color=col, thickness=0.30),
bgcolor="#1e293b",
borderwidth=1,
bordercolor=C["border_hi"],
steps=[
dict(range=[58.5, 59.0], color="rgba(251,113,133,0.55)"),
dict(range=[59.0, 59.2], color="rgba(248,113,113,0.50)"),
dict(range=[59.2, 59.5], color="rgba(251,146,60,0.45)"),
dict(range=[59.5, 59.7], color="rgba(251,191,36,0.40)"),
dict(range=[59.7, 60.3], color="rgba(52,211,153,0.48)"),
dict(range=[60.3, 61.5], color="rgba(251,191,36,0.40)"),
],
threshold=dict(line=dict(color=C["text"], width=2), thickness=0.8, value=freq_hz),
),
), row=1, col=1)
hist = list(history) if history else [60.0]
xs = list(range(len(hist)))
fig.add_trace(go.Scatter(
x=xs, y=hist, mode="lines",
line=dict(color=col, width=2.5),
fill="tozeroy",
fillcolor=C["accent_soft"] if zone == "NOMINAL" else C["amber_soft"],
name="History",
showlegend=False,
), row=2, col=1)
fig.add_trace(go.Scatter(
x=[0, max(len(hist) - 1, 1)], y=[60.0, 60.0], mode="lines",
line=dict(color=C["text_soft"], width=1.5, dash="dash"),
showlegend=False, hoverinfo="skip",
), row=2, col=1)
fig.update_xaxes(title_text="Simulation tick", title_font=dict(size=11), row=2, col=1)
fig.update_yaxes(
title_text="Hz",
range=[58.8, 61.2],
gridcolor=C["border"],
tickfont=dict(size=11),
row=2, col=1,
)
fig.update_layout(
**_pro_layout(height=PLOT_HEIGHT, margin=dict(l=32, r=32, t=16, b=32)),
)
return fig
# ═══════════════════════════════════════════════════════════════════
# PANEL 3 — POWER FLOW SANKEY
# ═══════════════════════════════════════════════════════════════════
def _type_stats(nodes: List[Dict]) -> tuple[Dict[str, float], Dict[str, float], Dict[str, int]]:
"""Installed gen capacity, peak load, and energized counts per asset class."""
installed: Dict[str, float] = {}
peak_load: Dict[str, float] = {}
energized_n: Dict[str, int] = {}
for nd in nodes:
t = nd.get("node_type", "load")
if t == "load":
peak_load[t] = peak_load.get(t, 0.0) + float(nd.get("peak_load_mw", 0) or 0)
else:
installed[t] = installed.get(t, 0.0) + float(nd.get("capacity_mw", 0) or 0)
if nd.get("energized"):
energized_n[t] = energized_n.get(t, 0) + 1
return installed, peak_load, energized_n
def _aggregate_flows(
edges: List[Dict],
id_to_type: Dict[str, str],
*,
use_capacity: bool,
) -> tuple[Dict[tuple, float], Dict[tuple, list]]:
"""Sum MW by (source_type, target_type); live flow or line rating when de-energized."""
flow_map: Dict[tuple, float] = {}
ratio_map: Dict[tuple, list] = {}
for edge in edges:
s, t = edge.get("source", ""), edge.get("target", "")
if s not in id_to_type or t not in id_to_type:
continue
line_cap = max(float(edge.get("capacity_mw", 0) or 0), 1.0)
if use_capacity:
val = line_cap
else:
val = max(float(edge.get("current_load_mw", 0) or 0), 0.0)
if val <= 0:
continue
st, tt = id_to_type[s], id_to_type[t]
key = (st, tt)
flow_map[key] = flow_map.get(key, 0.0) + val
if not use_capacity:
ratio_map.setdefault(key, []).append(val / line_cap)
return flow_map, ratio_map
def _sankey_link_color(pct: float, *, capacity_mode: bool) -> str:
if capacity_mode:
return "rgba(100,116,139,0.45)"
if pct >= 0.95:
return "rgba(251,113,133,0.55)"
if pct >= 0.80:
return "rgba(251,191,36,0.48)"
if pct >= 0.50:
return "rgba(34,211,238,0.42)"
return "rgba(148,163,184,0.28)"
def _create_capacity_bar_chart(
installed: Dict[str, float],
peak_load: Dict[str, float],
energized_n: Dict[str, int],
*,
title: str,
subtitle: str,
) -> go.Figure:
"""Horizontal bar chart for de-energized grid (Sankey cannot render cyclic graphs)."""
order = ["hydro", "nuclear", "gas", "wind", "solar", "battery", "load"]
cats: List[str] = []
vals: List[float] = []
cols: List[str] = []
for t in order:
if t == "load":
v = peak_load.get("load", 0.0)
if v <= 0:
continue
cats.append("Load · peak demand")
vals.append(v)
cols.append(TYPE_COL["load"])
else:
v = installed.get(t, 0.0)
if v <= 0:
continue
en = energized_n.get(t, 0)
cats.append(f"{t.replace('_', ' ').title()} ({en} live)")
vals.append(v)
cols.append(TYPE_COL.get(t, C["text_soft"]))
fig = go.Figure(go.Bar(
x=vals,
y=cats,
orientation="h",
marker=dict(color=cols, line=dict(width=1, color=C["border_hi"])),
text=[f"{v:,.0f} MW" for v in vals],
textposition="outside",
textfont=dict(size=12, color=C["text"]),
hovertemplate="<b>%{y}</b><br>%{x:,.0f} MW<extra></extra>",
))
layout = _pro_layout(
height=PLOT_HEIGHT_WIDE,
margin=dict(l=140, r=80, t=88, b=48),
font=dict(size=12),
)
layout["title"] = dict(
text=title,
font=dict(size=FS["title"], color=C["accent"]),
x=0.5,
xanchor="center",
)
layout["annotations"] = [dict(
text=subtitle,
xref="paper", yref="paper", x=0.5, y=1.02,
showarrow=False,
font=dict(size=11, color=C["text_mid"]),
)]
layout["xaxis"] = dict(
title="MW",
gridcolor=C["border"],
zeroline=False,
tickfont=dict(size=11),
)
layout["yaxis"] = dict(automargin=True, tickfont=dict(size=12))
layout["bargap"] = 0.28
fig.update_layout(**layout)
return fig
def _hub_sankey_from_flow_map(
flow_map: Dict[tuple, float],
ratio_map: Dict[tuple, list],
types: List[str],
*,
capacity_mode: bool,
) -> tuple[List[int], List[int], List[float], List[str], List[str], List[float], List[float]]:
"""Acyclic gen -> backbone -> load Sankey (Plotly cannot render cycles)."""
gen = [t for t in types if t != "load"]
labels = [t.replace("_", " ").title() for t in gen] + ["Grid Backbone", "Load"]
idx = {t: i for i, t in enumerate(gen)}
hub_i = len(gen)
load_i = hub_i + 1
x_pos = [0.07] * len(gen) + [0.50, 0.93]
if len(gen) == 1:
y_pos = [0.5, 0.5, 0.5]
else:
y_pos = [0.12 + 0.76 * i / (len(gen) - 1) for i in range(len(gen))] + [0.5, 0.5]
hub_color = C["indigo"]
ncolors = [TYPE_COL.get(t, C["text_soft"]) for t in gen] + [hub_color, TYPE_COL["load"]]
src, tgt, val, col = [], [], [], []
for (st, tt), v in flow_map.items():
if st == tt or v <= 0:
continue
pct = max(ratio_map.get((st, tt), [0.0])) if not capacity_mode else 0.0
link_col = _sankey_link_color(pct, capacity_mode=capacity_mode)
if st != "load" and tt == "load" and st in idx:
src.append(idx[st])
tgt.append(load_i)
elif st != "load" and tt != "load" and st in idx:
src.append(idx[st])
tgt.append(hub_i)
elif st == "load" and tt != "load" and tt in idx:
src.append(hub_i)
tgt.append(idx[tt])
else:
continue
val.append(v)
col.append(link_col)
return src, tgt, val, col, labels, ncolors, x_pos, y_pos
def _layout_power_panel(fig: go.Figure, *, title: str, subtitle: str) -> go.Figure:
layout = _pro_layout(
height=PLOT_HEIGHT_WIDE,
margin=dict(l=40, r=40, t=96, b=40),
font=dict(size=12),
)
layout["title"] = dict(
text=title,
font=dict(size=FS["title"], color=C["accent"]),
x=0.5,
xanchor="center",
)
layout["annotations"] = [dict(
text=subtitle,
xref="paper", yref="paper", x=0.5, y=1.02,
showarrow=False,
font=dict(size=11, color=C["text_mid"]),
)]
fig.update_layout(**layout)
return fig
def create_power_flow_sankey(obs_dict: Dict[str, Any]) -> go.Figure:
"""Power flow Sankey (live MW) or capacity bar chart when grid is dark (Task 5)."""
topo = obs_dict.get("topology_graph", {})
nodes = topo.get("nodes", [])
edges = topo.get("edges", [])
if not nodes:
fig = go.Figure()
fig.update_layout(**_pro_layout(height=PLOT_HEIGHT_WIDE))
fig.add_annotation(
text="Awaiting grid data",
xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False,
font=dict(size=13, color=C["text_soft"]),
)
return fig
id_to_type: Dict[str, str] = {nd["id"]: nd.get("node_type", "load") for nd in nodes}
live_map, live_ratio = _aggregate_flows(edges, id_to_type, use_capacity=False)
live_total = sum(live_map.values())
n_live = sum(1 for e in edges if e.get("status") == "LIVE")
n_tripped = sum(1 for e in edges if e.get("status") == "TRIPPED")
task_id = int(obs_dict.get("task_id", obs_dict.get("metadata", {}).get("task_id", -1)))
capacity_mode = live_total < 1.0
flow_map, ratio_map = (
_aggregate_flows(edges, id_to_type, use_capacity=True)
if capacity_mode else (live_map, live_ratio)
)
installed, peak_load, energized_n = _type_stats(nodes)
types = sorted({id_to_type[n["id"]] for n in nodes})
if capacity_mode:
title = "Grid Assets & Peak Demand (MW)"
if task_id == 5:
subtitle = (
f"Black start - grid de-energized - {n_tripped} lines TRIPPED - "
"dispatch NODE_01 hydro to restore flow"
)
else:
subtitle = f"No live MW flow ({n_tripped} tripped / {len(edges)} lines)"
return _create_capacity_bar_chart(
installed, peak_load, energized_n, title=title, subtitle=subtitle,
)
sankey_src, sankey_tgt, sankey_val, sankey_col, nlabels, ncolors, x_pos, y_pos = (
_hub_sankey_from_flow_map(flow_map, ratio_map, types, capacity_mode=False)
)
if not sankey_val:
fig = go.Figure()
_layout_power_panel(fig, title="Power Flow by Asset Class", subtitle="No live MW on transmission lines")
fig.add_annotation(
text="No live power flow",
xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False,
font=dict(size=13, color=C["text_soft"]),
)
return fig
fig = go.Figure(go.Sankey(
arrangement="snap",
valueformat=".0f",
valuesuffix=" MW",
node=dict(
pad=28,
thickness=20,
line=dict(color=C["border_hi"], width=1),
label=nlabels,
color=ncolors,
x=x_pos,
y=y_pos,
),
link=dict(
source=sankey_src,
target=sankey_tgt,
value=sankey_val,
color=sankey_col,
),
))
title = "Power Flow by Asset Class (MW)"
subtitle = f"{n_live} LIVE lines - {live_total:,.0f} MW total flow"
return _layout_power_panel(fig, title=title, subtitle=subtitle)
# ═══════════════════════════════════════════════════════════════════
# PANEL 4 — TASK PERFORMANCE RADAR
# ═══════════════════════════════════════════════════════════════════
def create_task_radar(scores: Dict[int, float]) -> go.Figure:
"""Short axis labels (T0–T5) + hover for full mission names — avoids label overlap."""
theta = [f"T{t['id']}" for t in TASK_META]
values = [float(scores.get(t["id"], 0.0)) for t in TASK_META]
labels = [f"{t['name']} · {t['difficulty']}" for t in TASK_META]
theta_c = theta + [theta[0]]
values_c = values + [values[0]]
labels_c = labels + [labels[0]]
fig = go.Figure()
fig.add_trace(go.Scatterpolar(
r=values_c,
theta=theta_c,
fill="toself",
fillcolor="rgba(167,139,250,0.18)",
line=dict(color=C["purple"], width=3),
marker=dict(size=10, color=C["purple"], line=dict(width=2, color="#f8fafc")),
mode="lines+markers",
name="Mission score",
hovertemplate="<b>%{theta}</b><br>Score: %{r:.3f}<extra></extra>",
))
fig.add_trace(go.Scatterpolar(
r=[0.5] * len(theta_c),
theta=theta_c,
line=dict(color=C["amber"], width=1.5, dash="dash"),
showlegend=False,
hoverinfo="skip",
mode="lines",
))
layout = _pro_layout(
height=PLOT_HEIGHT,
margin=dict(l=100, r=100, t=64, b=100),
)
layout["title"] = dict(
text="Mission Performance Radar",
font=dict(size=FS["title"], color=C["purple"]),
x=0.5,
xanchor="center",
)
layout["showlegend"] = False
layout["polar"] = dict(
bgcolor=C["surface_hi"],
radialaxis=dict(
visible=True,
range=[0, 1.08],
tickvals=[0.25, 0.5, 0.75, 1.0],
ticktext=["0.25", "0.50", "0.75", "1.00"],
tickfont=dict(size=11, color=C["text_mid"], family="JetBrains Mono, monospace"),
gridcolor="rgba(167,139,250,0.15)",
linecolor=C["border_hi"],
),
angularaxis=dict(
tickfont=dict(size=13, color=C["text"], family="JetBrains Mono, monospace"),
rotation=30,
direction="clockwise",
linecolor=C["border_hi"],
gridcolor="rgba(167,139,250,0.12)",
),
)
fig.update_layout(**layout)
return fig
# ═══════════════════════════════════════════════════════════════════
# PANEL 5 — REWARD BREAKDOWN
# ═══════════════════════════════════════════════════════════════════
def create_reward_breakdown(reward_history: List[Dict[str, float]]) -> go.Figure:
POS = [
"fault_isolation", "cyber_detection", "frequency_stable",
"proactive_dispatch", "reasoning_order", "stability_bonus",
]
NEG = ["overload_penalty", "hallucination_penalty", "redundant_estimation"]
acc: Dict[str, float] = {}
for entry in reward_history:
for k, v in entry.items():
acc[k] = acc.get(k, 0.0) + v
labels, values = [], []
for sig in POS:
labels.append(sig.replace("_", " ").title())
values.append(acc.get(sig, 0.0))
for sig in NEG:
labels.append(sig.replace("_", " ").title())
values.append(acc.get(sig, 0.0))
if not any(values):
labels = ["Awaiting signal data"]
values = [0.0]
pos_vals = [max(v, 0) for v in values]
neg_vals = [min(v, 0) for v in values]
max_abs = max(abs(v) for v in values) if values else 0.5
pad = max_abs * 0.35 + 0.05
fig = go.Figure()
fig.add_trace(go.Bar(
x=pos_vals, y=labels, orientation="h",
marker=dict(color=C["green"], opacity=0.85),
text=[f"+{v:.3f}" if v > 0 else "" for v in values],
textposition="inside",
insidetextanchor="end",
textfont=dict(color="#ecfdf5", size=10),
cliponaxis=True,
showlegend=False,
hovertemplate="<b>%{y}</b><br>+%{x:.3f}<extra></extra>",
))
fig.add_trace(go.Bar(
x=neg_vals, y=labels, orientation="h",
marker=dict(color=C["red"], opacity=0.85),
text=[f"{v:.3f}" if v < 0 else "" for v in values],
textposition="inside",
insidetextanchor="start",
textfont=dict(color="#fef2f2", size=10),
cliponaxis=True,
showlegend=False,
hovertemplate="<b>%{y}</b><br>%{x:.3f}<extra></extra>",
))
fig.add_vline(x=0, line_color=C["text_mid"], line_width=1)
layout = _pro_layout(height=PLOT_HEIGHT, margin=dict(l=148, r=48, t=56, b=44))
layout["barmode"] = "overlay"
layout["title"] = dict(
text="Reward signals — gains vs penalties",
font=dict(size=FS["title"], color=C["text"]),
x=0.5, xanchor="center",
)
layout["xaxis"] = dict(
title="Accumulated reward",
range=[-max_abs - pad, max_abs + pad],
gridcolor=C["border"],
zeroline=True,
zerolinecolor=C["text_mid"],
tickfont=dict(size=11),
)
layout["yaxis"] = dict(autorange="reversed", tickfont=dict(size=11), automargin=True)
layout["bargap"] = 0.35
fig.update_layout(**layout)
return fig
# ═══════════════════════════════════════════════════════════════════
# TEXT FORMATTERS
# ═══════════════════════════════════════════════════════════════════
def format_threat_feed(packets: List[Dict], obs: Dict) -> str:
hdr = "SCADA THREAT MONITOR · Kirchhoff Oracle\n" + ("─" * 52) + "\n\n"
if not packets:
return hdr + " System offline — reset to begin monitoring.\n"
lines = [
hdr,
f" {'TIME':>5} {'STATUS':<10} {'ROUTE':<18} {'LAT':>5}\n",
f" {'─' * 5} {'─' * 10} {'─' * 18} {'─' * 5}\n",
]
for pkt in packets[-16:]:
src = str(pkt.get("source_node", "?"))[:8]
dst = str(pkt.get("dest_node", "?"))[:8]
lat = pkt.get("latency_ms", 0)
anomaly = pkt.get("anomaly_flag", False)
ts = pkt.get("timestamp", 0)
route = f"{src} -> {dst}"
if anomaly:
tag = "ANOMALY"
elif lat > 50:
tag = "ELEVATED"
elif lat > 30:
tag = "CAUTION"
else:
tag = "CLEAR"
lines.append(f" {ts:>5.1f} {tag:<10} {route:<18} {lat:>4.0f}ms\n")
est = obs.get("last_state_estimation")
if est:
lines.append(f"\n Kirchhoff Oracle\n {'─' * 36}\n")
if not est.get("consistent", True):
vn = est.get("violation_node", "?")
est_mw = est.get("estimated_true_mw", 0)
lines.append(f" VIOLATION at {vn} · estimate {est_mw:.1f} MW\n")
else:
lines.append(" All nodes Kirchhoff-consistent\n")
spoofs = obs.get("active_spoofs", [])
if spoofs:
lines.append(f"\n Active spoofs: {', '.join(spoofs)}\n")
return "".join(lines)
def format_action_trace(actions: List[Dict]) -> str:
hdr = "AGENT ACTION TRACE · Decision log\n" + ("─" * 48) + "\n\n"
if not actions:
return hdr + " No actions — reset and step the environment.\n"
lines = [
hdr,
f" {'TK':>3} {'ACTION':<24} {'RWD':>8} {'SCORE':>7}\n",
f" {'─' * 3} {'─' * 24} {'─' * 8} {'─' * 7}\n",
]
for a in actions[-20:]:
tick = a.get("tick", 0)
act = a.get("action", "?")
reward = a.get("reward", "0.000")
score = a.get("score", "0.000")
params = a.get("params", "{}")
lines.append(
f" {tick:>3} {act[:24]:<24} {str(reward):>8} {str(score):>7}\n"
)
if params and params not in ("{}", "null", ""):
try:
pd = json.loads(params)
short = " ".join(f"{k}={v}" for k, v in pd.items())[:50]
except Exception:
short = str(params)[:50]
lines.append(f" {short}\n")
return "".join(lines)
def format_status_bar(state) -> str:
done_str = "COMPLETE" if state.done else "RUNNING"
return (
f"TASK {state.task_id} | TICK {state.tick:03d} | "
f"FREQ {state.frequency_hz:.3f} Hz | "
f"SCORE {state.cumulative_score:+.4f} | {done_str}"
)
def format_kpi_html(obs: Dict[str, Any], state) -> str:
topo = obs.get("topology_graph", {}) if obs else {}
node_list = topo.get("nodes", [])
edge_list = topo.get("edges", [])
n_nodes = len(node_list)
n_edges = len(edge_list)
n_energized = sum(1 for n in node_list if n.get("energized"))
n_live_lines = sum(1 for e in edge_list if e.get("status") == "LIVE")
packets = len(state.packet_log) if state.packet_log else 0
actions = len(state.action_log) if state.action_log else 0
freq = state.frequency_hz
if freq < 59.5:
freq_col = C["crimson"]
elif freq < 59.7 or freq > 60.3:
freq_col = C["amber"]
else:
freq_col = C["green"]
score_col = C["green"] if state.cumulative_score >= 0 else C["red"]
spoofs = len(obs.get("active_spoofs", [])) if obs else 0
lines_label = "Live Lines" if n_live_lines else "Lines (TRIPPED)"
lines_val = f"{n_live_lines}/{n_edges}" if n_edges else "—"
nodes_val = f"{n_energized}/{n_nodes}" if n_nodes else "—"
return f"""
<div class="kpi-grid">
<div class="kpi-pro"><span class="kpi-k">Energized Nodes</span><span class="kpi-v">{nodes_val}</span></div>
<div class="kpi-pro"><span class="kpi-k">{lines_label}</span><span class="kpi-v">{lines_val}</span></div>
<div class="kpi-pro"><span class="kpi-k">Grid Frequency</span><span class="kpi-v" style="color:{freq_col}">{freq:.3f} Hz</span></div>
<div class="kpi-pro"><span class="kpi-k">Episode Score</span><span class="kpi-v" style="color:{score_col}">{state.cumulative_score:+.3f}</span></div>
<div class="kpi-pro"><span class="kpi-k">SCADA Packets</span><span class="kpi-v">{packets}</span></div>
<div class="kpi-pro"><span class="kpi-k">Active Spoofs</span><span class="kpi-v" style="color:{C['crimson'] if spoofs else C['green']}">{spoofs}</span></div>
</div>
"""
def format_fleet_strip() -> str:
"""Static environment facts — always visible."""
return f"""
<div class="fleet-strip">
<div class="fleet-item"><span class="fleet-k">Network</span><span class="fleet-v">20 nodes</span></div>
<div class="fleet-item"><span class="fleet-k">Topology</span><span class="fleet-v">40 edges</span></div>
<div class="fleet-item"><span class="fleet-k">Missions</span><span class="fleet-v">6 tasks</span></div>
<div class="fleet-item"><span class="fleet-k">Threat model</span><span class="fleet-v" style="color:{C['crimson']}">3 vectors</span></div>
<div class="fleet-item"><span class="fleet-k">Physics</span><span class="fleet-v" style="color:{C['accent']}">DC power flow</span></div>
<div class="fleet-item"><span class="fleet-k">Oracle</span><span class="fleet-v" style="color:{C['indigo']}">Kirchhoff</span></div>
</div>
"""
def format_mission_board(scores: Dict[int, float], active_id: int) -> str:
cards = []
for t in TASK_META:
tid = t["id"]
sc = scores.get(tid, 0.0)
diff = t["difficulty"]
dcol = DIFF_COL.get(diff, C["text_mid"])
active = "mission-active" if tid == active_id else ""
pct = int(sc * 100)
cards.append(f"""
<div class="mission-card {active}">
<div class="mission-head">
<span class="mission-id">T{tid}</span>
<span class="mission-diff-badge" style="color:{dcol};border-color:{dcol}">{DIFF_SHORT.get(diff, diff)}</span>
</div>
<div class="mission-name">{t['name']}</div>
<div class="mission-bar"><div class="mission-fill" style="width:{pct}%;background:{dcol}"></div></div>
<div class="mission-score">{sc:.2f} / 1.00</div>
</div>
""")
return f'<div class="mission-wrap"><div class="mission-board">{"".join(cards)}</div></div>'
# ═══════════════════════════════════════════════════════════════════
# DASHBOARD STATE
# ═══════════════════════════════════════════════════════════════════
class DashboardState:
def __init__(self):
self.task_scores: Dict[int, float] = {t["id"]: 0.0 for t in TASK_META}
self.reset()
def reset(self):
self.current_obs: Dict[str, Any] = {}
self.action_log: List[Dict] = []
self.reward_history: List[Dict] = []
self.packet_log: List[Dict] = []
self.freq_history: List[float] = [60.0]
self.frequency_hz: float = 60.0
self.task_id: int = 0
self.tick: int = 0
self.cumulative_score: float = 0.0
self.done: bool = False
self.env = None
def _push_freq(self, hz: float):
self.freq_history.append(hz)
if len(self.freq_history) > 100:
self.freq_history = self.freq_history[-100:]
def init_env(self) -> bool:
try:
import sys
import os
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if root not in sys.path:
sys.path.insert(0, root)
try:
from server.nexusgrid_environment import NexusgridEnvironment
except ImportError:
from nexusgrid_environment import NexusgridEnvironment
self.env = NexusgridEnvironment()
return True
except Exception:
import traceback
traceback.print_exc()
return False
def do_reset(self, task_id: int, seed: int):
if self.env is None:
self.init_env()
if self.env is None:
return
env = self.env
self.action_log.clear()
self.reward_history.clear()
self.packet_log.clear()
self.freq_history = [60.0]
self.frequency_hz = 60.0
self.tick = 0
self.cumulative_score = 0.0
self.done = False
self.env = env
self.task_id = task_id
obs = self.env.reset(seed=seed, task_id=task_id)
od = obs.model_dump() if hasattr(obs, "model_dump") else obs.__dict__
self.current_obs = od
self.frequency_hz = od.get("grid_frequency_hz", 60.0)
self.tick = od.get("tick", 0)
self.packet_log = od.get("network_packet_logs", [])
self._push_freq(self.frequency_hz)
def do_step(self, action_dict: Dict[str, Any]):
if self.env is None:
return
import sys
import os
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if root not in sys.path:
sys.path.insert(0, root)
from models import GridAction
try:
action = GridAction(**action_dict)
except Exception as e:
print(f"[Dashboard] Bad action: {e}")
return
obs = self.env.step(action)
od = obs.model_dump() if hasattr(obs, "model_dump") else obs.__dict__
reward_val = od.get("reward", 0.0)
self.current_obs = od
self.frequency_hz = od.get("grid_frequency_hz", 60.0)
self.tick = od.get("tick", 0)
self.done = od.get("done", False)
self.cumulative_score += reward_val if isinstance(reward_val, (int, float)) else 0
self._push_freq(self.frequency_hz)
self.action_log.append({
"tick": self.tick,
"action": action_dict.get("action_type", "?"),
"params": json.dumps({k: v for k, v in action_dict.items() if k != "action_type"}),
"reward": f"{reward_val:+.3f}" if isinstance(reward_val, (int, float)) else str(reward_val),
"score": f"{self.cumulative_score:.3f}",
})
if pkts := od.get("network_packet_logs", []):
self.packet_log = pkts
meta = od.get("metadata", {})
if isinstance(meta, dict):
if bd := meta.get("reward_breakdown", {}):
self.reward_history.append(bd)
live_score = max(0.0, min(1.0, self.cumulative_score))
self.task_scores[self.task_id] = max(
self.task_scores.get(self.task_id, 0.0),
live_score,
)
if self.done and self.env is not None:
try:
graded = self.env.get_score()
if isinstance(graded, (int, float)):
self.task_scores[self.task_id] = max(
self.task_scores.get(self.task_id, 0.0),
max(0.0, min(1.0, graded)),
)
except Exception:
pass
dashboard_state = DashboardState()
def on_reset(task_id: int, seed: int):
dashboard_state.do_reset(int(task_id), int(seed))
return _refresh()
def on_step(action_type, node_id, edge_id, mw, status, hz_offset, duration, subgraph):
ad: Dict[str, Any] = {"action_type": action_type}
if action_type == "dispatch_generation":
ad["node_id"] = node_id
ad["mw"] = float(mw)
elif action_type == "toggle_circuit_breaker":
ad["edge_id"] = edge_id
ad["status"] = status
elif action_type == "run_state_estimation":
try:
ad["subgraph"] = json.loads(subgraph)
except Exception:
ad["subgraph"] = [s.strip() for s in subgraph.split(",") if s.strip()]
elif action_type == "quarantine_scada_node":
ad["node_id"] = node_id
elif action_type == "inject_counter_signal":
ad["node_id"] = node_id
ad["hz_offset"] = float(hz_offset)
ad["duration"] = int(duration)
dashboard_state.do_step(ad)
return _refresh()
def on_auto_run(task_id: int, seed: int, num_steps: int):
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
dashboard_state.do_reset(int(task_id), int(seed))
try:
from inference import get_fallback_action
except ImportError:
def get_fallback_action(tid, t):
return (
{"action_type": "dispatch_generation", "node_id": "NODE_01", "mw": 100}
if tid == 0
else {"action_type": "advance_tick"}
)
for s in range(int(num_steps)):
if dashboard_state.done:
break
dashboard_state.do_step(get_fallback_action(int(task_id), s))
return _refresh()
def _refresh():
obs = dashboard_state.current_obs
st = dashboard_state
return (
_safe_chart(create_topology_graph, obs),
_safe_chart(create_frequency_gauge, st.frequency_hz, st.freq_history),
format_freq_status(st.frequency_hz),
_safe_chart(create_power_flow_sankey, obs),
_safe_chart(create_task_radar, st.task_scores),
_safe_chart(create_reward_breakdown, st.reward_history),
format_threat_feed(st.packet_log, obs),
format_action_trace(st.action_log),
format_status_bar(st),
format_kpi_html(obs, st),
format_mission_board(st.task_scores, st.task_id),
)
CSS = f"""
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;600&display=swap');
:root {{
--bg: {C['bg']};
--surface: {C['surface']};
--surface-hi: {C['surface_hi']};
--border: {C['border']};
--border-hi: {C['border_hi']};
--text: {C['text']};
--text-mid: {C['text_mid']};
--accent: {C['accent']};
--indigo: {C['indigo']};
--radius: 12px;
--glow: 0 0 24px rgba(34,211,238,0.08);
}}
*, *::before, *::after {{ box-sizing: border-box; }}
html, body {{
width: 100%;
margin: 0;
padding: 0;
overflow-x: hidden;
}}
body, .gradio-container, .main, .wrap, .app {{
background: {C['bg']} !important;
background-image:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(34,211,238,0.08), transparent),
radial-gradient(ellipse 40% 30% at 100% 100%, rgba(129,140,248,0.06), transparent) !important;
color: var(--text) !important;
font-family: "DM Sans", system-ui, sans-serif !important;
}}
.gradio-container {{
width: 100% !important;
max-width: min(1680px, 100%) !important;
margin: 0 auto !important;
padding: 16px 20px 40px !important;
}}
.gradio-container footer {{ display: none !important; }}
.block, .form, .wrap, .contain, .gradio-group {{
background: transparent !important;
border-color: var(--border) !important;
}}
/* Flex children: stop horizontal bleed without changing desktop layout */
.gradio-container .gr-column {{
min-width: 0;
max-width: 100%;
}}
.gradio-container .gr-row {{
max-width: 100%;
}}
/* ── Header ── */
.pro-header {{
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 18px 22px;
margin-bottom: 16px;
background: linear-gradient(135deg, {C['surface']} 0%, {C['surface_hi']} 100%);
border: 1px solid var(--border-hi);
border-radius: var(--radius);
box-shadow: var(--glow);
}}
.pro-header .brand {{
display: flex;
flex-direction: column;
gap: 4px;
}}
.pro-header h1 {{
margin: 0 !important;
font-size: 1.45rem !important;
font-weight: 700 !important;
letter-spacing: -0.03em;
background: linear-gradient(90deg, {C['accent']}, {C['indigo']});
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}}
.pro-header .sub {{
font-size: 0.8rem;
color: var(--text-mid);
letter-spacing: 0.04em;
}}
.pro-badge {{
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(52,211,153,0.4);
color: {C['green']};
background: {C['green_soft']};
}}
/* ── Live KPI grid ── */
.kpi-grid {{
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
margin-bottom: 16px;
}}
.kpi-pro {{
background: {C['glass']};
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}}
.kpi-k {{
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-mid);
}}
.kpi-v {{
font-family: "JetBrains Mono", monospace;
font-size: 1.25rem;
font-weight: 600;
color: var(--accent);
}}
/* ── Status bar ── */
#status-bar textarea, #status-bar input {{
font-family: "JetBrains Mono", monospace !important;
font-size: 0.82rem !important;
font-weight: 600 !important;
color: var(--accent) !important;
background: {C['surface']} !important;
border: 1px solid var(--border-hi) !important;
border-radius: var(--radius) !important;
text-align: center !important;
padding: 10px 14px !important;
letter-spacing: 0.04em;
}}
/* ── Chart panels ── */
.chart-shell {{
background: {C['surface']};
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 12px;
box-shadow: var(--glow);
}}
.chart-head {{
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
background: rgba(0,0,0,0.2);
}}
.chart-head span {{
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-mid);
}}
.chart-head .dot {{
width: 8px; height: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px var(--accent);
}}
/* Contain every Plotly block inside its column — prevents bleed into sidebar */
.layout-sidebar {{
position: relative !important;
z-index: 3 !important;
isolation: isolate !important;
flex: 0 0 min(340px, 100%) !important;
max-width: 100% !important;
overflow-x: hidden !important;
overflow-y: visible !important;
}}
.layout-main-col {{
position: relative !important;
z-index: 1 !important;
min-width: 0 !important;
overflow: hidden !important;
isolation: isolate !important;
}}
.plot-panel {{
display: block !important;
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
position: relative !important;
contain: layout paint !important;
margin-bottom: 12px !important;
clear: both !important;
}}
.plot-panel .label-wrap, .plot-panel > label, .plot-panel .empty-label {{
display: none !important;
height: 0 !important;
margin: 0 !important;
padding: 0 !important;
}}
.plot-panel .block, .plot-panel > .wrap {{
padding: 4px 8px 12px !important;
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
}}
.plot-panel .js-plotly-plot, .plot-panel .plotly-graph-div {{
min-height: {PLOT_HEIGHT}px !important;
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
position: relative !important;
}}
.plot-panel .main-svg, .plot-panel svg {{
max-width: 100% !important;
}}
.plot-panel-tall .js-plotly-plot, .plot-panel-tall .plotly-graph-div {{
min-height: {PLOT_HEIGHT_TALL}px !important;
}}
.plot-panel-wide .js-plotly-plot, .plot-panel-wide .plotly-graph-div {{
min-height: {PLOT_HEIGHT_WIDE}px !important;
}}
.plot-panel-reward {{
width: 100% !important;
max-width: 100% !important;
margin-top: 4px !important;
}}
.layout-reward-section {{
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
clear: both !important;
margin-bottom: 20px !important;
}}
.mission-board, .legend-block, .ctrl-box, .kpi-grid, .fleet-strip {{
position: relative;
z-index: 1;
}}
/* ── Sidebar ── */
.ctrl-box {{
background: {C['surface']};
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 10px;
}}
.ctrl-box h3 {{
font-size: 0.7rem !important;
font-weight: 700 !important;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--accent) !important;
margin: 0 0 10px !important;
padding: 0 !important;
border: none !important;
}}
.gradio-container label span {{
font-size: 0.72rem !important;
font-weight: 600 !important;
color: var(--text-mid) !important;
text-transform: uppercase;
letter-spacing: 0.05em;
}}
.gradio-container input, .gradio-container select, .gradio-container textarea {{
background: {C['bg']} !important;
border: 1px solid var(--border) !important;
border-radius: 8px !important;
color: var(--text) !important;
font-family: "JetBrains Mono", monospace !important;
font-size: 0.82rem !important;
}}
.gradio-container input:focus, .gradio-container select:focus, .gradio-container textarea:focus {{
border-color: var(--accent) !important;
box-shadow: 0 0 0 2px rgba(34,211,238,0.2) !important;
}}
.mono-feed textarea {{
font-family: "JetBrains Mono", monospace !important;
font-size: 0.78rem !important;
line-height: 1.55 !important;
background: {C['bg']} !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
color: #cbd5e1 !important;
padding: 14px !important;
min-height: 300px !important;
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
}}
.log-threat textarea {{ border-color: rgba(248,113,113,0.35) !important; }}
.log-action textarea {{ border-color: rgba(34,211,238,0.28) !important; }}
.btn-reset, .btn-step, .btn-auto {{
font-weight: 700 !important;
font-size: 0.8rem !important;
letter-spacing: 0.08em !important;
text-transform: uppercase !important;
border-radius: 8px !important;
padding: 12px !important;
width: 100%;
border: none !important;
transition: transform 0.15s, box-shadow 0.15s !important;
}}
.btn-reset {{ background: linear-gradient(135deg, #2563eb, #3b82f6) !important; color: #fff !important; }}
.btn-step {{ background: linear-gradient(135deg, #0e7490, #22d3ee) !important; color: #0f172a !important; }}
.btn-auto {{ background: linear-gradient(135deg, #5b21b6, #a78bfa) !important; color: #fff !important; }}
.btn-reset:hover, .btn-step:hover, .btn-auto:hover {{
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(34,211,238,0.2) !important;
}}
.legend-block {{
font-size: 0.75rem;
color: var(--text-mid);
line-height: 1.8;
background: {C['surface']};
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
}}
/* Fleet + mission */
.fleet-strip {{
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
margin-bottom: 14px;
}}
.fleet-item {{
background: {C['surface']};
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
text-align: center;
}}
.fleet-k {{ font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-mid); display: block; }}
.fleet-v {{ font-family: "JetBrains Mono", monospace; font-size: 0.85rem; font-weight: 600; color: var(--accent); margin-top: 4px; display: block; }}
.ops-section {{
display: flex;
align-items: baseline;
gap: 10px;
margin: 18px 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}}
.ops-num {{
font-family: "JetBrains Mono", monospace;
font-size: 0.7rem;
font-weight: 700;
color: {C['accent']};
background: {C['accent_soft']};
padding: 4px 8px;
border-radius: 6px;
}}
.ops-title {{
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text);
}}
.ops-sub {{
font-size: 0.75rem;
color: var(--text-mid);
margin-left: auto;
}}
/* Frequency status — lives outside Plotly so titles never collide with the gauge */
.freq-status-bar {{
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 14px;
margin-bottom: 8px;
background: {C['surface_hi']};
border: 1px solid var(--border);
border-radius: var(--radius);
}}
.freq-status-zone {{
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}}
.freq-status-delta {{
font-size: 0.72rem;
color: var(--text-mid);
}}
.freq-status-val {{
font-family: "JetBrains Mono", monospace;
font-size: 1.1rem;
font-weight: 700;
margin-top: 2px;
}}
.freq-col {{
min-width: 0 !important;
max-width: 100% !important;
}}
.plot-panel-freq .js-plotly-plot, .plot-panel-freq .plotly-graph-div {{
min-height: {PLOT_HEIGHT}px !important;
}}
.mission-wrap {{
width: 100%;
max-width: 100%;
overflow: hidden;
margin-bottom: 12px;
}}
.layout-sidebar .mission-board {{
display: flex;
flex-direction: column;
gap: 10px;
grid-template-columns: unset;
}}
.mission-board {{
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}}
.mission-card {{
background: {C['surface']};
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
min-width: 0;
overflow: hidden;
}}
.mission-active {{
border-color: {C['accent']};
box-shadow: 0 0 0 1px rgba(34,211,238,0.25);
}}
.mission-head {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 26px;
margin-bottom: 4px;
}}
.mission-id {{
flex-shrink: 0;
font-family: "JetBrains Mono", monospace;
font-weight: 700;
font-size: 0.9rem;
color: {C['accent']};
}}
.mission-diff-badge {{
flex-shrink: 0;
font-size: 0.58rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid;
background: rgba(0,0,0,0.25);
white-space: nowrap;
}}
.mission-name {{
font-size: 0.78rem;
color: var(--text);
margin: 4px 0 6px;
font-weight: 600;
line-height: 1.35;
word-break: break-word;
}}
.mission-bar {{ height: 4px; background: {C['border']}; border-radius: 2px; overflow: hidden; }}
.mission-fill {{ height: 100%; border-radius: 2px; }}
.mission-score {{ font-family: "JetBrains Mono", monospace; font-size: 0.68rem; color: var(--text-mid); margin-top: 6px; }}
.arch-strip {{
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
font-size: 0.72rem;
color: var(--text-mid);
}}
.arch-pill {{
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: {C['surface']};
}}
@media (max-width: 1100px) {{
.kpi-grid, .fleet-strip {{ grid-template-columns: repeat(3, 1fr); }}
.layout-main-col .mission-board {{ grid-template-columns: repeat(2, 1fr); }}
}}
@media (max-width: 900px) {{
.layout-main-row {{
flex-wrap: wrap !important;
}}
.layout-main-row > .gr-column {{
flex: 1 1 100% !important;
min-width: 100% !important;
max-width: 100% !important;
}}
.layout-chart-row {{
flex-wrap: wrap !important;
}}
.layout-chart-row > .gr-column {{
flex: 1 1 100% !important;
min-width: 100% !important;
max-width: 100% !important;
}}
}}
@media (max-width: 640px) {{
.kpi-grid {{ grid-template-columns: repeat(2, 1fr); }}
.gradio-container {{ padding: 10px !important; }}
.pro-header h1 {{ font-size: 1.2rem !important; }}
}}
"""
IDLE_THREAT = (
"SCADA THREAT MONITOR · Kirchhoff Oracle\n"
+ ("─" * 52)
+ "\n\n System offline — reset to begin monitoring.\n"
)
IDLE_TRACE = (
"AGENT ACTION TRACE · Decision log\n"
+ ("─" * 48)
+ "\n\n No actions recorded — reset and step the environment.\n"
)
def create_dashboard() -> gr.Blocks:
if not PLOTLY_AVAILABLE:
with gr.Blocks(title="NexusGrid Dashboard") as demo:
gr.Markdown(
"# NexusGrid Dashboard\n\n"
"Install `plotly` for the full chart UI. API endpoints remain available."
)
return demo
import warnings
with warnings.catch_warnings():
warnings.filterwarnings("ignore")
demo = gr.Blocks(title="NexusGrid — Grid Defense Dashboard")
with demo:
gr.HTML(f"<style>{CSS}</style>")
gr.HTML(f"""
<div class="pro-header">
<div class="brand">
<h1>NEXUSGRID</h1>
<span class="sub">Defend the national grid · Physics + SCADA + agent reasoning</span>
</div>
<span class="pro-badge">LIVE OPS</span>
</div>
<div class="arch-strip">
<span class="arch-pill">Layer 1 · DC Power Flow</span>
<span class="arch-pill">Layer 2 · SCADA / Spoofs</span>
<span class="arch-pill">Layer 3 · OpenEnv API</span>
<span class="arch-pill">Layer 4 · LLM Agent</span>
</div>
""")
gr.HTML(format_fleet_strip())
kpi_panel = gr.HTML(format_kpi_html({}, dashboard_state))
status_bar = gr.Textbox(
value="TASK — | TICK 000 | FREQ 60.000 Hz | SCORE +0.0000 | IDLE",
label="",
interactive=False,
elem_id="status-bar",
)
with gr.Row(equal_height=False, elem_classes=["layout-main-row"]):
with gr.Column(scale=1, min_width=300, elem_classes=["layout-sidebar"]):
gr.HTML('<div class="ctrl-box"><h3>Episode Control</h3></div>')
task_select = gr.Slider(0, 5, value=0, step=1, label="Mission / Task ID")
seed_input = gr.Number(value=42, label="Random seed", precision=0)
reset_btn = gr.Button("Initialise Grid", elem_classes=["btn-reset"])
gr.HTML('<div class="ctrl-box"><h3>Manual Action</h3></div>')
action_type = gr.Dropdown(
[
"dispatch_generation", "toggle_circuit_breaker",
"run_state_estimation", "quarantine_scada_node",
"inject_counter_signal", "advance_tick",
],
value="dispatch_generation",
label="Action type",
)
node_id = gr.Textbox(value="NODE_01", label="Node ID")
edge_id = gr.Textbox(value="LINE_01", label="Edge ID")
mw_val = gr.Number(value=100, label="MW value")
breaker_status = gr.Dropdown(["OPEN", "CLOSED"], value="CLOSED", label="Breaker status")
hz_offset = gr.Number(value=-0.5, label="Hz offset")
duration = gr.Number(value=5, precision=0, label="Duration")
subgraph = gr.Textbox(value='["NODE_14","NODE_15"]', label="Subgraph JSON")
step_btn = gr.Button("Execute Step", elem_classes=["btn-step"])
gr.HTML('<div class="ctrl-box"><h3>Autonomous Agent</h3></div>')
auto_steps = gr.Slider(1, 50, value=10, step=1, label="Steps")
auto_btn = gr.Button("Run Agent", elem_classes=["btn-auto"])
gr.HTML('<div class="ctrl-box"><h3>Mission Suite</h3></div>')
mission_panel = gr.HTML(format_mission_board(dashboard_state.task_scores, 0))
gr.HTML(f"""
<div class="legend-block">
<b style="color:{C['accent']}">Transmission load</b><br>
<span style="color:#64748b">━━</span> &lt;50% &nbsp;
<span style="color:{C['accent']}">━━</span> 50–80% &nbsp;
<span style="color:{C['amber']}">━━</span> 80–95% &nbsp;
<span style="color:{C['crimson']}">━━</span> ≥95%<br><br>
<b style="color:{C['green']}">Frequency bands</b><br>
<span style="color:{C['green']}">●</span> 59.7–60.3 NOMINAL<br>
<span style="color:{C['amber']}">●</span> 59.5–59.7 WARNING<br>
<span style="color:{C['orange']}">●</span> 59.2–59.5 DANGER<br>
<span style="color:{C['red']}">●</span> 59.0–59.2 CRITICAL<br>
<span style="color:{C['crimson']}">●</span> &lt;59.0 BLACKOUT
</div>
""")
with gr.Column(scale=4, elem_classes=["layout-main-col"]):
gr.HTML(_section('01', 'Physical Grid', 'Topology · frequency · MW flow'))
with gr.Row(equal_height=False, elem_classes=["layout-chart-row"]):
with gr.Column(scale=3):
gr.HTML(_shell('Grid Topology · 20-node network', C['accent']))
topo_plot = gr.Plot(label='', elem_classes=['plot-panel', 'plot-panel-tall'])
with gr.Column(scale=2, elem_classes=["freq-col"]):
gr.HTML(_shell('Grid Frequency Monitor', C['green']))
freq_status = gr.HTML(format_freq_status(dashboard_state.frequency_hz))
freq_plot = gr.Plot(label='', elem_classes=['plot-panel', 'plot-panel-freq'])
with gr.Row(equal_height=False, elem_classes=["layout-chart-row"]):
with gr.Column(scale=3):
gr.HTML(_shell('Power Flow / Transmission Capacity', C['accent']))
sankey_plot = gr.Plot(label='', elem_classes=['plot-panel', 'plot-panel-wide'])
with gr.Column(scale=2):
gr.HTML(_shell('Mission Performance Radar', C['purple']))
radar_plot = gr.Plot(label='', elem_classes=['plot-panel'])
with gr.Column(elem_classes=["layout-reward-section"]):
gr.HTML(_section('02', 'Agent Training', 'Reward rubric signals'))
gr.HTML(_shell('Reward Signal Decomposition', C['green']))
reward_plot = gr.Plot(label='', elem_classes=['plot-panel', 'plot-panel-reward'])
gr.HTML(_section('03', 'Cyber Layer', 'SCADA · Kirchhoff oracle · agent log'))
with gr.Row(equal_height=False, elem_classes=["layout-chart-row"]):
with gr.Column():
gr.HTML(_shell('SCADA Threat Monitor', C['red']))
threat_feed = gr.Textbox(
value=IDLE_THREAT, label='', lines=14,
interactive=False, elem_classes=['mono-feed', 'log-threat'],
)
with gr.Column():
gr.HTML(_shell('Agent Decision Log', C['accent']))
action_trace = gr.Textbox(
value=IDLE_TRACE, label='', lines=14,
interactive=False, elem_classes=['mono-feed', 'log-action'],
)
all_out = [
topo_plot, freq_plot, freq_status, sankey_plot, radar_plot, reward_plot,
threat_feed, action_trace, status_bar, kpi_panel, mission_panel,
]
reset_btn.click(fn=on_reset, inputs=[task_select, seed_input], outputs=all_out)
step_btn.click(
fn=on_step,
inputs=[action_type, node_id, edge_id, mw_val, breaker_status, hz_offset, duration, subgraph],
outputs=all_out,
)
auto_btn.click(fn=on_auto_run, inputs=[task_select, seed_input, auto_steps], outputs=all_out)
return demo