import streamlit as st import pandas as pd import plotly.express as px import plotly.graph_objects as go from plotly.subplots import make_subplots import numpy as np import tempfile import os import time from datetime import datetime import calendar import base64 from PIL import Image as PILImage import io # Set page configuration st.set_page_config( page_title="ChargeNode Rapport Generator", page_icon="⚡", layout="wide", initial_sidebar_state="expanded" ) # Custom CSS for styling st.markdown(""" """, unsafe_allow_html=True) # Page title st.title("⚡ ChargeNode Rapport Generator - Internt verktyg för rapportskapande") st.markdown("Ladda upp dina datafiler för att analysera prestanda och användning av ladduttag.") # File upload section st.sidebar.header("Ladda upp Datafiler") sessions_file = st.sidebar.file_uploader("Ladda upp Sessions.xlsx", type=["xlsx"]) overview_file = st.sidebar.file_uploader("Ladda upp Overview.xlsx (Valfri)", type=["xlsx"]) # Made optional # Expected column names and their potential alternatives EXPECTED_COLUMNS = { 'Startad': ['Startad', 'Start', 'Started', 'Start Time', 'StartTime', 'Beginning', 'Start Date'], 'Avslutad': ['Avslutad', 'End', 'Ended', 'End Time', 'EndTime', 'Finish', 'End Date'], 'Laddat (kWh)': ['Laddat (kWh)', 'kWh', 'Energy', 'Charged', 'Energy (kWh)', 'Power', 'Consumption'], 'Kostnad (exkl)': ['Kostnad (exkl)', 'Cost', 'Price', 'Cost (excl)', 'Fee', 'Charge', 'Amount'], 'Uttag': ['Uttag', 'Outlet', 'Charger', 'Outlet ID', 'Charger ID', 'Terminal', 'Station'], 'Område': ['Område', 'Area', 'Location', 'Region', 'Zone', 'Site', 'Place'] } # Function to find matching columns def find_matching_columns(df, expected_columns_dict): column_mapping = {} available_columns = df.columns.tolist() for expected_col, alternatives in expected_columns_dict.items(): found = False # First try exact matches for alt in alternatives: if alt in available_columns: column_mapping[expected_col] = alt # Store as expected:actual found = True break # If no exact match, try case-insensitive matching if not found: for col in available_columns: for alt in alternatives: if col.lower() == alt.lower(): column_mapping[expected_col] = col # Store as expected:actual found = True break if found: break # Invert mapping for renaming: actual_name -> expected_name rename_map = {v: k for k, v in column_mapping.items()} return rename_map # Function to extract area code and name from combined format def extract_area_code_and_name(area_string): """ Extract area code and name from a string in the format '2571 - Stena Hildedalsgatan' Returns tuple of (code, name) """ try: if not isinstance(area_string, str): return (str(area_string), str(area_string)) area_string = area_string.strip() if ' - ' in area_string: # Format is "2571 - Stena Hildedalsgatan" parts = area_string.split(' - ', 1) code = parts[0].strip() name = parts[1].strip() if len(parts) > 1 else "" return (code, name) else: # Just return the whole string if it doesn't match the expected format return (area_string, area_string) except: return (str(area_string), str(area_string)) # Function to preprocess data def preprocess_data(sessions_df, overview_df): st.write("Tillgängliga kolumner i Sessions-filen:", sessions_df.columns.tolist()) if overview_df is not None: st.write("Tillgängliga kolumner i Overview-filen:", overview_df.columns.tolist()) column_rename_map = find_matching_columns(sessions_df, EXPECTED_COLUMNS) if column_rename_map: sessions_df = sessions_df.rename(columns=column_rename_map) required_columns = ['Startad', 'Avslutad', 'Laddat (kWh)', 'Kostnad (exkl)', 'Uttag', 'Område'] missing_columns = [col for col in required_columns if col not in sessions_df.columns] if missing_columns: st.warning(f"Följande obligatoriska kolumner saknas: {', '.join(missing_columns)}") st.warning("Skapar platshållardata för saknade kolumner. För korrekt analys, se till att din data har dessa kolumner.") if 'Startad' not in sessions_df.columns: sessions_df['Startad'] = pd.to_datetime('2023-01-01T00:00:00') if 'Avslutad' not in sessions_df.columns: sessions_df['Avslutad'] = pd.to_datetime('2023-01-01T01:00:00') if 'Laddat (kWh)' not in sessions_df.columns: sessions_df['Laddat (kWh)'] = 10.0 if 'Kostnad (exkl)' not in sessions_df.columns: sessions_df['Kostnad (exkl)'] = 5.0 if 'Uttag' not in sessions_df.columns: sessions_df['Uttag'] = "Uttag_1" if 'Område' not in sessions_df.columns: sessions_df['Område'] = 'Default Area' try: sessions_df['Startad'] = pd.to_datetime(sessions_df['Startad']) sessions_df['Avslutad'] = pd.to_datetime(sessions_df['Avslutad']) except Exception as e: st.error(f"Fel vid konvertering av datumkolumner: {e}. Kontrollera formatet.") st.stop() sessions_df['Year'] = sessions_df['Startad'].dt.year sessions_df['Month'] = sessions_df['Startad'].dt.month sessions_df['Month_Name'] = sessions_df['Startad'].dt.strftime('%b') sessions_df['Year_Month'] = sessions_df['Startad'].dt.strftime('%Y-%m') sessions_df['Hour'] = sessions_df['Startad'].dt.hour sessions_df['Weekday'] = sessions_df['Startad'].dt.day_name() sessions_df['Day_Type'] = np.where(sessions_df['Startad'].dt.weekday < 5, 'Vardag', 'Helg') sessions_df['Duration_Minutes'] = (sessions_df['Avslutad'] - sessions_df['Startad']).dt.total_seconds() / 60 sessions_df['Duration_Hours'] = sessions_df['Duration_Minutes'] / 60 sessions_df.loc[sessions_df['Duration_Hours'] < 0, 'Duration_Hours'] = 0 sessions_df.loc[sessions_df['Duration_Minutes'] < 0, 'Duration_Minutes'] = 0 for col in ['Laddat (kWh)', 'Kostnad (exkl)']: if col in sessions_df.columns: if sessions_df[col].dtype == object: sessions_df[col] = sessions_df[col].astype(str).str.replace(',', '.').astype(float) sessions_df[col] = pd.to_numeric(sessions_df[col], errors='coerce').fillna(0) if 'Uttag' in sessions_df.columns: sessions_df['Uttag'] = sessions_df['Uttag'].astype(str) # Remove sessions with zero or negative duration sessions_df = sessions_df[sessions_df['Duration_Minutes'] > 1] # Handle area code formats in overview file if overview_df is not None and not overview_df.empty: first_col = overview_df.columns[0] # Check if the first column might contain area codes in the format "2571 - Stena Hildedalsgatan" sample_values = overview_df[first_col].astype(str).head().tolist() has_area_code_format = any(' - ' in val for val in sample_values) if has_area_code_format: # Extract area codes and names extracted = overview_df[first_col].apply(extract_area_code_and_name) overview_df['AreaCode'] = extracted.apply(lambda x: x[0]) overview_df['AreaName'] = extracted.apply(lambda x: x[1]) # Try to convert AreaCode to numeric if possible try: overview_df['AreaCode'] = pd.to_numeric(overview_df['AreaCode'], errors='coerce').fillna(overview_df['AreaCode']) except: pass # Keep as string if conversion fails # Also check if there are separate area code and name columns in the sessions file if 'Område' in sessions_df.columns: # Look for potential area code column possible_code_columns = [col for col in sessions_df.columns if any(keyword in col.lower() for keyword in ['områdeskod', 'omradeskod', 'area code', 'areakod', 'kod'])] if possible_code_columns: area_code_col = possible_code_columns[0] # Ensure the area code column is properly formatted as string for comparison sessions_df[area_code_col] = sessions_df[area_code_col].astype(str).str.strip() # Look for a potential area name column possible_name_columns = [col for col in sessions_df.columns if any(keyword in col.lower() for keyword in ['områdesnamn', 'omradesnamn', 'area name', 'areanamn', 'namn'])] area_name_col = possible_name_columns[0] if possible_name_columns else None # If we have both area codes in the overview file and separate columns in sessions, we can match them if has_area_code_format: # Create a mapping from area code to original format area_mapping = {} for _, row in overview_df.iterrows(): area_mapping[str(row['AreaCode'])] = row[first_col] # Update the Område column to match the format in overview file sessions_df['Område_Original'] = sessions_df['Område'] # Keep original for reference # Update based on area code sessions_df['Område'] = sessions_df[area_code_col].map(area_mapping).fillna(sessions_df['Område']) return sessions_df, overview_df # Function to calculate metrics def calculate_metrics(sessions_df, overview_df): metrics = {} if sessions_df.empty: st.warning("Ingen data att analysera efter filtrering eller i den uppladdade filen.") # Return default empty metrics metrics['unique_areas'] = [] metrics['area_count'] = 0 metrics['outlets_per_area'] = pd.DataFrame(columns=['Område', 'Number_of_Outlets']) metrics['total_outlets'] = 0 metrics['kwh_per_month_area'] = pd.DataFrame(columns=['Område', 'Year_Month', 'Laddat (kWh)']) metrics['cost_per_month_area'] = pd.DataFrame(columns=['Område', 'Year_Month', 'Kostnad (exkl)']) metrics['kwh_per_month'] = pd.DataFrame(columns=['Year_Month', 'Laddat (kWh)']) metrics['avg_kwh_per_outlet_month'] = pd.DataFrame(columns=['Year_Month', 'Avg_kWh_per_Outlet']) metrics['kwh_outlet_month_area'] = pd.DataFrame(columns=['Område', 'Year_Month', 'Uttag', 'Laddat (kWh)']) metrics['hourly_utilization'] = pd.DataFrame(columns=['Date', 'Hour', 'IsWeekend', 'Outlets_In_Use', 'Total_Outlets', 'Utilization', 'Date_Str']) metrics['utilization'] = pd.DataFrame(columns=['Område', 'Year_Month', 'Used_Outlet_Hours', 'Total_Possible_Outlet_Hours', 'Utilization']) metrics['total_kwh'] = 0 metrics['total_cost'] = 0 metrics['total_sessions'] = 0 metrics['avg_kwh_per_session'] = 0 metrics['avg_duration_minutes'] = 0 metrics['avg_hourly_utilization'] = pd.DataFrame(columns=['Hour', 'Day_Type', 'Utilization']) metrics['avg_weekday_utilization'] = pd.DataFrame(columns=['Weekday', 'Utilization']) return metrics unique_areas = sessions_df['Område'].unique() metrics['unique_areas'] = unique_areas metrics['area_count'] = len(unique_areas) # Calculate outlets per area from sessions outlets_per_area = sessions_df.groupby('Område')['Uttag'].nunique().reset_index() outlets_per_area.columns = ['Område', 'Number_of_Outlets'] # If overview_df is available, use column 4 for a more accurate count of outlets total_outlets = 0 if overview_df is not None and not overview_df.empty: try: # Use column 4 (index 3) for outlet counts as specified in the problem outlet_col_idx = 3 # 0-based indexing, so column 4 is index 3 if outlet_col_idx < len(overview_df.columns): outlet_column = overview_df.columns[outlet_col_idx] # Use first column for area identification area_id_col = overview_df.columns[0] # If we extracted area codes earlier, use them for matching if 'AreaCode' in overview_df.columns: # Convert column 4 to numeric if it's not already if overview_df[outlet_column].dtype == object: overview_df[outlet_column] = pd.to_numeric(overview_df[outlet_column], errors='coerce').fillna(0) # Group by area code and sum the outlet counts outlet_counts = overview_df.groupby('AreaCode')[outlet_column].sum().reset_index() outlet_counts.columns = ['AreaCode', 'Number_of_Outlets'] # Create a mapping from original area format to area code for matching area_code_map = {} for _, row in overview_df.iterrows(): area_full = row[area_id_col] area_code = row['AreaCode'] if 'AreaCode' in overview_df.columns else area_full area_code_map[str(area_full)] = str(area_code) # Update outlets_per_area with counts from overview updated_outlets_per_area = [] for _, row in outlets_per_area.iterrows(): area = row['Område'] num_outlets = row['Number_of_Outlets'] # Try to find the area code from the session area area_code = None # Check if the area is already in the format "code - name" if ' - ' in str(area): area_code = extract_area_code_and_name(area)[0] else: # Otherwise try to find it in the mapping for full_name, code in area_code_map.items(): if str(area) == str(full_name) or str(area) == str(code): area_code = code break # If we found a matching area code, update the outlet count if area_code is not None and str(area_code) in outlet_counts['AreaCode'].astype(str).values: matching_row = outlet_counts[outlet_counts['AreaCode'].astype(str) == str(area_code)] if not matching_row.empty: num_outlets = matching_row.iloc[0]['Number_of_Outlets'] updated_outlets_per_area.append({ 'Område': area, 'Number_of_Outlets': num_outlets }) # Replace the original outlets_per_area with the updated one outlets_per_area = pd.DataFrame(updated_outlets_per_area) else: # If no AreaCode column, try matching directly by the first column if overview_df[outlet_column].dtype == object: overview_df[outlet_column] = pd.to_numeric(overview_df[outlet_column], errors='coerce').fillna(0) outlet_counts = overview_df.groupby(area_id_col)[outlet_column].sum().reset_index() outlet_counts.columns = ['Område', 'Number_of_Outlets'] # Try to update outlets_per_area based on area names for i, row in outlets_per_area.iterrows(): area = row['Område'] for j, count_row in outlet_counts.iterrows(): overview_area = count_row['Område'] # Check for exact match or if one contains the other if str(area) == str(overview_area) or str(area) in str(overview_area) or str(overview_area) in str(area): outlets_per_area.at[i, 'Number_of_Outlets'] = count_row['Number_of_Outlets'] break # Calculate total from the updated outlets_per_area total_outlets = outlets_per_area['Number_of_Outlets'].sum() else: st.warning(f"Column 4 not found in overview file. Using data from sessions instead.") total_outlets = int(outlets_per_area['Number_of_Outlets'].sum()) except Exception as e: st.warning(f"Kunde inte beräkna antalet uttag från översiktsfilen: {e}") import traceback st.warning(traceback.format_exc()) # More detailed error for debugging # Fall back to using only session data total_outlets = int(outlets_per_area['Number_of_Outlets'].sum()) else: # If no overview file, just use the active outlets from sessions total_outlets = int(outlets_per_area['Number_of_Outlets'].sum()) metrics['outlets_per_area'] = outlets_per_area metrics['total_outlets'] = total_outlets # For calculations that use total_outlets, make sure it's never zero to avoid division by zero total_outlets_for_calc = max(1, total_outlets) # Use at least 1 to avoid division by zero kwh_per_month_area = sessions_df.groupby(['Område', 'Year_Month'])['Laddat (kWh)'].sum().reset_index() metrics['kwh_per_month_area'] = kwh_per_month_area cost_per_month_area = sessions_df.groupby(['Område', 'Year_Month'])['Kostnad (exkl)'].sum().reset_index() metrics['cost_per_month_area'] = cost_per_month_area kwh_per_month = sessions_df.groupby(['Year_Month'])['Laddat (kWh)'].sum().reset_index() metrics['kwh_per_month'] = kwh_per_month total_kwh_per_month = sessions_df.groupby('Year_Month')['Laddat (kWh)'].sum().reset_index() outlets_active_per_month = sessions_df.groupby('Year_Month')['Uttag'].nunique().reset_index() avg_kwh_per_outlet = pd.merge(total_kwh_per_month, outlets_active_per_month, on='Year_Month', how='left') 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 metrics['avg_kwh_per_outlet_month'] = avg_kwh_per_outlet kwh_outlet_month_area = sessions_df.groupby(['Område', 'Year_Month', 'Uttag'])['Laddat (kWh)'].sum().reset_index() metrics['kwh_outlet_month_area'] = kwh_outlet_month_area # Calculate utilization by hour and date (Improved robustness) session_hours_list = [] if not sessions_df.empty and 'Startad' in sessions_df.columns and 'Avslutad' in sessions_df.columns: for _, session in sessions_df.iterrows(): try: start_time = session['Startad'] end_time = session['Avslutad'] outlet = session['Uttag'] area = session['Område'] if pd.isna(start_time) or pd.isna(end_time) or end_time < start_time: continue # Iterate over one-hour intervals within the session current_hour_start = start_time.floor('H') while current_hour_start < end_time: session_hours_list.append({ 'Date': current_hour_start.date(), 'Hour': current_hour_start.hour, 'Outlet': outlet, 'Område': area, # Add area for better segmentation 'IsWeekend': current_hour_start.weekday() >= 5, 'Day_Type': 'Helg' if current_hour_start.weekday() >= 5 else 'Vardag', 'Weekday': current_hour_start.day_name() }) current_hour_start += pd.Timedelta(hours=1) except Exception as e: st.warning(f"Skippade en session vid beräkning av timvis användning p.g.a. datafel: {e}") continue if session_hours_list: hourly_usage_df = pd.DataFrame(session_hours_list) # Count unique outlets in use for each hour, date, day_type, weekday # First by area for area-specific utilization area_hourly_utilization = hourly_usage_df.groupby(['Date', 'Hour', 'IsWeekend', 'Day_Type', 'Weekday', 'Område']).agg( Outlets_In_Use=('Outlet', 'nunique') ).reset_index() # Add total configured outlets per area area_hourly_utilization = area_hourly_utilization.merge( outlets_per_area, on='Område', how='left' ) area_hourly_utilization['Number_of_Outlets'] = area_hourly_utilization['Number_of_Outlets'].fillna(1) # Fallback to 1 if unknown # Calculate area-specific utilization area_hourly_utilization['Utilization'] = area_hourly_utilization['Outlets_In_Use'] / area_hourly_utilization['Number_of_Outlets'] area_hourly_utilization['Date_Str'] = pd.to_datetime(area_hourly_utilization['Date']).dt.strftime('%a %d %b %Y') metrics['area_hourly_utilization'] = area_hourly_utilization # Then overall for all areas combined hourly_utilization = hourly_usage_df.groupby(['Date', 'Hour', 'IsWeekend', 'Day_Type', 'Weekday']).agg( Outlets_In_Use=('Outlet', 'nunique') ).reset_index() # Use the total configured outlets for overall utilization hourly_utilization['Total_Outlets'] = total_outlets_for_calc hourly_utilization['Utilization'] = hourly_utilization['Outlets_In_Use'] / hourly_utilization['Total_Outlets'] hourly_utilization['Date_Str'] = pd.to_datetime(hourly_utilization['Date']).dt.strftime('%a %d %b %Y') metrics['hourly_utilization'] = hourly_utilization # Average hourly utilization (e.g. 9 AM is X% utilized on average on weekdays) avg_hourly_utilization = hourly_utilization.groupby(['Hour', 'Day_Type'])['Utilization'].mean().reset_index() metrics['avg_hourly_utilization'] = avg_hourly_utilization # Average weekday utilization avg_weekday_utilization = hourly_utilization.groupby(['Weekday'])['Utilization'].mean().reset_index() weekday_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] avg_weekday_utilization['Weekday'] = pd.Categorical(avg_weekday_utilization['Weekday'], categories=weekday_order, ordered=True) avg_weekday_utilization = avg_weekday_utilization.sort_values('Weekday') metrics['avg_weekday_utilization'] = avg_weekday_utilization else: metrics['hourly_utilization'] = pd.DataFrame(columns=['Date', 'Hour', 'IsWeekend', 'Outlets_In_Use', 'Total_Outlets', 'Utilization', 'Date_Str', 'Day_Type', 'Weekday']) metrics['area_hourly_utilization'] = pd.DataFrame(columns=['Date', 'Hour', 'IsWeekend', 'Outlets_In_Use', 'Number_of_Outlets', 'Utilization', 'Date_Str', 'Day_Type', 'Weekday', 'Område']) metrics['avg_hourly_utilization'] = pd.DataFrame(columns=['Hour', 'Day_Type', 'Utilization']) metrics['avg_weekday_utilization'] = pd.DataFrame(columns=['Weekday', 'Utilization']) # Monthly utilization utilization_data = [] all_months_in_data = sessions_df['Year_Month'].unique() all_areas_in_data = sessions_df['Område'].unique() for area in all_areas_in_data: # Get total number of outlets for this area from our updated outlets_per_area dataframe area_outlets_count = outlets_per_area[outlets_per_area['Område'] == area]['Number_of_Outlets'].values if len(area_outlets_count) == 0 or area_outlets_count[0] == 0: continue # Skip areas with no configured outlets area_outlets_count = area_outlets_count[0] for ym in all_months_in_data: year, month = map(int, ym.split('-')) days_in_month = calendar.monthrange(year, month)[1] total_possible_outlet_hours = area_outlets_count * days_in_month * 24 # Total available hours for all outlets in area for the month area_month_sessions = sessions_df[(sessions_df['Område'] == area) & (sessions_df['Year_Month'] == ym)] if area_month_sessions.empty: used_outlet_hours = 0 else: # Calculate active hours from session duration # For each session, count the hours it spans total_hours = 0 for _, session in area_month_sessions.iterrows(): try: start_time = session['Startad'] end_time = session['Avslutad'] if pd.isna(start_time) or pd.isna(end_time) or end_time < start_time: continue # Calculate hours this session spans duration_hours = (end_time - start_time).total_seconds() / 3600 total_hours += duration_hours except Exception as e: st.warning(f"Fel vid beräkning av timanvändning för session: {e}") continue used_outlet_hours = total_hours utilization = used_outlet_hours / total_possible_outlet_hours if total_possible_outlet_hours > 0 else 0 utilization_data.append({ 'Område': area, 'Year_Month': ym, 'Used_Outlet_Hours': used_outlet_hours, 'Total_Possible_Outlet_Hours': total_possible_outlet_hours, 'Utilization': utilization }) metrics['utilization'] = pd.DataFrame(utilization_data) metrics['total_kwh'] = sessions_df['Laddat (kWh)'].sum() metrics['total_cost'] = sessions_df['Kostnad (exkl)'].sum() metrics['total_sessions'] = len(sessions_df) metrics['avg_kwh_per_session'] = metrics['total_kwh'] / metrics['total_sessions'] if metrics['total_sessions'] > 0 else 0 metrics['avg_duration_minutes'] = sessions_df['Duration_Minutes'].mean() if metrics['total_sessions'] > 0 else 0 return metrics # Function to create plotly figures def create_visualizations(metrics, sessions_df): # Pass sessions_df for more detailed plots figures = {} # 1. Bar chart: Number of outlets per area fig_outlets = px.bar( metrics['outlets_per_area'], x='Område', y='Number_of_Outlets', title='Antal Uttag per Område', color='Number_of_Outlets', color_continuous_scale=px.colors.sequential.Greens, labels={'Number_of_Outlets': 'Antal Uttag', 'Område': 'Område'} ) fig_outlets.update_layout(xaxis_tickangle=-45) figures['outlets_per_area'] = fig_outlets # 2. Bar chart: kWh per month for each area fig_kwh_area = px.bar( metrics['kwh_per_month_area'], x='Year_Month', y='Laddat (kWh)', color='Område', title='Total Energi (kWh) per Månad och Område', labels={'Laddat (kWh)': 'Energi (kWh)', 'Year_Month': 'Månad', 'Område': 'Område'}, barmode='group', color_discrete_sequence=px.colors.qualitative.Pastel ) fig_kwh_area.update_layout(xaxis_tickangle=-45) figures['kwh_per_month_area'] = fig_kwh_area # 2b. Bar chart: Kostnad (exkl) per month for each area fig_cost_area = px.bar( metrics['cost_per_month_area'], x='Year_Month', y='Kostnad (exkl)', color='Område', title='Total Kostnad (exkl. moms) per Månad och Område', labels={'Kostnad (exkl)': 'Kostnad (SEK)', 'Year_Month': 'Månad', 'Område': 'Område'}, barmode='group', color_discrete_sequence=px.colors.qualitative.Set2 ) fig_cost_area.update_layout(xaxis_tickangle=-45) figures['cost_per_month_area'] = fig_cost_area # 3. Line chart: Average kWh per outlet per month (overall) fig_avg_kwh = px.line( metrics['avg_kwh_per_outlet_month'], x='Year_Month', y='Avg_kWh_per_Outlet', markers=True, title='Genomsnittlig Energi (kWh) per Aktivt Uttag per Månad (Totalt)', labels={'Avg_kWh_per_Outlet': 'Genomsnittlig kWh/Uttag', 'Year_Month': 'Månad'} ) fig_avg_kwh.update_layout(xaxis_tickangle=-45) figures['avg_kwh_per_outlet'] = fig_avg_kwh # 4. Heatmap: Date-Hour Utilization - CHANGED TO GREEN GRADIENT if not metrics['hourly_utilization'].empty: pivot_hourly = metrics['hourly_utilization'].pivot_table( index='Date_Str', columns='Hour', values='Utilization', aggfunc='mean' ).fillna(0) # Ensure chronological order of dates sorted_dates = sorted(metrics['hourly_utilization']['Date'].unique()) date_str_order = [d.strftime('%a %d %b %Y') for d in sorted_dates] pivot_hourly = pivot_hourly.reindex(index=date_str_order).fillna(0) # Find the min and max values for consistent color scale min_val = pivot_hourly.values.min() max_val = pivot_hourly.values.max() # Create the heatmap with green color scale fig_hourly = px.imshow( pivot_hourly, labels=dict(x='Timme på Dygnet', y='Datum', color='Beläggning (%)'), x=list(range(24)), y=pivot_hourly.index, color_continuous_scale=px.colors.sequential.Greens, # Changed to green title='Timvis Beläggningsgrad Heatmap (Aktiva Uttag / Totalt Antal Uttag)', zmin=min_val, zmax=max_val # Set fixed min/max for consistent gradient ) # Add ChargeNode logo to top right (placeholder) fig_hourly.add_layout_image( dict( source="https://chargenode.eu/wp-content/themes/chargenode/dist/assets/img/logo.svg", xref="paper", yref="paper", x=1.0, y=1.05, sizex=0.15, sizey=0.15, xanchor="right", yanchor="top" ) ) fig_hourly.update_layout( coloraxis_colorbar=dict(title='Beläggning', tickformat='.0%'), height=max(500, 20 * len(pivot_hourly.index)), margin=dict(l=150, r=20, t=80, b=50), xaxis_title="Timme på dygnet", yaxis_title="Datum" ) hour_labels = [f"{h:02d}:00" for h in range(24)] fig_hourly.update_xaxes(tickvals=list(range(24)), ticktext=hour_labels, side="top") fig_hourly.update_yaxes(autorange="reversed") # Show newest dates at top figures['hourly_utilization_heatmap'] = fig_hourly else: figures['hourly_utilization_heatmap'] = go.Figure().update_layout(title_text='Timvis Beläggningsgrad Heatmap (Ingen data)') # 4b. Aggregated Hourly Utilization (Line chart) - Changed to use green if not metrics['avg_hourly_utilization'].empty: fig_avg_hourly_util = px.line( metrics['avg_hourly_utilization'], x='Hour', y='Utilization', color='Day_Type', title='Genomsnittlig Beläggningsgrad per Timme (Vardag vs Helg)', labels={'Hour': 'Timme på Dygnet', 'Utilization': 'Genomsnittlig Beläggning', 'Day_Type': 'Dagtyp'}, markers=True, color_discrete_map={'Vardag': '#27ae60', 'Helg': '#f39c12'} # Changed to green for weekdays ) fig_avg_hourly_util.update_layout(yaxis_tickformat=".0%", xaxis_dtick=1) figures['avg_hourly_utilization_line'] = fig_avg_hourly_util else: figures['avg_hourly_utilization_line'] = go.Figure().update_layout(title_text='Genomsnittlig Beläggningsgrad per Timme (Ingen data)') # 4c. Aggregated Weekday Utilization (Bar chart) - Changed to green if not metrics['avg_weekday_utilization'].empty: fig_avg_weekday_util = px.bar( metrics['avg_weekday_utilization'], x='Weekday', y='Utilization', title='Genomsnittlig Beläggningsgrad per Veckodag', labels={'Weekday': 'Veckodag', 'Utilization': 'Genomsnittlig Beläggning'}, color='Utilization', color_continuous_scale=px.colors.sequential.Greens # Changed to green ) fig_avg_weekday_util.update_layout(yaxis_tickformat=".0%") figures['avg_weekday_utilization_bar'] = fig_avg_weekday_util else: figures['avg_weekday_utilization_bar'] = go.Figure().update_layout(title_text='Genomsnittlig Beläggningsgrad per Veckodag (Ingen data)') # 5. Heatmap: Monthly Utilization per area - Changed to green if not metrics['utilization'].empty and 'Utilization' in metrics['utilization'].columns: pivot_util = metrics['utilization'].pivot_table( index='Område', columns='Year_Month', values='Utilization' ).fillna(0) # Find min/max for consistent gradient min_val = pivot_util.values.min() max_val = pivot_util.values.max() fig_util = px.imshow( pivot_util, labels=dict(x='Månad', y='Område', color='Beläggning (%)'), x=pivot_util.columns, y=pivot_util.index, color_continuous_scale=px.colors.sequential.Greens, # Changed to green title='Månatlig Beläggningsgrad per Område (Baserat på Aktiva Uttagstimmar)', zmin=min_val, zmax=max(0.01, max_val) # Set fixed min/max for consistent gradient ) fig_util.update_layout(xaxis_tickangle=-45, coloraxis_colorbar=dict(tickformat='.0%')) figures['monthly_utilization_area_heatmap'] = fig_util else: figures['monthly_utilization_area_heatmap'] = go.Figure().update_layout(title_text='Månatlig Beläggningsgrad per Område (Ingen data)') # 6. Box plot: kWh per OUTLET per month per area (Distribution of individual outlet performance) if not metrics['kwh_outlet_month_area'].empty: fig_kwh_outlet_dist = px.box( metrics['kwh_outlet_month_area'], x='Område', y='Laddat (kWh)', color='Year_Month', title='Distribution av Energi (kWh) per Uttag (Månadsvis per Område)', labels={'Laddat (kWh)': 'Energi per Uttag (kWh)', 'Område': 'Område', 'Year_Month': 'Månad'}, color_discrete_sequence=px.colors.qualitative.Vivid ) fig_kwh_outlet_dist.update_layout(xaxis_tickangle=-45) figures['kwh_per_outlet_distribution'] = fig_kwh_outlet_dist else: figures['kwh_per_outlet_distribution'] = go.Figure().update_layout(title_text='Distribution av Energi (kWh) per Uttag (Ingen data)') # 7. Histogram: Session Duration - CHANGED TO HOURS AND REMOVE OUTLIERS OVER 12 HOURS if not sessions_df.empty and 'Duration_Hours' in sessions_df.columns: # Filter outliers - sessions longer than 12 hours filtered_sessions = sessions_df[sessions_df['Duration_Hours'] <= 12] fig_session_duration_hist = px.histogram( filtered_sessions, x='Duration_Hours', nbins=50, title='Distribution av Sessionslängd (Timmar)', labels={'Duration_Hours': 'Sessionslängd (Timmar)', 'count': 'Antal Sessioner'}, marginal="box", # adds a box plot above histogram color_discrete_sequence=['#27ae60'] # Use green color ) fig_session_duration_hist.update_layout(yaxis_title="Antal Sessioner") figures['session_duration_histogram'] = fig_session_duration_hist else: figures['session_duration_histogram'] = go.Figure().update_layout(title_text='Distribution av Sessionslängd (Ingen data)') # 8. Box Plot: Energy per Session by Area if not sessions_df.empty and 'Laddat (kWh)' in sessions_df.columns: fig_kwh_per_session_area_box = px.box( sessions_df, x='Område', y='Laddat (kWh)', color='Område', title='Energi (kWh) per Session fördelat på Område', labels={'Laddat (kWh)': 'Energi per Session (kWh)', 'Område': 'Område'}, color_discrete_sequence=px.colors.qualitative.Safe ) fig_kwh_per_session_area_box.update_layout(xaxis_tickangle=-45) figures['kwh_per_session_area_box'] = fig_kwh_per_session_area_box else: figures['kwh_per_session_area_box'] = go.Figure().update_layout(title_text='Energi (kWh) per Session (Ingen data)') return figures # Function to generate HTML report def generate_html_report(metrics, figures, selected_graph_keys, selected_areas, date_range_text): """ Generate HTML report with selected graphs and metrics """ # Create header information header_info_list = [] for area in selected_areas: area_outlets = metrics['outlets_per_area'][metrics['outlets_per_area']['Område'] == area]['Number_of_Outlets'].values if len(area_outlets) > 0: header_info_list.append({ 'Anläggningsnamn': area, 'Antal_Uttag': area_outlets[0] }) current_date_str_report = datetime.now().strftime('%Y-%m-%d %H:%M') # Create HTML header html_header_parts = ["
Områden valda: {', '.join(selected_areas)} (ingen sessionsdata för dessa i perioden, eller inga uttag registrerade).
") else: html_header_parts.append("Inga områden valda för rapporten.
") html_header_parts.append(f"Genererad: {current_date_str_report}
") # Build HTML content html_content_parts = [f"""{graph_description}
") try: # Include Plotly.js only once if not plotly_js_added: graph_html = fig.to_html(full_html=False, include_plotlyjs='cdn', config={'displayModeBar': False}) plotly_js_added = True else: graph_html = fig.to_html(full_html=False, include_plotlyjs=False, config={'displayModeBar': False}) html_content_parts.append(graph_html) except Exception as e: html_content_parts.append(f"Fel: Kunde inte rendera grafen '{graph_title}'. ({e})
") html_content_parts.append("