k96beni commited on
Commit
a023bfa
·
verified ·
1 Parent(s): bb29e5f

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +1324 -296
src/streamlit_app.py CHANGED
@@ -6,20 +6,16 @@ from plotly.subplots import make_subplots
6
  import numpy as np
7
  import tempfile
8
  import os
 
9
  from datetime import datetime
10
- from reportlab.lib.pagesizes import letter, A4
11
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle
12
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
13
- from reportlab.lib import colors
14
- from reportlab.lib.units import inch
15
- import io
16
  import base64
17
  from PIL import Image as PILImage
18
- import calendar
19
 
20
  # Set page configuration
21
  st.set_page_config(
22
- page_title="Charging Outlets Dashboard",
23
  page_icon="⚡",
24
  layout="wide",
25
  initial_sidebar_state="expanded"
@@ -48,11 +44,15 @@ st.markdown("""
48
  padding: 20px;
49
  box-shadow: 0 2px 5px rgba(0,0,0,0.1);
50
  text-align: center;
 
 
 
 
51
  }
52
  .metric-value {
53
  font-size: 2.5rem;
54
  font-weight: bold;
55
- color: #3498db;
56
  }
57
  .metric-label {
58
  font-size: 1.2rem;
@@ -62,382 +62,1410 @@ st.markdown("""
62
  """, unsafe_allow_html=True)
63
 
64
  # Page title
65
- st.title("⚡ Charging Outlets Analytics Dashboard")
66
- st.markdown("Upload your data files to analyze charging outlet performance and utilization.")
67
 
68
  # File upload section
69
- st.sidebar.header("Upload Data Files")
70
- sessions_file = st.sidebar.file_uploader("Upload Sessions.xlsx", type=["xlsx"])
71
- overview_file = st.sidebar.file_uploader("Upload Overview.xlsx", type=["xlsx"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  # Function to preprocess data
74
  def preprocess_data(sessions_df, overview_df):
75
- # Convert date columns to datetime
76
- sessions_df['Startad'] = pd.to_datetime(sessions_df['Startad'])
77
- sessions_df['Avslutad'] = pd.to_datetime(sessions_df['Avslutad'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
- # Extract year and month for analysis
80
  sessions_df['Year'] = sessions_df['Startad'].dt.year
81
  sessions_df['Month'] = sessions_df['Startad'].dt.month
82
  sessions_df['Month_Name'] = sessions_df['Startad'].dt.strftime('%b')
83
  sessions_df['Year_Month'] = sessions_df['Startad'].dt.strftime('%Y-%m')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
- # Calculate session duration in hours
86
- sessions_df['Duration_Hours'] = (sessions_df['Avslutad'] - sessions_df['Startad']).dt.total_seconds() / 3600
87
-
88
- # Clean numeric columns
89
- if isinstance(sessions_df['Laddat (kWh)'].iloc[0], str):
90
- sessions_df['Laddat (kWh)'] = sessions_df['Laddat (kWh)'].str.replace(',', '.').astype(float)
91
-
92
- if isinstance(sessions_df['Kostnad (exkl)'].iloc[0], str):
93
- sessions_df['Kostnad (exkl)'] = sessions_df['Kostnad (exkl)'].str.replace(',', '.').astype(float)
94
-
95
- # Ensure outlet numbers are integers
96
- sessions_df['Uttag'] = pd.to_numeric(sessions_df['Uttag'], errors='coerce').fillna(0).astype(int)
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  return sessions_df, overview_df
99
 
100
  # Function to calculate metrics
101
  def calculate_metrics(sessions_df, overview_df):
102
  metrics = {}
103
 
104
- # Get unique areas
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  unique_areas = sessions_df['Område'].unique()
106
  metrics['unique_areas'] = unique_areas
107
  metrics['area_count'] = len(unique_areas)
108
 
109
- # Calculate outlets per area
110
  outlets_per_area = sessions_df.groupby('Område')['Uttag'].nunique().reset_index()
111
  outlets_per_area.columns = ['Område', 'Number_of_Outlets']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  metrics['outlets_per_area'] = outlets_per_area
 
113
 
114
- # Calculate kWh per month per area
 
 
115
  kwh_per_month_area = sessions_df.groupby(['Område', 'Year_Month'])['Laddat (kWh)'].sum().reset_index()
116
  metrics['kwh_per_month_area'] = kwh_per_month_area
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
- # Calculate kWh per outlet per month per area
119
  kwh_outlet_month_area = sessions_df.groupby(['Område', 'Year_Month', 'Uttag'])['Laddat (kWh)'].sum().reset_index()
120
  metrics['kwh_outlet_month_area'] = kwh_outlet_month_area
121
 
122
- # Calculate utilization (this is simplified and might need adjustment)
123
- # First, get the total possible outlet days for each area (outlets × days in month)
124
- all_months = sessions_df['Year_Month'].unique()
125
- all_areas = sessions_df['Område'].unique()
126
-
127
- utilization_data = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
- for area in all_areas:
130
- area_outlets = sessions_df[sessions_df['Område'] == area]['Uttag'].nunique()
 
 
 
 
 
 
131
 
132
- for ym in all_months:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  year, month = map(int, ym.split('-'))
134
  days_in_month = calendar.monthrange(year, month)[1]
135
- total_outlet_days = area_outlets * days_in_month
136
-
137
- # Count actual used outlet days
138
- area_month_data = sessions_df[(sessions_df['Område'] == area) &
139
- (sessions_df['Year_Month'] == ym)]
140
 
141
- # Get unique (outlet, day) combinations
142
- area_month_data['Day'] = area_month_data['Startad'].dt.day
143
- used_outlet_days = area_month_data.groupby(['Uttag', 'Day']).size().reset_index().shape[0]
144
-
145
- utilization = used_outlet_days / total_outlet_days if total_outlet_days > 0 else 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  utilization_data.append({
148
  'Område': area,
149
  'Year_Month': ym,
150
- 'Used_Outlet_Days': used_outlet_days,
151
- 'Total_Outlet_Days': total_outlet_days,
152
  'Utilization': utilization
153
  })
154
-
155
  metrics['utilization'] = pd.DataFrame(utilization_data)
156
 
157
- # Total kWh and sessions
158
  metrics['total_kwh'] = sessions_df['Laddat (kWh)'].sum()
 
159
  metrics['total_sessions'] = len(sessions_df)
160
  metrics['avg_kwh_per_session'] = metrics['total_kwh'] / metrics['total_sessions'] if metrics['total_sessions'] > 0 else 0
 
161
 
162
  return metrics
163
 
164
  # Function to create plotly figures
165
- def create_visualizations(metrics):
166
  figures = {}
167
 
168
  # 1. Bar chart: Number of outlets per area
169
  fig_outlets = px.bar(
170
- metrics['outlets_per_area'],
171
- x='Område',
172
- y='Number_of_Outlets',
173
- title='Number of Outlets per Area',
174
- color='Number_of_Outlets',
175
- color_continuous_scale='Blues',
176
- labels={'Number_of_Outlets': 'Number of Outlets', 'Område': 'Area'}
177
  )
178
  fig_outlets.update_layout(xaxis_tickangle=-45)
179
  figures['outlets_per_area'] = fig_outlets
180
 
181
- # 2. Line chart: kWh per month per area
182
- fig_kwh = px.line(
183
- metrics['kwh_per_month_area'],
184
- x='Year_Month',
185
- y='Laddat (kWh)',
186
- color='Område',
187
- title='kWh per Month per Area',
188
- labels={'Laddat (kWh)': 'kWh', 'Year_Month': 'Month', 'Område': 'Area'}
189
  )
190
- fig_kwh.update_layout(xaxis_tickangle=-45)
191
- figures['kwh_per_month'] = fig_kwh
192
-
193
- # 3. Heatmap: Utilization per area per month
194
- pivot_util = metrics['utilization'].pivot(
195
- index='Område',
196
- columns='Year_Month',
197
- values='Utilization'
198
- ).fillna(0)
199
-
200
- fig_util = px.imshow(
201
- pivot_util,
202
- labels=dict(x='Month', y='Area', color='Utilization'),
203
- x=pivot_util.columns,
204
- y=pivot_util.index,
205
- color_continuous_scale='Viridis',
206
- title='Outlet Utilization Heatmap (Used Outlet Days / Total Outlet Days)'
207
- )
208
- fig_util.update_layout(xaxis_tickangle=-45)
209
- figures['utilization'] = fig_util
210
-
211
- # 4. Box plot: kWh per outlet per month per area
212
- fig_kwh_outlet = px.box(
213
- metrics['kwh_outlet_month_area'],
214
- x='Område',
215
- y='Laddat (kWh)',
216
- color='Year_Month',
217
- title='kWh per Outlet Distribution by Area and Month',
218
- labels={'Laddat (kWh)': 'kWh', 'Område': 'Area', 'Year_Month': 'Month'}
219
  )
220
- fig_kwh_outlet.update_layout(xaxis_tickangle=-45)
221
- figures['kwh_per_outlet'] = fig_kwh_outlet
222
 
223
- return figures
224
-
225
- # Function to create PDF report
226
- def generate_pdf(metrics, figures):
227
- buffer = io.BytesIO()
228
- doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=72)
229
- styles = getSampleStyleSheet()
230
-
231
- # Create a list to hold the PDF elements
232
- elements = []
233
-
234
- # Add title
235
- title_style = styles["Title"]
236
- elements.append(Paragraph("Charging Outlets Analytics Report", title_style))
237
- elements.append(Spacer(1, 20))
238
-
239
- # Add date
240
- date_style = styles["Normal"]
241
- date_style.alignment = 1 # Center alignment
242
- elements.append(Paragraph(f"Report generated on {datetime.now().strftime('%Y-%m-%d %H:%M')}", date_style))
243
- elements.append(Spacer(1, 30))
244
-
245
- # Add summary metrics
246
- elements.append(Paragraph("Key Metrics Summary", styles["Heading2"]))
247
- elements.append(Spacer(1, 10))
248
-
249
- # Create a table with key metrics
250
- data = [
251
- ["Metric", "Value"],
252
- ["Total Areas", metrics['area_count']],
253
- ["Total kWh Charged", f"{metrics['total_kwh']:.2f}"],
254
- ["Total Charging Sessions", metrics['total_sessions']],
255
- ["Average kWh per Session", f"{metrics['avg_kwh_per_session']:.2f}"]
256
- ]
257
 
258
- t = Table(data, colWidths=[200, 200])
259
- t.setStyle(TableStyle([
260
- ('BACKGROUND', (0, 0), (1, 0), colors.lightblue),
261
- ('TEXTCOLOR', (0, 0), (1, 0), colors.whitesmoke),
262
- ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
263
- ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
264
- ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
265
- ('BACKGROUND', (0, 1), (-1, -1), colors.beige),
266
- ('GRID', (0, 0), (-1, -1), 1, colors.black)
267
- ]))
268
-
269
- elements.append(t)
270
- elements.append(Spacer(1, 30))
271
-
272
- # Add visualizations
273
- for name, fig in figures.items():
274
- # Add section title
275
- elements.append(Paragraph(fig.layout.title.text, styles["Heading2"]))
276
- elements.append(Spacer(1, 10))
277
-
278
- # Save the Plotly figure to a temporary file and add it to the PDF
279
- img_bytes = fig.to_image(format="png", width=700, height=500, scale=1)
280
- img_stream = io.BytesIO(img_bytes)
281
- img = PILImage.open(img_stream)
282
- img_width = 450
283
- img_height = int(img_width * img.height / img.width)
284
- elements.append(Image(img_stream, width=img_width, height=img_height))
285
- elements.append(Spacer(1, 20))
286
-
287
- # Build the PDF
288
- doc.build(elements)
289
- buffer.seek(0)
290
- return buffer
291
-
292
- # Main application logic
293
- if sessions_file is not None and overview_file is not None:
294
- try:
295
- # Read the uploaded files
296
- sessions_df = pd.read_excel(sessions_file)
297
- overview_df = pd.read_excel(overview_file)
298
-
299
- # Display data loading success message
300
- st.sidebar.success("Data loaded successfully!")
301
 
302
- # Show data filtering options in sidebar
303
- st.sidebar.header("Filter Data")
 
304
 
305
- # Preprocess data
306
- sessions_df, overview_df = preprocess_data(sessions_df, overview_df)
 
 
 
 
 
 
307
 
308
- # Get unique areas for filtering
309
- all_areas = sorted(sessions_df['Område'].unique())
 
 
 
 
 
 
 
 
310
 
311
- # Area selection dropdown
312
- selected_areas = st.sidebar.multiselect(
313
- "Select Areas",
314
- options=all_areas,
315
- default=all_areas
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  )
 
 
 
 
317
 
318
- # Filter data based on selection
319
- if selected_areas:
320
- filtered_sessions = sessions_df[sessions_df['Område'].isin(selected_areas)]
321
- else:
322
- filtered_sessions = sessions_df # Use all data if nothing selected
323
 
324
- # Calculate metrics
325
- metrics = calculate_metrics(filtered_sessions, overview_df)
 
326
 
327
- # Create visualizations
328
- figures = create_visualizations(metrics)
 
 
 
 
 
 
 
 
 
329
 
330
- # Create tabs for different dashboard views
331
- tab1, tab2, tab3 = st.tabs(["Key Metrics", "Utilization Analysis", "Energy Consumption"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
 
333
- with tab1:
334
- st.header("Key Performance Metrics")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
 
336
- # Key metrics in cards
337
- col1, col2, col3 = st.columns(3)
 
 
 
 
 
 
338
 
339
- with col1:
340
- st.markdown(f"""
341
- <div class="metric-card">
342
- <div class="metric-value">{metrics['area_count']}</div>
343
- <div class="metric-label">Total Areas</div>
344
- </div>
345
- """, unsafe_allow_html=True)
 
 
346
 
347
- with col2:
348
- st.markdown(f"""
349
- <div class="metric-card">
350
- <div class="metric-value">{metrics['total_sessions']:,}</div>
351
- <div class="metric-label">Total Sessions</div>
352
- </div>
353
- """, unsafe_allow_html=True)
354
 
355
- with col3:
356
- st.markdown(f"""
357
- <div class="metric-card">
358
- <div class="metric-value">{metrics['total_kwh']:,.2f}</div>
359
- <div class="metric-label">Total kWh</div>
360
- </div>
361
- """, unsafe_allow_html=True)
362
 
363
- st.subheader("Number of Outlets per Area")
364
- st.plotly_chart(figures['outlets_per_area'], use_container_width=True)
 
 
 
 
 
365
 
366
- st.subheader("Monthly Energy Consumption by Area")
367
- st.plotly_chart(figures['kwh_per_month'], use_container_width=True)
368
-
369
- with tab2:
370
- st.header("Utilization Analysis")
371
- st.markdown("""
372
- This heatmap shows the utilization rate of charging outlets, calculated as:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
 
374
- **Utilization = Used Outlet Days / Total Outlet Days**
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
 
376
- Where Total Outlet Days = Number of Outlets × Days in Month
377
- """)
 
 
 
 
 
 
 
 
 
 
 
 
378
 
379
- st.plotly_chart(figures['utilization'], use_container_width=True)
 
380
 
381
- # Display utilization data table
382
- st.subheader("Utilization Data Table")
383
- st.dataframe(
384
- metrics['utilization'][['Område', 'Year_Month', 'Used_Outlet_Days', 'Total_Outlet_Days', 'Utilization']]
385
- .sort_values(['Område', 'Year_Month'])
386
- .style.format({'Utilization': '{:.2%}'})
 
 
 
 
387
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
 
389
- with tab3:
390
- st.header("Energy Consumption Analysis")
391
-
392
- st.subheader("kWh per Outlet Distribution")
393
- st.plotly_chart(figures['kwh_per_outlet'], use_container_width=True)
394
-
395
- # Additional insights section
396
- st.subheader("Energy Consumption Insights")
397
-
398
- # Average kWh per session by area
399
- avg_kwh_per_session = filtered_sessions.groupby('Område')['Laddat (kWh)'].mean().reset_index()
400
- avg_kwh_per_session.columns = ['Område', 'Average kWh per Session']
401
-
402
- fig_avg_kwh = px.bar(
403
- avg_kwh_per_session,
404
- x='Område',
405
- y='Average kWh per Session',
406
- color='Average kWh per Session',
407
- color_continuous_scale='Viridis',
408
- labels={'Average kWh per Session': 'Avg kWh per Session', 'Område': 'Area'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  )
410
- fig_avg_kwh.update_layout(xaxis_tickangle=-45)
411
 
412
- st.plotly_chart(fig_avg_kwh, use_container_width=True)
413
-
414
- # Generate PDF button
415
- st.sidebar.header("Export Report")
416
- if st.sidebar.button("Generate PDF Report"):
417
- with st.spinner("Generating PDF report..."):
418
- pdf_buffer = generate_pdf(metrics, figures)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
 
420
- # Create download link
421
- b64_pdf = base64.b64encode(pdf_buffer.read()).decode()
422
- href = f'<a href="data:application/pdf;base64,{b64_pdf}" download="charging_outlets_report.pdf">Download PDF Report</a>'
423
- st.sidebar.markdown(href, unsafe_allow_html=True)
424
- st.sidebar.success("PDF generated successfully!")
425
 
 
 
 
 
426
  except Exception as e:
427
- st.error(f"Error processing data: {e}")
428
- st.exception(e)
 
 
 
429
  else:
430
- # Show instructions when no files are uploaded
431
- st.info("Please upload the required Excel files to begin the analysis.")
432
-
433
- # Show example visualizations or instructions
434
- st.header("Dashboard Preview")
435
- st.markdown("""
436
- This dashboard will help you analyze charging outlet performance with:
437
-
438
- 1. **Key Metrics** - Number of outlets per area and energy consumption over time
439
- 2. **Utilization Analysis** - Heatmap showing outlet usage patterns
440
- 3. **Energy Consumption** - Detailed breakdowns of energy usage by outlet
441
-
442
- You can filter by specific areas and generate PDF reports with all visualizations.
443
- """)
 
6
  import numpy as np
7
  import tempfile
8
  import os
9
+ import time
10
  from datetime import datetime
11
+ import calendar
 
 
 
 
 
12
  import base64
13
  from PIL import Image as PILImage
14
+ import io
15
 
16
  # Set page configuration
17
  st.set_page_config(
18
+ page_title="ChargeNode Rapport Generator",
19
  page_icon="⚡",
20
  layout="wide",
21
  initial_sidebar_state="expanded"
 
44
  padding: 20px;
45
  box-shadow: 0 2px 5px rgba(0,0,0,0.1);
46
  text-align: center;
47
+ height: 150px; /* Ensure consistent height */
48
+ display: flex;
49
+ flex-direction: column;
50
+ justify-content: center;
51
  }
52
  .metric-value {
53
  font-size: 2.5rem;
54
  font-weight: bold;
55
+ color: #27ae60;
56
  }
57
  .metric-label {
58
  font-size: 1.2rem;
 
62
  """, unsafe_allow_html=True)
63
 
64
  # Page title
65
+ st.title("⚡ ChargeNode Rapport Generator - Internt verktyg för rapportskapande")
66
+ st.markdown("Ladda upp dina datafiler för att analysera prestanda och användning av ladduttag.")
67
 
68
  # File upload section
69
+ st.sidebar.header("Ladda upp Datafiler")
70
+ sessions_file = st.sidebar.file_uploader("Ladda upp Sessions.xlsx", type=["xlsx"])
71
+ overview_file = st.sidebar.file_uploader("Ladda upp Overview.xlsx (Valfri)", type=["xlsx"]) # Made optional
72
+
73
+ # Expected column names and their potential alternatives
74
+ EXPECTED_COLUMNS = {
75
+ 'Startad': ['Startad', 'Start', 'Started', 'Start Time', 'StartTime', 'Beginning', 'Start Date'],
76
+ 'Avslutad': ['Avslutad', 'End', 'Ended', 'End Time', 'EndTime', 'Finish', 'End Date'],
77
+ 'Laddat (kWh)': ['Laddat (kWh)', 'kWh', 'Energy', 'Charged', 'Energy (kWh)', 'Power', 'Consumption'],
78
+ 'Kostnad (exkl)': ['Kostnad (exkl)', 'Cost', 'Price', 'Cost (excl)', 'Fee', 'Charge', 'Amount'],
79
+ 'Uttag': ['Uttag', 'Outlet', 'Charger', 'Outlet ID', 'Charger ID', 'Terminal', 'Station'],
80
+ 'Område': ['Område', 'Area', 'Location', 'Region', 'Zone', 'Site', 'Place']
81
+ }
82
+
83
+ # Function to find matching columns
84
+ def find_matching_columns(df, expected_columns_dict):
85
+ column_mapping = {}
86
+ available_columns = df.columns.tolist()
87
+
88
+ for expected_col, alternatives in expected_columns_dict.items():
89
+ found = False
90
+ # First try exact matches
91
+ for alt in alternatives:
92
+ if alt in available_columns:
93
+ column_mapping[expected_col] = alt # Store as expected:actual
94
+ found = True
95
+ break
96
+
97
+ # If no exact match, try case-insensitive matching
98
+ if not found:
99
+ for col in available_columns:
100
+ for alt in alternatives:
101
+ if col.lower() == alt.lower():
102
+ column_mapping[expected_col] = col # Store as expected:actual
103
+ found = True
104
+ break
105
+ if found:
106
+ break
107
+
108
+ # Invert mapping for renaming: actual_name -> expected_name
109
+ rename_map = {v: k for k, v in column_mapping.items()}
110
+ return rename_map
111
+
112
+ # Function to extract area code and name from combined format
113
+ def extract_area_code_and_name(area_string):
114
+ """
115
+ Extract area code and name from a string in the format '2571 - Stena Hildedalsgatan'
116
+ Returns tuple of (code, name)
117
+ """
118
+ try:
119
+ if not isinstance(area_string, str):
120
+ return (str(area_string), str(area_string))
121
+
122
+ area_string = area_string.strip()
123
+ if ' - ' in area_string:
124
+ # Format is "2571 - Stena Hildedalsgatan"
125
+ parts = area_string.split(' - ', 1)
126
+ code = parts[0].strip()
127
+ name = parts[1].strip() if len(parts) > 1 else ""
128
+ return (code, name)
129
+ else:
130
+ # Just return the whole string if it doesn't match the expected format
131
+ return (area_string, area_string)
132
+ except:
133
+ return (str(area_string), str(area_string))
134
 
135
  # Function to preprocess data
136
  def preprocess_data(sessions_df, overview_df):
137
+ st.write("Tillgängliga kolumner i Sessions-filen:", sessions_df.columns.tolist())
138
+ if overview_df is not None:
139
+ st.write("Tillgängliga kolumner i Overview-filen:", overview_df.columns.tolist())
140
+
141
+ column_rename_map = find_matching_columns(sessions_df, EXPECTED_COLUMNS)
142
+ if column_rename_map:
143
+ sessions_df = sessions_df.rename(columns=column_rename_map)
144
+
145
+ required_columns = ['Startad', 'Avslutad', 'Laddat (kWh)', 'Kostnad (exkl)', 'Uttag', 'Område']
146
+ missing_columns = [col for col in required_columns if col not in sessions_df.columns]
147
+
148
+ if missing_columns:
149
+ st.warning(f"Följande obligatoriska kolumner saknas: {', '.join(missing_columns)}")
150
+ st.warning("Skapar platshållardata för saknade kolumner. För korrekt analys, se till att din data har dessa kolumner.")
151
+
152
+ if 'Startad' not in sessions_df.columns: sessions_df['Startad'] = pd.to_datetime('2023-01-01T00:00:00')
153
+ if 'Avslutad' not in sessions_df.columns: sessions_df['Avslutad'] = pd.to_datetime('2023-01-01T01:00:00')
154
+ if 'Laddat (kWh)' not in sessions_df.columns: sessions_df['Laddat (kWh)'] = 10.0
155
+ if 'Kostnad (exkl)' not in sessions_df.columns: sessions_df['Kostnad (exkl)'] = 5.0
156
+ if 'Uttag' not in sessions_df.columns: sessions_df['Uttag'] = "Uttag_1"
157
+ if 'Område' not in sessions_df.columns: sessions_df['Område'] = 'Default Area'
158
+
159
+ try:
160
+ sessions_df['Startad'] = pd.to_datetime(sessions_df['Startad'])
161
+ sessions_df['Avslutad'] = pd.to_datetime(sessions_df['Avslutad'])
162
+ except Exception as e:
163
+ st.error(f"Fel vid konvertering av datumkolumner: {e}. Kontrollera formatet.")
164
+ st.stop()
165
 
 
166
  sessions_df['Year'] = sessions_df['Startad'].dt.year
167
  sessions_df['Month'] = sessions_df['Startad'].dt.month
168
  sessions_df['Month_Name'] = sessions_df['Startad'].dt.strftime('%b')
169
  sessions_df['Year_Month'] = sessions_df['Startad'].dt.strftime('%Y-%m')
170
+ sessions_df['Hour'] = sessions_df['Startad'].dt.hour
171
+ sessions_df['Weekday'] = sessions_df['Startad'].dt.day_name()
172
+ sessions_df['Day_Type'] = np.where(sessions_df['Startad'].dt.weekday < 5, 'Vardag', 'Helg')
173
+
174
+ sessions_df['Duration_Minutes'] = (sessions_df['Avslutad'] - sessions_df['Startad']).dt.total_seconds() / 60
175
+ sessions_df['Duration_Hours'] = sessions_df['Duration_Minutes'] / 60
176
+ sessions_df.loc[sessions_df['Duration_Hours'] < 0, 'Duration_Hours'] = 0
177
+ sessions_df.loc[sessions_df['Duration_Minutes'] < 0, 'Duration_Minutes'] = 0
178
+
179
+ for col in ['Laddat (kWh)', 'Kostnad (exkl)']:
180
+ if col in sessions_df.columns:
181
+ if sessions_df[col].dtype == object:
182
+ sessions_df[col] = sessions_df[col].astype(str).str.replace(',', '.').astype(float)
183
+ sessions_df[col] = pd.to_numeric(sessions_df[col], errors='coerce').fillna(0)
184
 
185
+ if 'Uttag' in sessions_df.columns:
186
+ sessions_df['Uttag'] = sessions_df['Uttag'].astype(str)
187
+
188
+ # Remove sessions with zero or negative duration
189
+ sessions_df = sessions_df[sessions_df['Duration_Minutes'] > 1]
 
 
 
 
 
 
 
190
 
191
+ # Handle area code formats in overview file
192
+ if overview_df is not None and not overview_df.empty:
193
+ first_col = overview_df.columns[0]
194
+
195
+ # Check if the first column might contain area codes in the format "2571 - Stena Hildedalsgatan"
196
+ sample_values = overview_df[first_col].astype(str).head().tolist()
197
+ has_area_code_format = any(' - ' in val for val in sample_values)
198
+
199
+ if has_area_code_format:
200
+ # Extract area codes and names
201
+ extracted = overview_df[first_col].apply(extract_area_code_and_name)
202
+ overview_df['AreaCode'] = extracted.apply(lambda x: x[0])
203
+ overview_df['AreaName'] = extracted.apply(lambda x: x[1])
204
+
205
+ # Try to convert AreaCode to numeric if possible
206
+ try:
207
+ overview_df['AreaCode'] = pd.to_numeric(overview_df['AreaCode'], errors='coerce').fillna(overview_df['AreaCode'])
208
+ except:
209
+ pass # Keep as string if conversion fails
210
+
211
+ # Also check if there are separate area code and name columns in the sessions file
212
+ if 'Område' in sessions_df.columns:
213
+ # Look for potential area code column
214
+ possible_code_columns = [col for col in sessions_df.columns if any(keyword in col.lower() for keyword in
215
+ ['områdeskod', 'omradeskod', 'area code', 'areakod', 'kod'])]
216
+
217
+ if possible_code_columns:
218
+ area_code_col = possible_code_columns[0]
219
+
220
+ # Ensure the area code column is properly formatted as string for comparison
221
+ sessions_df[area_code_col] = sessions_df[area_code_col].astype(str).str.strip()
222
+
223
+ # Look for a potential area name column
224
+ possible_name_columns = [col for col in sessions_df.columns if any(keyword in col.lower() for keyword in
225
+ ['områdesnamn', 'omradesnamn', 'area name', 'areanamn', 'namn'])]
226
+
227
+ area_name_col = possible_name_columns[0] if possible_name_columns else None
228
+
229
+ # If we have both area codes in the overview file and separate columns in sessions, we can match them
230
+ if has_area_code_format:
231
+ # Create a mapping from area code to original format
232
+ area_mapping = {}
233
+ for _, row in overview_df.iterrows():
234
+ area_mapping[str(row['AreaCode'])] = row[first_col]
235
+
236
+ # Update the Område column to match the format in overview file
237
+ sessions_df['Område_Original'] = sessions_df['Område'] # Keep original for reference
238
+
239
+ # Update based on area code
240
+ sessions_df['Område'] = sessions_df[area_code_col].map(area_mapping).fillna(sessions_df['Område'])
241
+
242
  return sessions_df, overview_df
243
 
244
  # Function to calculate metrics
245
  def calculate_metrics(sessions_df, overview_df):
246
  metrics = {}
247
 
248
+ if sessions_df.empty:
249
+ st.warning("Ingen data att analysera efter filtrering eller i den uppladdade filen.")
250
+ # Return default empty metrics
251
+ metrics['unique_areas'] = []
252
+ metrics['area_count'] = 0
253
+ metrics['outlets_per_area'] = pd.DataFrame(columns=['Område', 'Number_of_Outlets'])
254
+ metrics['total_outlets'] = 0
255
+ metrics['kwh_per_month_area'] = pd.DataFrame(columns=['Område', 'Year_Month', 'Laddat (kWh)'])
256
+ metrics['cost_per_month_area'] = pd.DataFrame(columns=['Område', 'Year_Month', 'Kostnad (exkl)'])
257
+ metrics['kwh_per_month'] = pd.DataFrame(columns=['Year_Month', 'Laddat (kWh)'])
258
+ metrics['avg_kwh_per_outlet_month'] = pd.DataFrame(columns=['Year_Month', 'Avg_kWh_per_Outlet'])
259
+ metrics['kwh_outlet_month_area'] = pd.DataFrame(columns=['Område', 'Year_Month', 'Uttag', 'Laddat (kWh)'])
260
+ metrics['hourly_utilization'] = pd.DataFrame(columns=['Date', 'Hour', 'IsWeekend', 'Outlets_In_Use', 'Total_Outlets', 'Utilization', 'Date_Str'])
261
+ metrics['utilization'] = pd.DataFrame(columns=['Område', 'Year_Month', 'Used_Outlet_Hours', 'Total_Possible_Outlet_Hours', 'Utilization'])
262
+ metrics['total_kwh'] = 0
263
+ metrics['total_cost'] = 0
264
+ metrics['total_sessions'] = 0
265
+ metrics['avg_kwh_per_session'] = 0
266
+ metrics['avg_duration_minutes'] = 0
267
+ metrics['avg_hourly_utilization'] = pd.DataFrame(columns=['Hour', 'Day_Type', 'Utilization'])
268
+ metrics['avg_weekday_utilization'] = pd.DataFrame(columns=['Weekday', 'Utilization'])
269
+ return metrics
270
+
271
  unique_areas = sessions_df['Område'].unique()
272
  metrics['unique_areas'] = unique_areas
273
  metrics['area_count'] = len(unique_areas)
274
 
275
+ # Calculate outlets per area from sessions
276
  outlets_per_area = sessions_df.groupby('Område')['Uttag'].nunique().reset_index()
277
  outlets_per_area.columns = ['Område', 'Number_of_Outlets']
278
+
279
+ # If overview_df is available, use column 4 for a more accurate count of outlets
280
+ total_outlets = 0
281
+ if overview_df is not None and not overview_df.empty:
282
+ try:
283
+ # Use column 4 (index 3) for outlet counts as specified in the problem
284
+ outlet_col_idx = 3 # 0-based indexing, so column 4 is index 3
285
+
286
+ if outlet_col_idx < len(overview_df.columns):
287
+ outlet_column = overview_df.columns[outlet_col_idx]
288
+
289
+ # Use first column for area identification
290
+ area_id_col = overview_df.columns[0]
291
+
292
+ # If we extracted area codes earlier, use them for matching
293
+ if 'AreaCode' in overview_df.columns:
294
+ # Convert column 4 to numeric if it's not already
295
+ if overview_df[outlet_column].dtype == object:
296
+ overview_df[outlet_column] = pd.to_numeric(overview_df[outlet_column], errors='coerce').fillna(0)
297
+
298
+ # Group by area code and sum the outlet counts
299
+ outlet_counts = overview_df.groupby('AreaCode')[outlet_column].sum().reset_index()
300
+ outlet_counts.columns = ['AreaCode', 'Number_of_Outlets']
301
+
302
+ # Create a mapping from original area format to area code for matching
303
+ area_code_map = {}
304
+ for _, row in overview_df.iterrows():
305
+ area_full = row[area_id_col]
306
+ area_code = row['AreaCode'] if 'AreaCode' in overview_df.columns else area_full
307
+ area_code_map[str(area_full)] = str(area_code)
308
+
309
+ # Update outlets_per_area with counts from overview
310
+ updated_outlets_per_area = []
311
+ for _, row in outlets_per_area.iterrows():
312
+ area = row['Område']
313
+ num_outlets = row['Number_of_Outlets']
314
+
315
+ # Try to find the area code from the session area
316
+ area_code = None
317
+
318
+ # Check if the area is already in the format "code - name"
319
+ if ' - ' in str(area):
320
+ area_code = extract_area_code_and_name(area)[0]
321
+ else:
322
+ # Otherwise try to find it in the mapping
323
+ for full_name, code in area_code_map.items():
324
+ if str(area) == str(full_name) or str(area) == str(code):
325
+ area_code = code
326
+ break
327
+
328
+ # If we found a matching area code, update the outlet count
329
+ if area_code is not None and str(area_code) in outlet_counts['AreaCode'].astype(str).values:
330
+ matching_row = outlet_counts[outlet_counts['AreaCode'].astype(str) == str(area_code)]
331
+ if not matching_row.empty:
332
+ num_outlets = matching_row.iloc[0]['Number_of_Outlets']
333
+
334
+ updated_outlets_per_area.append({
335
+ 'Område': area,
336
+ 'Number_of_Outlets': num_outlets
337
+ })
338
+
339
+ # Replace the original outlets_per_area with the updated one
340
+ outlets_per_area = pd.DataFrame(updated_outlets_per_area)
341
+ else:
342
+ # If no AreaCode column, try matching directly by the first column
343
+ if overview_df[outlet_column].dtype == object:
344
+ overview_df[outlet_column] = pd.to_numeric(overview_df[outlet_column], errors='coerce').fillna(0)
345
+
346
+ outlet_counts = overview_df.groupby(area_id_col)[outlet_column].sum().reset_index()
347
+ outlet_counts.columns = ['Område', 'Number_of_Outlets']
348
+
349
+ # Try to update outlets_per_area based on area names
350
+ for i, row in outlets_per_area.iterrows():
351
+ area = row['Område']
352
+ for j, count_row in outlet_counts.iterrows():
353
+ overview_area = count_row['Område']
354
+ # Check for exact match or if one contains the other
355
+ if str(area) == str(overview_area) or str(area) in str(overview_area) or str(overview_area) in str(area):
356
+ outlets_per_area.at[i, 'Number_of_Outlets'] = count_row['Number_of_Outlets']
357
+ break
358
+
359
+ # Calculate total from the updated outlets_per_area
360
+ total_outlets = outlets_per_area['Number_of_Outlets'].sum()
361
+ else:
362
+ st.warning(f"Column 4 not found in overview file. Using data from sessions instead.")
363
+ total_outlets = int(outlets_per_area['Number_of_Outlets'].sum())
364
+ except Exception as e:
365
+ st.warning(f"Kunde inte beräkna antalet uttag från översiktsfilen: {e}")
366
+ import traceback
367
+ st.warning(traceback.format_exc()) # More detailed error for debugging
368
+ # Fall back to using only session data
369
+ total_outlets = int(outlets_per_area['Number_of_Outlets'].sum())
370
+ else:
371
+ # If no overview file, just use the active outlets from sessions
372
+ total_outlets = int(outlets_per_area['Number_of_Outlets'].sum())
373
+
374
  metrics['outlets_per_area'] = outlets_per_area
375
+ metrics['total_outlets'] = total_outlets
376
 
377
+ # For calculations that use total_outlets, make sure it's never zero to avoid division by zero
378
+ total_outlets_for_calc = max(1, total_outlets) # Use at least 1 to avoid division by zero
379
+
380
  kwh_per_month_area = sessions_df.groupby(['Område', 'Year_Month'])['Laddat (kWh)'].sum().reset_index()
381
  metrics['kwh_per_month_area'] = kwh_per_month_area
382
+
383
+ cost_per_month_area = sessions_df.groupby(['Område', 'Year_Month'])['Kostnad (exkl)'].sum().reset_index()
384
+ metrics['cost_per_month_area'] = cost_per_month_area
385
+
386
+ kwh_per_month = sessions_df.groupby(['Year_Month'])['Laddat (kWh)'].sum().reset_index()
387
+ metrics['kwh_per_month'] = kwh_per_month
388
+
389
+ total_kwh_per_month = sessions_df.groupby('Year_Month')['Laddat (kWh)'].sum().reset_index()
390
+ outlets_active_per_month = sessions_df.groupby('Year_Month')['Uttag'].nunique().reset_index()
391
+ avg_kwh_per_outlet = pd.merge(total_kwh_per_month, outlets_active_per_month, on='Year_Month', how='left')
392
+ avg_kwh_per_outlet['Avg_kWh_per_Outlet'] = avg_kwh_per_outlet['Laddat (kWh)'] / avg_kwh_per_outlet['Uttag'].replace(0, 1) # Avoid div by zero
393
+ metrics['avg_kwh_per_outlet_month'] = avg_kwh_per_outlet
394
 
 
395
  kwh_outlet_month_area = sessions_df.groupby(['Område', 'Year_Month', 'Uttag'])['Laddat (kWh)'].sum().reset_index()
396
  metrics['kwh_outlet_month_area'] = kwh_outlet_month_area
397
 
398
+ # Calculate utilization by hour and date (Improved robustness)
399
+ session_hours_list = []
400
+ if not sessions_df.empty and 'Startad' in sessions_df.columns and 'Avslutad' in sessions_df.columns:
401
+ for _, session in sessions_df.iterrows():
402
+ try:
403
+ start_time = session['Startad']
404
+ end_time = session['Avslutad']
405
+ outlet = session['Uttag']
406
+ area = session['Område']
407
+
408
+ if pd.isna(start_time) or pd.isna(end_time) or end_time < start_time:
409
+ continue
410
+
411
+ # Iterate over one-hour intervals within the session
412
+ current_hour_start = start_time.floor('H')
413
+ while current_hour_start < end_time:
414
+ session_hours_list.append({
415
+ 'Date': current_hour_start.date(),
416
+ 'Hour': current_hour_start.hour,
417
+ 'Outlet': outlet,
418
+ 'Område': area, # Add area for better segmentation
419
+ 'IsWeekend': current_hour_start.weekday() >= 5,
420
+ 'Day_Type': 'Helg' if current_hour_start.weekday() >= 5 else 'Vardag',
421
+ 'Weekday': current_hour_start.day_name()
422
+ })
423
+ current_hour_start += pd.Timedelta(hours=1)
424
+ except Exception as e:
425
+ st.warning(f"Skippade en session vid beräkning av timvis användning p.g.a. datafel: {e}")
426
+ continue
427
 
428
+ if session_hours_list:
429
+ hourly_usage_df = pd.DataFrame(session_hours_list)
430
+
431
+ # Count unique outlets in use for each hour, date, day_type, weekday
432
+ # First by area for area-specific utilization
433
+ area_hourly_utilization = hourly_usage_df.groupby(['Date', 'Hour', 'IsWeekend', 'Day_Type', 'Weekday', 'Område']).agg(
434
+ Outlets_In_Use=('Outlet', 'nunique')
435
+ ).reset_index()
436
 
437
+ # Add total configured outlets per area
438
+ area_hourly_utilization = area_hourly_utilization.merge(
439
+ outlets_per_area, on='Område', how='left'
440
+ )
441
+ area_hourly_utilization['Number_of_Outlets'] = area_hourly_utilization['Number_of_Outlets'].fillna(1) # Fallback to 1 if unknown
442
+
443
+ # Calculate area-specific utilization
444
+ area_hourly_utilization['Utilization'] = area_hourly_utilization['Outlets_In_Use'] / area_hourly_utilization['Number_of_Outlets']
445
+ area_hourly_utilization['Date_Str'] = pd.to_datetime(area_hourly_utilization['Date']).dt.strftime('%a %d %b %Y')
446
+ metrics['area_hourly_utilization'] = area_hourly_utilization
447
+
448
+ # Then overall for all areas combined
449
+ hourly_utilization = hourly_usage_df.groupby(['Date', 'Hour', 'IsWeekend', 'Day_Type', 'Weekday']).agg(
450
+ Outlets_In_Use=('Outlet', 'nunique')
451
+ ).reset_index()
452
+
453
+ # Use the total configured outlets for overall utilization
454
+ hourly_utilization['Total_Outlets'] = total_outlets_for_calc
455
+ hourly_utilization['Utilization'] = hourly_utilization['Outlets_In_Use'] / hourly_utilization['Total_Outlets']
456
+ hourly_utilization['Date_Str'] = pd.to_datetime(hourly_utilization['Date']).dt.strftime('%a %d %b %Y')
457
+ metrics['hourly_utilization'] = hourly_utilization
458
+
459
+ # Average hourly utilization (e.g. 9 AM is X% utilized on average on weekdays)
460
+ avg_hourly_utilization = hourly_utilization.groupby(['Hour', 'Day_Type'])['Utilization'].mean().reset_index()
461
+ metrics['avg_hourly_utilization'] = avg_hourly_utilization
462
+
463
+ # Average weekday utilization
464
+ avg_weekday_utilization = hourly_utilization.groupby(['Weekday'])['Utilization'].mean().reset_index()
465
+ weekday_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
466
+ avg_weekday_utilization['Weekday'] = pd.Categorical(avg_weekday_utilization['Weekday'], categories=weekday_order, ordered=True)
467
+ avg_weekday_utilization = avg_weekday_utilization.sort_values('Weekday')
468
+ metrics['avg_weekday_utilization'] = avg_weekday_utilization
469
+
470
+ else:
471
+ metrics['hourly_utilization'] = pd.DataFrame(columns=['Date', 'Hour', 'IsWeekend', 'Outlets_In_Use', 'Total_Outlets', 'Utilization', 'Date_Str', 'Day_Type', 'Weekday'])
472
+ metrics['area_hourly_utilization'] = pd.DataFrame(columns=['Date', 'Hour', 'IsWeekend', 'Outlets_In_Use', 'Number_of_Outlets', 'Utilization', 'Date_Str', 'Day_Type', 'Weekday', 'Område'])
473
+ metrics['avg_hourly_utilization'] = pd.DataFrame(columns=['Hour', 'Day_Type', 'Utilization'])
474
+ metrics['avg_weekday_utilization'] = pd.DataFrame(columns=['Weekday', 'Utilization'])
475
+
476
+ # Monthly utilization
477
+ utilization_data = []
478
+ all_months_in_data = sessions_df['Year_Month'].unique()
479
+ all_areas_in_data = sessions_df['Område'].unique()
480
+
481
+ for area in all_areas_in_data:
482
+ # Get total number of outlets for this area from our updated outlets_per_area dataframe
483
+ area_outlets_count = outlets_per_area[outlets_per_area['Område'] == area]['Number_of_Outlets'].values
484
+ if len(area_outlets_count) == 0 or area_outlets_count[0] == 0:
485
+ continue # Skip areas with no configured outlets
486
+
487
+ area_outlets_count = area_outlets_count[0]
488
+
489
+ for ym in all_months_in_data:
490
  year, month = map(int, ym.split('-'))
491
  days_in_month = calendar.monthrange(year, month)[1]
492
+ total_possible_outlet_hours = area_outlets_count * days_in_month * 24 # Total available hours for all outlets in area for the month
 
 
 
 
493
 
494
+ area_month_sessions = sessions_df[(sessions_df['Område'] == area) & (sessions_df['Year_Month'] == ym)]
495
+ if area_month_sessions.empty:
496
+ used_outlet_hours = 0
497
+ else:
498
+ # Calculate active hours from session duration
499
+ # For each session, count the hours it spans
500
+ total_hours = 0
501
+ for _, session in area_month_sessions.iterrows():
502
+ try:
503
+ start_time = session['Startad']
504
+ end_time = session['Avslutad']
505
+ if pd.isna(start_time) or pd.isna(end_time) or end_time < start_time:
506
+ continue
507
+
508
+ # Calculate hours this session spans
509
+ duration_hours = (end_time - start_time).total_seconds() / 3600
510
+ total_hours += duration_hours
511
+ except Exception as e:
512
+ st.warning(f"Fel vid beräkning av timanvändning för session: {e}")
513
+ continue
514
+
515
+ used_outlet_hours = total_hours
516
+
517
+ utilization = used_outlet_hours / total_possible_outlet_hours if total_possible_outlet_hours > 0 else 0
518
 
519
  utilization_data.append({
520
  'Område': area,
521
  'Year_Month': ym,
522
+ 'Used_Outlet_Hours': used_outlet_hours,
523
+ 'Total_Possible_Outlet_Hours': total_possible_outlet_hours,
524
  'Utilization': utilization
525
  })
 
526
  metrics['utilization'] = pd.DataFrame(utilization_data)
527
 
 
528
  metrics['total_kwh'] = sessions_df['Laddat (kWh)'].sum()
529
+ metrics['total_cost'] = sessions_df['Kostnad (exkl)'].sum()
530
  metrics['total_sessions'] = len(sessions_df)
531
  metrics['avg_kwh_per_session'] = metrics['total_kwh'] / metrics['total_sessions'] if metrics['total_sessions'] > 0 else 0
532
+ metrics['avg_duration_minutes'] = sessions_df['Duration_Minutes'].mean() if metrics['total_sessions'] > 0 else 0
533
 
534
  return metrics
535
 
536
  # Function to create plotly figures
537
+ def create_visualizations(metrics, sessions_df): # Pass sessions_df for more detailed plots
538
  figures = {}
539
 
540
  # 1. Bar chart: Number of outlets per area
541
  fig_outlets = px.bar(
542
+ metrics['outlets_per_area'], x='Område', y='Number_of_Outlets',
543
+ title='Antal Uttag per Område', color='Number_of_Outlets', color_continuous_scale=px.colors.sequential.Greens,
544
+ labels={'Number_of_Outlets': 'Antal Uttag', 'Område': 'Område'}
 
 
 
 
545
  )
546
  fig_outlets.update_layout(xaxis_tickangle=-45)
547
  figures['outlets_per_area'] = fig_outlets
548
 
549
+ # 2. Bar chart: kWh per month for each area
550
+ fig_kwh_area = px.bar(
551
+ metrics['kwh_per_month_area'], x='Year_Month', y='Laddat (kWh)', color='Område',
552
+ title='Total Energi (kWh) per Månad och Område',
553
+ labels={'Laddat (kWh)': 'Energi (kWh)', 'Year_Month': 'Månad', 'Område': 'Område'},
554
+ barmode='group', color_discrete_sequence=px.colors.qualitative.Pastel
 
 
555
  )
556
+ fig_kwh_area.update_layout(xaxis_tickangle=-45)
557
+ figures['kwh_per_month_area'] = fig_kwh_area
558
+
559
+ # 2b. Bar chart: Kostnad (exkl) per month for each area
560
+ fig_cost_area = px.bar(
561
+ metrics['cost_per_month_area'], x='Year_Month', y='Kostnad (exkl)', color='Område',
562
+ title='Total Kostnad (exkl. moms) per Månad och Område',
563
+ labels={'Kostnad (exkl)': 'Kostnad (SEK)', 'Year_Month': 'Månad', 'Område': 'Område'},
564
+ barmode='group', color_discrete_sequence=px.colors.qualitative.Set2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  )
566
+ fig_cost_area.update_layout(xaxis_tickangle=-45)
567
+ figures['cost_per_month_area'] = fig_cost_area
568
 
569
+ # 3. Line chart: Average kWh per outlet per month (overall)
570
+ fig_avg_kwh = px.line(
571
+ metrics['avg_kwh_per_outlet_month'], x='Year_Month', y='Avg_kWh_per_Outlet', markers=True,
572
+ title='Genomsnittlig Energi (kWh) per Aktivt Uttag per Månad (Totalt)',
573
+ labels={'Avg_kWh_per_Outlet': 'Genomsnittlig kWh/Uttag', 'Year_Month': 'Månad'}
574
+ )
575
+ fig_avg_kwh.update_layout(xaxis_tickangle=-45)
576
+ figures['avg_kwh_per_outlet'] = fig_avg_kwh
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
 
578
+ # 4. Heatmap: Date-Hour Utilization - CHANGED TO GREEN GRADIENT
579
+ if not metrics['hourly_utilization'].empty:
580
+ pivot_hourly = metrics['hourly_utilization'].pivot_table(
581
+ index='Date_Str', columns='Hour', values='Utilization', aggfunc='mean'
582
+ ).fillna(0)
583
+ # Ensure chronological order of dates
584
+ sorted_dates = sorted(metrics['hourly_utilization']['Date'].unique())
585
+ date_str_order = [d.strftime('%a %d %b %Y') for d in sorted_dates]
586
+ pivot_hourly = pivot_hourly.reindex(index=date_str_order).fillna(0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
 
588
+ # Find the min and max values for consistent color scale
589
+ min_val = pivot_hourly.values.min()
590
+ max_val = pivot_hourly.values.max()
591
 
592
+ # Create the heatmap with green color scale
593
+ fig_hourly = px.imshow(
594
+ pivot_hourly, labels=dict(x='Timme på Dygnet', y='Datum', color='Beläggning (%)'),
595
+ x=list(range(24)), y=pivot_hourly.index,
596
+ color_continuous_scale=px.colors.sequential.Greens, # Changed to green
597
+ title='Timvis Beläggningsgrad Heatmap (Aktiva Uttag / Totalt Antal Uttag)',
598
+ zmin=min_val, zmax=max_val # Set fixed min/max for consistent gradient
599
+ )
600
 
601
+ # Add ChargeNode logo to top right (placeholder)
602
+ fig_hourly.add_layout_image(
603
+ dict(
604
+ source="https://chargenode.eu/wp-content/themes/chargenode/dist/assets/img/logo.svg",
605
+ xref="paper", yref="paper",
606
+ x=1.0, y=1.05,
607
+ sizex=0.15, sizey=0.15,
608
+ xanchor="right", yanchor="top"
609
+ )
610
+ )
611
 
612
+ fig_hourly.update_layout(
613
+ coloraxis_colorbar=dict(title='Beläggning', tickformat='.0%'),
614
+ height=max(500, 20 * len(pivot_hourly.index)), margin=dict(l=150, r=20, t=80, b=50),
615
+ xaxis_title="Timme på dygnet", yaxis_title="Datum"
616
+ )
617
+ hour_labels = [f"{h:02d}:00" for h in range(24)]
618
+ fig_hourly.update_xaxes(tickvals=list(range(24)), ticktext=hour_labels, side="top")
619
+ fig_hourly.update_yaxes(autorange="reversed") # Show newest dates at top
620
+ figures['hourly_utilization_heatmap'] = fig_hourly
621
+ else:
622
+ figures['hourly_utilization_heatmap'] = go.Figure().update_layout(title_text='Timvis Beläggningsgrad Heatmap (Ingen data)')
623
+
624
+ # 4b. Aggregated Hourly Utilization (Line chart) - Changed to use green
625
+ if not metrics['avg_hourly_utilization'].empty:
626
+ fig_avg_hourly_util = px.line(
627
+ metrics['avg_hourly_utilization'], x='Hour', y='Utilization', color='Day_Type',
628
+ title='Genomsnittlig Beläggningsgrad per Timme (Vardag vs Helg)',
629
+ labels={'Hour': 'Timme på Dygnet', 'Utilization': 'Genomsnittlig Beläggning', 'Day_Type': 'Dagtyp'},
630
+ markers=True, color_discrete_map={'Vardag': '#27ae60', 'Helg': '#f39c12'} # Changed to green for weekdays
631
+ )
632
+ fig_avg_hourly_util.update_layout(yaxis_tickformat=".0%", xaxis_dtick=1)
633
+ figures['avg_hourly_utilization_line'] = fig_avg_hourly_util
634
+ else:
635
+ figures['avg_hourly_utilization_line'] = go.Figure().update_layout(title_text='Genomsnittlig Beläggningsgrad per Timme (Ingen data)')
636
+
637
+ # 4c. Aggregated Weekday Utilization (Bar chart) - Changed to green
638
+ if not metrics['avg_weekday_utilization'].empty:
639
+ fig_avg_weekday_util = px.bar(
640
+ metrics['avg_weekday_utilization'], x='Weekday', y='Utilization',
641
+ title='Genomsnittlig Beläggningsgrad per Veckodag',
642
+ labels={'Weekday': 'Veckodag', 'Utilization': 'Genomsnittlig Beläggning'},
643
+ color='Utilization', color_continuous_scale=px.colors.sequential.Greens # Changed to green
644
  )
645
+ fig_avg_weekday_util.update_layout(yaxis_tickformat=".0%")
646
+ figures['avg_weekday_utilization_bar'] = fig_avg_weekday_util
647
+ else:
648
+ figures['avg_weekday_utilization_bar'] = go.Figure().update_layout(title_text='Genomsnittlig Beläggningsgrad per Veckodag (Ingen data)')
649
 
650
+ # 5. Heatmap: Monthly Utilization per area - Changed to green
651
+ if not metrics['utilization'].empty and 'Utilization' in metrics['utilization'].columns:
652
+ pivot_util = metrics['utilization'].pivot_table(
653
+ index='Område', columns='Year_Month', values='Utilization'
654
+ ).fillna(0)
655
 
656
+ # Find min/max for consistent gradient
657
+ min_val = pivot_util.values.min()
658
+ max_val = pivot_util.values.max()
659
 
660
+ fig_util = px.imshow(
661
+ pivot_util, labels=dict(x='Månad', y='Område', color='Beläggning (%)'),
662
+ x=pivot_util.columns, y=pivot_util.index,
663
+ color_continuous_scale=px.colors.sequential.Greens, # Changed to green
664
+ title='Månatlig Beläggningsgrad per Område (Baserat på Aktiva Uttagstimmar)',
665
+ zmin=min_val, zmax=max(0.01, max_val) # Set fixed min/max for consistent gradient
666
+ )
667
+ fig_util.update_layout(xaxis_tickangle=-45, coloraxis_colorbar=dict(tickformat='.0%'))
668
+ figures['monthly_utilization_area_heatmap'] = fig_util
669
+ else:
670
+ figures['monthly_utilization_area_heatmap'] = go.Figure().update_layout(title_text='Månatlig Beläggningsgrad per Område (Ingen data)')
671
 
672
+ # 6. Box plot: kWh per OUTLET per month per area (Distribution of individual outlet performance)
673
+ if not metrics['kwh_outlet_month_area'].empty:
674
+ fig_kwh_outlet_dist = px.box(
675
+ metrics['kwh_outlet_month_area'], x='Område', y='Laddat (kWh)', color='Year_Month',
676
+ title='Distribution av Energi (kWh) per Uttag (Månadsvis per Område)',
677
+ labels={'Laddat (kWh)': 'Energi per Uttag (kWh)', 'Område': 'Område', 'Year_Month': 'Månad'},
678
+ color_discrete_sequence=px.colors.qualitative.Vivid
679
+ )
680
+ fig_kwh_outlet_dist.update_layout(xaxis_tickangle=-45)
681
+ figures['kwh_per_outlet_distribution'] = fig_kwh_outlet_dist
682
+ else:
683
+ figures['kwh_per_outlet_distribution'] = go.Figure().update_layout(title_text='Distribution av Energi (kWh) per Uttag (Ingen data)')
684
+
685
+ # 7. Histogram: Session Duration - CHANGED TO HOURS AND REMOVE OUTLIERS OVER 12 HOURS
686
+ if not sessions_df.empty and 'Duration_Hours' in sessions_df.columns:
687
+ # Filter outliers - sessions longer than 12 hours
688
+ filtered_sessions = sessions_df[sessions_df['Duration_Hours'] <= 12]
689
 
690
+ fig_session_duration_hist = px.histogram(
691
+ filtered_sessions, x='Duration_Hours', nbins=50,
692
+ title='Distribution av Sessionslängd (Timmar)',
693
+ labels={'Duration_Hours': 'Sessionslängd (Timmar)', 'count': 'Antal Sessioner'},
694
+ marginal="box", # adds a box plot above histogram
695
+ color_discrete_sequence=['#27ae60'] # Use green color
696
+ )
697
+ fig_session_duration_hist.update_layout(yaxis_title="Antal Sessioner")
698
+ figures['session_duration_histogram'] = fig_session_duration_hist
699
+ else:
700
+ figures['session_duration_histogram'] = go.Figure().update_layout(title_text='Distribution av Sessionslängd (Ingen data)')
701
+
702
+ # 8. Box Plot: Energy per Session by Area
703
+ if not sessions_df.empty and 'Laddat (kWh)' in sessions_df.columns:
704
+ fig_kwh_per_session_area_box = px.box(
705
+ sessions_df, x='Område', y='Laddat (kWh)', color='Område',
706
+ title='Energi (kWh) per Session fördelat på Område',
707
+ labels={'Laddat (kWh)': 'Energi per Session (kWh)', 'Område': 'Område'},
708
+ color_discrete_sequence=px.colors.qualitative.Safe
709
+ )
710
+ fig_kwh_per_session_area_box.update_layout(xaxis_tickangle=-45)
711
+ figures['kwh_per_session_area_box'] = fig_kwh_per_session_area_box
712
+ else:
713
+ figures['kwh_per_session_area_box'] = go.Figure().update_layout(title_text='Energi (kWh) per Session (Ingen data)')
714
+
715
+ return figures
716
+
717
+ # Function to generate HTML report
718
+ def generate_html_report(metrics, figures, selected_graph_keys, selected_areas, date_range_text):
719
+ """
720
+ Generate HTML report with selected graphs and metrics
721
+ """
722
+ # Create header information
723
+ header_info_list = []
724
+ for area in selected_areas:
725
+ area_outlets = metrics['outlets_per_area'][metrics['outlets_per_area']['Område'] == area]['Number_of_Outlets'].values
726
+ if len(area_outlets) > 0:
727
+ header_info_list.append({
728
+ 'Anläggningsnamn': area,
729
+ 'Antal_Uttag': area_outlets[0]
730
+ })
731
+
732
+ current_date_str_report = datetime.now().strftime('%Y-%m-%d %H:%M')
733
+
734
+ # Create HTML header
735
+ html_header_parts = ["<div class='report-title'>ChargeNode - Analysrapport</div>"]
736
+ html_header_parts.append(f"<div class='report-subtitle'>{date_range_text}</div>")
737
+
738
+ if header_info_list:
739
+ html_header_parts.append("<ul class='facility-list'>")
740
+ for facility_info in header_info_list:
741
+ html_header_parts.append(f"<li>{facility_info['Anläggningsnamn']} ({facility_info['Antal_Uttag']} uttag)</li>")
742
+ html_header_parts.append("</ul>")
743
+ else:
744
+ if selected_areas:
745
+ html_header_parts.append(f"<p class='facility-list-empty'>Områden valda: {', '.join(selected_areas)} (ingen sessionsdata för dessa i perioden, eller inga uttag registrerade).</p>")
746
+ else:
747
+ html_header_parts.append("<p class='facility-list-empty'>Inga områden valda för rapporten.</p>")
748
+
749
+ html_header_parts.append(f"<p class='generation-date'>Genererad: {current_date_str_report}</p>")
750
+
751
+ # Build HTML content
752
+ html_content_parts = [f"""
753
+ <!DOCTYPE html>
754
+ <html lang="sv">
755
+ <head>
756
+ <meta charset="UTF-8">
757
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
758
+ <title>ChargeNode - Laddningsrapport</title>
759
+ <style>
760
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
761
 
762
+ body {{
763
+ font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
764
+ margin: 0;
765
+ padding: 0;
766
+ background-color: #f4f7f6;
767
+ color: #333;
768
+ line-height: 1.6;
769
+ }}
770
 
771
+ .container {{
772
+ max-width: 1200px;
773
+ margin: 0 auto;
774
+ padding: 2rem;
775
+ background-color: #fff;
776
+ box-shadow: 0 0 20px rgba(0,0,0,0.1);
777
+ border-radius: 10px;
778
+ overflow: auto;
779
+ }}
780
 
781
+ .header-logo {{
782
+ position: absolute;
783
+ top: 2rem;
784
+ right: 2rem;
785
+ width: 180px;
786
+ height: auto;
787
+ }}
788
 
789
+ .report-header {{
790
+ position: relative;
791
+ padding-bottom: 1.5rem;
792
+ margin-bottom: 2rem;
793
+ border-bottom: 2px solid #27ae60;
794
+ text-align: left;
795
+ }}
796
 
797
+ .report-title {{
798
+ font-size: 2.2rem;
799
+ font-weight: 700;
800
+ color: #27ae60;
801
+ margin-bottom: 0.5rem;
802
+ line-height: 1.2;
803
+ }}
804
 
805
+ .report-subtitle {{
806
+ font-size: 1.4rem;
807
+ color: #555;
808
+ margin-bottom: 1rem;
809
+ font-weight: 400;
810
+ }}
811
+
812
+ .facility-list {{
813
+ list-style-type: none;
814
+ padding-left: 0;
815
+ margin-bottom: 1rem;
816
+ font-size: 1.1rem;
817
+ }}
818
+
819
+ .facility-list li {{
820
+ margin-bottom: 0.3rem;
821
+ display: inline-block;
822
+ margin-right: 1.5rem;
823
+ background-color: #f1f9f1;
824
+ padding: 0.4rem 0.8rem;
825
+ border-radius: 4px;
826
+ border-left: 3px solid #27ae60;
827
+ }}
828
+
829
+ .facility-list-empty {{
830
+ font-style: italic;
831
+ font-size: 1rem;
832
+ color: #777;
833
+ }}
834
+
835
+ .generation-date {{
836
+ font-size: 0.9rem;
837
+ color: #777;
838
+ }}
839
+
840
+ .metrics-container {{
841
+ display: flex;
842
+ flex-wrap: wrap;
843
+ justify-content: space-between;
844
+ margin-bottom: 2rem;
845
+ gap: 20px;
846
+ }}
847
+
848
+ .metric-card {{
849
+ flex: 1 1 calc(25% - 20px);
850
+ min-width: 200px;
851
+ background-color: #f8f9fa;
852
+ border-radius: 8px;
853
+ padding: 1.5rem;
854
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
855
+ text-align: center;
856
+ height: 150px;
857
+ display: flex;
858
+ flex-direction: column;
859
+ justify-content: center;
860
+ border-top: 4px solid #27ae60;
861
+ transition: transform 0.2s, box-shadow 0.2s;
862
+ }}
863
+
864
+ .metric-card:hover {{
865
+ transform: translateY(-5px);
866
+ box-shadow: 0 5px 15px rgba(0,0,0,0.1);
867
+ }}
868
+
869
+ .metric-value {{
870
+ font-size: 2.5rem;
871
+ font-weight: 700;
872
+ color: #27ae60;
873
+ margin-bottom: 0.5rem;
874
+ line-height: 1;
875
+ }}
876
+
877
+ .metric-label {{
878
+ font-size: 1rem;
879
+ color: #555;
880
+ font-weight: 500;
881
+ }}
882
+
883
+ .graph-section {{
884
+ margin-bottom: 3rem;
885
+ background-color: #fff;
886
+ border-radius: 10px;
887
+ padding: 1.5rem;
888
+ box-shadow: 0 2px 8px rgba(0,0,0,0.05);
889
+ }}
890
+
891
+ .graph-title {{
892
+ font-size: 1.5rem;
893
+ color: #27ae60;
894
+ margin-bottom: 1rem;
895
+ padding-bottom: 0.5rem;
896
+ border-bottom: 1px solid #e0e0e0;
897
+ font-weight: 600;
898
+ }}
899
+
900
+ .graph-description {{
901
+ font-size: 1rem;
902
+ color: #666;
903
+ margin-bottom: 1rem;
904
+ font-style: normal;
905
+ line-height: 1.6;
906
+ max-width: 80%;
907
+ }}
908
+
909
+ .plotly-graph-div {{
910
+ border: 1px solid #e0e0e0;
911
+ border-radius: 8px;
912
+ padding: 1rem;
913
+ background-color: #fff;
914
+ margin-bottom: 1rem;
915
+ height: 600px;
916
+ width: 100%;
917
+ box-sizing: border-box;
918
+ }}
919
+
920
+ .print-button-container {{
921
+ position: sticky;
922
+ bottom: 2rem;
923
+ right: 2rem;
924
+ text-align: right;
925
+ z-index: 100;
926
+ margin: 2rem 0;
927
+ }}
928
+
929
+ .print-button {{
930
+ background-color: #27ae60;
931
+ color: white;
932
+ padding: 0.8rem 1.5rem;
933
+ font-size: 1rem;
934
+ border: none;
935
+ border-radius: 5px;
936
+ cursor: pointer;
937
+ transition: background-color 0.3s ease;
938
+ font-weight: 500;
939
+ display: inline-flex;
940
+ align-items: center;
941
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
942
+ }}
943
+
944
+ .print-button:hover {{
945
+ background-color: #219653;
946
+ }}
947
+
948
+ .print-button svg {{
949
+ margin-right: 8px;
950
+ }}
951
+
952
+ .print-tips {{
953
+ margin: 2rem 0;
954
+ padding: 1rem 1.5rem;
955
+ background-color: #f1f9f1;
956
+ border-left: 4px solid #27ae60;
957
+ font-size: 0.9rem;
958
+ border-radius: 0 4px 4px 0;
959
+ }}
960
+
961
+ .print-tips h3 {{
962
+ margin-top: 0;
963
+ color: #27ae60;
964
+ font-size: 1.1rem;
965
+ }}
966
+
967
+ .print-tips ul {{
968
+ margin-bottom: 0;
969
+ padding-left: 1.2rem;
970
+ }}
971
+
972
+ .print-tips li {{
973
+ margin-bottom: 0.5rem;
974
+ }}
975
 
976
+ .print-tips li:last-child {{
977
+ margin-bottom: 0;
978
+ }}
979
+
980
+ .footer {{
981
+ margin-top: 3rem;
982
+ padding-top: 1.5rem;
983
+ border-top: 1px solid #e0e0e0;
984
+ color: #777;
985
+ font-size: 0.9rem;
986
+ text-align: center;
987
+ }}
988
+
989
+ /* Responsive adjustments */
990
+ @media (max-width: 992px) {{
991
+ .container {{
992
+ padding: 1.5rem;
993
+ max-width: 95%;
994
+ }}
995
+
996
+ .metric-card {{
997
+ flex: 1 1 calc(50% - 20px);
998
+ }}
999
+
1000
+ .graph-description {{
1001
+ max-width: 100%;
1002
+ }}
1003
+ }}
1004
+
1005
+ @media (max-width: 768px) {{
1006
+ .container {{
1007
+ padding: 1rem;
1008
+ }}
1009
+
1010
+ .report-title {{
1011
+ font-size: 1.8rem;
1012
+ }}
1013
+
1014
+ .report-subtitle {{
1015
+ font-size: 1.2rem;
1016
+ }}
1017
+
1018
+ .metric-card {{
1019
+ flex: 1 1 100%;
1020
+ }}
1021
+
1022
+ .header-logo {{
1023
+ position: static;
1024
+ display: block;
1025
+ margin: 0 auto 1rem;
1026
+ }}
1027
+
1028
+ .report-header {{
1029
+ text-align: center;
1030
+ }}
1031
+ }}
1032
+
1033
+ /* Print styles */
1034
+ @media print {{
1035
+ @page {{
1036
+ size: A4;;
1037
+ margin: 1cm;
1038
+ }}
1039
+
1040
+ body {{
1041
+ margin: 0;
1042
+ padding: 0;
1043
+ background-color: #fff;
1044
+ font-size: 11pt;
1045
+ }}
1046
+
1047
+ .container {{
1048
+ max-width: 100%;
1049
+ margin: 0;
1050
+ padding: 0;
1051
+ box-shadow: none;
1052
+ border: none;
1053
+ }}
1054
+
1055
+ .print-button-container,
1056
+ .print-tips {{
1057
+ display: none !important;
1058
+ }}
1059
+
1060
+ .graph-section {{
1061
+ page-break-inside: avoid;
1062
+ page-break-after: always;
1063
+ break-inside: avoid;
1064
+ margin-bottom: 20pt;
1065
+ box-shadow: none;
1066
+ border: 1px solid #eee;
1067
+ }}
1068
+
1069
+ .graph-section:last-child {{
1070
+ page-break-after: auto;
1071
+ }}
1072
+
1073
+ .plotly-graph-div {{
1074
+ height: 400pt !important;
1075
+ width: 100% !important;
1076
+ border: none;
1077
+ }}
1078
+
1079
+ .modebar {{
1080
+ display: none !important;
1081
+ }}
1082
+
1083
+ .metric-card {{
1084
+ page-break-inside: avoid;
1085
+ break-inside: avoid;
1086
+ box-shadow: none;
1087
+ border: 1px solid #eee;
1088
+ }}
1089
+ }}
1090
+ </style>
1091
+ </head>
1092
+ <body>
1093
+ <div class="container">
1094
+ <div class="report-header">
1095
+ <img src="https://chargenode.eu/wp-content/themes/chargenode/dist/assets/img/logo.svg" alt="ChargeNode Logo" class="header-logo">
1096
+ {''.join(html_header_parts)}
1097
+ </div>
1098
+
1099
+ <div class="print-tips">
1100
+ <h3>Hur du sparar denna rapport som PDF:</h3>
1101
+ <ul>
1102
+ <li>Klicka på "Skriv ut / Spara som PDF" knappen längst ner på sidan.</li>
1103
+ <li>Välj "Spara som PDF" som destination i utskriftsdialogen.</li>
1104
+ <li>I utskriftsinställningarna, se till att du har markerat "Bakgrundsgrafik" och st��ll in marginalerna till "Minimal" eller "Inga".</li>
1105
+ <li>Förhandsgranska för att kontrollera att alla grafer visas korrekt och klicka sedan på "Spara".</li>
1106
+ </ul>
1107
+ </div>
1108
+
1109
+ <!-- Key Metrics Section -->
1110
+ <div class="graph-section">
1111
+ <h2 class="graph-title">Nyckeltal</h2>
1112
+ <div class="metrics-container">
1113
+ <div class="metric-card">
1114
+ <div class="metric-value">{metrics['area_count']}</div>
1115
+ <div class="metric-label">Antal Områden</div>
1116
+ </div>
1117
+ <div class="metric-card">
1118
+ <div class="metric-value">{metrics['total_outlets']}</div>
1119
+ <div class="metric-label">Totalt Antal Uttag</div>
1120
+ </div>
1121
+ <div class="metric-card">
1122
+ <div class="metric-value">{metrics['total_sessions']:,}</div>
1123
+ <div class="metric-label">Totala Sessioner</div>
1124
+ </div>
1125
+ <div class="metric-card">
1126
+ <div class="metric-value">{metrics['total_kwh']:,.2f}</div>
1127
+ <div class="metric-label">Total Energi (kWh)</div>
1128
+ </div>
1129
+ </div>
1130
+ </div>
1131
+ """
1132
+ ]
1133
+
1134
+ # Explanatory descriptions for each graph
1135
+ graph_descriptions = {
1136
+ 'outlets_per_area': "Visar det totala antalet unika ladduttag som har använts inom den valda tidsperioden och för de valda områdena. Detta hjälper att få en översikt över fördelningen av laddinfrastruktur.",
1137
+ 'kwh_per_month_area': "Stapeldiagram som visar den totala laddade energin (kWh) per månad, uppdelat per valt område. Användbart för att följa energitrender över tid och mellan olika platser.",
1138
+ 'cost_per_month_area': "Stapeldiagram som visar den totala kostnaden (exklusive moms) per månad, uppdelat per valt område. Ger insikt i ekonomiska aspekter över tid och per plats.",
1139
+ 'avg_kwh_per_outlet': "Linjediagram som visar den genomsnittliga mängden energi (kWh) som varje aktivt uttag har levererat per månad, sett över alla valda områden. En ökande trend kan indikera effektivare användning eller längre sessioner.",
1140
+ 'hourly_utilization_heatmap': "Heatmap som visualiserar beläggningsgraden för varje timme och dag. Mörkare grön färg indikerar högre beläggning. Detta ger en tydlig bild av användningsmönster över tid och hjälper till att identifiera perioder med hög efterfrågan.",
1141
+ 'avg_hourly_utilization_line': "Linjediagram som visar den genomsnittliga beläggningsgraden fördelat per timme på dygnet, uppdelat på vardagar och helger. Detta hjälper att identifiera tidpunkter för mest aktiv användning.",
1142
+ 'avg_weekday_utilization_bar': "Stapeldiagram som jämför den genomsnittliga beläggningsgraden för varje veckodag. Perfekt för att planera underhåll eller identifiera mönster i veckoanvändning.",
1143
+ 'monthly_utilization_area_heatmap': "Heatmap som visar den beräknade månatliga beläggningsgraden per område. Detta ger insikt i hur effektivt laddinfrastrukturen används över tid i olika områden.",
1144
+ 'session_duration_histogram': "Histogram som visar fördelningen av längden på laddningssessionerna (i timmar). Outliers över 12 timmar har filtrerats bort för att ge en tydligare bild av vanliga laddningsmönster.",
1145
+ 'kwh_per_session_area_box': "Boxplot som illustrerar spridningen av laddad energi (kWh) per session, för varje valt område. Visar median, kvartiler och ger insikt i typiska laddningsvolymer.",
1146
+ 'kwh_per_outlet_distribution': "Boxplot som visar fördelningen av total energi (kWh) som varje enskilt uttag har levererat under en månad, per område. Hjälper till att identifiera hög- och lågpresterande uttag."
1147
+ }
1148
+
1149
+ # Add selected graphs
1150
+ plotly_js_added = False
1151
+
1152
+ for key in selected_graph_keys:
1153
+ if key in figures:
1154
+ fig = figures[key]
1155
 
1156
+ # Get graph display name and description
1157
+ graph_display_names = {
1158
+ 'outlets_per_area': 'Antal Uttag per Område',
1159
+ 'kwh_per_month_area': 'Energi per Månad och Område',
1160
+ 'cost_per_month_area': 'Kostnad per Månad och Område',
1161
+ 'avg_kwh_per_outlet': 'Genomsnittlig kWh per Uttag per Månad',
1162
+ 'hourly_utilization_heatmap': 'Timvis Beläggningsgrad (Heatmap)',
1163
+ 'avg_hourly_utilization_line': 'Genomsnittlig Beläggning per Timme',
1164
+ 'avg_weekday_utilization_bar': 'Genomsnittlig Beläggning per Veckodag',
1165
+ 'monthly_utilization_area_heatmap': 'Månatlig Beläggning per Område',
1166
+ 'session_duration_histogram': 'Distribution av Sessionslängd',
1167
+ 'kwh_per_session_area_box': 'Energi per Session per Område',
1168
+ 'kwh_per_outlet_distribution': 'Distribution av Energi per Uttag'
1169
+ }
1170
 
1171
+ graph_title = fig.layout.title.text if hasattr(fig.layout, 'title') and fig.layout.title else graph_display_names.get(key, key)
1172
+ graph_description = graph_descriptions.get(key, "")
1173
 
1174
+ # Optimize graph for better display
1175
+ fig.update_layout(
1176
+ autosize=True,
1177
+ height=600,
1178
+ margin=dict(l=80, r=40, t=100, b=80),
1179
+ font=dict(size=14),
1180
+ title=dict(
1181
+ font=dict(size=18),
1182
+ y=0.95
1183
+ )
1184
  )
1185
+
1186
+ html_content_parts.append(f"<div class='graph-section'><h2 class='graph-title'>{graph_title}</h2>")
1187
+ if graph_description:
1188
+ html_content_parts.append(f"<p class='graph-description'>{graph_description}</p>")
1189
+
1190
+ try:
1191
+ # Include Plotly.js only once
1192
+ if not plotly_js_added:
1193
+ graph_html = fig.to_html(full_html=False, include_plotlyjs='cdn', config={'displayModeBar': False})
1194
+ plotly_js_added = True
1195
+ else:
1196
+ graph_html = fig.to_html(full_html=False, include_plotlyjs=False, config={'displayModeBar': False})
1197
+ html_content_parts.append(graph_html)
1198
+ except Exception as e:
1199
+ html_content_parts.append(f"<p><strong>Fel:</strong> Kunde inte rendera grafen '{graph_title}'. ({e})</p>")
1200
+
1201
+ html_content_parts.append("</div>")
1202
+
1203
+ # Add print button and footer
1204
+ html_content_parts.append("""
1205
+ <div class="print-button-container">
1206
+ <button class="print-button" onclick="window.print()">
1207
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1208
+ <polyline points="6 9 6 2 18 2 18 9"></polyline>
1209
+ <path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
1210
+ <rect x="6" y="14" width="12" height="8"></rect>
1211
+ </svg>
1212
+ Skriv ut / Spara som PDF
1213
+ </button>
1214
+ </div>
1215
+
1216
+ <div class="footer">
1217
+ <p>ChargeNode Rapport Generator © 2025. Alla data är konfidentiella och endast för internt bruk.</p>
1218
+ </div>
1219
+ """)
1220
+
1221
+ # Close HTML structure
1222
+ html_content_parts.append("</div></body></html>")
1223
+
1224
+ # Join HTML parts
1225
+ final_html = "".join(html_content_parts)
1226
+ return final_html
1227
+
1228
+ # --- Main application logic ---
1229
+ if sessions_file is not None: # Overview file is now optional
1230
+ try:
1231
+ st.info("Läser in och förbereder data...")
1232
+ sessions_df_original = pd.read_excel(sessions_file)
1233
+ overview_df_original = pd.read_excel(overview_file) if overview_file else None
1234
+
1235
+ st.sidebar.success("Data inläst!")
1236
+
1237
+ st.sidebar.header("Filter")
1238
 
1239
+ sessions_df_processed, overview_df_processed = preprocess_data(sessions_df_original.copy(), overview_df_original.copy() if overview_df_original is not None else None) # Use copies
1240
+
1241
+ if sessions_df_processed.empty:
1242
+ st.error("Ingen sessionsdata hittades eller all data filtrerades bort under förbehandling. Kontrollera din fil.")
1243
+ st.stop()
1244
+
1245
+ all_areas = sorted(sessions_df_processed['Område'].unique())
1246
+ selected_areas = st.sidebar.multiselect("Välj Områden", options=all_areas, default=all_areas)
1247
+
1248
+ # Date range filter
1249
+ min_data_date = sessions_df_processed['Startad'].min().date()
1250
+ max_data_date = sessions_df_processed['Startad'].max().date()
1251
+
1252
+ selected_date_range = st.sidebar.date_input(
1253
+ "Välj Datumintervall",
1254
+ value=(min_data_date, max_data_date),
1255
+ min_value=min_data_date,
1256
+ max_value=max_data_date,
1257
+ key="date_range_filter"
1258
+ )
1259
+
1260
+ # Generate date range text for reports
1261
+ if len(selected_date_range) == 2:
1262
+ start_date_filter = pd.to_datetime(selected_date_range[0])
1263
+ end_date_filter = pd.to_datetime(selected_date_range[1]) + pd.Timedelta(days=1) # Include the whole end day
1264
+ filtered_sessions = sessions_df_processed[
1265
+ (sessions_df_processed['Område'].isin(selected_areas)) &
1266
+ (sessions_df_processed['Startad'] >= start_date_filter) &
1267
+ (sessions_df_processed['Startad'] < end_date_filter)
1268
+ ]
1269
+ date_range_text = f"Period: {selected_date_range[0].strftime('%Y-%m-%d')} till {selected_date_range[1].strftime('%Y-%m-%d')}"
1270
+ else: # Default to all dates if range is not correctly selected
1271
+ filtered_sessions = sessions_df_processed[sessions_df_processed['Område'].isin(selected_areas)]
1272
+ date_range_text = f"Period: {min_data_date.strftime('%Y-%m-%d')} till {max_data_date.strftime('%Y-%m-%d')}"
1273
+
1274
+ if filtered_sessions.empty:
1275
+ st.warning("Ingen data matchar de valda filtren. Prova att ändra filterinställningarna.")
1276
+ else:
1277
+ st.info("Beräknar nyckeltal...")
1278
+ metrics = calculate_metrics(filtered_sessions, overview_df_processed)
1279
+
1280
+ st.info("Skapar visualiseringar...")
1281
+ figures = create_visualizations(metrics, filtered_sessions) # Pass filtered_sessions for detailed plots
1282
+
1283
+ # Clear info messages
1284
+ st.empty() # Clears the last message, may need more if they stack
1285
+ st.empty()
1286
+
1287
+ # --- Display Dashboard ---
1288
+ tab_titles = [
1289
+ "📊 Nyckeltal & Översikt",
1290
+ "⏱️ Beläggningsanalys",
1291
+ "💡 Energiförbrukning",
1292
+ "🔌 Sessionsanalys"
1293
+ ]
1294
+ tab1, tab2, tab3, tab4 = st.tabs(tab_titles)
1295
+
1296
+ with tab1:
1297
+ st.header("Nyckeltal")
1298
+ cols = st.columns(4)
1299
+ key_metrics_display = {
1300
+ "Antal Områden": metrics.get('area_count', 'N/A'),
1301
+ "Totalt Antal Uttag": f"{metrics.get('total_outlets', 'N/A'):,}",
1302
+ "Totala Sessioner": f"{metrics.get('total_sessions', 'N/A'):,}",
1303
+ "Total Energi (kWh)": f"{metrics.get('total_kwh', 0):,.2f}"
1304
+ }
1305
+ for i, (label, value) in enumerate(key_metrics_display.items()):
1306
+ with cols[i % 4]:
1307
+ st.markdown(f"""
1308
+ <div class="metric-card">
1309
+ <div class="metric-value">{value}</div>
1310
+ <div class="metric-label">{label}</div>
1311
+ </div>""", unsafe_allow_html=True)
1312
+ st.markdown("---")
1313
+ st.subheader(figures['outlets_per_area'].layout.title.text)
1314
+ st.caption("Visar det totala antalet unika ladduttag som har använts inom den valda tidsperioden och för de valda områdena.")
1315
+ st.plotly_chart(figures['outlets_per_area'], use_container_width=True)
1316
+
1317
+ with tab2:
1318
+ st.header("Beläggningsanalys")
1319
+ st.subheader(figures['hourly_utilization_heatmap'].layout.title.text)
1320
+ st.caption("Heatmap som visualiserar beläggningsgraden (andelen aktiva uttag av totalt antal uttag i de valda områdena) för varje timme och dag. Mörkare grön färg indikerar högre beläggning. Detta hjälper till att identifiera mönster och tider med hög/låg efterfrågan.")
1321
+ st.plotly_chart(figures['hourly_utilization_heatmap'], use_container_width=True)
1322
+
1323
+ st.subheader(figures['avg_hourly_utilization_line'].layout.title.text)
1324
+ st.caption("Linjediagram som visar den genomsnittliga beläggningsgraden fördelat per timme på dygnet, uppdelat på vardagar och helger. Toppar indikerar perioder med högst genomsnittlig användning.")
1325
+ st.plotly_chart(figures['avg_hourly_utilization_line'], use_container_width=True)
1326
+
1327
+ st.subheader(figures['avg_weekday_utilization_bar'].layout.title.text)
1328
+ st.caption("Stapeldiagram som jämför den genomsnittliga beläggningsgraden för varje veckodag. Ger en snabb överblick över vilka dagar som har högst respektive lägst användning.")
1329
+ st.plotly_chart(figures['avg_weekday_utilization_bar'], use_container_width=True)
1330
+
1331
+ st.subheader(figures['monthly_utilization_area_heatmap'].layout.title.text)
1332
+ st.caption("Heatmap som visar den beräknade månatliga beläggningsgraden per område. Beläggningen är här definierad som totala antalet timmar uttagen varit aktiva i förhållande till det totala antalet möjliga drifttimmar för alla uttag i området under månaden.")
1333
+ st.plotly_chart(figures['monthly_utilization_area_heatmap'], use_container_width=True)
1334
+
1335
+ with tab3:
1336
+ st.header("Energiförbrukning och Kostnad")
1337
+ st.subheader(figures['kwh_per_month_area'].layout.title.text)
1338
+ st.caption("Stapeldiagram som visar den totala laddade energin (kWh) per månad, uppdelat per valt område. Användbart för att följa energitrender över tid och mellan olika platser.")
1339
+ st.plotly_chart(figures['kwh_per_month_area'], use_container_width=True)
1340
+
1341
+ st.subheader(figures['cost_per_month_area'].layout.title.text)
1342
+ st.caption("Stapeldiagram som visar den totala kostnaden (exklusive moms) per månad, uppdelat per valt område. Ger insikt i ekonomiska aspekter över tid och per plats.")
1343
+ st.plotly_chart(figures['cost_per_month_area'], use_container_width=True)
1344
+
1345
+ st.subheader(figures['avg_kwh_per_outlet'].layout.title.text)
1346
+ st.caption("Linjediagram som visar den genomsnittliga mängden energi (kWh) som varje aktivt uttag har levererat per månad, sett över alla valda områden. En ökande trend kan indikera effektivare användning eller längre sessioner per uttag.")
1347
+ st.plotly_chart(figures['avg_kwh_per_outlet'], use_container_width=True)
1348
+
1349
+ with tab4:
1350
+ st.header("Sessionsanalys")
1351
+ st.subheader(figures['session_duration_histogram'].layout.title.text)
1352
+ st.caption("Histogram som visar fördelningen av längden på laddningssessionerna (i timmar). Outliers över 12 timmar har filtrerats bort. Detta ger en bild av hur länge användare typiskt laddar sina fordon.")
1353
+ st.plotly_chart(figures['session_duration_histogram'], use_container_width=True)
1354
+
1355
+ st.subheader(figures['kwh_per_session_area_box'].layout.title.text)
1356
+ st.caption("Boxplot som illustrerar spridningen (median, kvartiler, och extremvärden) av laddad energi (kWh) per session, för varje valt område. Hjälper till att förstå typisk energimängd per laddning och variationen mellan områden.")
1357
+ st.plotly_chart(figures['kwh_per_session_area_box'], use_container_width=True)
1358
+
1359
+ st.subheader(figures['kwh_per_outlet_distribution'].layout.title.text)
1360
+ st.caption("Boxplot som visar fördelningen av total energi (kWh) som varje enskilt uttag har levererat under en månad, per område. Detta kan hjälpa till att identifiera uttag som är särskilt hög- eller lågpresterande inom ett område.")
1361
+ st.plotly_chart(figures['kwh_per_outlet_distribution'], use_container_width=True)
1362
+
1363
+ # --- Export Section ---
1364
+ st.sidebar.markdown("---")
1365
+ st.sidebar.header("Exportera Rapport")
1366
+
1367
+ # Initialize session state for graph selection
1368
+ if 'default_selected_graphs' not in st.session_state:
1369
+ st.session_state.default_selected_graphs = list(figures.keys())
1370
+ if 'all_graph_keys' not in st.session_state:
1371
+ st.session_state.all_graph_keys = list(figures.keys())
1372
+ if 'select_all_toggle' not in st.session_state:
1373
+ st.session_state.select_all_toggle = True
1374
+
1375
+ # Section for HTML report
1376
+ st.sidebar.subheader("HTML Rapport")
1377
+
1378
+ # Buttons for selecting/deselecting all graphs
1379
+ col1, col2 = st.sidebar.columns(2)
1380
+ if col1.button("Markera alla", key="select_all_html"):
1381
+ st.session_state.select_all_toggle = True
1382
+ st.session_state.default_selected_graphs = st.session_state.all_graph_keys
1383
+ st.rerun()
1384
+
1385
+ if col2.button("Avmarkera alla", key="deselect_all_html"):
1386
+ st.session_state.select_all_toggle = False
1387
+ st.session_state.default_selected_graphs = []
1388
+ st.rerun()
1389
+
1390
+ # If select_all_toggle is true and no graphs are selected, select all
1391
+ if st.session_state.select_all_toggle and not st.session_state.default_selected_graphs and st.session_state.all_graph_keys:
1392
+ st.session_state.default_selected_graphs = st.session_state.all_graph_keys
1393
+
1394
+ # Map of more readable graph names for display
1395
+ graph_display_names = {
1396
+ 'outlets_per_area': 'Antal Uttag per Område',
1397
+ 'kwh_per_month_area': 'Energi per Månad och Område',
1398
+ 'cost_per_month_area': 'Kostnad per Månad och Område',
1399
+ 'avg_kwh_per_outlet': 'Genomsnittlig kWh per Uttag per Månad',
1400
+ 'hourly_utilization_heatmap': 'Timvis Beläggningsgrad (Heatmap)',
1401
+ 'avg_hourly_utilization_line': 'Genomsnittlig Beläggning per Timme',
1402
+ 'avg_weekday_utilization_bar': 'Genomsnittlig Beläggning per Veckodag',
1403
+ 'monthly_utilization_area_heatmap': 'Månatlig Beläggning per Område',
1404
+ 'session_duration_histogram': 'Distribution av Sessionslängd',
1405
+ 'kwh_per_session_area_box': 'Energi per Session per Område',
1406
+ 'kwh_per_outlet_distribution': 'Distribution av Energi per Uttag'
1407
+ }
1408
+
1409
+ # Multiselect for graph selection
1410
+ selected_graph_keys = st.sidebar.multiselect(
1411
+ "Välj grafer för HTML-rapport:",
1412
+ options=st.session_state.all_graph_keys,
1413
+ format_func=lambda key: graph_display_names.get(key, str(key)),
1414
+ default=st.session_state.default_selected_graphs,
1415
+ key="graph_selector_html"
1416
  )
 
1417
 
1418
+ # Update default_selected_graphs to remember selection
1419
+ st.session_state.default_selected_graphs = selected_graph_keys
1420
+
1421
+ # Generate HTML button
1422
+ if st.sidebar.button("Generera HTML-rapport"):
1423
+ if not selected_graph_keys:
1424
+ st.sidebar.warning("Vänligen välj minst en graf att inkludera i rapporten.")
1425
+ else:
1426
+ # Generate HTML report
1427
+ final_html = generate_html_report(metrics, figures, selected_graph_keys, selected_areas, date_range_text)
1428
+
1429
+ # Create download button
1430
+ report_filename = f"ChargeNode_Rapport_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
1431
+ st.sidebar.download_button(
1432
+ label="📥 Ladda ner HTML-rapporten",
1433
+ data=final_html,
1434
+ file_name=report_filename,
1435
+ mime="text/html",
1436
+ key="download_html_button"
1437
+ )
1438
+ st.sidebar.success(f"HTML-rapport '{report_filename}' genererad och redo för nedladdning!")
1439
+
1440
+ # Show preview (optional)
1441
+ with st.sidebar.expander("Förhandsgranska HTML-rapport"):
1442
+ st.markdown(f"<iframe srcdoc='{final_html}' width='100%' height='400px'></iframe>", unsafe_allow_html=True)
1443
+
1444
+ # PDF instructions
1445
+ with st.sidebar.expander("Så här skapar du en PDF-rapport"):
1446
+ st.markdown("""
1447
+ ### Skapa PDF från HTML-rapporten
1448
+
1449
+ 1. Generera och ladda ner HTML-rapporten först genom att klicka på knappen "Generera HTML-rapport"
1450
+ 2. Öppna HTML-filen i Chrome, Firefox eller Edge
1451
+ 3. Tryck på "Skriv ut" (Ctrl+P eller ⌘+P) eller använd knappen längst ner i rapporten
1452
+ 4. Välj "Spara som PDF" som destination/skrivare
1453
+ 5. Kontrollera att "Bakgrundsgrafik" är markerat i utskriftsalternativen
1454
+ 6. Ställ in marginalerna till "Minimal" eller "Inga" för bästa resultat
1455
+ 7. Klicka på "Spara" för att skapa PDF-filen
1456
 
1457
+ Detta ger dig en professionell rapport med ChargeNode grafik och alla valda visualiseringar.
1458
+ """)
 
 
 
1459
 
1460
+ except FileNotFoundError:
1461
+ st.error(f"Fel: En eller båda Excel-filerna kunde inte hittas. Kontrollera filnamn och sökvägar.")
1462
+ except ValueError as ve:
1463
+ st.error(f"Värdefel vid databehandling: {ve}. Kontrollera att dina datafiler har förväntat format och innehåll, särskilt datum och numeriska värden.")
1464
  except Exception as e:
1465
+ st.error(f"Ett oväntat fel inträffade: {e}")
1466
+ st.exception(e) # Shows full traceback for debugging
1467
+
1468
+ elif sessions_file is None and overview_file is not None:
1469
+ st.warning("Ladda upp 'Sessions.xlsx' för att påbörja analysen. 'Overview.xlsx' är valfri.")
1470
  else:
1471
+ st.info("Vänligen ladda upp 'Sessions.xlsx' för att visualisera data. 'Overview.xlsx' är valfri men kan ge ytterligare kontext.")