IDSS / app.py
Zainajabroh's picture
Upload app.py
78ace06 verified
Raw History Blame Contribute Delete
25 kB
"""
IDSS β€” AI Use Case Prioritization untuk UMKM Kuliner
Streamlit app interaktif: manager bisa mengubah stakeholder, bobot, dan matriks
keputusan, lalu melihat hasil SAW, TOPSIS, dan Weighted Product secara real-time.
Jalankan dengan: streamlit run app.py
"""
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
# ======================================================================================
# KONFIGURASI HALAMAN
# ======================================================================================
st.set_page_config(
page_title="IDSS - AI Use Case Prioritization",
page_icon="πŸ“Š",
layout="wide",
)
HIGHLIGHT_COLOR = "#f97316" # oranye β€” dipakai KHUSUS untuk alternatif terbaik keseluruhan
METHOD_COLORS = {"SAW": "#3b82f6", "TOPSIS": "#22c55e", "WP": "#a855f7"}
# ======================================================================================
# DATA DEFAULT (studi kasus UMKM Kuliner DIY β€” sama seperti di notebook)
# ======================================================================================
DEFAULT_CRITERIA = pd.DataFrame(
[
{"kode": "C1", "nama": "Business impact", "arah": "Benefit", "batas_min": None, "batas_maks": None},
{"kode": "C2", "nama": "Data readiness", "arah": "Benefit", "batas_min": None, "batas_maks": None},
{"kode": "C3", "nama": "Ease of adoption", "arah": "Benefit", "batas_min": None, "batas_maks": None},
{"kode": "C4", "nama": "Implementation cost (juta IDR)", "arah": "Cost", "batas_min": None, "batas_maks": 20.0},
{"kode": "C5", "nama": "Implementation time (minggu)", "arah": "Cost", "batas_min": None, "batas_maks": 12.0},
{"kode": "C6", "nama": "Implementation risk", "arah": "Cost", "batas_min": None, "batas_maks": None},
]
)
DEFAULT_ALTERNATIVES = pd.DataFrame(
[
{"kode": "A1", "nama": "WhatsApp customer assistant", "C1": 4.0, "C2": 4.4, "C3": 4.8, "C4": 8, "C5": 5, "C6": 2.8},
{"kode": "A2", "nama": "Demand & inventory forecasting", "C1": 4.8, "C2": 2.8, "C3": 3.1, "C4": 18, "C5": 12, "C6": 3.1},
{"kode": "A3", "nama": "Automated financial recording", "C1": 4.4, "C2": 4.1, "C3": 4.0, "C4": 10, "C5": 6, "C6": 2.2},
{"kode": "A4", "nama": "Marketing content generation", "C1": 3.8, "C2": 4.3, "C3": 4.5, "C4": 5, "C5": 3, "C6": 3.2},
{"kode": "A5", "nama": "AI customer review analysis", "C1": 3.9, "C2": 4.8, "C3": 4.2, "C4": 7, "C5": 4, "C6": 2.6},
]
)
DEFAULT_STAKEHOLDERS = pd.DataFrame(
[
{"jabatan": "Coordinator", "C1": 30, "C2": 20, "C3": 15, "C4": 10, "C5": 10, "C6": 15},
{"jabatan": "MSME Owner 1", "C1": 25, "C2": 15, "C3": 20, "C4": 20, "C5": 10, "C6": 10},
{"jabatan": "MSME Owner 2", "C1": 25, "C2": 20, "C3": 20, "C4": 15, "C5": 10, "C6": 10},
{"jabatan": "Business mentor", "C1": 30, "C2": 15, "C3": 15, "C4": 15, "C5": 10, "C6": 15},
{"jabatan": "Technical team", "C1": 20, "C2": 25, "C3": 10, "C4": 15, "C5": 10, "C6": 20},
]
)
# ======================================================================================
# SESSION STATE
# ======================================================================================
if "criteria" not in st.session_state:
st.session_state.criteria = DEFAULT_CRITERIA.copy()
if "alternatives" not in st.session_state:
st.session_state.alternatives = DEFAULT_ALTERNATIVES.copy()
if "stakeholders" not in st.session_state:
st.session_state.stakeholders = DEFAULT_STAKEHOLDERS.copy()
def sync_criteria_columns():
"""Samakan kolom kriteria di tabel alternatif & stakeholder dengan daftar kriteria terbaru."""
codes = [c for c in st.session_state.criteria["kode"].dropna().tolist() if str(c).strip() != ""]
alt = st.session_state.alternatives.copy()
for c in codes:
if c not in alt.columns:
alt[c] = 0.0
keep = ["kode", "nama"] + codes
alt = alt[[c for c in keep if c in alt.columns]]
st.session_state.alternatives = alt
sh = st.session_state.stakeholders.copy()
for c in codes:
if c not in sh.columns:
sh[c] = 0
keep2 = ["jabatan"] + codes
sh = sh[[c for c in keep2 if c in sh.columns]]
st.session_state.stakeholders = sh
def reset_to_default():
st.session_state.criteria = DEFAULT_CRITERIA.copy()
st.session_state.alternatives = DEFAULT_ALTERNATIVES.copy()
st.session_state.stakeholders = DEFAULT_STAKEHOLDERS.copy()
# ======================================================================================
# FUNGSI INTI: FEASIBILITY GATE, SAW, TOPSIS, WP
# ======================================================================================
def apply_feasibility(alt_df: pd.DataFrame, criteria_df: pd.DataFrame) -> pd.Series:
"""True/False per alternatif berdasarkan batas_min / batas_maks tiap kriteria (opsional)."""
mask = pd.Series(True, index=alt_df.index)
for _, row in criteria_df.iterrows():
code = row["kode"]
if code not in alt_df.columns:
continue
if pd.notna(row.get("batas_min")):
mask &= alt_df[code] >= row["batas_min"]
if pd.notna(row.get("batas_maks")):
mask &= alt_df[code] <= row["batas_maks"]
return mask
def compute_weights(stakeholder_df: pd.DataFrame, codes: list) -> tuple[pd.Series, pd.Series]:
"""
Normalisasi TIAP baris stakeholder ke total 100 dulu (biar tetap adil walau ada yang
salah input), baru dirata-rata antar stakeholder, lalu dinormalisasi jadi bobot total = 1.
Mengembalikan (bobot_final, total_poin_asli_per_stakeholder).
"""
pts = stakeholder_df[codes].astype(float)
row_sums = pts.sum(axis=1)
row_sums_safe = row_sums.replace(0, np.nan)
normalized_rows = pts.div(row_sums_safe, axis=0) * 100
mean_points = normalized_rows.mean(axis=0)
weights = mean_points / mean_points.sum()
return weights, row_sums
def normalize_saw(matrix: pd.DataFrame, direction: dict) -> pd.DataFrame:
norm = pd.DataFrame(index=matrix.index, columns=matrix.columns, dtype=float)
for col in matrix.columns:
if direction[col] == "Benefit":
norm[col] = matrix[col] / matrix[col].max()
else:
norm[col] = matrix[col].min() / matrix[col]
return norm
def compute_saw(matrix: pd.DataFrame, weights: pd.Series, direction: dict):
norm = normalize_saw(matrix, direction)
weighted = norm * weights[matrix.columns]
scores = weighted.sum(axis=1)
return scores, weighted
def compute_topsis(matrix: pd.DataFrame, weights: pd.Series, direction: dict):
denom = np.sqrt((matrix ** 2).sum(axis=0))
r = matrix / denom
v = r * weights[matrix.columns]
a_pos, a_neg = {}, {}
for col in matrix.columns:
if direction[col] == "Benefit":
a_pos[col], a_neg[col] = v[col].max(), v[col].min()
else:
a_pos[col], a_neg[col] = v[col].min(), v[col].max()
a_pos, a_neg = pd.Series(a_pos), pd.Series(a_neg)
s_pos = np.sqrt(((v - a_pos) ** 2).sum(axis=1))
s_neg = np.sqrt(((v - a_neg) ** 2).sum(axis=1))
closeness = s_neg / (s_pos + s_neg)
return closeness, v, a_pos, a_neg, s_pos, s_neg
def compute_wp(matrix: pd.DataFrame, weights: pd.Series, direction: dict):
if (matrix <= 0).any().any():
return None, None, "Ada nilai \u2264 0 di matriks β€” WP tidak bisa dihitung (pemangkatan negatif terhadap 0/negatif tidak terdefinisi)."
signed_w = pd.Series(
{c: weights[c] if direction[c] == "Benefit" else -weights[c] for c in matrix.columns}
)
S = (matrix ** signed_w).prod(axis=1)
V = S / S.sum()
return V, S, None
def ranked_bar(series: pd.Series, names: dict, winner: str, x_title: str, title: str) -> go.Figure:
s = series.sort_values()
colors = [HIGHLIGHT_COLOR if a == winner else "#93c5fd" for a in s.index]
labels = [f"{a} β€” {names.get(a, '')}" for a in s.index]
fig = go.Figure(
go.Bar(
x=s.values, y=labels, orientation="h", marker_color=colors,
text=[f"{v:.4f}" for v in s.values], textposition="outside",
)
)
fig.update_layout(title=title, xaxis_title=x_title, height=90 + 60 * len(s), margin=dict(l=10, r=10, t=60, b=10))
return fig
# ======================================================================================
# SIDEBAR
# ======================================================================================
with st.sidebar:
st.header("βš™οΈ Kontrol")
st.caption(
"Nilai di semua tabel bisa diedit langsung dengan klik sel. Baris kriteria & alternatif "
"sudah baku (tetap); hanya tabel stakeholder yang barisnya bisa ditambah/dihapus."
)
if st.button("πŸ”„ Reset ke data contoh (UMKM Kuliner)", use_container_width=True):
reset_to_default()
st.rerun()
st.divider()
st.caption(
"Dibuat berdasarkan studi kasus *AI Use Case Prioritization for Culinary MSMEs* "
"(UGM). Mendukung 3 metode MCDM: **SAW**, **TOPSIS**, **Weighted Product**."
)
st.title("πŸ“Š Intelligent Decision Support System (IDSS)")
st.caption("AI Use Case Prioritization β€” bandingkan SAW, TOPSIS, dan Weighted Product secara interaktif")
tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs(
["πŸ“‹ Kriteria & Alternatif", "βš–οΈ Bobot Stakeholder", "πŸ“Š SAW", "πŸ“ TOPSIS", "βœ–οΈ Weighted Product", "πŸ† Dashboard Perbandingan"]
)
# ======================================================================================
# TAB 1 β€” KRITERIA & ALTERNATIF
# ======================================================================================
with tab1:
st.subheader("1. Daftar Kriteria")
st.caption(
"Kode, nama, dan arah kriteria sudah baku (tidak bisa diubah). Yang bisa diedit hanya "
"`batas_min` / `batas_maks` β€” opsional, dipakai sebagai *feasibility gate* (alternatif yang "
"melanggar batas akan otomatis dicoret dari perhitungan). Kosongkan kalau tidak ada batas."
)
edited_criteria = st.data_editor(
st.session_state.criteria,
num_rows="fixed",
use_container_width=True,
key="criteria_editor",
column_config={
"kode": st.column_config.TextColumn("Kode", disabled=True),
"nama": st.column_config.TextColumn("Nama Kriteria", disabled=True),
"arah": st.column_config.TextColumn("Arah", disabled=True),
"batas_min": st.column_config.NumberColumn("Batas Min (opsional)", format="%.2f"),
"batas_maks": st.column_config.NumberColumn("Batas Maks (opsional)", format="%.2f"),
},
)
if not edited_criteria.equals(st.session_state.criteria):
st.session_state.criteria = edited_criteria
sync_criteria_columns()
st.rerun()
criteria_df = st.session_state.criteria.dropna(subset=["kode"]).copy()
criteria_df = criteria_df[criteria_df["kode"].astype(str).str.strip() != ""]
if criteria_df["kode"].duplicated().any():
st.error("Ada kode kriteria yang duplikat β€” perbaiki dulu sebelum lanjut.")
st.stop()
if criteria_df.empty:
st.error("Minimal harus ada 1 kriteria.")
st.stop()
criteria_codes = criteria_df["kode"].tolist()
criteria_names = dict(zip(criteria_df["kode"], criteria_df["nama"]))
criteria_direction = dict(zip(criteria_df["kode"], criteria_df["arah"]))
st.subheader("2. Matriks Keputusan (nilai mentah tiap alternatif)")
st.caption("Kode dan nama use case sudah baku (tidak bisa diubah). Yang bisa diedit hanya nilai C1-C6.")
edited_alt = st.data_editor(
st.session_state.alternatives,
num_rows="fixed",
use_container_width=True,
key="alt_editor",
column_config={
"kode": st.column_config.TextColumn("Kode", disabled=True),
"nama": st.column_config.TextColumn("Nama Use Case", disabled=True),
**{c: st.column_config.NumberColumn(f"{c}: {criteria_names.get(c, '')}", format="%.2f") for c in criteria_codes},
},
)
st.session_state.alternatives = edited_alt
alt_df = st.session_state.alternatives.dropna(subset=["kode"]).copy()
alt_df = alt_df[alt_df["kode"].astype(str).str.strip() != ""]
if alt_df["kode"].duplicated().any():
st.error("Ada kode alternatif yang duplikat β€” perbaiki dulu sebelum lanjut.")
st.stop()
if len(alt_df) < 2:
st.error("Minimal harus ada 2 alternatif supaya bisa dibandingkan.")
st.stop()
alt_names = dict(zip(alt_df["kode"], alt_df["nama"]))
matrix_full = alt_df.set_index("kode")[criteria_codes].astype(float)
st.subheader("3. Feasibility Gate")
feasible_mask = apply_feasibility(matrix_full, criteria_df)
gate_view = matrix_full.copy()
gate_view.insert(0, "nama", [alt_names[a] for a in gate_view.index])
gate_view["Status"] = np.where(feasible_mask, "βœ… Lolos", "❌ Tidak lolos")
st.dataframe(gate_view, use_container_width=True)
st.caption(f"{feasible_mask.sum()}/{len(matrix_full)} alternatif lolos gate dan dipakai di perhitungan SAW/TOPSIS/WP.")
matrix = matrix_full.loc[feasible_mask]
if len(matrix) < 2:
st.error("Kurang dari 2 alternatif yang lolos feasibility gate β€” longgarkan batas_min/batas_maks dulu.")
st.stop()
# ======================================================================================
# TAB 2 β€” BOBOT STAKEHOLDER
# ======================================================================================
with tab2:
st.subheader("1. Alokasi 100 Poin per Stakeholder")
st.caption(
"Tambah/hapus baris untuk menambah/mengurangi stakeholder. Masukkan **Jabatan** dan alokasikan "
"poin ke tiap kriteria β€” total per baris **harus 100**."
)
edited_stakeholders = st.data_editor(
st.session_state.stakeholders,
num_rows="dynamic",
use_container_width=True,
key="stakeholder_editor",
column_config={
"jabatan": st.column_config.TextColumn("Jabatan", required=True),
**{c: st.column_config.NumberColumn(f"{c}: {criteria_names.get(c, '')}", min_value=0, max_value=100, step=1) for c in criteria_codes},
},
)
st.session_state.stakeholders = edited_stakeholders
sh_df = st.session_state.stakeholders.dropna(subset=["jabatan"]).copy()
sh_df = sh_df[sh_df["jabatan"].astype(str).str.strip() != ""]
if sh_df.empty:
st.error("Minimal harus ada 1 stakeholder.")
st.stop()
weights, row_sums = compute_weights(sh_df, criteria_codes)
st.subheader("2. Validasi Total Poin")
validasi = pd.DataFrame({"Jabatan": sh_df["jabatan"].values, "Total Poin": row_sums.values})
validasi["Status"] = np.where(np.isclose(validasi["Total Poin"], 100), "βœ… 100", "⚠️ Bukan 100 (akan dinormalisasi otomatis)")
st.dataframe(validasi, use_container_width=True, hide_index=True)
bermasalah = validasi[~np.isclose(validasi["Total Poin"], 100)]
if not bermasalah.empty:
daftar = ", ".join(f"{r.Jabatan} ({r['Total Poin']:.0f})" for r in bermasalah.itertuples())
st.warning(
f"Stakeholder berikut totalnya **bukan 100**: {daftar}. "
"Poinnya tetap dipakai tapi sudah dinormalisasi otomatis secara proporsional supaya "
"perhitungan tidak berhenti β€” sebaiknya tetap diperbaiki di tabel atas untuk akurasi."
)
else:
st.success("Semua stakeholder totalnya sudah tepat 100. βœ…")
st.subheader("3. Bobot Final per Kriteria")
weights_view = pd.DataFrame(
{"Kriteria": [f"{c}: {criteria_names[c]}" for c in criteria_codes], "Bobot": [weights[c] for c in criteria_codes]}
).sort_values("Bobot", ascending=True)
fig_w = go.Figure(
go.Bar(x=weights_view["Bobot"], y=weights_view["Kriteria"], orientation="h", marker_color="#3b82f6",
text=[f"{v:.1%}" for v in weights_view["Bobot"]], textposition="outside")
)
fig_w.update_layout(title="Bobot Akhir per Kriteria", xaxis_title="Bobot (proporsi dari 1.0)",
height=90 + 45 * len(weights_view), margin=dict(l=10, r=10, t=60, b=10))
st.plotly_chart(fig_w, use_container_width=True)
# ======================================================================================
# PERHITUNGAN SENTRAL (dipakai tab 3-6)
# ======================================================================================
saw_scores, saw_weighted = compute_saw(matrix, weights, criteria_direction)
topsis_scores, topsis_v, topsis_apos, topsis_aneg, topsis_spos, topsis_sneg = compute_topsis(matrix, weights, criteria_direction)
wp_scores, wp_S, wp_error = compute_wp(matrix, weights, criteria_direction)
saw_winner = saw_scores.idxmax()
topsis_winner = topsis_scores.idxmax()
wp_winner = wp_scores.idxmax() if wp_scores is not None else None
# ======================================================================================
# TAB 3 β€” SAW
# ======================================================================================
with tab3:
st.subheader("Hasil SAW (Simple Additive Weighting)")
st.latex(r"V_i = \sum_j w_j \cdot r_{ij}")
saw_table = pd.DataFrame({"nama": [alt_names[a] for a in saw_scores.index], "Skor SAW": saw_scores})
saw_table = saw_table.sort_values("Skor SAW", ascending=False)
st.dataframe(saw_table, use_container_width=True)
st.success(f"πŸ† Pemenang SAW: **{saw_winner} β€” {alt_names[saw_winner]}** (skor {saw_scores[saw_winner]:.4f})")
st.plotly_chart(ranked_bar(saw_scores, alt_names, saw_winner, "Skor SAW", "Ranking SAW"), use_container_width=True)
st.markdown("**Dekomposisi skor per kriteria**")
decomp = saw_weighted.copy()
decomp.index = [f"{a}: {alt_names[a]}" for a in decomp.index]
decomp_long = decomp.reset_index().melt(id_vars="index", var_name="Kriteria", value_name="Kontribusi")
fig_decomp = px.bar(decomp_long, x="Kontribusi", y="index", color="Kriteria", orientation="h",
title="Kontribusi Tiap Kriteria terhadap Skor SAW")
fig_decomp.update_layout(yaxis_title="", height=90 + 45 * len(decomp))
st.plotly_chart(fig_decomp, use_container_width=True)
# ======================================================================================
# TAB 4 β€” TOPSIS
# ======================================================================================
with tab4:
st.subheader("Hasil TOPSIS")
st.latex(r"C_i^{*} = \frac{S_i'}{S_i^{*} + S_i'}")
topsis_table = pd.DataFrame(
{
"nama": [alt_names[a] for a in topsis_scores.index],
"Skor TOPSIS (C*)": topsis_scores,
"Jarak ke ideal positif (S*)": topsis_spos,
"Jarak ke ideal negatif (S')": topsis_sneg,
}
).sort_values("Skor TOPSIS (C*)", ascending=False)
st.dataframe(topsis_table, use_container_width=True)
st.success(f"πŸ† Pemenang TOPSIS: **{topsis_winner} β€” {alt_names[topsis_winner]}** (skor {topsis_scores[topsis_winner]:.4f})")
st.plotly_chart(ranked_bar(topsis_scores, alt_names, topsis_winner, "Skor TOPSIS (C*)", "Ranking TOPSIS"), use_container_width=True)
with st.expander("Lihat solusi ideal positif (A*) & negatif (A')"):
st.dataframe(pd.DataFrame({"A* (ideal positif)": topsis_apos, "A' (ideal negatif)": topsis_aneg}), use_container_width=True)
# ======================================================================================
# TAB 5 β€” WEIGHTED PRODUCT
# ======================================================================================
with tab5:
st.subheader("Hasil Weighted Product (WP)")
st.latex(r"S_i = \prod_{j=1}^{n} x_{ij}^{\,w_j} \qquad V_i = \frac{S_i}{\sum_i S_i}")
if wp_error:
st.error(wp_error)
else:
wp_table = pd.DataFrame({"nama": [alt_names[a] for a in wp_scores.index], "Skor WP (V)": wp_scores, "S_i": wp_S})
wp_table = wp_table.sort_values("Skor WP (V)", ascending=False)
st.dataframe(wp_table, use_container_width=True)
st.success(f"πŸ† Pemenang WP: **{wp_winner} β€” {alt_names[wp_winner]}** (skor {wp_scores[wp_winner]:.4f})")
st.plotly_chart(ranked_bar(wp_scores, alt_names, wp_winner, "Skor WP (V)", "Ranking Weighted Product"), use_container_width=True)
# ======================================================================================
# TAB 6 β€” DASHBOARD PERBANDINGAN
# ======================================================================================
with tab6:
st.subheader("Dashboard Perbandingan SAW vs TOPSIS vs WP")
st.caption(
"Skor ketiga metode sama-sama berada di rentang 0-1, tapi maknanya beda "
"(agregat bobotΓ—nilai vs kedekatan ke solusi ideal) β€” yang paling valid dibandingkan "
"lintas metode adalah **urutan rangking-nya**, bukan besaran angka mentahnya."
)
comp = pd.DataFrame({"SAW": saw_scores, "TOPSIS": topsis_scores})
if wp_scores is not None:
comp["WP"] = wp_scores
comp.insert(0, "nama", [alt_names[a] for a in comp.index])
method_cols = [c for c in ["SAW", "TOPSIS", "WP"] if c in comp.columns]
rank_table = comp[method_cols].rank(ascending=False).astype(int)
rank_table.columns = [f"Rank_{c}" for c in rank_table.columns]
comp_full = pd.concat([comp, rank_table], axis=1)
avg_rank = rank_table.mean(axis=1).sort_values()
overall_best = avg_rank.index[0]
winners = {"SAW": saw_winner, "TOPSIS": topsis_winner}
if wp_winner is not None:
winners["WP"] = wp_winner
n_won = sum(1 for w in winners.values() if w == overall_best)
st.dataframe(comp_full.sort_values("SAW", ascending=False), use_container_width=True)
c1, c2 = st.columns([2, 1])
with c1:
st.info(
f"**Pemenang per metode:** " + " | ".join(f"{m}: {w} ({alt_names[w]})" for m, w in winners.items())
)
with c2:
st.success(
f"πŸ† **Terbaik keseluruhan:** {overall_best} β€” {alt_names[overall_best]}\n\n"
f"Menang di {n_won}/{len(method_cols)} metode langsung, rata-rata rank = {avg_rank[overall_best]:.2f}"
)
# --- Grouped bar chart interaktif: skor per metode, highlight alternatif terbaik ---
fig = go.Figure()
for m in method_cols:
colors = [HIGHLIGHT_COLOR if a == overall_best else METHOD_COLORS[m] for a in comp.index]
fig.add_trace(
go.Bar(
name=m, x=[f"{a}<br>{alt_names[a]}" for a in comp.index], y=comp[m],
marker_color=colors, text=[f"{v:.3f}" for v in comp[m]], textposition="outside",
)
)
fig.update_layout(
barmode="group", title="Skor SAW vs TOPSIS vs WP per Alternatif (oranye = terbaik keseluruhan)",
yaxis_title="Skor (0-1)", height=500, legend_title="Metode",
)
st.plotly_chart(fig, use_container_width=True)
# --- Rata-rata rank ---
fig_rank = go.Figure(
go.Bar(
x=avg_rank.sort_values(ascending=False).values,
y=[f"{a}: {alt_names[a]}" for a in avg_rank.sort_values(ascending=False).index],
orientation="h",
marker_color=[HIGHLIGHT_COLOR if a == overall_best else "#94a3b8" for a in avg_rank.sort_values(ascending=False).index],
text=[f"{v:.2f}" for v in avg_rank.sort_values(ascending=False).values],
textposition="outside",
)
)
fig_rank.update_layout(title="Rata-rata Rank Lintas 3 Metode (1 = terbaik)", xaxis_title="Rata-rata rank",
height=90 + 45 * len(avg_rank), xaxis_autorange="reversed")
st.plotly_chart(fig_rank, use_container_width=True)
# --- Radar chart: profil kriteria ternormalisasi tiap alternatif ---
st.markdown("**Profil kriteria (nilai ternormalisasi SAW) β€” bonus untuk lihat kekuatan/kelemahan tiap alternatif**")
norm_saw = normalize_saw(matrix, criteria_direction)
fig_radar = go.Figure()
for a in norm_saw.index:
fig_radar.add_trace(
go.Scatterpolar(
r=norm_saw.loc[a, criteria_codes].tolist() + [norm_saw.loc[a, criteria_codes[0]]],
theta=criteria_codes + [criteria_codes[0]],
fill="toself",
name=f"{a}: {alt_names[a]}",
line_color=HIGHLIGHT_COLOR if a == overall_best else None,
opacity=1.0 if a == overall_best else 0.55,
)
)
fig_radar.update_layout(polar=dict(radialaxis=dict(visible=True, range=[0, 1])), height=500,
title="Radar Perbandingan Kriteria Antar Alternatif")
st.plotly_chart(fig_radar, use_container_width=True)
st.download_button(
"⬇️ Unduh hasil perbandingan (CSV)",
comp_full.to_csv(index=True).encode("utf-8"),
file_name="perbandingan_saw_topsis_wp.csv",
mime="text/csv",
)