sunbal7 commited on
Commit
d1402fb
Β·
verified Β·
1 Parent(s): deefe4d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +320 -209
app.py CHANGED
@@ -3,220 +3,331 @@ import pandas as pd
3
  import numpy as np
4
  import plotly.express as px
5
  from sklearn.ensemble import IsolationForest
6
- import io
7
- from fpdf import FPDF
8
- import requests
9
- import PyPDF2
10
- import tempfile
11
- import os
12
-
13
- # -------------------------------
14
- # Page Configuration and Header
15
- # -------------------------------
16
- st.set_page_config(page_title="πŸš€ WiFi Anomaly Detection", layout="wide")
17
- st.title("πŸš€ WiFi Anomaly Detection System")
18
- st.markdown("""
19
- > "Innovation distinguishes between a leader and a follower." – *Steve Jobs*
20
 
21
- > "The future depends on what you do today." – *Mahatma Gandhi*
22
- """)
 
 
 
 
 
 
23
  st.markdown("""
24
- Welcome to the WiFi Anomaly Detection System. This application uses AI to proactively detect abnormal behavior in Public Wi-Fi systems, identifying suspicious spikes that may indicate hacking attempts. Let’s build a more secure network, one anomaly at a time!
25
- """)
26
-
27
- # -------------------------------
28
- # Define Helper Functions
29
- # -------------------------------
30
- def load_data(uploaded_file):
31
- file_type = uploaded_file.name.split('.')[-1].lower()
32
- if file_type == 'csv':
33
- try:
34
- df = pd.read_csv(uploaded_file)
35
- return df, "csv"
36
- except Exception as e:
37
- st.error("Error reading CSV file.")
38
- return None, None
39
- elif file_type == 'txt':
40
- try:
41
- try:
42
- df = pd.read_csv(uploaded_file, sep=",")
43
- except:
44
- df = pd.read_csv(uploaded_file, sep="\s+")
45
- return df, "txt"
46
- except Exception as e:
47
- st.error("Error reading TXT file.")
48
- return None, None
49
- elif file_type == 'pdf':
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  try:
51
- pdf_reader = PyPDF2.PdfReader(uploaded_file)
52
- text = ""
53
- for page in pdf_reader.pages:
54
- text += page.extract_text()
55
- df = pd.DataFrame({"text": [text]})
56
- return df, "pdf"
 
 
 
 
 
 
 
 
 
57
  except Exception as e:
58
- st.error("Error reading PDF file.")
59
- return None, None
60
- else:
61
- st.error("Unsupported file type.")
62
- return None, None
63
-
64
- def run_local_anomaly_detection(df):
65
- # Use IsolationForest for numeric data anomaly detection.
66
- numeric_cols = df.select_dtypes(include=[np.number]).columns
67
- if len(numeric_cols) < 2:
68
- st.warning("Not enough numeric columns for anomaly detection. (Need at least 2 numeric columns)")
69
- return df
70
- X = df[numeric_cols].fillna(0)
71
- model = IsolationForest(contamination=0.1, random_state=42)
72
- model.fit(X)
73
- df['anomaly'] = model.predict(X)
74
- df['anomaly_flag'] = df['anomaly'].apply(lambda x: "🚨 Anomaly" if x == -1 else "βœ… Normal")
75
- return df
76
-
77
- def call_groq_api(df):
78
- # ----- Dummy Groq API integration -----
79
- # Replace this dummy call with an actual Groq API call as needed.
80
- df = run_local_anomaly_detection(df)
81
- return df
82
-
83
- def generate_plots(df):
84
- # Generate 2D and 3D plots from the first numeric columns
85
- numeric_cols = df.select_dtypes(include=[np.number]).columns
86
- fig2d, fig3d = None, None
87
- if len(numeric_cols) >= 2:
88
- fig2d = px.scatter(df, x=numeric_cols[0], y=numeric_cols[1],
89
- color='anomaly_flag',
90
- title="πŸ“ˆ 2D Anomaly Detection Plot")
91
- if len(numeric_cols) >= 3:
92
- fig3d = px.scatter_3d(df, x=numeric_cols[0], y=numeric_cols[1], z=numeric_cols[2],
93
- color='anomaly_flag',
94
- title="πŸ“Š 3D Anomaly Detection Plot")
95
- return fig2d, fig3d
96
-
97
- def generate_pdf_report(summary_text, fig2d, fig3d):
98
- pdf = FPDF()
99
- pdf.add_page()
100
- pdf.set_font("Arial", 'B', 16)
101
- pdf.cell(0, 10, "WiFi Anomaly Detection Report", ln=True)
102
- pdf.ln(10)
103
- pdf.set_font("Arial", size=12)
104
- pdf.multi_cell(0, 10, summary_text)
105
- pdf.ln(10)
106
 
107
- # Save figures as temporary image files using Kaleido (Plotly's image export engine)
108
- image_files = []
109
- if fig2d is not None:
110
- with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmpfile:
111
- fig2d.write_image(tmpfile.name)
112
- image_files.append(tmpfile.name)
113
- if fig3d is not None:
114
- with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmpfile:
115
- fig3d.write_image(tmpfile.name)
116
- image_files.append(tmpfile.name)
 
 
 
 
 
 
117
 
118
- # Add each image to the PDF
119
- for image in image_files:
120
- pdf.image(image, w=pdf.w - 40)
121
- pdf.ln(10)
122
 
123
- # Clean up temporary image files
124
- for image in image_files:
125
- os.remove(image)
 
 
 
 
 
 
 
 
 
 
 
126
 
127
- pdf_data = pdf.output(dest="S").encode("latin1")
128
- return pdf_data
129
-
130
- # -------------------------------
131
- # Initialize Session State Variables
132
- # -------------------------------
133
- if "step" not in st.session_state:
134
- st.session_state.step = "upload"
135
- if "df" not in st.session_state:
136
- st.session_state.df = None
137
- if "df_processed" not in st.session_state:
138
- st.session_state.df_processed = None
139
- if "fig2d" not in st.session_state:
140
- st.session_state.fig2d = None
141
- if "fig3d" not in st.session_state:
142
- st.session_state.fig3d = None
143
- if "summary_text" not in st.session_state:
144
- st.session_state.summary_text = ""
145
-
146
- # -------------------------------
147
- # Sidebar: Step Buttons
148
- # -------------------------------
149
- st.sidebar.title("πŸ”§ Application Steps")
150
- if st.sidebar.button("πŸ“ Upload File"):
151
- st.session_state.step = "upload"
152
- if st.sidebar.button("πŸ“Š Data Visualization"):
153
- st.session_state.step = "viz"
154
- if st.sidebar.button("πŸ“ˆ Statistic Analysis"):
155
- st.session_state.step = "stats"
156
- if st.sidebar.button("⬇️ Download Report"):
157
- st.session_state.step = "download"
158
-
159
- # -------------------------------
160
- # Main Workflow Based on Step
161
- # -------------------------------
162
- if st.session_state.step == "upload":
163
- st.subheader("Step 1: Upload Your Data File")
164
- st.markdown("Please upload a CSV, TXT, or PDF file with network data. The expected columns for CSV/TXT files are:")
165
- st.code("['traffic', 'latency', 'packet_loss']", language="python")
166
- uploaded_file = st.file_uploader("Choose a file", type=["csv", "txt", "pdf"])
167
- if uploaded_file is not None:
168
- df, file_type = load_data(uploaded_file)
169
- if df is not None:
170
- st.session_state.df = df
171
- st.success("File uploaded and processed successfully!")
172
- if file_type == "pdf":
173
- st.subheader("Extracted Text from PDF:")
174
- st.text_area("PDF Content", df["text"][0], height=300)
175
- else:
176
- st.subheader("Data Preview:")
177
- st.dataframe(df.head())
178
- else:
179
- st.info("Awaiting file upload. 😊")
180
-
181
- elif st.session_state.step == "viz":
182
- st.subheader("Step 2: Data Visualization")
183
- if st.session_state.df is None:
184
- st.error("Please upload a file first in the 'Upload File' step.")
185
- else:
186
- # Process the data if not already done
187
- if st.session_state.df_processed is None:
188
- # Here, you can choose between the local model or Groq API; we use the local model for demo.
189
- st.session_state.df_processed = run_local_anomaly_detection(st.session_state.df)
190
- fig2d, fig3d = generate_plots(st.session_state.df_processed)
191
- st.session_state.fig2d = fig2d
192
- st.session_state.fig3d = fig3d
193
- if fig2d:
194
- st.plotly_chart(fig2d, use_container_width=True)
195
- if fig3d:
196
- st.plotly_chart(fig3d, use_container_width=True)
197
-
198
- elif st.session_state.step == "stats":
199
- st.subheader("Step 3: Statistic Analysis")
200
- if st.session_state.df_processed is None:
201
- st.error("Data has not been processed yet. Please complete the Data Visualization step first.")
202
- else:
203
- df_result = st.session_state.df_processed
204
- anomaly_count = (df_result['anomaly'] == -1).sum()
205
- total_count = df_result.shape[0]
206
- st.session_state.summary_text = f"Total records: {total_count}\nDetected anomalies: {anomaly_count}"
207
- st.markdown("**Anomaly Detection Summary:**")
208
- st.text(st.session_state.summary_text)
209
- st.markdown("**Detailed Data:**")
210
- st.dataframe(df_result.head())
211
- st.markdown("**Descriptive Statistics:**")
212
- st.dataframe(df_result.describe())
213
-
214
- elif st.session_state.step == "download":
215
- st.subheader("Step 4: Download PDF Report")
216
- if st.session_state.df_processed is None or (st.session_state.fig2d is None and st.session_state.fig3d is None):
217
- st.error("Please complete the previous steps (Upload, Visualization, Statistic Analysis) before downloading the report.")
218
- else:
219
- pdf_data = generate_pdf_report(st.session_state.summary_text, st.session_state.fig2d, st.session_state.fig3d)
220
- st.download_button("⬇️ Download PDF Report", data=pdf_data,
221
- file_name="wifi_anomaly_report.pdf",
222
- mime="application/pdf")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import numpy as np
4
  import plotly.express as px
5
  from sklearn.ensemble import IsolationForest
6
+ from io import BytesIO
7
+ from reportlab.lib.pagesizes import letter
8
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, PageBreak
9
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
10
+ from reportlab.lib.units import inch
11
+ from reportlab.lib import colors
12
+ import pdfplumber
13
+ import base64
14
+ import random
15
+ import plotly.io as pio
16
+
17
+ # Fix for Kaleido
18
+ pio.kaleido.scope.mathjax = None
 
19
 
20
+ # App Configuration
21
+ st.set_page_config(
22
+ page_title="WiFi Guardian πŸ›‘οΈ",
23
+ page_icon="πŸ“Ά",
24
+ layout="wide"
25
+ )
26
+
27
+ # Custom CSS for a polished interface
28
  st.markdown("""
29
+ <style>
30
+ .st-emotion-cache-1kyxreq {
31
+ display: flex;
32
+ flex-flow: wrap;
33
+ gap: 2rem;
34
+ }
35
+ .reportview-container .main .block-container{
36
+ padding-top: 2rem;
37
+ }
38
+ .sidebar .sidebar-content {
39
+ background: linear-gradient(180deg, #2e3b4e, #1a2639);
40
+ }
41
+ .stButton>button {
42
+ width: 100%;
43
+ margin: 5px 0;
44
+ transition: all 0.3s;
45
+ }
46
+ .stButton>button:hover {
47
+ transform: scale(1.05);
48
+ }
49
+ .summary-box {
50
+ padding: 20px;
51
+ border-radius: 10px;
52
+ background-color: #2e3b4e;
53
+ margin: 10px 0;
54
+ }
55
+ </style>
56
+ """, unsafe_allow_html=True)
57
+
58
+ # Motivational Quotes
59
+ QUOTES = [
60
+ "πŸ›‘οΈ Cybersecurity is not a product, but a process!",
61
+ "πŸ”’ Better safe than hacked!",
62
+ "πŸ“Ά A secure network is a happy network!",
63
+ "πŸ€– AI guards while you sleep!",
64
+ "🚨 Detect before you regret!",
65
+ "πŸ’» Security is always worth the investment!",
66
+ "πŸ” Stay vigilant, stay secure!"
67
+ ]
68
+
69
+ def show_quote():
70
+ st.markdown(f"<h3 style='text-align: center; color: #4CAF50;'>{random.choice(QUOTES)}</h3>",
71
+ unsafe_allow_html=True)
72
+
73
+ # Main App Function
74
+ def main():
75
+ # Initialize session state variables
76
+ if 'current_step' not in st.session_state:
77
+ st.session_state.current_step = 1
78
+ if 'file_uploaded' not in st.session_state:
79
+ st.session_state.file_uploaded = False
80
+ if 'df' not in st.session_state:
81
+ st.session_state.df = None
82
+
83
+ # Sidebar Navigation
84
+ with st.sidebar:
85
+ st.title("πŸ” Navigation")
86
+ st.markdown("---")
87
+
88
+ if st.button("πŸ“€ 1. Upload File", help="Upload your network logs"):
89
+ st.session_state.current_step = 1
90
+ if st.button("πŸ“Š 2. Data Visualization", disabled=not st.session_state.file_uploaded):
91
+ st.session_state.current_step = 2
92
+ if st.button("πŸ“ˆ 3. Statistics Analysis", disabled=not st.session_state.file_uploaded):
93
+ st.session_state.current_step = 3
94
+ if st.button("πŸ“₯ 4. Download Report", disabled=not st.session_state.file_uploaded):
95
+ st.session_state.current_step = 4
96
+
97
+ # Main Content Area
98
+ if st.session_state.current_step == 1:
99
+ upload_file_section()
100
+ elif st.session_state.current_step == 2:
101
+ visualization_section()
102
+ elif st.session_state.current_step == 3:
103
+ statistics_section()
104
+ elif st.session_state.current_step == 4:
105
+ download_section()
106
+
107
+ def upload_file_section():
108
+ st.title("πŸ“€ Upload Network Logs")
109
+ st.markdown("---")
110
+
111
+ if not st.session_state.file_uploaded:
112
+ show_quote()
113
+ st.markdown("""
114
+ ### Welcome to WiFi Guardian! πŸ€–
115
+ **Protect your network with AI-powered anomaly detection**
116
+ 1. Upload network logs πŸ“€
117
+ 2. Visualize patterns πŸ“Š
118
+ 3. Generate reports πŸ“„
119
+ """)
120
+
121
+ uploaded_file = st.file_uploader(
122
+ "Choose network logs (CSV/TXT/PDF)",
123
+ type=["csv", "txt", "pdf"],
124
+ label_visibility="collapsed"
125
+ )
126
+
127
+ if uploaded_file:
128
  try:
129
+ process_file(uploaded_file)
130
+ st.session_state.file_uploaded = True
131
+ st.success("βœ… File processed successfully!")
132
+
133
+ # Show file summary
134
+ st.subheader("πŸ“‹ Upload Summary")
135
+ col1, col2, col3 = st.columns(3)
136
+ with col1:
137
+ st.metric("Total Records", len(st.session_state.df))
138
+ with col2:
139
+ anomalies = sum(st.session_state.df['anomaly'] == -1)
140
+ st.metric("Anomalies Detected", f"{anomalies} ({anomalies/len(st.session_state.df)*100:.1f}%)")
141
+ with col3:
142
+ st.metric("Max Traffic", f"{st.session_state.df['traffic'].max():.2f} Mbps")
143
+
144
  except Exception as e:
145
+ st.error(f"Error processing file: {str(e)}")
146
+
147
+ def visualization_section():
148
+ st.title("πŸ“Š Data Visualization")
149
+ st.markdown("---")
150
+
151
+ # 2D Visualization
152
+ st.subheader("2D Traffic Analysis 🌐")
153
+ # Use 'timestamp' if available; if not, generate a dummy one
154
+ df = st.session_state.df.copy()
155
+ if 'timestamp' not in df.columns:
156
+ df['timestamp'] = pd.date_range(start="2021-01-01", periods=len(df), freq="T")
157
+ fig2d = px.scatter(
158
+ df,
159
+ x='timestamp',
160
+ y='traffic',
161
+ color='anomaly',
162
+ color_discrete_map={-1: 'orange', 1: 'blue'},
163
+ title="2D Traffic Analysis"
164
+ )
165
+ st.plotly_chart(fig2d, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
+ # 3D Visualization
168
+ st.subheader("3D Network Health 🌍")
169
+ fig3d = px.scatter_3d(
170
+ df,
171
+ x='latency',
172
+ y='packet_loss',
173
+ z='traffic',
174
+ color='anomaly',
175
+ color_discrete_map={-1: 'orange', 1: 'blue'},
176
+ title="3D Network Analysis"
177
+ )
178
+ st.plotly_chart(fig3d, use_container_width=True)
179
+
180
+ def statistics_section():
181
+ st.title("πŸ“ˆ Statistical Analysis")
182
+ st.markdown("---")
183
 
184
+ st.subheader("Data Summary πŸ“")
185
+ st.dataframe(st.session_state.df.describe(), use_container_width=True)
 
 
186
 
187
+ st.subheader("Anomaly Distribution πŸ“Š")
188
+ anomaly_counts = st.session_state.df['anomaly'].value_counts()
189
+ fig = px.pie(
190
+ names=['Normal', 'Anomaly'],
191
+ values=[anomaly_counts.get(1, 0), anomaly_counts.get(-1, 0)],
192
+ hole=0.4,
193
+ color_discrete_sequence=['blue', 'orange'],
194
+ title="Anomaly Distribution"
195
+ )
196
+ st.plotly_chart(fig, use_container_width=True)
197
+
198
+ def download_section():
199
+ st.title("πŸ“₯ Download Report")
200
+ st.markdown("---")
201
 
202
+ if st.button("πŸ–¨οΈ Generate Full Report"):
203
+ with st.spinner("Generating PDF report..."):
204
+ generate_pdf_report()
205
+ st.success("Report generated successfully!")
206
+
207
+ if 'pdf_report' in st.session_state:
208
+ st.markdown("---")
209
+ b64 = base64.b64encode(st.session_state.pdf_report).decode()
210
+ href = f'<a href="data:application/octet-stream;base64,{b64}" download="wifi_report.pdf">πŸ“₯ Download Full Report</a>'
211
+ st.markdown(href, unsafe_allow_html=True)
212
+
213
+ def process_file(uploaded_file):
214
+ try:
215
+ # Process CSV files
216
+ if uploaded_file.name.endswith('.csv'):
217
+ df = pd.read_csv(uploaded_file)
218
+ # Process TXT files
219
+ elif uploaded_file.name.endswith('.txt'):
220
+ lines = [line.decode().strip().split(',') for line in uploaded_file.readlines()]
221
+ df = pd.DataFrame(lines[1:], columns=lines[0])
222
+ # Process PDF files using pdfplumber
223
+ elif uploaded_file.name.endswith('.pdf'):
224
+ with pdfplumber.open(uploaded_file) as pdf:
225
+ text = '\n'.join([page.extract_text() for page in pdf.pages])
226
+ lines = [line.split(',') for line in text.split('\n') if line]
227
+ df = pd.DataFrame(lines[1:], columns=lines[0])
228
+ else:
229
+ raise ValueError("Unsupported file type.")
230
+
231
+ # Ensure required numeric columns exist and convert them
232
+ numeric_cols = ['traffic', 'latency', 'packet_loss']
233
+ for col in numeric_cols:
234
+ if col not in df.columns:
235
+ raise ValueError(f"Column '{col}' not found in data.")
236
+ df[col] = pd.to_numeric(df[col], errors='coerce')
237
+
238
+ # Run anomaly detection using IsolationForest with 40% contamination
239
+ clf = IsolationForest(contamination=0.4, random_state=42)
240
+ df['anomaly'] = clf.fit_predict(df[numeric_cols])
241
+
242
+ st.session_state.df = df
243
+
244
+ except Exception as e:
245
+ st.error(f"Error processing file: {str(e)}")
246
+ raise
247
+
248
+ def generate_pdf_report():
249
+ try:
250
+ buffer = BytesIO()
251
+ doc = SimpleDocTemplate(buffer, pagesize=letter)
252
+ styles = getSampleStyleSheet()
253
+ elements = []
254
+
255
+ # Custom Title Style
256
+ title_style = ParagraphStyle(
257
+ name='Title',
258
+ parent=styles['Heading1'],
259
+ fontSize=18,
260
+ textColor=colors.darkblue,
261
+ spaceAfter=14
262
+ )
263
+
264
+ # Add Title
265
+ elements.append(Paragraph("WiFi Network Anomaly Detection", title_style))
266
+ elements.append(Spacer(1, 12))
267
+
268
+ # Add Summary Section
269
+ elements.append(Paragraph("<b>Detection Summary:</b>", styles['Heading2']))
270
+ summary_text = f"""
271
+ β€’ Total Data Points: {len(st.session_state.df)}<br/>
272
+ β€’ Anomalies Detected: {sum(st.session_state.df['anomaly'] == -1)}<br/>
273
+ β€’ Maximum Traffic: {st.session_state.df['traffic'].max():.2f} Mbps<br/>
274
+ β€’ Average Latency: {st.session_state.df['latency'].mean():.2f} ms<br/>
275
+ β€’ Peak Packet Loss: {st.session_state.df['packet_loss'].max():.2f}%<br/>
276
+ """
277
+ elements.append(Paragraph(summary_text, styles['BodyText']))
278
+ elements.append(PageBreak())
279
+
280
+ # Generate and embed plots in memory using BytesIO
281
+
282
+ # 2D Plot
283
+ df = st.session_state.df.copy()
284
+ if 'timestamp' not in df.columns:
285
+ df['timestamp'] = pd.date_range(start="2021-01-01", periods=len(df), freq="T")
286
+ fig2d = px.scatter(df, x='timestamp', y='traffic',
287
+ color='anomaly', title="2D Traffic Analysis",
288
+ color_discrete_map={-1: 'orange', 1: 'blue'})
289
+ img_bytes_2d = fig2d.to_image(format="png", engine="kaleido")
290
+ img2d_io = BytesIO(img_bytes_2d)
291
+
292
+ # 3D Plot
293
+ fig3d = px.scatter_3d(df, x='latency', y='packet_loss',
294
+ z='traffic', color='anomaly', title="3D Network Analysis",
295
+ color_discrete_map={-1: 'orange', 1: 'blue'})
296
+ img_bytes_3d = fig3d.to_image(format="png", engine="kaleido")
297
+ img3d_io = BytesIO(img_bytes_3d)
298
+
299
+ # Add 2D Plot
300
+ elements.append(Paragraph("<b>2D Traffic Analysis</b>", styles['Heading2']))
301
+ elements.append(Image(img2d_io, width=6*inch, height=4*inch))
302
+ elements.append(Spacer(1, 12))
303
+
304
+ # Add 3D Plot
305
+ elements.append(Paragraph("<b>3D Network Analysis</b>", styles['Heading2']))
306
+ elements.append(Image(img3d_io, width=6*inch, height=4*inch))
307
+ elements.append(PageBreak())
308
+
309
+ # Add Statistics Section
310
+ elements.append(Paragraph("<b>Statistical Report</b>", styles['Heading1']))
311
+ stats = st.session_state.df.describe()
312
+ for col in ['traffic', 'latency', 'packet_loss']:
313
+ elements.append(Paragraph(f"<b>{col.capitalize()} Statistics:</b>", styles['Heading3']))
314
+ stats_text = f"""
315
+ β€’ Mean: {stats[col]['mean']:.2f}<br/>
316
+ β€’ Std Dev: {stats[col]['std']:.2f}<br/>
317
+ β€’ Min: {stats[col]['min']:.2f}<br/>
318
+ β€’ 25%: {stats[col]['25%']:.2f}<br/>
319
+ β€’ 50%: {stats[col]['50%']:.2f}<br/>
320
+ β€’ 75%: {stats[col]['75%']:.2f}<br/>
321
+ β€’ Max: {stats[col]['max']:.2f}<br/>
322
+ """
323
+ elements.append(Paragraph(stats_text, styles['BodyText']))
324
+ elements.append(Spacer(1, 12))
325
+
326
+ doc.build(elements)
327
+ st.session_state.pdf_report = buffer.getvalue()
328
+
329
+ except Exception as e:
330
+ st.error(f"Error generating report: {str(e)}")
331
+
332
+ if __name__ == "__main__":
333
+ main()