"""
step3_analytics.py — Long-term Time-series Infrastructure Data Visualization Engine (Partitioned & Safe Version)
"""
import os
import re
import argparse
import pandas as pd
import numpy as np
from pathlib import Path
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import seaborn as sns
BASE_DATA_DIR = Path("./data")
MERGED_DIR = BASE_DATA_DIR / "merged"
BASE_PLOT_DIR = BASE_DATA_DIR / "output" / "plots"
BASE_PLOT_DIR.mkdir(parents=True, exist_ok=True)
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['axes.unicode_minus'] = False
sns.set_theme(style="whitegrid")
def check_and_handle_empty(df, output_path, chart_name):
"""
데이터가 비어있으면 True를 반환하고, 6단계 엑셀 빌더가 터지지 않도록
'No Data Available' 문구가 적힌 플레이스홀더 이미지를 강제 생성합니다.
"""
if df is None or df.empty:
print(f" ⚠️ [Empty Data] Generating placeholder for {chart_name} (Dataset is empty).")
fig, ax = plt.subplots(figsize=(10, 5))
ax.text(0.5, 0.5, f"No Data Available\n({chart_name})",
ha='center', va='center', fontsize=14, color='gray', weight='bold')
ax.axis('off')
plt.tight_layout()
plt.savefig(output_path, dpi=100, bbox_inches='tight')
plt.close()
return True
return False
def main():
parser = argparse.ArgumentParser(description="FinOps Analytics Visualization Engine")
parser.add_argument("--cluster", type=str, default="ALL", choices=["COMPUTE", "STORAGE", "ALL"])
args = parser.parse_args()
print(f"🚀 [Step3] Starting FinOps Time-series English Visualization Engine for Cluster: {args.cluster}...")
target_clusters = ["COMPUTE", "STORAGE"] if args.cluster.upper() == "ALL" else [args.cluster.upper()]
all_pod_dfs = []
all_ns_dfs = []
for cl in target_clusters:
cl_dir = MERGED_DIR / cl
if cl_dir.exists():
all_pod_dfs.extend([pd.read_parquet(f) for f in cl_dir.glob("daily_enriched_*.parquet")])
all_ns_dfs.extend([pd.read_parquet(f) for f in cl_dir.glob("pareto_ns_*.parquet")])
if not all_pod_dfs:
print(f"❌ Error: No processed daily Parquet files found for cluster '{args.cluster}'. Please run step2 first.")
return
df_pod = pd.concat(all_pod_dfs, ignore_index=True)
df_ns = pd.concat(all_ns_dfs, ignore_index=True) if all_ns_dfs else pd.DataFrame()
cluster_type_str = args.cluster.upper() if args.cluster.upper() != "ALL" else "INTEGRATED"
PLOT_DIR = BASE_PLOT_DIR / cluster_type_str
PLOT_DIR.mkdir(parents=True, exist_ok=True)
print(f"✅ Data lake aggregated successfully -> Container rows: {len(df_pod):,}")
df_pod["date"] = df_pod["date"].astype(str)
df_pod["cpu_util"] = np.where(df_pod["cpu_request_max"] > 0, (df_pod["cpu_usage_p95"] / df_pod["cpu_request_max"] * 100), 0)
df_pod["mem_util"] = np.where(df_pod["mem_request_max"] > 0, (df_pod["mem_usage_p95"] / df_pod["mem_request_max"] * 100), 0)
df_pod["lim_req_ratio"] = np.where(df_pod["cpu_request_max"] > 0, df_pod["cpu_limit_max"] / df_pod["cpu_request_max"], 0)
print("⏳ [1/19] Rendering chart1_cpu_req_vs_usage_by_workload...")
out1 = PLOT_DIR / "chart1_cpu_req_vs_usage_by_workload.png"
if not check_and_handle_empty(df_pod, out1, "chart1_cpu_req_vs_usage_by_workload"):
df_wl_cpu = df_pod.groupby("workload_type")[["cpu_request_max", "cpu_usage_p95"]].mean().reset_index()
plt.figure(figsize=(10, 5))
df_melt_cpu = df_wl_cpu.melt(id_vars="workload_type", value_vars=["cpu_request_max", "cpu_usage_p95"])
sns.barplot(data=df_melt_cpu, x="workload_type", y="value", hue="variable", palette="Blues_r")
plt.xticks(rotation=30, ha='right')
plt.title("Average CPU Request vs P95 Peak Usage by Workload Type")
plt.xlabel("Workload Type")
plt.ylabel("CPU Cores")
plt.tight_layout()
plt.savefig(out1, dpi=100)
plt.close()
print("⏳ [2/19] Rendering chart2_mem_req_vs_usage_by_workload...")
out2 = PLOT_DIR / "chart2_mem_req_vs_usage_by_workload.png"
if not check_and_handle_empty(df_pod, out2, "chart2_mem_req_vs_usage_by_workload"):
df_wl_mem = df_pod.groupby("workload_type")[["mem_request_max", "mem_usage_p95"]].mean().reset_index()
plt.figure(figsize=(10, 5))
df_melt_mem = df_wl_mem.melt(id_vars="workload_type", value_vars=["mem_request_max", "mem_usage_p95"])
sns.barplot(data=df_melt_mem, x="workload_type", y="value", hue="variable", palette="Purples_r")
plt.xticks(rotation=30, ha='right')
plt.title("Average Memory Request vs P95 Peak Usage (GB) by Workload Type")
plt.xlabel("Workload Type")
plt.ylabel("Memory Capacity (GB)")
plt.tight_layout()
plt.savefig(out2, dpi=100)
plt.close()
print("⏳ [3/19] Rendering chart3_daily_waste_stack...")
out3 = PLOT_DIR / "chart3_daily_waste_stack.png"
df_daily_waste = df_pod.groupby(["date", "workload_type"])["cpu_waste_core_hours"].sum().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_daily_waste, out3, "chart3_daily_waste_stack"):
df_daily_waste.plot(kind='bar', stacked=True, figsize=(11, 5), cmap="tab20")
plt.title("Daily Total CPU Waste Core-Hours Stacked by Workload (KST)")
plt.xlabel("Date (KST)")
plt.ylabel("Waste Volume (Core-Hours)")
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig(out3, dpi=100)
plt.close()
print("⏳ [4/19] Rendering chart4_cpu_efficiency_heatmap...")
out4 = PLOT_DIR / "chart4_cpu_efficiency_heatmap.png"
df_heat_cpu = df_pod.groupby(["workload_type", "date"])["cpu_util"].mean().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_heat_cpu, out4, "chart4_cpu_efficiency_heatmap"):
plt.figure(figsize=(10, 5))
sns.heatmap(df_heat_cpu, annot=True, fmt=".1f", cmap="RdYlGn", cbar=True)
plt.title("Mean CPU Utilization Heatmap (%) (Workload Type x Date)")
plt.xlabel("Date (KST)")
plt.ylabel("Workload Type")
plt.tight_layout()
plt.savefig(out4, dpi=100)
plt.close()
print("⏳ [5/19] Rendering chart18_mem_waste_heatmap...")
out18 = PLOT_DIR / "chart18_mem_waste_heatmap.png"
df_heat_mem = df_pod.groupby(["workload_type", "date"])["mem_waste_gb_hours"].sum().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_heat_mem, out18, "chart18_mem_waste_heatmap"):
plt.figure(figsize=(10, 5))
sns.heatmap(df_heat_mem, annot=False, cmap="BuPu", cbar=True)
plt.title("Total Memory Waste Volume Heatmap (GB-Hours)")
plt.xlabel("Date (KST)")
plt.ylabel("Workload Type")
plt.tight_layout()
plt.savefig(out18, dpi=100)
plt.close()
print("⏳ [6/19] Rendering chart5_pareto_ns_waste...")
out5 = PLOT_DIR / "chart5_pareto_ns_waste.png"
if not check_and_handle_empty(df_ns, out5, "chart5_pareto_ns_waste"):
df_ns_top = df_ns.groupby("namespace")["total_waste_core_hours"].sum().reset_index().sort_values("total_waste_core_hours", ascending=False).head(15)
df_ns_top["waste_share_pct"] = (df_ns_top["total_waste_core_hours"] / df_ns_top["total_waste_core_hours"].sum() * 100).round(2)
df_ns_top["waste_cumsum_pct"] = df_ns_top["waste_share_pct"].cumsum().round(2)
fig, ax1 = plt.subplots(figsize=(11, 5))
sns.barplot(data=df_ns_top, x="namespace", y="total_waste_core_hours", ax=ax1, color="steelblue")
ax1.set_xticklabels(ax1.get_xticklabels(), rotation=45, ha="right")
ax2 = ax1.twinx()
ax2.plot(df_ns_top["namespace"], df_ns_top["waste_cumsum_pct"], color="crimson", marker="o", linewidth=2)
ax2.set_ylim(0, 110)
plt.title("Top 15 Namespace Cost-Waste Pareto Chart (Cumulative Share %)")
ax1.set_xlabel("Tenant Namespace")
ax1.set_ylabel("Waste Volume (Core-Hours)")
plt.tight_layout()
plt.savefig(out5, dpi=100)
plt.close()
print("⏳ [7/19] Rendering chart11_pareto_workload_waste...")
out11 = PLOT_DIR / "chart11_pareto_workload_waste.png"
if not check_and_handle_empty(df_pod, out11, "chart11_pareto_workload_waste"):
df_wl_waste = df_pod.groupby("workload_type")["cpu_waste_core_hours"].sum().reset_index().sort_values("cpu_waste_core_hours", ascending=False)
plt.figure(figsize=(10, 5))
sns.barplot(data=df_wl_waste, x="workload_type", y="cpu_waste_core_hours", palette="Oranges_r")
plt.xticks(rotation=30, ha="right")
plt.title("Total CPU Waste Volume by Workload Type")
plt.xlabel("Workload Type")
plt.ylabel("Total Waste (Core-Hours)")
plt.tight_layout()
plt.savefig(out11, dpi=100)
plt.close()
print("⏳ [8/19] Rendering chart6_status_donut...")
out6 = PLOT_DIR / "chart6_status_donut.png"
if not check_and_handle_empty(df_pod, out6, "chart6_status_donut"):
status_summary = df_pod["status"].value_counts()
plt.figure(figsize=(6, 5))
colors = ["#70AD47", "#1F4E79", "#FFC000", "#C00000"]
plt.pie(status_summary, labels=status_summary.index, autopct='%1.1f%%', startangle=90, colors=colors[:len(status_summary)], wedgeprops=dict(width=0.4, edgecolor='w'))
plt.title("Infrastructure Resource Governance Status Ratio")
plt.tight_layout()
plt.savefig(out6, dpi=100)
plt.close()
print("⏳ [9/19] Rendering chart7_waste_footprint_bubble...")
out7 = PLOT_DIR / "chart7_waste_footprint_bubble.png"
if not check_and_handle_empty(df_pod, out7, "chart7_waste_footprint_bubble"):
df_bubble = df_pod.groupby("workload_type").agg(
x_alloc=("cpu_allocated_core_hours", "sum"),
y_util=("cpu_util", "mean"),
z_waste=("cpu_waste_core_hours", "sum")
).reset_index()
plt.figure(figsize=(9, 6))
sns.scatterplot(data=df_bubble, x="x_alloc", y="y_util", size="z_waste", hue="workload_type", sizes=(100, 2000), alpha=0.7, legend="brief")
plt.title("Resource Footprint Bubble Chart (Allocated x Utilization x Waste Size)")
plt.xlabel("Total Allocated Core-Hours")
plt.ylabel("Average Utilization (%)")
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
plt.tight_layout()
plt.savefig(out7, dpi=100)
plt.close()
print("⏳ [10/19] Rendering chart14_cpu_mem_waste_scatter...")
out14 = PLOT_DIR / "chart14_cpu_mem_waste_scatter.png"
if not check_and_handle_empty(df_pod, out14, "chart14_cpu_mem_waste_scatter"):
plt.figure(figsize=(8, 6))
sns.scatterplot(data=df_pod.head(5000), x="cpu_waste_core_hours", y="mem_waste_gb_hours", hue="workload_type", alpha=0.5)
plt.title("Co-relation Scatter Plot: CPU Waste vs Memory Waste (Sampled)")
plt.xlabel("CPU Waste (Core-Hours)")
plt.ylabel("Memory Waste (GB-Hours)")
plt.tight_layout()
plt.savefig(out14, dpi=100)
plt.close()
print("⏳ [11/19] Rendering chart8_shortfall_footprint...")
out8 = PLOT_DIR / "chart8_shortfall_footprint.png"
df_short = df_pod.groupby(["workload_type", "date"])["cpu_shortage_cores"].sum().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_short, out8, "chart8_shortfall_footprint"):
plt.figure(figsize=(10, 4))
sns.heatmap(df_short, annot=False, cmap="YlOrRd", cbar=True)
plt.title("Total CPU Shortfall (Deficit) Cores Footprint")
plt.xlabel("Date (KST)")
plt.ylabel("Workload Type")
plt.tight_layout()
plt.savefig(out8, dpi=100)
plt.close()
print("⏳ [12/19] Rendering chart9_boxplot_cpu_util_by_workload...")
out9 = PLOT_DIR / "chart9_boxplot_cpu_util_by_workload.png"
if not check_and_handle_empty(df_pod, out9, "chart9_boxplot_cpu_util_by_workload"):
plt.figure(figsize=(11, 5))
sns.boxplot(data=df_pod, x="workload_type", y="cpu_util", palette="Set3")
plt.xticks(rotation=30, ha="right")
plt.ylim(-5, 105)
plt.title("CPU Utilization P95 Boxplot Distribution by Workload Type")
plt.xlabel("Workload Type")
plt.ylabel("P95 Actual Utilization (%)")
plt.tight_layout()
plt.savefig(out9, dpi=100)
plt.close()
print("⏳ [13/19] Rendering chart10_boxplot_mem_util_by_workload...")
out10 = PLOT_DIR / "chart10_boxplot_mem_util_by_workload.png"
if not check_and_handle_empty(df_pod, out10, "chart10_boxplot_mem_util_by_workload"):
plt.figure(figsize=(11, 5))
sns.boxplot(data=df_pod, x="workload_type", y="mem_util", palette="Pastel1")
plt.xticks(rotation=30, ha="right")
plt.ylim(-5, 105)
plt.title("Memory Utilization P95 Boxplot Distribution by Workload Type")
plt.xlabel("Workload Type")
plt.ylabel("P95 Actual Utilization (%)")
plt.tight_layout()
plt.savefig(out10, dpi=100)
plt.close()
print("⏳ [14/19] Rendering chart12_daily_waste_trend_by_workload...")
out12 = PLOT_DIR / "chart12_daily_waste_trend_by_workload.png"
df_trend_wl = df_pod.groupby(["date", "workload_type"])["cpu_waste_core_hours"].sum().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_trend_wl, out12, "chart12_daily_waste_trend_by_workload"):
plt.figure(figsize=(11, 5))
sns.lineplot(data=df_trend_wl, markers=True, dashes=False, linewidth=2)
plt.title("Daily CPU Waste Timeline Trend Line by Workload Type")
plt.xlabel("Date (KST)")
plt.ylabel("Waste Volume (Core-Hours)")
plt.xticks(rotation=30)
plt.tight_layout()
plt.savefig(out12, dpi=100)
plt.close()
print("⏳ [15/19] Rendering chart15_oom_status_by_workload...")
out15 = PLOT_DIR / "chart15_oom_status_by_workload.png"
if not check_and_handle_empty(df_pod, out15, "chart15_oom_status_by_workload"):
plt.figure(figsize=(11, 5))
sns.countplot(data=df_pod, x="workload_type", hue="status", palette="muted")
plt.xticks(rotation=30, ha="right")
plt.title("Governance Status Distribution Count per Workload Type")
plt.xlabel("Workload Type")
plt.ylabel("Pod Count")
plt.legend(loc="upper right")
plt.tight_layout()
plt.savefig(out15, dpi=100)
plt.close()
print("⏳ [16/19] Rendering chart13_violin_cpu_util...")
out13 = PLOT_DIR / "chart13_violin_cpu_util.png"
if not check_and_handle_empty(df_pod, out13, "chart13_violin_cpu_util"):
plt.figure(figsize=(10, 5))
sns.violinplot(data=df_pod, x="workload_type", y="cpu_util", inner="quartile", palette="pastel")
plt.xticks(rotation=30, ha="right")
plt.title("CPU Utilization Kernel Density Violin Plot")
plt.xlabel("Workload Type")
plt.ylabel("CPU Utilization (%)")
plt.tight_layout()
plt.savefig(out13, dpi=100)
plt.close()
print("⏳ [17/19] Rendering chart17_cpu_limit_request_ratio...")
out17 = PLOT_DIR / "chart17_cpu_limit_request_ratio.png"
if not check_and_handle_empty(df_pod, out17, "chart17_cpu_limit_request_ratio"):
plt.figure(figsize=(10, 5))
sns.boxplot(data=df_pod, x="workload_type", y="lim_req_ratio", palette="vlag")
plt.xticks(rotation=30, ha="right")
plt.title("Kubernetes Pod CPU Limit / Request Overcommit Ratio")
plt.xlabel("Workload Type")
plt.ylabel("Limit / Request Ratio")
plt.tight_layout()
plt.savefig(out17, dpi=100)
plt.close()
print("⏳ [18/19] Rendering chart19_daily_cpu_per_workload...")
out19 = PLOT_DIR / "chart19_daily_cpu_per_workload.png"
if not check_and_handle_empty(df_pod, out19, "chart19_daily_cpu_per_workload"):
df_daily_cpu_req = df_pod.groupby("date")["cpu_request_max"].sum()
df_daily_cpu_use = df_pod.groupby("date")["cpu_usage_p95"].sum()
plt.figure(figsize=(11, 5))
plt.fill_between(df_daily_cpu_req.index, df_daily_cpu_req.values, label="Total CPU Request Cores", color="skyblue", alpha=0.4)
plt.plot(df_daily_cpu_use.index, df_daily_cpu_use.values, label="Total CPU P95 Actual Cores", color="navy", linewidth=2.5, marker="o")
plt.title("Daily Total CPU Capacity Allocation vs Actual Peak Consumption (KST)")
plt.xlabel("Date (KST)")
plt.ylabel("Total CPU Cores")
plt.xticks(rotation=30)
plt.legend(loc="upper left")
plt.tight_layout()
plt.savefig(out19, dpi=100)
plt.close()
print("⏳ [19/19] Rendering chart20_daily_mem_per_workload...")
out20 = PLOT_DIR / "chart20_daily_mem_per_workload.png"
if not check_and_handle_empty(df_pod, out20, "chart20_daily_mem_per_workload"):
df_daily_mem_req = df_pod.groupby("date")["mem_request_max"].sum()
df_daily_mem_use = df_pod.groupby("date")["mem_usage_p95"].sum()
plt.figure(figsize=(11, 5))
plt.fill_between(df_daily_mem_req.index, df_daily_mem_req.values, label="Total Memory Request (GB)", color="plum", alpha=0.4)
plt.plot(df_daily_mem_use.index, df_daily_mem_use.values, label="Total Memory P95 Actual (GB)", color="purple", linewidth=2.5, marker="o")
plt.title("Daily Total Memory Capacity Allocation vs Actual Peak Consumption (KST)")
plt.xlabel("Date (KST)")
plt.ylabel("Total Memory (GB)")
plt.xticks(rotation=30)
plt.legend(loc="upper left")
plt.tight_layout()
plt.savefig(out20, dpi=100)
plt.close()
print(f"\n🏁 === [Step3 Success] All charts generated cleanly under ./data/output/plots/{cluster_type_str} ===")
if __name__ == "__main__":
main()