Deevyankar commited on
Commit
a146115
·
verified ·
1 Parent(s): 9472d3b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +54 -286
app.py CHANGED
@@ -1,20 +1,16 @@
1
- # app.py (FINAL FULL REPLACEMENT - no NoneType crashes + openpyxl forced + Grade>=C pass)
2
  import streamlit as st
3
  import pandas as pd
4
  import numpy as np
5
  import plotly.express as px
6
  import io
7
 
8
- st.set_page_config(page_title="Excel → Management Insights (Power BI style)", layout="wide")
9
 
10
  st.title("📊 Excel → Interactive Management Dashboard (Power BI style)")
11
- st.caption(
12
- "Decision rule: **PASS if Grade ≥ C (C, C+, B-, etc.)** and **FAIL if below C (C-, D, F, etc.)**. "
13
- "Pass/Fail uses **Grade only**."
14
- )
15
 
16
  # -----------------------------
17
- # Grade logic (as specified)
18
  # -----------------------------
19
  def grade_pass_fail(g):
20
  if pd.isna(g):
@@ -25,9 +21,7 @@ def grade_pass_fail(g):
25
  return "Fail"
26
 
27
  if g.startswith("C"):
28
- if g == "C-" or g.startswith("C-"):
29
- return "Fail"
30
- return "Pass"
31
 
32
  if g.startswith(("A", "B")):
33
  return "Pass"
@@ -35,35 +29,18 @@ def grade_pass_fail(g):
35
  return "Unknown"
36
 
37
 
38
- def normalize_headers(df: pd.DataFrame) -> pd.DataFrame:
39
  df = df.copy()
40
  df.columns = [str(c).strip() for c in df.columns]
41
  return df
42
 
43
 
44
- def pick_grade_column(df: pd.DataFrame) -> str:
45
  candidates = [c for c in df.columns if "grade" in str(c).lower()]
46
  return candidates[-1] if candidates else df.columns[-1]
47
 
48
 
49
- def coerce_numeric(df: pd.DataFrame, cols):
50
- for c in cols:
51
- if c in df.columns:
52
- df[c] = pd.to_numeric(df[c], errors="coerce")
53
- return df
54
-
55
-
56
- def detect_student_rows(df: pd.DataFrame, grade_col: str) -> pd.DataFrame:
57
- tmp_grade = df[grade_col].astype(str).str.strip()
58
- grade_like = tmp_grade.str.match(r"^[A-Fa-f][\+\-]?$", na=False)
59
-
60
- other_cols = [c for c in df.columns if c != grade_col]
61
- numeric_signal = df[other_cols].apply(pd.to_numeric, errors="coerce").notna().sum(axis=1) > 0
62
-
63
- return df[grade_like | numeric_signal].copy()
64
-
65
-
66
- def ensure_sno(df: pd.DataFrame) -> tuple[pd.DataFrame, str]:
67
  sno_col = None
68
  for c in df.columns:
69
  if str(c).strip().lower() in ["sno", "sno.", "sr", "sr.", "id", "studentid", "student id"]:
@@ -76,329 +53,120 @@ def ensure_sno(df: pd.DataFrame) -> tuple[pd.DataFrame, str]:
76
  return df, sno_col
77
 
78
 
79
- def infer_component_cols(df: pd.DataFrame, grade_col: str, sno_col: str) -> list[str]:
80
- common = [
81
- "Test -1", "Test-1", "Test 1", "Test",
82
- "Mid Exam", "Mid", "Midterm",
83
- "Lab Total", "Lab",
84
- "Final Exam", "Final",
85
- "Total"
86
- ]
87
- component_cols = [c for c in df.columns if c in common and c not in [grade_col, sno_col]]
88
-
89
- if not component_cols:
90
- numeric_cols = []
91
- for c in df.columns:
92
- if c in [grade_col, sno_col]:
93
- continue
94
- s = pd.to_numeric(df[c], errors="coerce")
95
- if s.notna().mean() > 0.4:
96
- numeric_cols.append(c)
97
- component_cols = numeric_cols
98
-
99
- preferred = ["Test -1", "Test-1", "Test 1", "Test", "Mid Exam", "Mid", "Midterm", "Lab Total", "Lab", "Final Exam", "Final", "Total"]
100
- ordered = [c for c in preferred if c in component_cols]
101
- for c in component_cols:
102
- if c not in ordered:
103
- ordered.append(c)
104
- return ordered
105
-
106
-
107
- def add_consistency(df: pd.DataFrame, component_cols: list[str]) -> pd.DataFrame:
108
- df = df.copy()
109
- cols_for_sd = [
110
- c for c in component_cols
111
- if c.lower() != "total" and c in df.columns and pd.api.types.is_numeric_dtype(df[c])
112
- ]
113
- df["Consistency_SD"] = df[cols_for_sd].std(axis=1, skipna=True) if len(cols_for_sd) >= 2 else np.nan
114
- return df
115
-
116
-
117
- def make_fail_reason_hints(df: pd.DataFrame, component_cols: list[str]) -> pd.DataFrame:
118
- df = df.copy()
119
- comps = [
120
- c for c in component_cols
121
- if c.lower() != "total" and c in df.columns and pd.api.types.is_numeric_dtype(df[c])
122
- ]
123
-
124
- if not comps:
125
- df["FailReasonHint"] = np.where(df["PassFail"] == "Fail", "Grade below C.", "")
126
- return df
127
-
128
- q25 = {c: df[c].dropna().quantile(0.25) if df[c].dropna().shape[0] else np.nan for c in comps}
129
-
130
- def reason(row):
131
- if row.get("PassFail") != "Fail":
132
- return ""
133
- hints = []
134
- for c in comps:
135
- v = row.get(c)
136
- if pd.notna(v) and pd.notna(q25[c]) and v < q25[c]:
137
- cl = c.lower()
138
- if "final" in cl:
139
- hints.append("Final exam is in the lower quartile")
140
- elif "lab" in cl:
141
- hints.append("Lab total is in the lower quartile")
142
- elif "mid" in cl:
143
- hints.append("Mid exam is in the lower quartile")
144
- elif "test" in cl:
145
- hints.append("Test score is in the lower quartile")
146
- else:
147
- hints.append(f"{c} is in the lower quartile")
148
- return " | ".join(hints) if hints else "Grade below C (review support plan)."
149
-
150
- df["FailReasonHint"] = df.apply(reason, axis=1)
151
- return df
152
-
153
-
154
- # -----------------------------
155
- # Session state init
156
- # -----------------------------
157
- if "file_bytes" not in st.session_state:
158
- st.session_state["file_bytes"] = None
159
- if "file_name" not in st.session_state:
160
- st.session_state["file_name"] = None
161
- if "sheet_names" not in st.session_state:
162
- st.session_state["sheet_names"] = None
163
-
164
- # Reset button (helps a lot on HF reruns)
165
- topc1, topc2 = st.columns([1, 3])
166
- with topc1:
167
- if st.button("🔄 Reset upload"):
168
- st.session_state["file_bytes"] = None
169
- st.session_state["file_name"] = None
170
- st.session_state["sheet_names"] = None
171
- st.rerun()
172
-
173
- with topc2:
174
- if st.session_state["file_name"]:
175
- st.info(f"Current file loaded: {st.session_state['file_name']}")
176
-
177
  # -----------------------------
178
- # Upload
179
  # -----------------------------
180
- uploaded = st.file_uploader("Upload Excel (.xlsx)", type=["xlsx"], key="uploader")
181
 
182
- # On upload, store bytes
183
- if uploaded is not None:
184
- fb = uploaded.getvalue()
185
- # fb can *rarely* be None/empty on buggy reruns; guard it
186
- if fb:
187
- st.session_state["file_bytes"] = fb
188
- st.session_state["file_name"] = uploaded.name
189
- st.session_state["sheet_names"] = None
190
-
191
- # Re-check bytes RIGHT BEFORE use
192
- file_bytes = st.session_state.get("file_bytes", None)
193
-
194
- if file_bytes is None or not isinstance(file_bytes, (bytes, bytearray)) or len(file_bytes) == 0:
195
  st.info("Upload an Excel file to begin.")
196
  st.stop()
197
 
198
- # Signature check for XLSX (zip => PK)
199
- if len(file_bytes) < 2 or file_bytes[:2] != b"PK":
200
- st.error("This does not look like a valid .xlsx file. Please Save As → Excel Workbook (.xlsx) and upload again.")
 
201
  st.stop()
202
 
203
- # Load sheet names (FORCE openpyxl)
204
- if st.session_state["sheet_names"] is None:
205
- try:
206
- bio = io.BytesIO(file_bytes)
207
- xls_local = pd.ExcelFile(bio, engine="openpyxl")
208
- st.session_state["sheet_names"] = xls_local.sheet_names
209
- except Exception as e:
210
- st.error(f"Could not read Excel file (openpyxl). Error: {e}")
211
- st.stop()
212
 
213
- sheet = st.selectbox("Select sheet", st.session_state["sheet_names"], index=0)
214
 
215
- # Read selected sheet (FORCE openpyxl)
216
  try:
217
  bio = io.BytesIO(file_bytes)
218
  raw = pd.read_excel(bio, sheet_name=sheet, engine="openpyxl")
219
  except Exception as e:
220
- st.error(f"Could not read the selected sheet (openpyxl). Error: {e}")
221
  st.stop()
222
 
223
  raw = normalize_headers(raw)
224
 
225
- # -----------------------------
226
- # Build dataframe
227
- # -----------------------------
228
- grade_col_name = pick_grade_column(raw)
229
-
230
- df = detect_student_rows(raw, grade_col_name)
231
  df, sno_col = ensure_sno(df)
232
 
233
- df["Grade"] = df[grade_col_name].astype(str).str.strip().str.upper()
234
  df["PassFail"] = df["Grade"].apply(grade_pass_fail)
235
  df["Pass"] = df["PassFail"].eq("Pass")
236
  df["Fail"] = df["PassFail"].eq("Fail")
237
- df["At_Risk"] = df["Fail"]
238
-
239
- component_cols = infer_component_cols(df, grade_col_name, sno_col)
240
- df = coerce_numeric(df, component_cols)
241
- df = add_consistency(df, component_cols)
242
- df = make_fail_reason_hints(df, component_cols)
243
 
244
  # -----------------------------
245
- # Sidebar: Views + Filters
246
  # -----------------------------
247
  st.sidebar.header("Perspective")
248
  view = st.sidebar.radio(
249
  "Choose a view",
250
- ["Executive (Management)", "Risk & Intervention", "Assessment Quality", "Student Drill-down", "Export for Power BI"],
251
  index=0
252
  )
253
 
254
  st.sidebar.header("Filters")
255
- pf_choices = ["Pass", "Fail", "Unknown"]
256
- pf = st.sidebar.multiselect("Pass/Fail", pf_choices, default=pf_choices)
257
 
258
- grade_unique = sorted([g for g in df["Grade"].dropna().unique()])
259
- sel_grades = st.sidebar.multiselect("Grades", grade_unique, default=grade_unique)
260
 
261
  filtered = df[df["PassFail"].isin(pf)]
262
  filtered = filtered[filtered["Grade"].isin(sel_grades)]
263
 
264
  # -----------------------------
265
- # KPI Row
266
  # -----------------------------
267
- k1, k2, k3, k4, k5 = st.columns(5)
268
- with k1:
269
  st.metric("Students", int(filtered.shape[0]))
270
- with k2:
271
  st.metric("Pass", int(filtered["Pass"].sum()))
272
- with k3:
273
  st.metric("Fail", int(filtered["Fail"].sum()))
274
- with k4:
275
  pr = (filtered["Pass"].mean() * 100) if filtered.shape[0] else 0
276
  st.metric("Pass Rate", f"{pr:.1f}%")
277
- with k5:
278
- if "Total" in filtered.columns and pd.api.types.is_numeric_dtype(filtered["Total"]):
279
- st.metric("Average Total", f"{filtered['Total'].mean():.2f}")
280
- else:
281
- st.metric("Average Total", "—")
282
 
283
  st.divider()
284
 
285
  # -----------------------------
286
  # Views
287
  # -----------------------------
288
- def executive_view(d: pd.DataFrame):
289
- left, right = st.columns([1, 1])
290
 
291
  with left:
292
  st.subheader("Grade Distribution")
293
- gc = d["Grade"].value_counts(dropna=False).reset_index()
294
  gc.columns = ["Grade", "Count"]
295
  st.plotly_chart(px.bar(gc, x="Grade", y="Count"), use_container_width=True)
296
 
297
  with right:
298
  st.subheader("Pass/Fail Distribution")
299
- pc = d["PassFail"].value_counts(dropna=False).reset_index()
300
  pc.columns = ["Status", "Count"]
301
  st.plotly_chart(px.pie(pc, names="Status", values="Count"), use_container_width=True)
302
 
303
- st.subheader("Hidden Patterns (Quick Signals)")
304
- c1, c2, c3 = st.columns(3)
305
-
306
- lab_candidates = [c for c in component_cols if "lab" in c.lower() and c in d.columns and pd.api.types.is_numeric_dtype(d[c])]
307
- if lab_candidates:
308
- lab_col = lab_candidates[0]
309
- strong_lab_fail = d[(d["Fail"]) & (d[lab_col].notna()) & (d[lab_col] >= d[lab_col].quantile(0.75))]
310
- with c1:
311
- st.metric("Fail with Strong Lab", int(strong_lab_fail.shape[0]))
312
- else:
313
- with c1:
314
- st.metric("Fail with Strong Lab", "—")
315
-
316
- if "Consistency_SD" in d.columns and d["Consistency_SD"].notna().any():
317
- top_incons = d["Consistency_SD"].quantile(0.90)
318
- with c2:
319
- st.metric("High Inconsistency (Top 10%)", int((d["Consistency_SD"] >= top_incons).sum()))
320
- else:
321
- with c2:
322
- st.metric("High Inconsistency (Top 10%)", "—")
323
-
324
- if "Total" in d.columns and pd.api.types.is_numeric_dtype(d["Total"]) and d["Total"].notna().any():
325
- good_total_fail = d[(d["Fail"]) & (d["Total"] >= d["Total"].quantile(0.75))]
326
- with c3:
327
- st.metric("Fail with High Total", int(good_total_fail.shape[0]))
328
- else:
329
- with c3:
330
- st.metric("Fail with High Total", "—")
331
-
332
-
333
- def risk_view(d: pd.DataFrame):
334
  st.subheader("Fail List (Grade below C)")
335
- fails = d[d["Fail"]].copy()
336
-
337
  if fails.empty:
338
  st.success("No failing students in the current filter.")
339
- return
340
-
341
- fails["FailType"] = np.where(fails["Grade"].str.startswith("C-"), "C- (Borderline Fail)", "Below C")
342
- bucket = fails["FailType"].value_counts().reset_index()
343
- bucket.columns = ["Fail Type", "Count"]
344
-
345
- c1, c2 = st.columns([1, 2])
346
- with c1:
347
- st.plotly_chart(px.bar(bucket, x="Fail Type", y="Count"), use_container_width=True)
348
-
349
- with c2:
350
  show_cols = [sno_col, "Grade", "PassFail"]
351
- for c in ["Total"] + component_cols:
352
- if c in fails.columns and c not in show_cols:
353
- show_cols.append(c)
354
- show_cols.append("FailReasonHint")
355
- st.dataframe(fails[show_cols].sort_values(by=["Grade", sno_col]), use_container_width=True, height=420)
356
 
357
-
358
- def assessment_quality_view(d: pd.DataFrame):
359
- st.subheader("Assessment Component Overview")
360
- numeric_comps = [c for c in component_cols if c in d.columns and pd.api.types.is_numeric_dtype(d[c]) and c.lower() != "total"]
361
- if not numeric_comps:
362
- st.warning("No numeric component columns detected for assessment analysis.")
363
- return
364
-
365
- comp = st.selectbox("Choose component", numeric_comps, index=0)
366
- st.plotly_chart(px.histogram(d, x=comp, nbins=20), use_container_width=True)
367
- st.subheader("Component vs Grade (Boxplot)")
368
- st.plotly_chart(px.box(d, x="Grade", y=comp), use_container_width=True)
369
-
370
-
371
- def student_drilldown_view(d: pd.DataFrame):
372
  st.subheader("Student Drill-down")
373
- sid = st.selectbox("Select student (Sno)", sorted(d[sno_col].unique()))
374
- row = d[d[sno_col] == sid].iloc[0]
375
-
376
- c1, c2, c3 = st.columns(3)
377
- with c1:
378
- st.metric("Grade", str(row.get("Grade", "—")))
379
- with c2:
380
- st.metric("Status", str(row.get("PassFail", "—")))
381
- with c3:
382
- st.metric("At Risk", "Yes" if row.get("Fail") else "No")
383
-
384
- hint = row.get("FailReasonHint", "")
385
- if hint:
386
- st.write("**Reason hint:**", hint)
387
-
388
 
389
- def export_view(d: pd.DataFrame):
390
- st.subheader("Export for Power BI")
391
- clean_csv = d.to_csv(index=False).encode("utf-8")
392
- st.download_button("⬇️ Download Cleaned Data (CSV)", clean_csv, file_name="cleaned_marks_with_passfail.csv", mime="text/csv")
393
-
394
-
395
- if view == "Executive (Management)":
396
- executive_view(filtered)
397
- elif view == "Risk & Intervention":
398
- risk_view(filtered)
399
- elif view == "Assessment Quality":
400
- assessment_quality_view(filtered)
401
- elif view == "Student Drill-down":
402
- student_drilldown_view(filtered)
403
  else:
404
- export_view(filtered)
 
 
 
 
1
  import streamlit as st
2
  import pandas as pd
3
  import numpy as np
4
  import plotly.express as px
5
  import io
6
 
7
+ st.set_page_config(page_title="Excel → Management Insights", layout="wide")
8
 
9
  st.title("📊 Excel → Interactive Management Dashboard (Power BI style)")
10
+ st.caption("Rule: **PASS if Grade ≥ C** (C, C+, B-, etc.). **FAIL if below C** (C-, D, F...).")
 
 
 
11
 
12
  # -----------------------------
13
+ # Grade logic
14
  # -----------------------------
15
  def grade_pass_fail(g):
16
  if pd.isna(g):
 
21
  return "Fail"
22
 
23
  if g.startswith("C"):
24
+ return "Fail" if g.startswith("C-") else "Pass"
 
 
25
 
26
  if g.startswith(("A", "B")):
27
  return "Pass"
 
29
  return "Unknown"
30
 
31
 
32
+ def normalize_headers(df):
33
  df = df.copy()
34
  df.columns = [str(c).strip() for c in df.columns]
35
  return df
36
 
37
 
38
+ def pick_grade_column(df):
39
  candidates = [c for c in df.columns if "grade" in str(c).lower()]
40
  return candidates[-1] if candidates else df.columns[-1]
41
 
42
 
43
+ def ensure_sno(df):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  sno_col = None
45
  for c in df.columns:
46
  if str(c).strip().lower() in ["sno", "sno.", "sr", "sr.", "id", "studentid", "student id"]:
 
53
  return df, sno_col
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  # -----------------------------
57
+ # Upload (NO session_state)
58
  # -----------------------------
59
+ uploaded = st.file_uploader("Upload Excel (.xlsx)", type=["xlsx"])
60
 
61
+ if uploaded is None:
 
 
 
 
 
 
 
 
 
 
 
 
62
  st.info("Upload an Excel file to begin.")
63
  st.stop()
64
 
65
+ # Read bytes safely
66
+ file_bytes = uploaded.getvalue()
67
+ if not file_bytes:
68
+ st.warning("Uploaded file is empty. Please re-upload.")
69
  st.stop()
70
 
71
+ # Force openpyxl
72
+ try:
73
+ bio = io.BytesIO(file_bytes)
74
+ xls = pd.ExcelFile(bio, engine="openpyxl")
75
+ except Exception as e:
76
+ st.error(f"Cannot open this Excel file. Make sure it is a real .xlsx. Error: {e}")
77
+ st.stop()
 
 
78
 
79
+ sheet = st.selectbox("Select sheet", xls.sheet_names, index=0)
80
 
 
81
  try:
82
  bio = io.BytesIO(file_bytes)
83
  raw = pd.read_excel(bio, sheet_name=sheet, engine="openpyxl")
84
  except Exception as e:
85
+ st.error(f"Cannot read this sheet. Error: {e}")
86
  st.stop()
87
 
88
  raw = normalize_headers(raw)
89
 
90
+ grade_col = pick_grade_column(raw)
91
+ df = raw.copy()
 
 
 
 
92
  df, sno_col = ensure_sno(df)
93
 
94
+ df["Grade"] = df[grade_col].astype(str).str.strip().str.upper()
95
  df["PassFail"] = df["Grade"].apply(grade_pass_fail)
96
  df["Pass"] = df["PassFail"].eq("Pass")
97
  df["Fail"] = df["PassFail"].eq("Fail")
 
 
 
 
 
 
98
 
99
  # -----------------------------
100
+ # Sidebar filters
101
  # -----------------------------
102
  st.sidebar.header("Perspective")
103
  view = st.sidebar.radio(
104
  "Choose a view",
105
+ ["Executive (Management)", "Risk & Intervention", "Student Drill-down", "Export for Power BI"],
106
  index=0
107
  )
108
 
109
  st.sidebar.header("Filters")
110
+ pf = st.sidebar.multiselect("Pass/Fail", ["Pass", "Fail", "Unknown"], default=["Pass", "Fail", "Unknown"])
 
111
 
112
+ grades = sorted([g for g in df["Grade"].dropna().unique()])
113
+ sel_grades = st.sidebar.multiselect("Grades", grades, default=grades)
114
 
115
  filtered = df[df["PassFail"].isin(pf)]
116
  filtered = filtered[filtered["Grade"].isin(sel_grades)]
117
 
118
  # -----------------------------
119
+ # KPIs
120
  # -----------------------------
121
+ c1, c2, c3, c4 = st.columns(4)
122
+ with c1:
123
  st.metric("Students", int(filtered.shape[0]))
124
+ with c2:
125
  st.metric("Pass", int(filtered["Pass"].sum()))
126
+ with c3:
127
  st.metric("Fail", int(filtered["Fail"].sum()))
128
+ with c4:
129
  pr = (filtered["Pass"].mean() * 100) if filtered.shape[0] else 0
130
  st.metric("Pass Rate", f"{pr:.1f}%")
 
 
 
 
 
131
 
132
  st.divider()
133
 
134
  # -----------------------------
135
  # Views
136
  # -----------------------------
137
+ if view == "Executive (Management)":
138
+ left, right = st.columns(2)
139
 
140
  with left:
141
  st.subheader("Grade Distribution")
142
+ gc = filtered["Grade"].value_counts(dropna=False).reset_index()
143
  gc.columns = ["Grade", "Count"]
144
  st.plotly_chart(px.bar(gc, x="Grade", y="Count"), use_container_width=True)
145
 
146
  with right:
147
  st.subheader("Pass/Fail Distribution")
148
+ pc = filtered["PassFail"].value_counts(dropna=False).reset_index()
149
  pc.columns = ["Status", "Count"]
150
  st.plotly_chart(px.pie(pc, names="Status", values="Count"), use_container_width=True)
151
 
152
+ elif view == "Risk & Intervention":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  st.subheader("Fail List (Grade below C)")
154
+ fails = filtered[filtered["Fail"]].copy()
 
155
  if fails.empty:
156
  st.success("No failing students in the current filter.")
157
+ else:
 
 
 
 
 
 
 
 
 
 
158
  show_cols = [sno_col, "Grade", "PassFail"]
159
+ st.dataframe(fails[show_cols], use_container_width=True, height=450)
 
 
 
 
160
 
161
+ elif view == "Student Drill-down":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  st.subheader("Student Drill-down")
163
+ sid = st.selectbox("Select student", sorted(filtered[sno_col].unique()))
164
+ row = filtered[filtered[sno_col] == sid].iloc[0]
165
+ st.write("**Grade:**", row["Grade"])
166
+ st.write("**Status:**", row["PassFail"])
167
+ st.dataframe(pd.DataFrame(row).T, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  else:
170
+ st.subheader("Export for Power BI")
171
+ out = filtered.to_csv(index=False).encode("utf-8")
172
+ st.download_button("⬇️ Download CSV", out, file_name="cleaned_marks_with_passfail.csv", mime="text/csv")