Spaces:
Running
Running
""" | |
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)} | |