2025-06-07 15:42:40 -04:00
|
|
|
"""Plotting and visualization for paw burn risk assessment."""
|
|
|
|
|
|
|
|
import matplotlib
|
|
|
|
import matplotlib.pyplot as plt
|
|
|
|
import matplotlib.dates as mdates
|
|
|
|
from datetime import datetime
|
|
|
|
from typing import List, Optional, Tuple
|
|
|
|
import numpy as np
|
|
|
|
import warnings
|
|
|
|
import os
|
|
|
|
import shutil
|
|
|
|
from models import WeatherHour, RiskScore
|
2025-06-13 18:14:15 -04:00
|
|
|
from config import RiskConfig, get_config
|
2025-06-07 15:42:40 -04:00
|
|
|
|
|
|
|
# Suppress matplotlib UserWarnings and macOS GUI warnings
|
|
|
|
warnings.filterwarnings('ignore', category=UserWarning, module='matplotlib')
|
|
|
|
warnings.filterwarnings('ignore', message='.*NSSavePanel.*')
|
|
|
|
|
|
|
|
class RiskPlotter:
|
|
|
|
"""Handles plotting and visualization of risk data."""
|
|
|
|
|
2025-06-13 18:14:15 -04:00
|
|
|
def __init__(self, figure_size: Tuple[int, int] = (12, 8), use_24hr_time: Optional[bool] = None):
|
2025-06-07 15:42:40 -04:00
|
|
|
self.figure_size = figure_size
|
|
|
|
self.plots_dir = "plots"
|
|
|
|
self._plots_dir_setup = False
|
|
|
|
|
2025-06-13 18:14:15 -04:00
|
|
|
# Get time format preference from config if not specified
|
|
|
|
if use_24hr_time is None:
|
|
|
|
config = get_config().risk_config
|
|
|
|
self.use_24hr_time = getattr(config, 'use_24hr_time', False)
|
|
|
|
else:
|
|
|
|
self.use_24hr_time = use_24hr_time
|
|
|
|
|
2025-06-07 15:42:40 -04:00
|
|
|
# Set up matplotlib style and suppress warnings
|
|
|
|
plt.style.use('default')
|
|
|
|
plt.rcParams['figure.figsize'] = figure_size
|
|
|
|
plt.rcParams['font.size'] = 10
|
|
|
|
|
2025-06-13 18:14:15 -04:00
|
|
|
def get_time_formatter(self):
|
|
|
|
"""Get the appropriate time formatter based on config."""
|
|
|
|
if self.use_24hr_time:
|
|
|
|
return mdates.DateFormatter('%H:%M')
|
|
|
|
else:
|
|
|
|
return mdates.DateFormatter('%I:%M %p')
|
|
|
|
|
2025-06-07 15:42:40 -04:00
|
|
|
def _setup_plots_directory(self):
|
|
|
|
"""Create and clear plots directory."""
|
|
|
|
if os.path.exists(self.plots_dir):
|
|
|
|
# Clear existing plots
|
|
|
|
shutil.rmtree(self.plots_dir)
|
|
|
|
os.makedirs(self.plots_dir, exist_ok=True)
|
|
|
|
print(f"📁 Plots directory ready: {self.plots_dir}/")
|
|
|
|
|
|
|
|
def _safe_save_plot(self, filename: str, dpi: int = 300):
|
|
|
|
"""Safely save plot to plots directory."""
|
|
|
|
if filename:
|
|
|
|
# Setup plots directory on first save
|
|
|
|
if not self._plots_dir_setup:
|
|
|
|
self._setup_plots_directory()
|
|
|
|
self._plots_dir_setup = True
|
|
|
|
|
|
|
|
# Create full path in plots directory
|
|
|
|
save_path = os.path.join(self.plots_dir, filename)
|
|
|
|
|
|
|
|
# Save with current backend (don't switch backends as it causes blank files)
|
|
|
|
with warnings.catch_warnings():
|
|
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
|
|
plt.savefig(save_path, dpi=dpi, bbox_inches='tight', facecolor='white')
|
|
|
|
|
|
|
|
print(f"📊 Plot saved: {save_path}")
|
|
|
|
|
|
|
|
def plot_risk_timeline(self, risk_scores: List[RiskScore],
|
|
|
|
weather_hours: List[WeatherHour],
|
|
|
|
threshold: float = 6.0,
|
|
|
|
save_path: Optional[str] = None,
|
|
|
|
show: bool = True) -> None:
|
|
|
|
"""Plot risk scores over time with recommendation threshold."""
|
|
|
|
if not risk_scores:
|
|
|
|
print("No risk data to plot")
|
|
|
|
return
|
|
|
|
|
|
|
|
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=self.figure_size,
|
|
|
|
sharex=True, gridspec_kw={'height_ratios': [2, 1, 1]})
|
|
|
|
|
|
|
|
# Extract data
|
|
|
|
times = [score.datetime for score in risk_scores]
|
|
|
|
total_scores = [score.total_score for score in risk_scores]
|
|
|
|
temperatures = [hour.temperature_f for hour in weather_hours]
|
|
|
|
uv_indices = [hour.uv_index or 0 for hour in weather_hours]
|
|
|
|
|
|
|
|
# Main risk score plot
|
|
|
|
ax1.plot(times, total_scores, 'b-', linewidth=2, label='Risk Score')
|
|
|
|
ax1.axhline(y=threshold, color='red', linestyle='--', alpha=0.7,
|
|
|
|
label=f'Shoe Threshold ({threshold})')
|
|
|
|
|
|
|
|
# Highlight high-risk periods
|
|
|
|
high_risk_times = []
|
|
|
|
high_risk_scores = []
|
|
|
|
for score in risk_scores:
|
|
|
|
if score.recommend_shoes:
|
|
|
|
high_risk_times.append(score.datetime)
|
|
|
|
high_risk_scores.append(score.total_score)
|
|
|
|
|
|
|
|
if high_risk_times:
|
|
|
|
ax1.scatter(high_risk_times, high_risk_scores, color='red', s=50,
|
|
|
|
alpha=0.7, zorder=5, label='Shoes Recommended')
|
|
|
|
|
|
|
|
ax1.set_ylabel('Risk Score')
|
|
|
|
ax1.set_title('Paw Burn Risk Assessment Timeline')
|
|
|
|
ax1.legend()
|
|
|
|
ax1.grid(True, alpha=0.3)
|
|
|
|
ax1.set_ylim(0, 10)
|
|
|
|
|
|
|
|
# Temperature subplot
|
|
|
|
ax2.plot(times, temperatures, 'orange', linewidth=2, label='Temperature (°F)')
|
|
|
|
ax2.axhline(y=80, color='yellow', linestyle=':', alpha=0.7, label='80°F')
|
|
|
|
ax2.axhline(y=90, color='orange', linestyle=':', alpha=0.7, label='90°F')
|
|
|
|
ax2.axhline(y=100, color='red', linestyle=':', alpha=0.7, label='100°F')
|
|
|
|
ax2.set_ylabel('Temperature (°F)')
|
|
|
|
ax2.legend(loc='upper right')
|
|
|
|
ax2.grid(True, alpha=0.3)
|
|
|
|
|
|
|
|
# UV Index subplot
|
|
|
|
ax3.plot(times, uv_indices, 'purple', linewidth=2, label='UV Index')
|
|
|
|
ax3.axhline(y=6, color='yellow', linestyle=':', alpha=0.7, label='UV 6')
|
|
|
|
ax3.axhline(y=8, color='orange', linestyle=':', alpha=0.7, label='UV 8')
|
|
|
|
ax3.axhline(y=10, color='red', linestyle=':', alpha=0.7, label='UV 10')
|
|
|
|
ax3.set_ylabel('UV Index')
|
|
|
|
ax3.set_xlabel('Time')
|
|
|
|
ax3.legend(loc='upper right')
|
|
|
|
ax3.grid(True, alpha=0.3)
|
|
|
|
|
|
|
|
# Format x-axis
|
2025-06-13 18:14:15 -04:00
|
|
|
time_formatter = self.get_time_formatter()
|
2025-06-07 15:42:40 -04:00
|
|
|
for ax in [ax1, ax2, ax3]:
|
2025-06-13 18:14:15 -04:00
|
|
|
ax.xaxis.set_major_formatter(time_formatter)
|
2025-06-07 15:42:40 -04:00
|
|
|
ax.xaxis.set_major_locator(mdates.HourLocator(interval=2))
|
|
|
|
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)
|
|
|
|
|
|
|
|
with warnings.catch_warnings():
|
|
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
|
|
try:
|
|
|
|
plt.tight_layout()
|
|
|
|
except:
|
|
|
|
# If tight_layout fails, adjust manually
|
|
|
|
plt.subplots_adjust(hspace=0.4, bottom=0.15)
|
|
|
|
|
|
|
|
if save_path:
|
|
|
|
self._safe_save_plot(save_path)
|
|
|
|
|
|
|
|
if show:
|
|
|
|
plt.show()
|
|
|
|
|
|
|
|
def plot_risk_components(self, risk_scores: List[RiskScore],
|
|
|
|
save_path: Optional[str] = None,
|
|
|
|
show: bool = True) -> None:
|
|
|
|
"""Plot breakdown of risk score components."""
|
|
|
|
if not risk_scores:
|
|
|
|
print("No risk data to plot")
|
|
|
|
return
|
|
|
|
|
|
|
|
fig, ax = plt.subplots(figsize=self.figure_size)
|
|
|
|
|
|
|
|
# Extract data
|
|
|
|
times = [score.datetime for score in risk_scores]
|
|
|
|
temp_scores = [score.temperature_score for score in risk_scores]
|
|
|
|
uv_scores = [score.uv_score for score in risk_scores]
|
|
|
|
condition_scores = [score.condition_score for score in risk_scores]
|
|
|
|
accumulated_scores = [score.accumulated_heat_score for score in risk_scores]
|
|
|
|
recovery_scores = [score.surface_recovery_score for score in risk_scores]
|
|
|
|
|
|
|
|
# Create stacked area plot
|
|
|
|
ax.stackplot(times, temp_scores, uv_scores, condition_scores,
|
|
|
|
accumulated_scores, recovery_scores,
|
|
|
|
labels=['Temperature', 'UV Index', 'Condition', 'Accumulated Heat', 'Surface Recovery'],
|
|
|
|
alpha=0.7)
|
|
|
|
|
|
|
|
ax.set_ylabel('Risk Score Components')
|
|
|
|
ax.set_xlabel('Time')
|
|
|
|
ax.set_title('Risk Score Component Breakdown')
|
|
|
|
ax.legend(loc='upper left', bbox_to_anchor=(1, 1))
|
|
|
|
ax.grid(True, alpha=0.3)
|
|
|
|
|
|
|
|
# Format x-axis
|
2025-06-13 18:14:15 -04:00
|
|
|
time_formatter = self.get_time_formatter()
|
|
|
|
ax.xaxis.set_major_formatter(time_formatter)
|
2025-06-07 15:42:40 -04:00
|
|
|
ax.xaxis.set_major_locator(mdates.HourLocator(interval=2))
|
|
|
|
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)
|
|
|
|
|
|
|
|
with warnings.catch_warnings():
|
|
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
|
|
try:
|
|
|
|
plt.tight_layout()
|
|
|
|
except:
|
|
|
|
# If tight_layout fails, adjust manually
|
|
|
|
plt.subplots_adjust(right=0.75, bottom=0.15)
|
|
|
|
|
|
|
|
if save_path:
|
|
|
|
self._safe_save_plot(save_path)
|
|
|
|
print(f"Component plot saved")
|
|
|
|
|
|
|
|
if show:
|
|
|
|
plt.show()
|
|
|
|
|
|
|
|
def plot_risk_heatmap(self, risk_scores: List[RiskScore],
|
|
|
|
save_path: Optional[str] = None,
|
|
|
|
show: bool = True) -> None:
|
|
|
|
"""Create a heatmap visualization of risk throughout the day."""
|
|
|
|
if not risk_scores:
|
|
|
|
print("No risk data to plot")
|
|
|
|
return
|
|
|
|
|
|
|
|
fig, ax = plt.subplots(figsize=(12, 3))
|
|
|
|
|
|
|
|
# Create time vs risk matrix
|
|
|
|
hours = [score.datetime.hour for score in risk_scores]
|
|
|
|
scores = [score.total_score for score in risk_scores]
|
|
|
|
|
|
|
|
# Create a matrix for the heatmap
|
|
|
|
hour_range = list(range(24))
|
|
|
|
risk_matrix = np.zeros((1, 24))
|
|
|
|
|
|
|
|
for hour, score in zip(hours, scores):
|
|
|
|
risk_matrix[0, hour] = score
|
|
|
|
|
|
|
|
# Create heatmap
|
|
|
|
im = ax.imshow(risk_matrix, cmap='RdYlBu_r', aspect='auto', vmin=0, vmax=10)
|
|
|
|
|
|
|
|
# Customize the plot
|
|
|
|
ax.set_xticks(range(24))
|
|
|
|
ax.set_xticklabels([f'{h:02d}:00' for h in range(24)])
|
|
|
|
ax.set_yticks([])
|
|
|
|
ax.set_xlabel('Hour of Day')
|
|
|
|
ax.set_title('Paw Burn Risk Heatmap')
|
|
|
|
|
|
|
|
# Add colorbar
|
|
|
|
cbar = plt.colorbar(im, ax=ax, orientation='horizontal', pad=0.1)
|
|
|
|
cbar.set_label('Risk Score')
|
|
|
|
|
|
|
|
# Add threshold line
|
|
|
|
threshold_line = 6.0 / 10.0 # Normalize to colormap scale
|
|
|
|
|
|
|
|
with warnings.catch_warnings():
|
|
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
|
|
try:
|
|
|
|
plt.tight_layout()
|
|
|
|
except:
|
|
|
|
# If tight_layout fails, adjust manually
|
|
|
|
plt.subplots_adjust(bottom=0.2)
|
|
|
|
|
|
|
|
if save_path:
|
|
|
|
self._safe_save_plot(save_path)
|
|
|
|
print(f"Heatmap saved")
|
|
|
|
|
|
|
|
if show:
|
|
|
|
plt.show()
|
|
|
|
|
|
|
|
def create_summary_dashboard(self, risk_scores: List[RiskScore],
|
|
|
|
weather_hours: List[WeatherHour],
|
|
|
|
recommendations: dict,
|
|
|
|
save_path: Optional[str] = None,
|
|
|
|
show: bool = True) -> None:
|
|
|
|
"""Create a comprehensive dashboard with all visualizations."""
|
|
|
|
if not risk_scores or not weather_hours:
|
|
|
|
print("Insufficient data for dashboard")
|
|
|
|
return
|
|
|
|
|
|
|
|
fig = plt.figure(figsize=(16, 12))
|
|
|
|
|
|
|
|
# Create subplots
|
|
|
|
gs = fig.add_gridspec(3, 2, height_ratios=[2, 1, 1], hspace=0.3, wspace=0.3)
|
|
|
|
|
|
|
|
# Main timeline plot
|
|
|
|
ax1 = fig.add_subplot(gs[0, :])
|
|
|
|
times = [score.datetime for score in risk_scores]
|
|
|
|
total_scores = [score.total_score for score in risk_scores]
|
|
|
|
|
|
|
|
ax1.plot(times, total_scores, 'b-', linewidth=3, label='Risk Score')
|
|
|
|
ax1.axhline(y=6, color='red', linestyle='--', alpha=0.7, label='Shoe Threshold')
|
|
|
|
|
|
|
|
# Highlight high-risk periods
|
|
|
|
high_risk_periods = []
|
|
|
|
current_start = None
|
|
|
|
|
|
|
|
for i, score in enumerate(risk_scores):
|
|
|
|
if score.recommend_shoes and current_start is None:
|
|
|
|
current_start = i
|
|
|
|
elif not score.recommend_shoes and current_start is not None:
|
|
|
|
high_risk_periods.append((current_start, i))
|
|
|
|
current_start = None
|
|
|
|
|
|
|
|
if current_start is not None:
|
|
|
|
high_risk_periods.append((current_start, len(risk_scores)))
|
|
|
|
|
|
|
|
for start_idx, end_idx in high_risk_periods:
|
|
|
|
ax1.axvspan(times[start_idx], times[end_idx-1], alpha=0.3, color='red')
|
|
|
|
|
|
|
|
ax1.set_ylabel('Risk Score')
|
|
|
|
ax1.set_title('Paw Burn Risk Assessment - Daily Overview')
|
|
|
|
ax1.legend()
|
|
|
|
ax1.grid(True, alpha=0.3)
|
|
|
|
ax1.set_ylim(0, 10)
|
|
|
|
|
|
|
|
# Component breakdown
|
|
|
|
ax2 = fig.add_subplot(gs[1, 0])
|
|
|
|
component_names = ['Temp', 'UV', 'Condition', 'Heat Accum', 'Recovery']
|
|
|
|
avg_components = [
|
|
|
|
np.mean([s.temperature_score for s in risk_scores]),
|
|
|
|
np.mean([s.uv_score for s in risk_scores]),
|
|
|
|
np.mean([s.condition_score for s in risk_scores]),
|
|
|
|
np.mean([s.accumulated_heat_score for s in risk_scores]),
|
|
|
|
np.mean([s.surface_recovery_score for s in risk_scores])
|
|
|
|
]
|
|
|
|
|
|
|
|
bars = ax2.bar(component_names, avg_components, color=['orange', 'purple', 'lightblue', 'yellow', 'green'])
|
|
|
|
ax2.set_ylabel('Average Score')
|
|
|
|
ax2.set_title('Average Risk Components')
|
|
|
|
ax2.tick_params(axis='x', rotation=45)
|
|
|
|
|
|
|
|
# Statistics
|
|
|
|
ax3 = fig.add_subplot(gs[1, 1])
|
|
|
|
ax3.axis('off')
|
|
|
|
|
|
|
|
stats_text = f"""
|
|
|
|
DAILY SUMMARY
|
|
|
|
|
|
|
|
Total Hours Analyzed: {recommendations['summary']['total_hours_analyzed']}
|
|
|
|
High Risk Hours: {recommendations['summary']['high_risk_hours']}
|
|
|
|
Max Risk Score: {recommendations['summary']['max_risk_score']}
|
|
|
|
Average Risk Score: {recommendations['summary']['average_risk_score']}
|
|
|
|
Peak Risk Time: {recommendations['summary']['peak_risk_time']}
|
|
|
|
Risk Periods: {recommendations['summary']['continuous_risk_blocks']}
|
|
|
|
"""
|
|
|
|
|
|
|
|
ax3.text(0.1, 0.9, stats_text, transform=ax3.transAxes, fontsize=10,
|
|
|
|
verticalalignment='top', fontfamily='monospace')
|
|
|
|
|
|
|
|
# Weather conditions
|
|
|
|
ax4 = fig.add_subplot(gs[2, :])
|
|
|
|
temperatures = [hour.temperature_f for hour in weather_hours]
|
|
|
|
uv_indices = [hour.uv_index or 0 for hour in weather_hours]
|
|
|
|
|
|
|
|
ax4_twin = ax4.twinx()
|
|
|
|
|
|
|
|
line1 = ax4.plot(times, temperatures, 'orange', linewidth=2, label='Temperature (°F)')
|
|
|
|
line2 = ax4_twin.plot(times, uv_indices, 'purple', linewidth=2, label='UV Index')
|
|
|
|
|
|
|
|
ax4.set_ylabel('Temperature (°F)', color='orange')
|
|
|
|
ax4_twin.set_ylabel('UV Index', color='purple')
|
|
|
|
ax4.set_xlabel('Time')
|
|
|
|
ax4.set_title('Weather Conditions')
|
|
|
|
|
|
|
|
# Format x-axis for all subplots
|
2025-06-13 18:14:15 -04:00
|
|
|
time_formatter = self.get_time_formatter()
|
2025-06-07 15:42:40 -04:00
|
|
|
for ax in [ax1, ax4]:
|
2025-06-13 18:14:15 -04:00
|
|
|
ax.xaxis.set_major_formatter(time_formatter)
|
2025-06-07 15:42:40 -04:00
|
|
|
ax.xaxis.set_major_locator(mdates.HourLocator(interval=2))
|
|
|
|
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)
|
|
|
|
|
|
|
|
# Use constrained layout or manual adjustment instead of tight_layout
|
|
|
|
with warnings.catch_warnings():
|
|
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
|
|
try:
|
|
|
|
plt.tight_layout()
|
|
|
|
except:
|
|
|
|
# If tight_layout fails with complex layouts, adjust manually
|
|
|
|
plt.subplots_adjust(hspace=0.4, wspace=0.4, bottom=0.1, top=0.95)
|
|
|
|
|
|
|
|
if save_path:
|
|
|
|
self._safe_save_plot(save_path)
|
|
|
|
print(f"Dashboard saved")
|
|
|
|
|
|
|
|
if show:
|
|
|
|
plt.show()
|
|
|
|
|
|
|
|
def create_plotter(figure_size: Tuple[int, int] = (12, 8)) -> RiskPlotter:
|
|
|
|
"""Create a risk plotter with specified figure size."""
|
|
|
|
return RiskPlotter(figure_size)
|