""" 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}
{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", )