Spaces:
Sleeping
Sleeping
| """ | |
| Base chart class for creating visualizations. | |
| """ | |
| import plotly.graph_objects as go | |
| import plotly.express as px | |
| import pandas as pd | |
| import logging | |
| from datetime import datetime | |
| from typing import List, Dict, Any, Optional, Tuple | |
| from abc import ABC, abstractmethod | |
| from ..config.constants import CHART_CONFIG, CHART_COLORS, Y_AXIS_RANGES, FILE_PATHS | |
| from ..data.data_processor import DataProcessor | |
| logger = logging.getLogger(__name__) | |
| class BaseChart(ABC): | |
| """Base class for all chart visualizations.""" | |
| def __init__(self, data_processor: DataProcessor = None): | |
| self.data_processor = data_processor or DataProcessor() | |
| self.config = CHART_CONFIG | |
| self.colors = CHART_COLORS | |
| self.y_ranges = Y_AXIS_RANGES | |
| self.file_paths = FILE_PATHS | |
| def create_chart(self, df: pd.DataFrame, **kwargs) -> go.Figure: | |
| """Create the chart visualization.""" | |
| pass | |
| def _create_base_figure(self) -> go.Figure: | |
| """Create a base figure with common settings.""" | |
| return go.Figure() | |
| def _add_background_shapes(self, fig: go.Figure, min_time: datetime, max_time: datetime, | |
| y_min: float, y_max: float) -> None: | |
| """Add background shapes for positive and negative regions.""" | |
| # Add shape for positive region (above zero) | |
| fig.add_shape( | |
| type="rect", | |
| fillcolor=self.colors['positive_region'], | |
| line=dict(width=0), | |
| y0=0, y1=y_max, | |
| x0=min_time, x1=max_time, | |
| layer="below" | |
| ) | |
| # Add shape for negative region (below zero) | |
| fig.add_shape( | |
| type="rect", | |
| fillcolor=self.colors['negative_region'], | |
| line=dict(width=0), | |
| y0=y_min, y1=0, | |
| x0=min_time, x1=max_time, | |
| layer="below" | |
| ) | |
| def _add_zero_line(self, fig: go.Figure, min_time: datetime, max_time: datetime) -> None: | |
| """Add a zero line to the chart.""" | |
| fig.add_shape( | |
| type="line", | |
| line=dict(dash="solid", width=1.5, color=self.colors['zero_line']), | |
| y0=0, y1=0, | |
| x0=min_time, x1=max_time | |
| ) | |
| def _update_layout(self, fig: go.Figure, title: str, y_axis_title: str = None, | |
| height: int = None, y_range: List[float] = None) -> None: | |
| """Update the figure layout with common settings.""" | |
| fig.update_layout( | |
| title=dict( | |
| text=title, | |
| font=dict( | |
| family=self.config['font_family'], | |
| size=self.config['title_size'], | |
| color="black", | |
| weight="bold" | |
| ) | |
| ), | |
| xaxis_title=None, | |
| yaxis_title=None, | |
| template=self.config['template'], | |
| height=height or self.config['height'], | |
| autosize=True, | |
| legend=dict( | |
| orientation="h", | |
| yanchor="bottom", | |
| y=1.05, | |
| xanchor="center", | |
| x=0.5, | |
| groupclick="toggleitem", | |
| font=dict( | |
| family=self.config['font_family'], | |
| size=self.config['legend_font_size'], | |
| color="black", | |
| weight="bold" | |
| ) | |
| ), | |
| margin=dict(r=30, l=120, t=80, b=60), | |
| hovermode="closest" | |
| ) | |
| # Add y-axis annotation if provided | |
| if y_axis_title: | |
| fig.add_annotation( | |
| x=-0.08, | |
| y=0 if y_range is None else (y_range[0] + y_range[1]) / 2, | |
| xref="paper", | |
| yref="y", | |
| text=y_axis_title, | |
| showarrow=False, | |
| font=dict( | |
| size=16, | |
| family=self.config['font_family'], | |
| color="black", | |
| weight="bold" | |
| ), | |
| textangle=-90, | |
| align="center" | |
| ) | |
| def _update_axes(self, fig: go.Figure, x_range: List[datetime] = None, | |
| y_range: List[float] = None, y_auto: bool = False) -> None: | |
| """Update the axes with common settings.""" | |
| # Update y-axis | |
| y_axis_config = { | |
| 'showgrid': True, | |
| 'gridwidth': 1, | |
| 'gridcolor': 'rgba(0,0,0,0.1)', | |
| 'tickformat': ".2f", | |
| 'tickfont': dict( | |
| size=self.config['axis_font_size'], | |
| family=self.config['font_family'], | |
| color="black", | |
| weight="bold" | |
| ), | |
| 'title': None | |
| } | |
| if y_auto: | |
| y_axis_config['autorange'] = True | |
| elif y_range: | |
| y_axis_config['autorange'] = False | |
| y_axis_config['range'] = y_range | |
| fig.update_yaxes(**y_axis_config) | |
| # Update x-axis | |
| x_axis_config = { | |
| 'showgrid': True, | |
| 'gridwidth': 1, | |
| 'gridcolor': 'rgba(0,0,0,0.1)', | |
| 'tickformat': "%b %d", | |
| 'tickangle': -30, | |
| 'tickfont': dict( | |
| size=self.config['axis_font_size'], | |
| family=self.config['font_family'], | |
| color="black", | |
| weight="bold" | |
| ), | |
| 'title': None | |
| } | |
| if x_range: | |
| x_axis_config['autorange'] = False | |
| x_axis_config['range'] = x_range | |
| fig.update_xaxes(**x_axis_config) | |
| def _add_agent_data_points(self, fig: go.Figure, df: pd.DataFrame, value_column: str, | |
| color_map: Dict[str, str], max_visible: int = None) -> None: | |
| """Add individual agent data points to the chart.""" | |
| if df.empty: | |
| return | |
| unique_agents = df['agent_name'].unique() | |
| max_visible = max_visible or self.config['max_visible_agents'] | |
| # Calculate agent activity to determine which to show by default | |
| agent_counts = df['agent_name'].value_counts() | |
| top_agents = agent_counts.nlargest(min(max_visible, len(agent_counts))).index.tolist() | |
| logger.info(f"Showing {len(top_agents)} agents by default out of {len(unique_agents)} total agents") | |
| for agent_name in unique_agents: | |
| agent_data = df[df['agent_name'] == agent_name] | |
| x_values = agent_data['timestamp'].tolist() | |
| y_values = agent_data[value_column].tolist() | |
| # Determine visibility | |
| is_visible = False # Hide all agent data points by default | |
| fig.add_trace( | |
| go.Scatter( | |
| x=x_values, | |
| y=y_values, | |
| mode='markers', | |
| marker=dict( | |
| color=color_map.get(agent_name, 'gray'), | |
| symbol='circle', | |
| size=10, | |
| line=dict(width=1, color='black') | |
| ), | |
| name=f'Agent: {agent_name} ({value_column.upper()})', | |
| hovertemplate=f'Time: %{{x}}<br>{value_column.upper()}: %{{y:.2f}}<br>Agent: {agent_name}<extra></extra>', | |
| visible=is_visible | |
| ) | |
| ) | |
| logger.info(f"Added {value_column} data points for agent {agent_name} with {len(x_values)} points (visible: {is_visible})") | |
| def _add_moving_average_line(self, fig: go.Figure, avg_data: pd.DataFrame, | |
| value_column: str, line_name: str, color: str, | |
| width: int = 2, hover_data: List[str] = None) -> None: | |
| """Add a moving average line to the chart.""" | |
| if avg_data.empty or 'moving_avg' not in avg_data.columns: | |
| return | |
| # Filter out NaT values before processing - be more aggressive | |
| clean_data = avg_data.copy() | |
| # Remove rows with NaT timestamps more comprehensively | |
| clean_data = clean_data.dropna(subset=['timestamp']) | |
| clean_data = clean_data[clean_data['timestamp'].notna()] | |
| clean_data = clean_data[~clean_data['timestamp'].isnull()] | |
| # Additional check for pandas NaT specifically | |
| if hasattr(pd, 'NaT'): | |
| clean_data = clean_data[clean_data['timestamp'] != pd.NaT] | |
| # Also filter out NaN moving averages | |
| clean_data = clean_data.dropna(subset=['moving_avg']) | |
| clean_data = clean_data[clean_data['moving_avg'].notna()] | |
| if clean_data.empty: | |
| logger.warning("No valid timestamps found for " + str(line_name)) | |
| return | |
| x_values = clean_data['timestamp'].tolist() | |
| y_values = clean_data['moving_avg'].tolist() | |
| # Create hover text without any f-strings to avoid strftime issues | |
| if hover_data: | |
| hover_text = hover_data | |
| else: | |
| hover_text = [] | |
| for _, row in clean_data.iterrows(): | |
| try: | |
| # Convert timestamp to string safely | |
| ts = row['timestamp'] | |
| # More comprehensive NaT checking | |
| if pd.isna(ts) or pd.isnull(ts) or (hasattr(pd, 'NaT') and ts is pd.NaT): | |
| time_str = "Invalid Date" | |
| elif hasattr(ts, 'strftime'): | |
| try: | |
| time_str = ts.strftime('%Y-%m-%d %H:%M:%S') | |
| except (ValueError, TypeError): | |
| time_str = str(ts) | |
| else: | |
| time_str = str(ts) | |
| # Build hover text using string concatenation only | |
| hover_line = "Time: " + time_str + "<br>" | |
| # Safely format moving average value | |
| try: | |
| avg_val = row['moving_avg'] | |
| if pd.isna(avg_val) or pd.isnull(avg_val): | |
| avg_str = "N/A" | |
| else: | |
| avg_str = "{:.2f}".format(float(avg_val)) | |
| except (ValueError, TypeError): | |
| avg_str = "N/A" | |
| hover_line += "Avg " + value_column.upper() + " (7d window): " + avg_str | |
| hover_text.append(hover_line) | |
| except Exception as e: | |
| logger.warning("Error formatting timestamp for hover text: " + str(e)) | |
| # Fallback hover text | |
| hover_line = "Time: Invalid Date<br>" | |
| hover_line += "Avg " + value_column.upper() + " (3d window): N/A" | |
| hover_text.append(hover_line) | |
| fig.add_trace( | |
| go.Scatter( | |
| x=x_values, | |
| y=y_values, | |
| mode='lines', | |
| line=dict(color=color, width=width, shape='spline', smoothing=1.3), | |
| name=line_name, | |
| hovertext=hover_text, | |
| hoverinfo='text', | |
| visible=True | |
| ) | |
| ) | |
| logger.info("Added moving average line '" + str(line_name) + "' with " + str(len(x_values)) + " points") | |
| def _filter_outliers(self, df: pd.DataFrame, column: str) -> pd.DataFrame: | |
| """Filter outliers from the data - DISABLED: Return data unchanged.""" | |
| # Outlier filtering disabled - return original data | |
| logger.info(f"Outlier filtering disabled for {column} column - returning all data") | |
| return df | |
| def _calculate_moving_average(self, df: pd.DataFrame, value_column: str) -> pd.DataFrame: | |
| """Calculate moving average for the data.""" | |
| return self.data_processor.calculate_moving_average(df, value_column) | |
| def _save_chart(self, fig: go.Figure, html_filename: str, png_filename: str = None) -> None: | |
| """Save the chart to HTML and optionally PNG.""" | |
| try: | |
| fig.write_html(html_filename, include_plotlyjs='cdn', full_html=False) | |
| logger.info(f"Chart saved to {html_filename}") | |
| if png_filename: | |
| try: | |
| fig.write_image(png_filename) | |
| logger.info(f"Chart also saved to {png_filename}") | |
| except Exception as e: | |
| logger.error(f"Error saving PNG image: {e}") | |
| logger.info(f"Chart saved to {html_filename} only") | |
| except Exception as e: | |
| logger.error(f"Error saving chart: {e}") | |
| def generate_visualization(self, df: pd.DataFrame, **kwargs) -> Tuple[go.Figure, Optional[str]]: | |
| """Generate the complete visualization including chart and CSV export.""" | |
| if df.empty: | |
| logger.info("No data available for visualization.") | |
| fig = self._create_empty_chart("No data available") | |
| return fig, None | |
| # Create the chart | |
| fig = self.create_chart(df, **kwargs) | |
| # Save to CSV | |
| csv_filename = kwargs.get('csv_filename') | |
| if csv_filename: | |
| csv_path = self.data_processor.save_to_csv(df, csv_filename) | |
| else: | |
| csv_path = None | |
| return fig, csv_path | |
| def _create_empty_chart(self, message: str) -> go.Figure: | |
| """Create an empty chart with a message.""" | |
| fig = go.Figure() | |
| fig.add_annotation( | |
| x=0.5, y=0.5, | |
| text=message, | |
| font=dict(size=20), | |
| showarrow=False | |
| ) | |
| fig.update_layout( | |
| xaxis=dict(showgrid=False, zeroline=False, showticklabels=False), | |
| yaxis=dict(showgrid=False, zeroline=False, showticklabels=False) | |
| ) | |
| return fig | |
| def _get_color_map(self, agents: List[str]) -> Dict[str, str]: | |
| """Generate a color map for agents.""" | |
| colors = px.colors.qualitative.Plotly[:len(agents)] | |
| return {agent: colors[i % len(colors)] for i, agent in enumerate(agents)} | |