Networking

P2P networking analysis for PQ Devnet clients.

This notebook examines:

  • Peer connections over time
  • Peer coverage (% of validators connected)
  • Peer connection and disconnection events
  • Cumulative attestation arrivals (valid vs invalid, by source)
  • Network bandwidth per client (rx/tx throughput)
Show code
import json
from pathlib import Path

import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from IPython.display import display

# Set default renderer for static HTML output
import plotly.io as pio
pio.renderers.default = "notebook"
Show code
# Resolve devnet_id
DATA_DIR = Path("../data")

if devnet_id is None:
    # Use latest devnet from manifest
    devnets_path = DATA_DIR / "devnets.json"
    if devnets_path.exists():
        with open(devnets_path) as f:
            devnets = json.load(f).get("devnets", [])
        if devnets:
            devnet_id = devnets[-1]["id"]  # Latest
            print(f"Using latest devnet: {devnet_id}")
    else:
        raise ValueError("No devnets.json found. Run 'just detect-devnets' first.")

DEVNET_DIR = DATA_DIR / devnet_id
print(f"Loading data from: {DEVNET_DIR}")
Loading data from: ../data/pqdevnet-20260518T1327Z
Show code
# Load devnet metadata
with open(DATA_DIR / "devnets.json") as f:
    devnets_data = json.load(f)
    devnet_info = next((d for d in devnets_data["devnets"] if d["id"] == devnet_id), None)

if devnet_info:
    print(f"Devnet: {devnet_info['id']}")
    print(f"Duration: {devnet_info['duration_hours']:.1f} hours")
    print(f"Time: {devnet_info['start_time']} to {devnet_info['end_time']}")
    print(f"Slots: {devnet_info['start_slot']} \u2192 {devnet_info['end_slot']}")
    print(f"Clients: {', '.join(devnet_info['clients'])}")
Devnet: pqdevnet-20260518T1327Z
Duration: 0.4 hours
Time: 2026-05-18T13:27:21+00:00 to 2026-05-18T13:53:33+00:00
Slots: 0 → 685
Clients: ethlambda_0, ethlambda_1, ethlambda_10, ethlambda_11, ethlambda_12, ethlambda_13, ethlambda_14, ethlambda_15, ethlambda_2, ethlambda_3, ethlambda_4, ethlambda_5, ethlambda_6, ethlambda_7, ethlambda_8, ethlambda_9, gean_0, gean_1, gean_2, gean_3, gean_4, gean_5, gean_6, gean_7, nlean_0, nlean_1, nlean_2, nlean_3, nlean_4, nlean_5, nlean_6, nlean_7, ream_1, ream_10, ream_11, ream_12, ream_13, ream_14, ream_15, ream_2, ream_3, ream_4, ream_5, ream_6, ream_7, ream_8, ream_9, zeam_0, zeam_1, zeam_11, zeam_12, zeam_14, zeam_15, zeam_3, zeam_4, zeam_5, zeam_6, zeam_7, zeam_8, zeam_9

Load Data

Show code
# Load network peer data
peers_df = pd.read_parquet(DEVNET_DIR / "network_peers.parquet")
peers_df = peers_df.groupby(["client", "timestamp"], as_index=False)["value"].max()
print(f"Peers: {len(peers_df)} records, clients: {sorted(peers_df['client'].unique())}")

# Load peer connection/disconnection events
peer_events_path = DEVNET_DIR / "peer_events.parquet"
if peer_events_path.exists():
    peer_events_df = pd.read_parquet(peer_events_path)
    peer_events_df = peer_events_df.groupby(["client", "metric", "timestamp"], as_index=False)["value"].max()
    print(f"Peer events: {len(peer_events_df)} records, clients: {sorted(peer_events_df['client'].unique())}")
else:
    peer_events_df = pd.DataFrame()
    print("Peer events: no data")

# Load attestation metrics
att_df = pd.read_parquet(DEVNET_DIR / "attestation_metrics.parquet")
att_df = att_df.groupby(["client", "metric", "source", "timestamp"], as_index=False)["value"].max()
print(f"Attestations: {len(att_df)} records, clients: {sorted(att_df['client'].unique())}")
print(f"Attestation metrics: {sorted(att_df['metric'].unique())}")
print(f"Attestation sources: {sorted(att_df['source'].unique())}")

# Load network throughput (container-level)
EXCLUDED_CONTAINERS = {"unknown", "cadvisor", "prometheus", "promtail", "node-exporter", "node_exporter", "grafana"}
net_path = DEVNET_DIR / "container_network.parquet"
if net_path.exists():
    net_df = pd.read_parquet(net_path)
    net_df = net_df[~net_df["container"].isin(EXCLUDED_CONTAINERS)]
    net_df = net_df.groupby(["container", "metric", "timestamp"], as_index=False)["value"].sum()
    print(f"Network throughput: {len(net_df)} records, containers: {sorted(net_df['container'].unique())}")
else:
    net_df = pd.DataFrame()
    print("Network throughput: no data")

# Unified client list from devnet metadata (includes all containers via cAdvisor)
all_clients = sorted(devnet_info["clients"])
n_cols = min(len(all_clients), 2)
n_rows = -(-len(all_clients) // n_cols)
print(f"\nAll clients ({len(all_clients)}): {all_clients}")
Peers: 1041 records, clients: ['ethlambda_0', 'ethlambda_1', 'ethlambda_10', 'ethlambda_11', 'ethlambda_12', 'ethlambda_13', 'ethlambda_14', 'ethlambda_15', 'ethlambda_2', 'ethlambda_3', 'ethlambda_4', 'ethlambda_5', 'ethlambda_6', 'ethlambda_7', 'ethlambda_8', 'ethlambda_9', 'gean_0', 'gean_1', 'gean_2', 'gean_3', 'gean_4', 'gean_5', 'gean_6', 'gean_7', 'nlean_0', 'nlean_1', 'nlean_2', 'nlean_3', 'nlean_4', 'nlean_5', 'nlean_6', 'nlean_7', 'ream_1', 'ream_10', 'ream_11', 'ream_12', 'ream_13', 'ream_14', 'ream_15', 'ream_2', 'ream_3', 'ream_4', 'ream_5', 'ream_6', 'ream_7', 'ream_8', 'ream_9', 'zeam_0', 'zeam_1', 'zeam_11', 'zeam_12', 'zeam_14', 'zeam_15', 'zeam_3', 'zeam_4', 'zeam_5', 'zeam_6', 'zeam_7', 'zeam_8', 'zeam_9']
Peer events: 1906 records, clients: ['ethlambda_0', 'ethlambda_1', 'ethlambda_10', 'ethlambda_11', 'ethlambda_12', 'ethlambda_13', 'ethlambda_14', 'ethlambda_15', 'ethlambda_2', 'ethlambda_3', 'ethlambda_4', 'ethlambda_5', 'ethlambda_6', 'ethlambda_7', 'ethlambda_8', 'ethlambda_9', 'gean_0', 'gean_1', 'gean_2', 'gean_3', 'gean_4', 'gean_5', 'gean_6', 'gean_7', 'nlean_0', 'nlean_1', 'nlean_2', 'nlean_3', 'nlean_4', 'nlean_5', 'nlean_6', 'nlean_7', 'ream_1', 'ream_10', 'ream_11', 'ream_12', 'ream_13', 'ream_14', 'ream_15', 'ream_2', 'ream_3', 'ream_4', 'ream_5', 'ream_6', 'ream_7', 'ream_8', 'ream_9', 'zeam_0', 'zeam_1', 'zeam_11', 'zeam_12', 'zeam_14', 'zeam_15', 'zeam_3', 'zeam_4', 'zeam_5', 'zeam_6', 'zeam_7', 'zeam_8', 'zeam_9']
Attestations: 1941 records, clients: ['ethlambda_0', 'ethlambda_1', 'ethlambda_10', 'ethlambda_11', 'ethlambda_12', 'ethlambda_13', 'ethlambda_14', 'ethlambda_15', 'ethlambda_2', 'ethlambda_3', 'ethlambda_4', 'ethlambda_5', 'ethlambda_6', 'ethlambda_7', 'ethlambda_8', 'ethlambda_9', 'gean_0', 'gean_1', 'gean_2', 'gean_3', 'gean_4', 'gean_5', 'gean_6', 'gean_7', 'nlean_0', 'nlean_1', 'nlean_2', 'nlean_3', 'nlean_4', 'nlean_5', 'nlean_6', 'nlean_7', 'zeam_0', 'zeam_1', 'zeam_11', 'zeam_12', 'zeam_14', 'zeam_15', 'zeam_3', 'zeam_4', 'zeam_5', 'zeam_6', 'zeam_7', 'zeam_8', 'zeam_9']
Attestation metrics: ['lean_attestations_invalid_total', 'lean_attestations_valid_total']
Attestation sources: ['aggregation', 'block', 'gossip', 'unknown']
Network throughput: 590 records, containers: ['ethlambda_0', 'ethlambda_1', 'ethlambda_10', 'ethlambda_11', 'ethlambda_12', 'ethlambda_13', 'ethlambda_14', 'ethlambda_15', 'ethlambda_2', 'ethlambda_3', 'ethlambda_4', 'ethlambda_5', 'ethlambda_6', 'ethlambda_7', 'ethlambda_8', 'ethlambda_9', 'gean_0', 'gean_1', 'gean_2', 'gean_3', 'gean_4', 'gean_5', 'gean_6', 'gean_7', 'nlean_0', 'nlean_1', 'nlean_2', 'nlean_3', 'nlean_4', 'nlean_5', 'nlean_6', 'nlean_7', 'ream_1', 'ream_10', 'ream_11', 'ream_12', 'ream_13', 'ream_14', 'ream_15', 'ream_2', 'ream_3', 'ream_4', 'ream_5', 'ream_6', 'ream_7', 'ream_8', 'ream_9', 'zeam_0', 'zeam_1', 'zeam_11', 'zeam_12', 'zeam_14', 'zeam_15', 'zeam_3', 'zeam_4', 'zeam_5', 'zeam_6', 'zeam_7', 'zeam_8', 'zeam_9']

All clients (60): ['ethlambda_0', 'ethlambda_1', 'ethlambda_10', 'ethlambda_11', 'ethlambda_12', 'ethlambda_13', 'ethlambda_14', 'ethlambda_15', 'ethlambda_2', 'ethlambda_3', 'ethlambda_4', 'ethlambda_5', 'ethlambda_6', 'ethlambda_7', 'ethlambda_8', 'ethlambda_9', 'gean_0', 'gean_1', 'gean_2', 'gean_3', 'gean_4', 'gean_5', 'gean_6', 'gean_7', 'nlean_0', 'nlean_1', 'nlean_2', 'nlean_3', 'nlean_4', 'nlean_5', 'nlean_6', 'nlean_7', 'ream_1', 'ream_10', 'ream_11', 'ream_12', 'ream_13', 'ream_14', 'ream_15', 'ream_2', 'ream_3', 'ream_4', 'ream_5', 'ream_6', 'ream_7', 'ream_8', 'ream_9', 'zeam_0', 'zeam_1', 'zeam_11', 'zeam_12', 'zeam_14', 'zeam_15', 'zeam_3', 'zeam_4', 'zeam_5', 'zeam_6', 'zeam_7', 'zeam_8', 'zeam_9']

Peer Connections

Number of connected P2P peers over time. More peers generally means better attestation propagation and network resilience. Drops to 0 or 1 may indicate connectivity issues.

Show code
fig = make_subplots(
    rows=n_rows, cols=n_cols,
    subplot_titles=all_clients,
    vertical_spacing=0.12 / max(n_rows - 1, 1) * 2,
    horizontal_spacing=0.08,
)

for i, client in enumerate(all_clients):
    row = i // n_cols + 1
    col = i % n_cols + 1
    cdf = peers_df[peers_df["client"] == client].sort_values("timestamp")
    if not cdf.empty:
        fig.add_trace(
            go.Scatter(
                x=cdf["timestamp"], y=cdf["value"],
                name=client, showlegend=False,
                line=dict(color="#636EFA"),
            ),
            row=row, col=col,
        )
    else:
        fig.add_trace(
            go.Scatter(x=[None], y=[None], showlegend=False, hoverinfo='skip'),
            row=row, col=col,
        )
        _n = (row - 1) * n_cols + col
        _s = "" if _n == 1 else str(_n)
        fig.add_annotation(
            text="No data available",
            xref=f"x{_s} domain", yref=f"y{_s} domain",
            x=0.5, y=0.5,
            showarrow=False,
            font=dict(size=12, color="#999"),
        )
    fig.update_yaxes(title_text="Peers", row=row, col=col)

fig.update_layout(
    title="Connected Peers Over Time",
    height=270 * n_rows,
)
fig.show()

Peer Coverage

Percentage of connected peers over the total number of validators in the network. 100% means a client is connected to all other validators.

Show code
total_validators = len(all_clients)
max_peers = total_validators - 1  # exclude self

fig = make_subplots(
    rows=n_rows, cols=n_cols,
    subplot_titles=all_clients,
    vertical_spacing=0.12 / max(n_rows - 1, 1) * 2,
    horizontal_spacing=0.08,
)

for i, client in enumerate(all_clients):
    row = i // n_cols + 1
    col = i % n_cols + 1
    cdf = peers_df[peers_df["client"] == client].sort_values("timestamp")
    if not cdf.empty and max_peers > 0:
        coverage = cdf["value"] / max_peers * 100
        fig.add_trace(
            go.Scatter(
                x=cdf["timestamp"], y=coverage,
                name=client, showlegend=False,
                line=dict(color="#636EFA"),
            ),
            row=row, col=col,
        )
    else:
        fig.add_trace(
            go.Scatter(x=[None], y=[None], showlegend=False, hoverinfo='skip'),
            row=row, col=col,
        )
        _n = (row - 1) * n_cols + col
        _s = "" if _n == 1 else str(_n)
        fig.add_annotation(
            text="No data available",
            xref=f"x{_s} domain", yref=f"y{_s} domain",
            x=0.5, y=0.5,
            showarrow=False,
            font=dict(size=12, color="#999"),
        )
    fig.update_yaxes(title_text="%", range=[0, 105], row=row, col=col)

fig.update_layout(
    title=f"Peer Coverage (% of {max_peers} validators)",
    height=270 * n_rows,
)
fig.show()

Peer Connection & Disconnection Events

Connection and disconnection events per minute. Spikes in disconnections may indicate network instability or incompatible peers being dropped.

Show code
if peer_events_df.empty:
    print("No peer event data available")
else:
    fig = make_subplots(
        rows=n_rows, cols=n_cols,
        subplot_titles=all_clients,
        vertical_spacing=0.12 / max(n_rows - 1, 1) * 2,
        horizontal_spacing=0.08,
    )

    colors = {"connection": "#00CC96", "disconnection": "#EF553B"}
    legend_added = set()

    for i, client in enumerate(all_clients):
        row = i // n_cols + 1
        col = i % n_cols + 1
        cdf = peer_events_df[peer_events_df["client"] == client]
        if not cdf.empty:
            for metric in ["connection", "disconnection"]:
                mdf = cdf[cdf["metric"] == metric].sort_values("timestamp").copy()
                if mdf.empty:
                    continue
                mdf["rate"] = mdf["value"].diff()
                mdf = mdf[(mdf["rate"] >= 0) & mdf["rate"].notna()]
                if mdf.empty:
                    continue
                show_legend = metric not in legend_added
                legend_added.add(metric)
                fig.add_trace(
                    go.Scatter(
                        x=mdf["timestamp"], y=mdf["rate"],
                        name=metric, legendgroup=metric,
                        showlegend=show_legend,
                        line=dict(color=colors[metric]),
                    ),
                    row=row, col=col,
                )
        else:
            fig.add_trace(
                go.Scatter(x=[None], y=[None], showlegend=False, hoverinfo='skip'),
                row=row, col=col,
            )
            _n = (row - 1) * n_cols + col
            _s = "" if _n == 1 else str(_n)
            fig.add_annotation(
                text="No data available",
                xref=f"x{_s} domain", yref=f"y{_s} domain",
                x=0.5, y=0.5,
                showarrow=False,
                font=dict(size=12, color="#999"),
            )
        fig.update_yaxes(title_text="Events/min", row=row, col=col)

    fig.update_layout(
        title="Peer Connection & Disconnection Events by Client",
        height=270 * n_rows,
    )
    fig.show()

Cumulative Attestation Arrivals

Cumulative valid and invalid attestations received per client. Attestations arrive via two channels:

  • gossip: received directly from peers over the P2P network
  • block: included in received blocks

High invalid counts may indicate signature verification failures or incompatible messages. A flat line means the client has stopped receiving new attestations. A steeper slope means the client is receiving more attestations, while a shallower slope means fewer.

Show code
fig = make_subplots(
    rows=n_rows, cols=n_cols,
    subplot_titles=all_clients,
    vertical_spacing=0.12 / max(n_rows - 1, 1) * 2,
    horizontal_spacing=0.08,
)

colors = {
    ("valid", "gossip"): "#636EFA",
    ("valid", "block"): "#00CC96",
    ("valid", "unknown"): "#AB63FA",
    ("invalid", "gossip"): "#EF553B",
    ("invalid", "block"): "#FFA15A",
    ("invalid", "unknown"): "#FF6692",
}
legend_added = set()

for i, client in enumerate(all_clients):
    row = i // n_cols + 1
    col = i % n_cols + 1
    cdf = att_df[att_df["client"] == client]

    if not cdf.empty:
        for metric in ["lean_attestations_valid_total", "lean_attestations_invalid_total"]:
            mdf = cdf[cdf["metric"] == metric]
            validity = "valid" if "valid" in metric else "invalid"
            for source in sorted(mdf["source"].unique()):
                sdf = mdf[mdf["source"] == source].sort_values("timestamp")
                if sdf.empty or sdf["value"].max() == 0:
                    continue
                # Insert None at counter resets to break the line
                resets = sdf["value"].diff() < 0
                if resets.any():
                    rows = []
                    for idx, is_reset in resets.items():
                        if is_reset:
                            rows.append({"timestamp": sdf.loc[idx, "timestamp"], "value": None})
                        rows.append(sdf.loc[idx].to_dict())
                    sdf = pd.DataFrame(rows)
                key = (validity, source)
                label = f"{validity} ({source})"
                show_legend = key not in legend_added
                legend_added.add(key)
                fig.add_trace(
                    go.Scatter(
                        x=sdf["timestamp"], y=sdf["value"],
                        name=label, legendgroup=label,
                        showlegend=show_legend,
                        line=dict(color=colors.get(key, "#636EFA")),
                        connectgaps=False,
                    ),
                    row=row, col=col,
                )
    else:
        fig.add_trace(
            go.Scatter(x=[None], y=[None], showlegend=False, hoverinfo='skip'),
            row=row, col=col,
        )
        _n = (row - 1) * n_cols + col
        _s = "" if _n == 1 else str(_n)
        fig.add_annotation(
            text="No data available",
            xref=f"x{_s} domain", yref=f"y{_s} domain",
            x=0.5, y=0.5,
            showarrow=False,
            font=dict(size=12, color="#999"),
        )
    fig.update_yaxes(title_text="Count", row=row, col=col)

fig.update_layout(
    title="Attestation Counts by Client",
    height=270 * n_rows,
)
fig.show()
Show code
# Attestation summary: final counts per client
att_summary_rows = []

for client in all_clients:
    row_data = {"Client": client}
    cdf = att_df[att_df["client"] == client]

    for metric in ["lean_attestations_valid_total", "lean_attestations_invalid_total"]:
        mdf = cdf[cdf["metric"] == metric]
        validity = "Valid" if "valid" in metric else "Invalid"
        for source in sorted(mdf["source"].unique()):
            sdf = mdf[mdf["source"] == source]
            if not sdf.empty:
                col_name = f"{validity} ({source})"
                row_data[col_name] = f"{sdf['value'].max():.0f}"

    att_summary_rows.append(row_data)

if att_summary_rows:
    att_summary = pd.DataFrame(att_summary_rows).set_index("Client").fillna("-")
    display(att_summary)
Valid (unknown) Valid (gossip) Valid (aggregation) Valid (block)
Client
ethlambda_0 42 - - -
ethlambda_1 688 - - -
ethlambda_10 159 - - -
ethlambda_11 34 - - -
ethlambda_12 82 - - -
ethlambda_13 633 - - -
ethlambda_14 120 - - -
ethlambda_15 357 - - -
ethlambda_2 63 - - -
ethlambda_3 17 - - -
ethlambda_4 57 - - -
ethlambda_5 82 - - -
ethlambda_6 15 - - -
ethlambda_7 457 - - -
ethlambda_8 22 - - -
ethlambda_9 66 - - -
gean_0 0 - - -
gean_1 0 - - -
gean_2 0 - - -
gean_3 0 - - -
gean_4 0 - - -
gean_5 0 - - -
gean_6 0 - - -
gean_7 0 - - -
nlean_0 - 117 - -
nlean_1 - 606 - -
nlean_2 - 3 - -
nlean_3 - 59 - -
nlean_4 - 595 - -
nlean_5 - 624 - -
nlean_6 - 668 - -
nlean_7 - 610 - -
ream_1 - - - -
ream_10 - - - -
ream_11 - - - -
ream_12 - - - -
ream_13 - - - -
ream_14 - - - -
ream_15 - - - -
ream_2 - - - -
ream_3 - - - -
ream_4 - - - -
ream_5 - - - -
ream_6 - - - -
ream_7 - - - -
ream_8 - - - -
ream_9 - - - -
zeam_0 - 1230 5 13211
zeam_1 - 751 32 12054
zeam_11 - 4492 9 12951
zeam_12 - 10595 7924 12952
zeam_14 - 2864 10128 13238
zeam_15 - 4466 9996 13238
zeam_3 - 1253 4 12947
zeam_4 - 85 1260 4960
zeam_5 - 1308 10308 13233
zeam_6 - 1302 10060 13238
zeam_7 - 50 7996 13238
zeam_8 - 10877 1 13238
zeam_9 - 18899 3377 9380

Attestation Arrivals per Slot

Estimated attestations received per slot (4 seconds). Computed by diffing cumulative counters at each 1-minute scrape interval and dividing by 15 (slots per minute). Shows combined valid attestations across all sources.

Show code
SLOT_DURATION = 4  # seconds
SLOTS_PER_MINUTE = 60 / SLOT_DURATION

# Sum valid attestations across all sources per client per timestamp
valid_att = att_df[att_df["metric"] == "lean_attestations_valid_total"].copy()
valid_per_client = valid_att.groupby(["client", "timestamp"], as_index=False)["value"].sum()

fig = make_subplots(
    rows=n_rows, cols=n_cols,
    subplot_titles=all_clients,
    vertical_spacing=0.12 / max(n_rows - 1, 1) * 2,
    horizontal_spacing=0.08,
)

for i, client in enumerate(all_clients):
    row = i // n_cols + 1
    col = i % n_cols + 1
    cdf = valid_per_client[valid_per_client["client"] == client].sort_values("timestamp").copy()
    if not cdf.empty:
        cdf["delta"] = cdf["value"].diff()
        cdf["dt"] = cdf["timestamp"].diff().dt.total_seconds()
        cdf = cdf[(cdf["delta"] >= 0) & (cdf["dt"] > 0) & cdf["delta"].notna()]
        if not cdf.empty:
            cdf["per_slot"] = cdf["delta"] / (cdf["dt"] / SLOT_DURATION)
            fig.add_trace(
                go.Scatter(
                    x=cdf["timestamp"], y=cdf["per_slot"],
                    name=client, showlegend=False,
                    line=dict(color="#636EFA"),
                ),
                row=row, col=col,
            )
    else:
        fig.add_trace(
            go.Scatter(x=[None], y=[None], showlegend=False, hoverinfo='skip'),
            row=row, col=col,
        )
        _n = (row - 1) * n_cols + col
        _s = "" if _n == 1 else str(_n)
        fig.add_annotation(
            text="No data available",
            xref=f"x{_s} domain", yref=f"y{_s} domain",
            x=0.5, y=0.5,
            showarrow=False,
            font=dict(size=12, color="#999"),
        )
    fig.update_yaxes(title_text="Atts/slot", row=row, col=col)

fig.update_layout(
    title="Valid Attestations Received per Slot by Client",
    height=270 * n_rows,
)
fig.show()

Network Bandwidth

Receive (rx) and transmit (tx) throughput per client container. Dashed horizontal lines show EIP-7870 recommended bandwidth tiers at 15, 25, and 50 Mbps.

Show code
if net_df.empty:
    print("No network throughput data available")
else:
    # EIP-7870 bandwidth tiers (Mbps -> KB/s)
    def mbps_to_kbps(mbps: float) -> float:
        return mbps * 1e6 / 8 / 1024

    EIP7870_TIERS = [15, 25, 50]  # Mbps

    # Use devnet_info["clients"] directly as container names
    client_net = net_df[net_df["container"].isin(all_clients)].copy()
    client_net["value_kb"] = client_net["value"] / 1024

    n_cols_net = min(len(all_clients), 2)
    n_rows_net = -(-len(all_clients) // n_cols_net)

    fig = make_subplots(
        rows=n_rows_net, cols=n_cols_net,
        subplot_titles=all_clients,
        vertical_spacing=0.12 / max(n_rows_net - 1, 1) * 2,
        horizontal_spacing=0.08,
    )

    colors = {"rx": "#636EFA", "tx": "#EF553B"}
    legend_added = set()

    for i, container in enumerate(all_clients):
        row = i // n_cols_net + 1
        col = i % n_cols_net + 1
        cdf = client_net[client_net["container"] == container]
        if not cdf.empty:
            for metric in ["rx", "tx"]:
                mdf = cdf[cdf["metric"] == metric].sort_values("timestamp")
                if mdf.empty:
                    continue
                show_legend = metric not in legend_added
                legend_added.add(metric)
                fig.add_trace(
                    go.Scatter(
                        x=mdf["timestamp"], y=mdf["value_kb"],
                        name=metric, legendgroup=metric,
                        showlegend=show_legend,
                        line=dict(color=colors[metric]),
                    ),
                    row=row, col=col,
                )

            # Add EIP-7870 reference lines
            for mbps in EIP7870_TIERS:
                fig.add_hline(
                    y=mbps_to_kbps(mbps),
                    row=row, col=col,
                    line=dict(color="#888", dash="dash", width=1),
                    annotation=dict(
                        text=f"{mbps} Mbps",
                        font=dict(size=9, color="#888"),
                    ),
                )
        else:
            fig.add_trace(
                go.Scatter(x=[None], y=[None], showlegend=False, hoverinfo='skip'),
                row=row, col=col,
            )
            _n = (row - 1) * n_cols_net + col
            _s = "" if _n == 1 else str(_n)
            fig.add_annotation(
                text="No data available",
                xref=f"x{_s} domain", yref=f"y{_s} domain",
                x=0.5, y=0.5,
                showarrow=False,
                font=dict(size=12, color="#999"),
            )
        fig.update_yaxes(title_text="KB/s", row=row, col=col)

    fig.update_layout(
        title="Network Throughput per Client (RX vs TX)",
        height=270 * n_rows_net,
    )
    fig.show()

Summary

Show code
def format_bytes_per_sec(val: float) -> str:
    """Format bytes/s to human-readable units."""
    for unit in ["B/s", "KB/s", "MB/s", "GB/s"]:
        if abs(val) < 1024:
            return f"{val:.1f} {unit}"
        val /= 1024
    return f"{val:.1f} TB/s"


summary_rows = []
for client in all_clients:
    row = {"Client": client}

    # Peers
    client_peers = peers_df[peers_df["client"] == client]["value"]
    if not client_peers.empty:
        row["Avg Peers"] = f"{client_peers.mean():.1f}"
        row["Max Peers"] = f"{client_peers.max():.0f}"
        row["Min Peers"] = f"{client_peers.min():.0f}"

    # Attestations
    client_att = att_df[att_df["client"] == client]
    valid = client_att[client_att["metric"] == "lean_attestations_valid_total"]["value"].max()
    invalid = client_att[client_att["metric"] == "lean_attestations_invalid_total"]["value"].max()
    if pd.notna(valid):
        row["Valid Atts"] = f"{valid:.0f}"
    if pd.notna(invalid) and invalid > 0:
        row["Invalid Atts"] = f"{invalid:.0f}"

    # Network bandwidth — container name matches client name directly
    if not net_df.empty:
        cnet = net_df[net_df["container"] == client]
        rx = cnet[cnet["metric"] == "rx"]["value"]
        tx = cnet[cnet["metric"] == "tx"]["value"]
        if not rx.empty:
            row["Avg RX"] = format_bytes_per_sec(rx.mean())
            row["Max RX"] = format_bytes_per_sec(rx.max())
        if not tx.empty:
            row["Avg TX"] = format_bytes_per_sec(tx.mean())
            row["Max TX"] = format_bytes_per_sec(tx.max())

    summary_rows.append(row)

if summary_rows:
    summary_df = pd.DataFrame(summary_rows).set_index("Client").fillna("-")
    display(summary_df)

print(f"\nDevnet: {devnet_id}")
if devnet_info:
    print(f"Duration: {devnet_info['duration_hours']:.1f} hours")
Avg Peers Max Peers Min Peers Valid Atts Invalid Atts Avg RX Max RX Avg TX Max TX
Client
ethlambda_0 1.0 1 1 79027 42 8.1 MB/s 14.2 MB/s 26.6 MB/s 34.3 MB/s
ethlambda_1 1.0 1 1 70295 688 14.4 MB/s 28.8 MB/s 13.2 MB/s 26.3 MB/s
ethlambda_10 0.9 1 0 75492 159 23.3 MB/s 30.2 MB/s 27.7 MB/s 34.6 MB/s
ethlambda_11 1.0 1 1 79438 34 11.0 MB/s 16.6 MB/s 22.9 MB/s 32.6 MB/s
ethlambda_12 1.0 1 1 72855 82 11.8 MB/s 18.5 MB/s 15.2 MB/s 26.1 MB/s
ethlambda_13 1.0 1 1 65290 633 7.3 MB/s 15.9 MB/s 7.1 MB/s 16.7 MB/s
ethlambda_14 1.0 1 1 78826 120 18.8 MB/s 29.7 MB/s 20.1 MB/s 35.3 MB/s
ethlambda_15 1.0 1 1 68934 357 19.7 MB/s 35.6 MB/s 17.7 MB/s 27.6 MB/s
ethlambda_2 1.0 1 1 79762 63 8.4 MB/s 12.8 MB/s 23.2 MB/s 31.6 MB/s
ethlambda_3 1.0 1 1 79810 17 10.6 MB/s 16.4 MB/s 22.2 MB/s 38.6 MB/s
ethlambda_4 1.0 1 1 79719 57 22.8 MB/s 34.6 MB/s 45.4 MB/s 62.7 MB/s
ethlambda_5 1.0 1 1 79482 82 23.1 MB/s 37.8 MB/s 33.1 MB/s 47.5 MB/s
ethlambda_6 1.0 1 1 79617 15 23.0 MB/s 37.3 MB/s 35.7 MB/s 55.9 MB/s
ethlambda_7 1.0 1 1 70164 457 44.6 MB/s 80.8 MB/s 18.9 MB/s 32.2 MB/s
ethlambda_8 1.0 1 1 79066 22 8.7 MB/s 12.3 MB/s 29.8 MB/s 39.0 MB/s
ethlambda_9 1.0 1 1 79531 66 20.7 MB/s 26.7 MB/s 34.1 MB/s 47.4 MB/s
gean_0 38.3 45 33 6606 - 73.9 MB/s 83.4 MB/s 7.5 MB/s 12.1 MB/s
gean_1 38.4 43 35 0 - 22.9 MB/s 34.6 MB/s 45.9 MB/s 62.7 MB/s
gean_2 38.1 45 35 0 - 23.1 MB/s 37.9 MB/s 33.1 MB/s 47.6 MB/s
gean_3 38.5 44 32 0 - 23.0 MB/s 37.3 MB/s 35.6 MB/s 55.9 MB/s
gean_4 32.5 42 5 0 - 44.5 MB/s 81.1 MB/s 18.9 MB/s 32.1 MB/s
gean_5 33.0 39 1 0 - 19.3 MB/s 40.7 MB/s 10.1 MB/s 19.4 MB/s
gean_6 21.5 42 0 0 - 10.6 MB/s 29.3 MB/s 6.7 MB/s 18.4 MB/s
gean_7 36.1 43 28 0 - 32.1 MB/s 44.8 MB/s 18.9 MB/s 29.0 MB/s
nlean_0 16.0 16 16 117 - 10.3 MB/s 19.3 MB/s 15.5 MB/s 23.7 MB/s
nlean_1 16.0 16 16 606 - 32.0 MB/s 45.4 MB/s 18.9 MB/s 28.9 MB/s
nlean_2 15.7 16 14 3 - 16.1 MB/s 18.3 MB/s 10.3 MB/s 20.4 MB/s
nlean_3 14.3 16 1 59 - 11.7 MB/s 23.8 MB/s 11.0 MB/s 21.2 MB/s
nlean_4 16.0 16 16 595 - 22.4 MB/s 33.5 MB/s 10.5 MB/s 16.0 MB/s
nlean_5 16.0 16 16 624 - 18.8 MB/s 24.7 MB/s 19.0 MB/s 32.1 MB/s
nlean_6 16.0 16 16 668 - 20.7 MB/s 26.7 MB/s 34.0 MB/s 47.4 MB/s
nlean_7 15.8 16 15 610 - 23.4 MB/s 30.2 MB/s 27.7 MB/s 34.6 MB/s
ream_1 34.2 44 16 - - 12.0 MB/s 18.7 MB/s 15.5 MB/s 26.9 MB/s
ream_10 36.1 43 26 - - 19.6 MB/s 40.6 MB/s 10.3 MB/s 19.4 MB/s
ream_11 23.9 42 0 - - 10.6 MB/s 29.3 MB/s 6.7 MB/s 18.3 MB/s
ream_12 35.9 43 24 - - 32.7 MB/s 45.5 MB/s 19.1 MB/s 29.8 MB/s
ream_13 33.1 42 9 - - 16.4 MB/s 18.7 MB/s 10.2 MB/s 20.9 MB/s
ream_14 22.5 53 0 - - 11.7 MB/s 23.7 MB/s 11.1 MB/s 21.5 MB/s
ream_15 32.8 41 25 - - 22.5 MB/s 33.5 MB/s 10.7 MB/s 16.4 MB/s
ream_2 21.3 42 1 - - 7.6 MB/s 17.0 MB/s 7.4 MB/s 17.6 MB/s
ream_3 36.2 44 28 - - 19.0 MB/s 30.4 MB/s 20.2 MB/s 35.4 MB/s
ream_4 33.1 42 3 - - 20.2 MB/s 35.6 MB/s 18.3 MB/s 30.7 MB/s
ream_5 19.0 31 0 - - 5.2 MB/s 6.8 MB/s 50.2 KB/s 70.3 KB/s
ream_6 31.1 41 12 - - 8.4 MB/s 14.1 MB/s 100.7 KB/s 180.8 KB/s
ream_7 29.6 36 11 - - 14.9 MB/s 28.9 MB/s 13.7 MB/s 26.4 MB/s
ream_8 30.0 43 23 - - 5.6 MB/s 10.4 MB/s 69.6 KB/s 124.0 KB/s
ream_9 35.8 42 33 - - 42.9 MB/s 80.9 MB/s 18.0 MB/s 32.0 MB/s
zeam_0 1.0 1 1 13211 5 10.5 KB/s 12.2 KB/s 8.4 KB/s 9.3 KB/s
zeam_1 1.0 1 1 12054 32 19.7 MB/s 35.6 MB/s 17.7 MB/s 27.7 MB/s
zeam_11 1.0 1 1 12951 4492 18.8 MB/s 24.6 MB/s 19.0 MB/s 32.1 MB/s
zeam_12 1.0 1 1 12952 10595 20.7 MB/s 26.7 MB/s 34.0 MB/s 47.4 MB/s
zeam_14 1.0 1 1 21200 2864 11.1 MB/s 16.6 MB/s 23.2 MB/s 33.0 MB/s
zeam_15 1.0 1 1 22577 4466 23.1 MB/s 37.9 MB/s 33.1 MB/s 47.5 MB/s
zeam_3 1.0 1 1 12947 4 8.2 MB/s 14.1 MB/s 99.2 KB/s 180.9 KB/s
zeam_4 1.0 1 1 4960 85 14.5 MB/s 28.8 MB/s 13.3 MB/s 26.4 MB/s
zeam_5 1.0 1 1 13233 - 8.4 MB/s 12.8 MB/s 23.1 MB/s 31.4 MB/s
zeam_6 1.0 1 1 13238 - 10.7 MB/s 16.7 MB/s 22.5 MB/s 39.5 MB/s
zeam_7 1.0 1 1 13238 50 22.8 MB/s 34.7 MB/s 45.9 MB/s 62.7 MB/s
zeam_8 1.0 1 1 13238 10877 10.6 KB/s 12.0 KB/s 9.8 KB/s 11.5 KB/s
zeam_9 1.0 1 1 18899 3377 11.7 MB/s 25.0 MB/s 11.1 MB/s 21.2 MB/s
Devnet: pqdevnet-20260518T1327Z
Duration: 0.4 hours