Resource Utilization

CPU, memory, disk I/O, and network throughput analysis for PQ Devnet clients.

This notebook examines container-level resource usage using cAdvisor metrics:

  • CPU usage (cores) per client
  • Memory working set and RSS per client
  • Disk read/write throughput
  • Disk usage over time
  • Network receive/transmit throughput
Show code
import json
from pathlib import Path

import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from IPython.display import HTML, 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-20260517T0909Z
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-20260517T0909Z
Duration: 0.5 hours
Time: 2026-05-17T09:09:06+00:00 to 2026-05-17T09:39:33+00:00
Slots: 0 β†’ 2757
Clients: ethlambda_0, ethlambda_1, ethlambda_10, ethlambda_11, ethlambda_12, ethlambda_13, ethlambda_14, 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, grandine_0, grandine_1, grandine_2, grandine_3, grandine_4, grandine_5, grandine_6, grandine_7, lantern_6, nlean_0, nlean_1, nlean_2, nlean_3, nlean_4, nlean_5, nlean_6, nlean_7, qlean_0, qlean_1, qlean_3, qlean_4, qlean_6, qlean_7, ream_0, ream_1, ream_2, ream_3, ream_4, ream_5, ream_6, ream_7, zeam_0, zeam_1, zeam_10, zeam_11, zeam_12, zeam_13, zeam_14, zeam_2, zeam_3, zeam_4, zeam_5, zeam_6, zeam_7, zeam_8, zeam_9
Show code
def format_bytes(val: float) -> str:
    """Format bytes to human-readable units."""
    for unit in ["B", "KB", "MB", "GB", "TB"]:
        if abs(val) < 1024:
            return f"{val:.1f} {unit}"
        val /= 1024
    return f"{val:.1f} PB"


def format_bytes_per_sec(val: float) -> str:
    """Format bytes/s to human-readable units."""
    return format_bytes(val) + "/s"

Load DataΒΆ

Show code
# Load container resource data
data_files = {
    "cpu": "container_cpu.parquet",
    "memory": "container_memory.parquet",
    "disk_io": "container_disk_io.parquet",
    "network": "container_network.parquet",
}

# Infrastructure containers irrelevant to devnet client analysis
EXCLUDED_CONTAINERS = {"unknown", "cadvisor", "prometheus", "promtail", "node-exporter", "node_exporter", "grafana"}

# Aggregation strategy per data type:
# - cpu/memory: max (gauge-like, take the active container's value)
# - disk_io/network: sum (per-device/interface rates should be summed)
AGG_STRATEGY = {"cpu": "max", "memory": "max", "disk_io": "sum", "network": "sum"}

# Group-by columns per data type (all have container+timestamp, some have metric)
GROUP_COLS = {
    "cpu": ["container", "timestamp"],
    "memory": ["container", "metric", "timestamp"],
    "disk_io": ["container", "metric", "timestamp"],
    "network": ["container", "metric", "timestamp"],
}

dfs = {}
for key, filename in data_files.items():
    path = DEVNET_DIR / filename
    if path.exists():
        df = pd.read_parquet(path)
        df = df[~df["container"].isin(EXCLUDED_CONTAINERS)]
        # Deduplicate: multiple Prometheus series (interfaces, devices, container
        # IDs after restarts) can produce duplicate rows per container+timestamp.
        df = df.groupby(GROUP_COLS[key], as_index=False)["value"].agg(AGG_STRATEGY[key])
        dfs[key] = df
        print(f"{key}: {len(df)} records, containers: {df['container'].nunique()}")
    else:
        dfs[key] = pd.DataFrame()
        print(f"{key}: no data (file not found)")

# Unified container list: use devnet_info["clients"] which already contains
# full names with instance suffixes (e.g., "ream_0", "ream_1").
all_containers = sorted(devnet_info["clients"])
n_cols = min(len(all_containers), 2)
n_rows = -(-len(all_containers) // n_cols)
print(f"\nAll containers ({len(all_containers)}): {all_containers}")
cpu: 177 records, containers: 51
memory: 1448 records, containers: 60
disk_io: 330 records, containers: 47
network: 354 records, containers: 51

All containers (69): ['ethlambda_0', 'ethlambda_1', 'ethlambda_10', 'ethlambda_11', 'ethlambda_12', 'ethlambda_13', 'ethlambda_14', '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', 'grandine_0', 'grandine_1', 'grandine_2', 'grandine_3', 'grandine_4', 'grandine_5', 'grandine_6', 'grandine_7', 'lantern_6', 'nlean_0', 'nlean_1', 'nlean_2', 'nlean_3', 'nlean_4', 'nlean_5', 'nlean_6', 'nlean_7', 'qlean_0', 'qlean_1', 'qlean_3', 'qlean_4', 'qlean_6', 'qlean_7', 'ream_0', 'ream_1', 'ream_2', 'ream_3', 'ream_4', 'ream_5', 'ream_6', 'ream_7', 'zeam_0', 'zeam_1', 'zeam_10', 'zeam_11', 'zeam_12', 'zeam_13', 'zeam_14', 'zeam_2', 'zeam_3', 'zeam_4', 'zeam_5', 'zeam_6', 'zeam_7', 'zeam_8', 'zeam_9']

CPU UsageΒΆ

CPU cores used per container over time, derived from rate(container_cpu_usage_seconds_total[5m]).

Show code
cpu_df = dfs["cpu"]

if cpu_df.empty:
    print("No CPU data available")
else:
    fig = make_subplots(
        rows=n_rows, cols=n_cols,
        subplot_titles=all_containers,
        vertical_spacing=0.12 / max(n_rows - 1, 1) * 2,
        horizontal_spacing=0.08,
    )

    for i, container in enumerate(all_containers):
        row = i // n_cols + 1
        col = i % n_cols + 1
        cdf = cpu_df[cpu_df["container"] == container].sort_values("timestamp")
        if not cdf.empty:
            fig.add_trace(
                go.Scatter(
                    x=cdf["timestamp"], y=cdf["value"],
                    name=container, 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="CPU (cores)", row=row, col=col)

    fig.update_layout(
        title="CPU Usage per Container",
        height=270 * n_rows,
    )
    fig.show()
Show code
# CPU summary statistics
if not cpu_df.empty:
    cpu_summary = cpu_df.groupby("container")["value"].agg(
        ["mean", "max", "min", "std"]
    ).round(3)
    cpu_summary.columns = ["Mean (cores)", "Max (cores)", "Min (cores)", "Std Dev"]
    cpu_summary = cpu_summary.sort_index()
    display(cpu_summary)
Mean (cores) Max (cores) Min (cores) Std Dev
container
ethlambda_0 2.431 3.431 0.557 1.624
ethlambda_1 1.066 3.011 0.139 1.070
ethlambda_2 1.534 2.604 0.384 1.112
ethlambda_3 1.706 2.751 0.472 1.151
ethlambda_4 1.014 1.685 0.247 0.724
ethlambda_5 1.337 2.277 0.383 0.947
ethlambda_6 1.312 2.272 0.312 0.981
ethlambda_7 1.603 2.377 0.411 1.048
gean_0 4.320 6.073 0.832 3.021
gean_1 0.507 1.006 0.307 0.324
gean_2 0.919 1.313 0.216 0.610
gean_3 0.975 1.425 0.245 0.638
gean_4 1.114 1.636 0.231 0.769
gean_5 1.149 1.601 0.271 0.761
gean_6 1.011 1.441 0.275 0.640
gean_7 1.187 1.624 0.352 0.723
grandine_0 2.278 3.247 0.438 1.595
grandine_1 1.411 2.059 0.245 1.012
grandine_4 1.576 2.189 0.385 1.032
grandine_5 2.209 3.105 0.429 1.542
grandine_6 1.664 2.334 0.350 1.138
grandine_7 1.828 2.568 0.392 1.243
nlean_0 3.480 4.912 1.016 2.143
nlean_1 0.327 0.443 0.134 0.168
nlean_3 0.733 1.015 0.212 0.452
nlean_4 0.812 1.246 0.209 0.539
nlean_5 0.741 1.396 0.286 0.581
nlean_6 0.897 1.260 0.274 0.542
nlean_7 0.836 1.196 0.287 0.483
qlean_0 4.801 6.826 0.784 3.479
qlean_1 1.143 1.611 0.222 0.798
qlean_3 1.239 1.977 0.274 0.874
qlean_4 1.098 1.982 0.189 0.897
qlean_6 1.122 2.173 0.350 0.943
qlean_7 1.079 1.901 0.330 0.788
ream_0 0.521 0.974 0.048 0.464
ream_1 0.800 1.394 0.220 0.588
ream_2 1.024 1.449 0.276 0.649
ream_3 0.765 1.299 0.232 0.755
ream_4 0.202 0.306 0.035 0.097
ream_5 0.784 1.354 0.200 0.577
ream_6 0.552 0.825 0.214 0.311
ream_7 0.851 1.684 0.494 0.424
zeam_0 0.021 0.063 0.001 0.028
zeam_1 0.014 0.016 0.013 0.001
zeam_2 0.005 0.008 0.001 0.004
zeam_3 0.048 0.100 0.001 0.050
zeam_4 0.026 0.071 0.011 0.022
zeam_5 0.006 0.008 0.001 0.004
zeam_6 0.006 0.008 0.001 0.004
zeam_7 0.002 0.003 0.000 0.001

Memory UsageΒΆ

Memory consumption per container, including working set (total usage minus inactive file cache) and RSS (Resident Set Size -- anonymous memory only, excluding file-backed pages). The gap between the two shows active file cache usage.

Show code
mem_df = dfs["memory"]

if mem_df.empty:
    print("No memory data available")
else:
    # Combine working_set and rss for per-container comparison
    mem_plot_df = mem_df[mem_df["metric"].isin(["working_set", "rss"])].copy()
    if not mem_plot_df.empty:
        mem_plot_df["value_mb"] = mem_plot_df["value"] / (1024 * 1024)

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

        colors = {"working_set": "#636EFA", "rss": "#EF553B"}
        legend_added = set()

        for i, container in enumerate(all_containers):
            row = i // n_cols + 1
            col = i % n_cols + 1
            cdf = mem_plot_df[mem_plot_df["container"] == container]
            if not cdf.empty:
                for metric in ["working_set", "rss"]:
                    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_mb"],
                            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="MB", row=row, col=col)

        fig.update_layout(
            title="Memory Usage per Container (Working Set vs RSS)",
            height=270 * n_rows,
        )
        fig.show()
Show code
# Memory summary
if not mem_df.empty:
    ws_df = mem_df[mem_df["metric"] == "working_set"]
    if not ws_df.empty:
        mem_summary = ws_df.groupby("container")["value"].agg(["mean", "max"]).reset_index()
        mem_summary["Mean"] = mem_summary["mean"].apply(format_bytes)
        mem_summary["Peak"] = mem_summary["max"].apply(format_bytes)
        mem_summary = mem_summary.rename(columns={"container": "Container"})[["Container", "Mean", "Peak"]]
        mem_summary = mem_summary.sort_values("Container")
        display(mem_summary.set_index("Container"))
Mean Peak
Container
ethlambda_0 1.4 GB 1.8 GB
ethlambda_1 2.0 GB 2.9 GB
ethlambda_11 1.1 MB 1.1 MB
ethlambda_12 424.8 MB 424.8 MB
ethlambda_2 1.2 GB 2.1 GB
ethlambda_3 1.1 GB 1.2 GB
ethlambda_4 1.0 GB 1.1 GB
ethlambda_5 1007.8 MB 1.2 GB
ethlambda_6 1.0 GB 1.2 GB
ethlambda_7 2.0 GB 2.2 GB
ethlambda_8 530.3 MB 530.3 MB
gean_0 5.3 GB 6.5 GB
gean_1 2.6 GB 3.8 GB
gean_2 1.4 GB 1.5 GB
gean_3 1.1 GB 1.3 GB
gean_4 1.4 GB 1.5 GB
gean_5 1.1 GB 1.2 GB
gean_6 1.0 GB 1.2 GB
gean_7 1.1 GB 1.3 GB
grandine_0 4.2 GB 4.6 GB
grandine_1 2.8 GB 2.8 GB
grandine_4 2.0 GB 2.2 GB
grandine_5 2.7 GB 2.7 GB
grandine_6 2.8 GB 2.8 GB
grandine_7 2.1 GB 2.3 GB
lantern_6 1.6 MB 1.6 MB
nlean_0 3.6 GB 4.2 GB
nlean_1 1.5 GB 3.5 GB
nlean_2 3.6 MB 3.6 MB
nlean_3 2.8 GB 3.9 GB
nlean_4 4.0 GB 6.6 GB
nlean_5 4.3 GB 6.4 GB
nlean_6 4.0 GB 6.0 GB
nlean_7 5.3 GB 6.6 GB
qlean_0 1.3 GB 1.4 GB
qlean_1 2.4 GB 2.7 GB
qlean_3 1.3 GB 1.7 GB
qlean_4 1.9 GB 2.1 GB
qlean_6 1.8 GB 2.1 GB
qlean_7 1.8 GB 2.1 GB
ream_0 11.2 GB 14.5 GB
ream_1 7.9 GB 10.5 GB
ream_2 10.2 GB 14.5 GB
ream_3 11.7 GB 14.5 GB
ream_4 5.2 GB 11.3 GB
ream_5 10.0 GB 11.3 GB
ream_6 9.0 GB 11.1 GB
ream_7 7.8 GB 10.7 GB
zeam_0 2.4 GB 2.5 GB
zeam_1 3.3 GB 3.3 GB
zeam_10 561.4 MB 561.4 MB
zeam_13 964.0 KB 964.0 KB
zeam_14 452.4 MB 452.4 MB
zeam_2 2.4 GB 2.6 GB
zeam_3 3.4 GB 3.4 GB
zeam_4 2.3 GB 2.3 GB
zeam_5 4.1 GB 4.4 GB
zeam_6 4.1 GB 4.1 GB
zeam_7 1.9 GB 1.9 GB
zeam_8 488.1 MB 488.1 MB

Disk I/OΒΆ

Disk read/write throughput per container.

Show code
disk_df = dfs["disk_io"]

if disk_df.empty:
    print("No disk I/O data available")
else:
    # Read/write throughput per container
    throughput_df = disk_df[disk_df["metric"].isin(["read_throughput", "write_throughput"])].copy()
    if not throughput_df.empty:
        throughput_df["value_mb"] = throughput_df["value"] / (1024 * 1024)

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

        colors = {"read_throughput": "#636EFA", "write_throughput": "#EF553B"}
        legend_added = set()

        for i, container in enumerate(all_containers):
            row = i // n_cols + 1
            col = i % n_cols + 1
            cdf = throughput_df[throughput_df["container"] == container]
            if not cdf.empty:
                for metric in ["read_throughput", "write_throughput"]:
                    mdf = cdf[cdf["metric"] == metric].sort_values("timestamp")
                    if mdf.empty:
                        continue
                    label = metric.replace("_throughput", "")
                    show_legend = metric not in legend_added
                    legend_added.add(metric)
                    fig.add_trace(
                        go.Scatter(
                            x=mdf["timestamp"], y=mdf["value_mb"],
                            name=label, 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="MB/s", row=row, col=col)

        fig.update_layout(
            title="Disk I/O Throughput per Container (Read vs Write)",
            height=270 * n_rows,
        )
        fig.show()

Disk UsageΒΆ

Total disk space used per container over time.

Show code
# Disk usage uses the 'client' column (container is 'unknown' for this metric)
disk_io_path = DEVNET_DIR / "container_disk_io.parquet"
if disk_io_path.exists():
    raw_disk = pd.read_parquet(disk_io_path)
    usage_df = raw_disk[raw_disk["metric"] == "disk_usage"].copy()
    usage_df = usage_df.groupby(["client", "timestamp"], as_index=False)["value"].max()
else:
    usage_df = pd.DataFrame()

if usage_df.empty:
    print("No disk usage data available")
else:
    usage_df["value_gb"] = usage_df["value"] / (1024 * 1024 * 1024)

    # Use client names from devnet metadata
    all_clients_sorted = sorted(devnet_info["clients"])
    n_cols_du = min(len(all_clients_sorted), 2)
    n_rows_du = -(-len(all_clients_sorted) // n_cols_du)

    fig = make_subplots(
        rows=n_rows_du, cols=n_cols_du,
        subplot_titles=all_clients_sorted,
        vertical_spacing=0.12 / max(n_rows_du - 1, 1) * 2,
        horizontal_spacing=0.08,
    )

    for i, client in enumerate(all_clients_sorted):
        row = i // n_cols_du + 1
        col = i % n_cols_du + 1
        cdf = usage_df[usage_df["client"] == client].sort_values("timestamp")
        if not cdf.empty and cdf["value"].max() > 0:
            fig.add_trace(
                go.Scatter(
                    x=cdf["timestamp"], y=cdf["value_gb"],
                    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_du + 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="GB", row=row, col=col)

    fig.update_layout(
        title="Disk Usage per Client",
        height=270 * n_rows_du,
    )
    fig.show()

Network ThroughputΒΆ

Network receive (rx) and transmit (tx) throughput per container.

Show code
net_df = dfs["network"]

if net_df.empty:
    print("No network data available")
else:
    net_df["value_mb"] = net_df["value"] / (1024 * 1024)

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

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

    for i, container in enumerate(all_containers):
        row = i // n_cols + 1
        col = i % n_cols + 1
        cdf = net_df[net_df["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_mb"],
                        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="MB/s", row=row, col=col)

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

SummaryΒΆ

Peak and average resource usage per container across the devnet.

Show code
# Build summary table across all resource types
summary_rows = []

# CPU
if not cpu_df.empty:
    for container, group in cpu_df.groupby("container"):
        summary_rows.append({
            "Container": container,
            "Avg CPU (cores)": f"{group['value'].mean():.3f}",
            "Peak CPU (cores)": f"{group['value'].max():.3f}",
        })

# Memory
if not mem_df.empty:
    ws_df = mem_df[mem_df["metric"] == "working_set"]
    for container, group in ws_df.groupby("container"):
        existing = next((r for r in summary_rows if r["Container"] == container), None)
        if existing is None:
            existing = {"Container": container}
            summary_rows.append(existing)
        existing["Avg Memory"] = format_bytes(group["value"].mean())
        existing["Peak Memory"] = format_bytes(group["value"].max())

# Network
if not net_df.empty:
    for container, group in net_df.groupby("container"):
        existing = next((r for r in summary_rows if r["Container"] == container), None)
        if existing is None:
            existing = {"Container": container}
            summary_rows.append(existing)
        rx = group[group["metric"] == "rx"]["value"]
        tx = group[group["metric"] == "tx"]["value"]
        if not rx.empty:
            existing["Avg RX"] = format_bytes_per_sec(rx.mean())
        if not tx.empty:
            existing["Avg TX"] = format_bytes_per_sec(tx.mean())

if summary_rows:
    summary_df = pd.DataFrame(summary_rows).set_index("Container").sort_index().fillna("-")
    display(summary_df)
else:
    print("No resource data available for summary.")
Avg CPU (cores) Peak CPU (cores) Avg Memory Peak Memory Avg RX Avg TX
Container
ethlambda_0 2.431 3.431 1.4 GB 1.8 GB 11.6 MB/s 30.1 MB/s
ethlambda_1 1.066 3.011 2.0 GB 2.9 GB 14.5 MB/s 30.5 MB/s
ethlambda_11 - - 1.1 MB 1.1 MB - -
ethlambda_12 - - 424.8 MB 424.8 MB - -
ethlambda_2 1.534 2.604 1.2 GB 2.1 GB 9.8 MB/s 30.4 MB/s
ethlambda_3 1.706 2.751 1.1 GB 1.2 GB 12.4 MB/s 39.9 MB/s
ethlambda_4 1.014 1.685 1.0 GB 1.1 GB 25.4 MB/s 39.8 MB/s
ethlambda_5 1.337 2.277 1007.8 MB 1.2 GB 24.4 MB/s 46.6 MB/s
ethlambda_6 1.312 2.272 1.0 GB 1.2 GB 25.4 MB/s 45.4 MB/s
ethlambda_7 1.603 2.377 2.0 GB 2.2 GB 40.4 MB/s 43.7 MB/s
ethlambda_8 - - 530.3 MB 530.3 MB - -
gean_0 4.320 6.073 5.3 GB 6.5 GB 14.4 MB/s 11.3 MB/s
gean_1 0.507 1.006 2.6 GB 3.8 GB 32.2 MB/s 17.4 MB/s
gean_2 0.919 1.313 1.4 GB 1.5 GB 23.8 MB/s 45.6 MB/s
gean_3 0.975 1.425 1.1 GB 1.3 GB 25.5 MB/s 45.4 MB/s
gean_4 1.114 1.636 1.4 GB 1.5 GB 39.7 MB/s 42.8 MB/s
gean_5 1.149 1.601 1.1 GB 1.2 GB 14.9 MB/s 17.3 MB/s
gean_6 1.011 1.441 1.0 GB 1.2 GB 19.2 MB/s 27.9 MB/s
gean_7 1.187 1.624 1.1 GB 1.3 GB 36.0 MB/s 22.9 MB/s
grandine_0 2.278 3.247 4.2 GB 4.6 GB 13.1 MB/s 1.4 MB/s
grandine_1 1.411 2.059 2.8 GB 2.8 GB 39.6 MB/s 42.8 MB/s
grandine_4 1.576 2.189 2.0 GB 2.2 GB 36.0 MB/s 22.6 MB/s
grandine_5 2.209 3.105 2.7 GB 2.7 GB 24.9 MB/s 691.3 KB/s
grandine_6 1.664 2.334 2.8 GB 2.8 GB 34.4 MB/s 22.4 MB/s
grandine_7 1.828 2.568 2.1 GB 2.3 GB 27.0 MB/s 8.0 MB/s
lantern_6 - - 1.6 MB 1.6 MB - -
nlean_0 3.480 4.912 3.6 GB 4.2 GB 10.5 MB/s 9.8 MB/s
nlean_1 0.327 0.443 1.5 GB 3.5 GB 35.3 MB/s 21.8 MB/s
nlean_2 - - 3.6 MB 3.6 MB - -
nlean_3 0.733 1.015 2.8 GB 3.9 GB 34.4 MB/s 22.4 MB/s
nlean_4 0.812 1.246 4.0 GB 6.6 GB 25.8 MB/s 8.4 MB/s
nlean_5 0.741 1.396 4.3 GB 6.4 GB 23.6 MB/s 20.6 MB/s
nlean_6 0.897 1.260 4.0 GB 6.0 GB 18.4 MB/s 12.3 MB/s
nlean_7 0.836 1.196 5.3 GB 6.6 GB 10.0 MB/s 10.3 MB/s
qlean_0 4.801 6.826 1.3 GB 1.4 GB 347.7 KB/s 877.3 KB/s
qlean_1 1.143 1.611 2.4 GB 2.7 GB 34.2 MB/s 22.3 MB/s
qlean_3 1.239 1.977 1.3 GB 1.7 GB 23.0 MB/s 20.5 MB/s
qlean_4 1.098 1.982 1.9 GB 2.1 GB 17.6 MB/s 12.1 MB/s
qlean_6 1.122 2.173 1.8 GB 2.1 GB 5.7 MB/s 9.4 MB/s
qlean_7 1.079 1.901 1.8 GB 2.1 GB 12.0 MB/s 7.0 MB/s
ream_0 0.521 0.974 11.2 GB 14.5 GB 7.8 MB/s 178.0 KB/s
ream_1 0.800 1.394 7.9 GB 10.5 GB 11.9 MB/s 7.0 MB/s
ream_2 1.024 1.449 10.2 GB 14.5 GB 14.3 MB/s 184.4 KB/s
ream_3 0.765 1.299 11.7 GB 14.5 GB 7.3 MB/s 96.1 KB/s
ream_4 0.202 0.306 5.2 GB 11.3 GB 66.6 KB/s 18.4 KB/s
ream_5 0.784 1.354 10.0 GB 11.3 GB 7.7 MB/s 101.2 KB/s
ream_6 0.552 0.825 9.0 GB 11.1 GB 10.4 MB/s 147.0 KB/s
ream_7 0.851 1.684 7.8 GB 10.7 GB 6.2 MB/s 8.1 MB/s
zeam_0 0.021 0.063 2.4 GB 2.5 GB 139.6 KB/s 75.1 KB/s
zeam_1 0.014 0.016 3.3 GB 3.3 GB 6.5 MB/s 7.4 MB/s
zeam_10 - - 561.4 MB 561.4 MB - -
zeam_13 - - 964.0 KB 964.0 KB - -
zeam_14 - - 452.4 MB 452.4 MB - -
zeam_2 0.005 0.008 2.4 GB 2.6 GB 7.7 MB/s 101.3 KB/s
zeam_3 0.048 0.100 3.4 GB 3.4 GB 9.9 MB/s 139.0 KB/s
zeam_4 0.026 0.071 2.3 GB 2.3 GB 6.2 MB/s 8.1 MB/s
zeam_5 0.006 0.008 4.1 GB 4.4 GB 9.8 MB/s 30.3 MB/s
zeam_6 0.006 0.008 4.1 GB 4.1 GB 12.4 MB/s 39.9 MB/s
zeam_7 0.002 0.003 1.9 GB 1.9 GB 25.4 MB/s 39.8 MB/s
zeam_8 - - 488.1 MB 488.1 MB - -
Show code
print(f"Devnet: {devnet_id}")
if devnet_info:
    print(f"Duration: {devnet_info['duration_hours']:.1f} hours")
print(f"Containers analyzed: {cpu_df['container'].nunique() if not cpu_df.empty else 0}")
Devnet: pqdevnet-20260517T0909Z
Duration: 0.5 hours
Containers analyzed: 51