From 2e853aad93db59cd72f50a22c2a18d60d095575c Mon Sep 17 00:00:00 2001
From: Michel Spils <msp@informatik.uni-kiel.de>
Date: Mon, 25 Nov 2024 09:38:48 +0100
Subject: [PATCH] dashboard metrics

---
 src/app.py                        | 270 +++++++++++++++++++++++++-----
 src/dashboard/styles.py           |  32 +++-
 src/dashboard/tables_and_plots.py | 140 +++++-----------
 3 files changed, 298 insertions(+), 144 deletions(-)

diff --git a/src/app.py b/src/app.py
index bb7532c..828c7d7 100644
--- a/src/app.py
+++ b/src/app.py
@@ -4,26 +4,22 @@ from datetime import datetime, timedelta
 import dash_bootstrap_components as dbc
 import oracledb
 import pandas as pd
-#import plotly.express as px
-#import plotly.graph_objs as go
-import dash
-from dash import Dash, dash_table, dcc, html,ctx
+from dash import Dash, dash_table, dcc, html
 from dash.dependencies import MATCH, Input, Output, State
 from sqlalchemy import and_, create_engine, desc, func, select,distinct
 from sqlalchemy.orm import Session
 
-from dashboard.styles import TABLE_STYLE
+from dashboard.styles import TABLE_STYLE, TAB_STYLE
 #from dash_tools.layout_helper import create_collapsible_section
 from dashboard.tables_and_plots import (create_historical_plot,
                                         create_historical_table,
                                         create_inp_forecast_status_table,
-                                        create_input_forecasts_plot,
-                                        create_log_table,
+                                        create_log_table, create_metrics_plots,
                                         create_model_forecasts_plot,
                                         create_status_summary_chart)
 
 from dashboard.constants import NUM_RECENT_FORECASTS, NUM_RECENT_LOGS, BUFFER_TIME,LOOKBACK_EXTERNAL_FORECAST_EXISTENCE
-from utils.db_tools.orm_classes import (InputForecasts, Log, Modell,
+from utils.db_tools.orm_classes import (InputForecasts, Log, Metric, Modell,
                                         ModellSensor, PegelForecasts,
                                         SensorData)
 
@@ -78,23 +74,40 @@ class ForecastMonitor:
 
     def setup_layout(self):
         # Header
-        header = dbc.Navbar(
-            dbc.Container(
-                dbc.Row([
-                    dbc.Col(
-                        html.H1("Pegel Dashboard", className="mb-0 text-white"),
-                        width="auto"
-                    )
-                ]),
-                fluid=True
-            ),
-            color="primary",
-            dark=True,
-            className="mb-4"
-        )
+        header = html.Div([
+            # Main header bar
+            dbc.Navbar(
+                [
+                    dbc.Container([
+                        dbc.Row([
+                            dbc.Col(html.H1("Pegel Dashboard",
+                                    className="mb-0 text-white d-flex align-items-center"#className="mb-0 text-white"
+                                    ),
+                                width="auto"),
+                            dbc.Col(dbc.Tabs([
+                                    dbc.Tab(label="Modell Monitoring",tab_id="model-monitoring",**TAB_STYLE),
+                                    dbc.Tab(label="System Logs",tab_id="system-logs",**TAB_STYLE)
+                                    ],
+                                    id="main-tabs",
+                                    active_tab="model-monitoring",
+                                    className="nav-tabs border-0",
+                                ),
+                                width="auto",
+                                className="d-flex align-items-end"
+                                )
+                            ]),
+                        ],
+                    fluid=True,
+                    className="px-4"),
+                ],
+                color="primary",
+                dark=True
+                )],
+            className="mb-4")
+
 
         # Main content
-        main_content = dbc.Container([
+        model_monitoring_tab = dbc.Container([
             # Row for the entire dashboard content
             dbc.Row([
                 # Left column - fixed width
@@ -125,10 +138,72 @@ class ForecastMonitor:
                     #self._make_section("Input Forecasts",'input-forecasts','inp-fcst-view'),
                     # Recent Logs Section
                     self._make_section("Logs",'logs','log-view'),
+                    # Metrics Section
+                    self._make_section("Metriken", 'metrics', 'metrics-view'),
                 ], xs=12, md=7)  # Responsive column
             ])
         ], fluid=True, className="py-4")
 
+        # System Logs Tab Content
+        system_logs_tab = dbc.Container([
+            dbc.Card([
+                dbc.CardBody([
+                    html.H3("System Logs", className="mb-4"),
+                    dcc.Interval( #TODO rausnehmen?
+                        id='logs-update',
+                        interval=30000  # Update every 30 seconds
+                    ),
+                    dash_table.DataTable(
+                        id='system-log-table',
+                        columns=[
+                            {'name': 'Zeitstempel', 'id': 'timestamp'},
+                            {'name': 'Level', 'id': 'level'},
+                            {'name': 'Pegel', 'id': 'sensor'},
+                            {'name': 'Nachricht', 'id': 'message'},
+                            {'name': 'Modul', 'id': 'module'},
+                            {'name': 'Funktion', 'id': 'function'},
+                            {'name': 'Zeile', 'id': 'line'},
+                            {'name': 'Exception', 'id': 'exception'}
+                        ],
+                        **TABLE_STYLE,
+                        page_size=20,
+                        page_action='native',
+                        sort_action='native',
+                        sort_mode='multi',
+                        filter_action='native',
+                    )
+                ])
+            ])
+        ], fluid=True, className="py-4")
+
+        # Add a container to hold the tab content
+        tab_content = html.Div(
+            id="tab-content",
+            children=[
+                dbc.Container(
+                    id="page-content",
+                    children=[],
+                    fluid=True,
+                    className="py-4"
+                )
+            ]
+        )
+        # Main content with tabs
+        main_content = dbc.Tabs([
+            dbc.Tab(model_monitoring_tab, label="Modell Monitoring", tab_id="model-monitoring"),
+            dbc.Tab(system_logs_tab, label="System Logs", tab_id="system-logs")
+        ], id="main-tabs", active_tab="model-monitoring")
+
+        # Combine all elements
+        self.app.layout = html.Div([
+            header,
+            html.Div(id="tab-content", children=[
+                model_monitoring_tab,
+                system_logs_tab
+            ], style={"display": "none"}),  # Hidden container for both tabs
+            html.Div(id="visible-tab-content")  # Container for the visible tab
+        ], className="min-vh-100 bg-light")
+
         # Combine all elements
         self.app.layout = html.Div([
             header,
@@ -279,7 +354,7 @@ class ForecastMonitor:
                 # Get all sensors for this model
                 model_sensors = session.query(ModellSensor).filter(
                     ModellSensor.modell_id == model_id
-                ).all()
+                    ).all()
                 
                 sensor_names = [ms.sensor_name for ms in model_sensors]
                 
@@ -299,6 +374,32 @@ class ForecastMonitor:
         except Exception as e:
             print(f"Error getting historical data: {str(e)}")
             return pd.DataFrame()
+
+    def get_model_metrics(self, model_id, metric_name='mae'):
+        """Get metrics for a specific model. Assumes that each model only has on set of metrics with the same name"""
+ 
+        try:
+
+            metric_names = [f"train_{metric_name}", f"test_{metric_name}", f"val_{metric_name}",
+                            f"train_{metric_name}_flood", f"test_{metric_name}_flood", f"val_{metric_name}_flood"]
+            # Get metrics for the model
+            stmt = select(Metric).where(Metric.model_id == model_id,Metric.metric_name.in_(metric_names))
+
+            df = pd.read_sql(sql=stmt, con=self.engine)
+
+            train_start,train_end = df[df["metric_name"] == f"train_{metric_name}"][["start_time","end_time"]].iloc[0]
+            test_start,test_end = df[df["metric_name"] == f"test_{metric_name}"][["start_time","end_time"]].iloc[0]
+            times = [train_start,train_end,test_start,test_end]
+        
+            # Group metrics by tag and timestep
+            df = df.pivot(index="timestep",columns="metric_name",values="value")
+            
+            return df, times
+        except Exception as e:
+            print(f"Error getting model metrics: {str(e)}")
+            return None, None
+
+
             
 
     def setup_callbacks(self):
@@ -361,7 +462,7 @@ class ForecastMonitor:
             [Output('fcst-view', 'children'),
              Output('historical-view', 'children'),
              Output('log-view', 'children'),
-            #Output('inp-fcst-view', 'children'),
+             Output('metrics-view', 'children'),
             Output('current-sensor-names', 'data')],  # Removed input-forecasts-view
             [Input('status-table', 'selected_rows')],
             [State('status-table', 'data')]
@@ -370,8 +471,8 @@ class ForecastMonitor:
             if not selected_rows:
                 return (html.Div("Wähle ein Modell um Vorhersagen anzuzeigen."),
                         html.Div("Wähle ein Modell um Messwerte anzuzeigen."),
-                        #html.Div("Wähle ein Modell um Eingangsvorhersagen anzuzeigen."),
                         html.Div("Wähle ein Modell um Logs anzuzeigen."),
+                        html.Div("Wähle ein Modell um Metriken anzuzeigen."),
                         None)  # Removed input forecasts return
             
             selected_row = table_data[selected_rows[0]]
@@ -409,21 +510,6 @@ class ForecastMonitor:
                 historical_view = html.Div("Keine Messdaten verfügbar")
 
 
-            ## Get Input Forecasts
-            #df_inp_fcst = self.get_input_forecasts(sensor_names)
-            #if not df_inp_fcst.empty:
-            #    fig_inp_fcst = create_input_forecasts_plot(df_inp_fcst,df_historical)
-            #    inp_fcst_table = create_inp_forecast_status_table(df_inp_fcst)
-            #    inp_fcst_view = html.Div([
-            #        html.H4(f"Input Forecasts for {model_name}"),
-            #        dcc.Graph(figure=fig_inp_fcst),
-            #        html.H4("Input Forecast Status", style={'marginTop': '20px', 'marginBottom': '10px'}),
-            #        html.Div(inp_fcst_table, style={'width': '100%', 'padding': '10px'})
-            #    ])
-            #else:
-            #    inp_fcst_view = html.Div("No input forecasts available")
-
-
             # Get forecast data
             df_inp_fcst,ext_forecast_names = self.get_input_forecasts(sensor_names)
             inp_fcst_table = create_inp_forecast_status_table(df_inp_fcst,ext_forecast_names)
@@ -439,12 +525,33 @@ class ForecastMonitor:
             else:
                 fcst_view = html.Div("No forecasts available")
 
+            # Get metrics
+            metrics_view = html.Div([
+                html.H4(f"Modell Metriken für {model_name}"),
+                dbc.Row([
+                    dbc.Col([
+                        html.Label("Metric:", className="fw-bold mb-2"),
+                        dcc.Dropdown(
+                            id={'type': 'metric-selector', 'section': 'metrics'},
+                            options=[
+                                {'label': metric.upper(), 'value': metric}
+                                for metric in ['mae', 'mse', 'nse', 'kge', 'p10', 'p20', 'r2', 'rmse', 'wape']
+                            ],
+                            value='mae',
+                            clearable=False,
+                            className="mb-4"
+                        )
+                    ], width=4)
+                ]),
+                html.Div(id='metric-plots-container'),
+                html.Div(id='metric-info-container', className="mt-4")
+            ])
 
             return (fcst_view,
                     historical_view,
                     log_view,
-                    #inp_fcst_view,
-                    sensor_names)  # Removed input forecasts return
+                    metrics_view,
+                    sensor_names)
 
 
         @self.app.callback(
@@ -478,6 +585,83 @@ class ForecastMonitor:
             return df_filtered.to_dict('records')
 
 
+
+        # Add new callback for updating metric plots
+        @self.app.callback(
+            [Output('metric-plots-container', 'children'),
+            Output('metric-info-container', 'children')],
+            [Input({'type': 'metric-selector', 'section': 'metrics'}, 'value'),
+            Input('status-table', 'selected_rows')],
+            [State('status-table', 'data')]
+        )
+        def update_metric_plots(metric_name, selected_rows, table_data):
+            if not selected_rows or not metric_name:
+                return html.Div(), html.Div()
+            
+            selected_row = table_data[selected_rows[0]]
+            model_id = selected_row['model_id']
+            
+            metrics_data, edge_times = self.get_model_metrics(model_id, metric_name)
+            if metrics_data.empty:
+                return html.Div("Keine Metriken verfügbar"), html.Div()
+            
+            fig = create_metrics_plots(metrics_data, metric_name)
+            
+            plots = html.Div([dcc.Graph(figure=fig, className="mb-4")])
+            
+            info = html.Div([
+                html.H5("Zeitraum der Metrikberechnung:", className="h6 mb-3"),
+                dbc.Row([
+                    dbc.Col([
+                        html.Strong("Start Trainingszeitraum: "),
+                        html.Span(edge_times[0].strftime('%Y-%m-%d %H:%M:%S'))
+                    ], width=6),
+                    dbc.Col([
+                        html.Strong("Start Validierungszeitraum: "),
+                        html.Span(edge_times[1].strftime('%Y-%m-%d %H:%M:%S'))
+                    ], width=6),
+                    dbc.Col([
+                        html.Strong("Start Testzeitraum: "),
+                        html.Span(edge_times[2].strftime('%Y-%m-%d %H:%M:%S'))
+                    ], width=6),
+                    dbc.Col([
+                        html.Strong("Ende Testzeitraum: "),
+                        html.Span(edge_times[3].strftime('%Y-%m-%d %H:%M:%S'))
+                    ], width=6)
+                ])
+            ], className="bg-light p-3 rounded")
+            
+            return plots, info
+
+        @self.app.callback(
+            Output('system-log-table', 'data'),
+            Input('logs-update', 'n_intervals')
+        )
+        def update_system_logs(n):
+            """Get all logs from the database"""
+            try:
+                with Session(self.engine) as session:
+                    # Get last 1000 logs
+                    logs = session.query(Log).order_by(
+                        desc(Log.created)
+                    ).limit(1000).all()
+                    
+                    return [{
+                        'timestamp': log.created.strftime('%Y-%m-%d %H:%M:%S'),
+                        'level': log.loglevelname,
+                        'sensor': log.gauge,
+                        'message': log.message,
+                        'module': log.module,
+                        'function': log.funcname,
+                        'line': log.lineno,
+                        'exception': log.exception or ''
+                    } for log in logs]
+                    
+            except Exception as e:
+                print(f"Error getting system logs: {str(e)}")
+                return []
+
+
         # Update the collapse callback
         @self.app.callback(
             [Output({'type': 'collapse-content', 'section': MATCH}, 'is_open'),
diff --git a/src/dashboard/styles.py b/src/dashboard/styles.py
index 3ac83db..93c40ba 100644
--- a/src/dashboard/styles.py
+++ b/src/dashboard/styles.py
@@ -7,12 +7,17 @@ COLORS = {
     'danger': '#dc3545',
     'light_gray': '#f4f4f4',
     'border': 'rgba(0,0,0,.125)',
-    'background': '#f8f9fa'
+    'background': '#f8f9fa',
+    'warning_bg': '#fff3e0',
+    'error_bg': '#ffebee',
+    'warning' : '#ef6c00',
+    'error': '#c62828',
 }
 
+
 COLOR_SET = px.colors.qualitative.Set3
 
-# Table Styles
+# Table Styles (This is messy, the conditional style data is not the same for each table)
 TABLE_STYLE = {
     'style_table': {
         'overflowX': 'auto',
@@ -43,10 +48,33 @@ TABLE_STYLE = {
         {
             'if': {'filter_query': '{has_current_forecast} = "✗"', "column_id": "has_current_forecast"},
             'color': COLORS['danger']
+        },
+        {
+            'if': {'filter_query': '{level} = "ERROR"'},
+            'backgroundColor': COLORS['error_bg'],
+            'color': COLORS['error']
+        },
+        {
+            'if': {'filter_query': '{level} = "WARNING"'},
+            'backgroundColor': COLORS['warning_bg'],
+            'color': COLORS['warning']
         }
     ]
 }
 
+TAB_STYLE = {
+    "active_label_class_name": 'fw-bold', 
+    "label_style": {
+        'color': 'rgba(255, 255, 255, 0.9)', 
+        'padding': '1rem 1.5rem'
+        },
+    "active_label_style": {
+        'color': 'white',
+        'background': 'rgba(255, 255, 255, 0.1)'
+        }
+    }
+
+
 # Historical Table Style
 HISTORICAL_TABLE_STYLE = {
     'style_table': {
diff --git a/src/dashboard/tables_and_plots.py b/src/dashboard/tables_and_plots.py
index 300fad2..7348ee4 100644
--- a/src/dashboard/tables_and_plots.py
+++ b/src/dashboard/tables_and_plots.py
@@ -330,7 +330,7 @@ def create_inp_forecast_status_table(df_forecast,ext_forecast_names):
         dash.html.Div: Div containing configured DataTable
     """
     # Get unique sensor names
-    sensor_names = sorted(df_forecast['sensor_name'].unique())
+    #sensor_names = sorted(df_forecast['sensor_name'].unique())
     
     # Create index of last 48 hours at 3-hour intervals
     last_required_hour = datetime.now().replace(minute=0, second=0, microsecond=0)
@@ -384,112 +384,54 @@ def create_inp_forecast_status_table(df_forecast,ext_forecast_names):
         )
     )
 
-def create_input_forecasts_plot(df_forecast, df_historical):
-    """
-    Creates a figure with subplots for each sensor, showing forecast lines and historical data
-    
-    Args:
-        df_forecast: DataFrame with columns tstamp, sensor_name, member, h1-h48
-        df_historical: DataFrame with sensor_name columns and timestamp index
-        
-    Returns:
-        plotly.graph_objects.Figure: Figure with subplots
-    """
-    # Get unique sensors and members
-    sensors = df_forecast['sensor_name'].unique()
-    
-    # Create a color sequence for different forecast start times
-    colors = px.colors.qualitative.Set3
+
+def create_metrics_plots(df_metrics, metric_name):
+    """Create plots for model metrics"""
     
-    # Create subplot figure
     fig = make_subplots(
-        rows=len(sensors),
+        rows=2,
         cols=1,
-        subplot_titles=[f'Sensor: {sensor}' for sensor in sensors],
+        subplot_titles=["Gesamt", "Flutbereich (95. Percentil)"],
         vertical_spacing=0.1
     )
 
-    # For each sensor
-    for sensor_idx, sensor in enumerate(sensors, 1):
-        # Add historical data
-        if sensor in df_historical.columns:
-            fig.add_trace(
-                go.Scatter(
-                    x=df_historical.index,
-                    y=df_historical[sensor],
-                    name=sensor,
-                    legendgroup=sensor,
-                    showlegend=True,  # Show in legend for all sensors
-                    line=dict(color='black', width=2),
-                    hovertemplate='Time: %{x}<br>Value: %{y:.2f}<br>Historical<extra></extra>'
-                ),
-                row=sensor_idx,
-                col=1
-            )
-        
-        sensor_data = df_forecast[df_forecast['sensor_name'] == sensor]
-        members = sensor_data['member'].unique()
+    for col in df_metrics:
+        row = 2 if 'flood' in col else 1
+        set_name = col.split("_")[0]
+        if set_name == 'train':
+            color = COLOR_SET[0]
+        elif set_name == 'val':
+            color = COLOR_SET[1]
+        elif set_name == 'test':
+            color = COLOR_SET[2]
+        else:
+            color = COLOR_SET[3]
 
-        
-        # Get unique forecast start times for color assignment
-        start_times = sorted(sensor_data['tstamp'].unique())
-        start_time_colors = {t: COLOR_SET[i % len(COLOR_SET)] for i, t in enumerate(start_times)}
-        
-        # For each member
-        for member in members:
-            member_data = sensor_data[sensor_data['member'] == member]
-            
-            # For each forecast (row)
-            for _, row in member_data.iterrows():
-                start_time = row['tstamp']
-                legend_group = f'{sensor} {start_time.strftime("%Y%m%d%H%M")}'
 
-                timestamps = [start_time + timedelta(hours=i) for i in range(1, 49)]
-                values = [row[f'h{i}'] for i in range(1, 49)]
-                
-                fig.add_trace(
-                    go.Scatter(
-                        x=timestamps,
-                        y=values,
-                        name=f'{start_time.strftime("%Y-%m-%d %H:%M")}',
-                        legendgroup=legend_group,
-                        showlegend=(member == members[0]).item(),
-                        line=dict(
-                            color=start_time_colors[start_time],
-                            width=1,
-                            dash='solid' if member == members[0] else 'dot'
-                        ),
-                        hovertemplate=(
-                            'Time: %{x}<br>'
-                            'Value: %{y:.2f}<br>'
-                            f'Member: {member}<br>'
-                            f'Start: {start_time}<extra></extra>'
-                        )
-                    ),
-                    row=sensor_idx,
-                    col=1
-                )
-    
-    # Update layout
+        fig.add_trace(
+            go.Scatter(
+                x=df_metrics.index,
+                y=df_metrics[col],
+                name=set_name,
+                legendgroup=set_name,
+                showlegend= row==1,
+                mode='lines+markers',
+                line={"color":color},
+            ),
+            row=row,
+            col=1
+        )
+
     fig.update_layout(
-        height=400 * len(sensors),  # Adjust height based on number of sensors
-        #title='Forecast Values by Sensor with Historical Data',
+        xaxis_title='Vorhersagehorizont',
+        yaxis_title=metric_name.upper(),
+        height=600,
         showlegend=True,
-        legend=dict(
-            yanchor="top",
-            y=0.99,
-            xanchor="left",
-            x=1.05,
-            groupclick="togglegroup",  # Allows clicking group title to toggle all traces
-            itemsizing='constant',  # Makes legend items constant size
-            tracegroupgap=5  # Add small gap between groups
-        ),
-        margin=dict(r=150)
+        legend={
+            "yanchor": 'top', 
+            "y": 0.99, 
+            "xanchor": 'left', 
+            "x": 1.05},
+        margin={"r":50}
     )
-    
-    # Update all x and y axes labels
-    for i in range(len(sensors)):
-        fig.update_xaxes(title_text="Time", row=i+1, col=1)
-        fig.update_yaxes(title_text="Value", row=i+1, col=1)
-    
-    return fig
\ No newline at end of file
+    return fig
-- 
GitLab