From a2c25e603ad0eac7dd199072d42d99bcf1732b98 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Fri, 13 Jun 2025 14:08:37 -0400 Subject: [PATCH] Enhancement: Advanced surface recovery modeling - Added configurable threshold, graduated recovery scoring, surface type models, time-of-day factors, and sun exposure effects with test scripts and documentation --- README.md | 27 ++++++ config.py | 15 +++- constants.py | 17 ++++ env_template.txt | 9 +- risk_calculator.py | 86 ++++++++++++++++++-- test_real_data.py | 154 +++++++++++++++++++++++++++++++++++ test_recovery.py | 199 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 497 insertions(+), 10 deletions(-) create mode 100644 constants.py create mode 100644 test_real_data.py create mode 100644 test_recovery.py diff --git a/README.md b/README.md index 180a60c..5e4e7f1 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,35 @@ UV_THRESHOLD_HIGH=10 RISK_THRESHOLD_SHOES=6 ROLLING_WINDOW_HOURS=2 SURFACE_RECOVERY_HOURS=2 + +# Surface recovery enhancements (Optional) +SURFACE_RECOVERY_TEMP_THRESHOLD=90 # Temperature threshold to start recovery period (°F) +SURFACE_TYPE=asphalt # Options: asphalt, concrete, mixed, grass +SURFACE_MAX_RECOVERY_SCORE=2.0 # Maximum recovery score reduction with graduated recovery +ENABLE_GRADUATED_RECOVERY=true # Enable graduated recovery (more cooling time = more benefit) +ENABLE_TIME_OF_DAY_FACTOR=true # Apply day/night cooling adjustments ``` +### Surface Recovery Feature + +The application includes an enhanced surface recovery model that accounts for cooling of surfaces after peak temperatures: + +- **Basic Recovery**: Surfaces begin to cool after being exposed to high temperatures (above `SURFACE_RECOVERY_TEMP_THRESHOLD`). After `SURFACE_RECOVERY_HOURS`, a recovery credit reduces the risk score. + +- **Graduated Recovery**: When enabled, provides a progressively stronger recovery benefit as more cooling time passes, up to `SURFACE_MAX_RECOVERY_SCORE`. + +- **Surface Type**: Different surfaces cool at different rates: + - Asphalt: Slowest cooling (coefficient 0.7) + - Concrete: Moderate cooling (coefficient 0.85) + - Mixed: Standard cooling (coefficient 1.0) + - Grass: Fastest cooling (coefficient 1.5) + +- **Time-of-Day**: When enabled, night hours (7pm-6am) provide 30% faster cooling than daylight hours. + +- **Sun Exposure**: The algorithm considers sun exposure during the recovery period, which can slow cooling by up to 30%. + +These factors combine to provide a more accurate assessment of surface temperatures throughout the day and the resulting paw burn risk. + ## Output Examples ### Summary Output diff --git a/config.py b/config.py index a238d4c..b1b9d30 100644 --- a/config.py +++ b/config.py @@ -23,6 +23,13 @@ class RiskConfig: rolling_window_hours: int = 2 surface_recovery_hours: int = 2 + # Surface recovery enhancement parameters + surface_recovery_temp_threshold: float = 90.0 + surface_type: str = "asphalt" # Options: asphalt, concrete, mixed, grass + surface_max_recovery_score: float = 2.0 + enable_graduated_recovery: bool = True + enable_time_of_day_factor: bool = True + @classmethod def from_env(cls) -> 'RiskConfig': """Create configuration from environment variables.""" @@ -35,7 +42,13 @@ class RiskConfig: uv_threshold_high=float(os.getenv('UV_THRESHOLD_HIGH', 10)), risk_threshold_shoes=float(os.getenv('RISK_THRESHOLD_SHOES', 6)), rolling_window_hours=int(os.getenv('ROLLING_WINDOW_HOURS', 2)), - surface_recovery_hours=int(os.getenv('SURFACE_RECOVERY_HOURS', 2)) + surface_recovery_hours=int(os.getenv('SURFACE_RECOVERY_HOURS', 2)), + # Surface recovery enhancement parameters + surface_recovery_temp_threshold=float(os.getenv('SURFACE_RECOVERY_TEMP_THRESHOLD', 90)), + surface_type=os.getenv('SURFACE_TYPE', 'asphalt'), + surface_max_recovery_score=float(os.getenv('SURFACE_MAX_RECOVERY_SCORE', 2.0)), + enable_graduated_recovery=os.getenv('ENABLE_GRADUATED_RECOVERY', 'true').lower() == 'true', + enable_time_of_day_factor=os.getenv('ENABLE_TIME_OF_DAY_FACTOR', 'true').lower() == 'true' ) @dataclass diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..32abaa1 --- /dev/null +++ b/constants.py @@ -0,0 +1,17 @@ +"""Constants used throughout the application.""" + +# Surface cooling coefficients (slower to faster cooling) +SURFACE_COOLING_COEFFICIENTS = { + 'asphalt': 0.7, # Slower cooling + 'concrete': 0.85, + 'mixed': 1.0, # Normal cooling + 'grass': 1.5 # Faster cooling +} + +# Night hours definition (7pm to 6am) +NIGHT_START_HOUR = 19 # 7pm +NIGHT_END_HOUR = 6 # 6am + +# Time of day cooling multipliers +NIGHT_COOLING_MULTIPLIER = 1.3 # Night cools 30% faster +DAY_COOLING_MULTIPLIER = 1.0 # Standard cooling during day \ No newline at end of file diff --git a/env_template.txt b/env_template.txt index 4e35461..c3fbf77 100644 --- a/env_template.txt +++ b/env_template.txt @@ -25,4 +25,11 @@ UV_THRESHOLD_HIGH=9 # Risk assessment parameters (Optional) RISK_THRESHOLD_SHOES=5 ROLLING_WINDOW_HOURS=2 -SURFACE_RECOVERY_HOURS=2 \ No newline at end of file +SURFACE_RECOVERY_HOURS=2 + +# Surface recovery enhancements (Optional) +SURFACE_RECOVERY_TEMP_THRESHOLD=90 +SURFACE_TYPE=asphalt +SURFACE_MAX_RECOVERY_SCORE=2.0 +ENABLE_GRADUATED_RECOVERY=true +ENABLE_TIME_OF_DAY_FACTOR=true \ No newline at end of file diff --git a/risk_calculator.py b/risk_calculator.py index fa98ded..9506496 100644 --- a/risk_calculator.py +++ b/risk_calculator.py @@ -5,6 +5,8 @@ from datetime import datetime, timedelta from typing import List, Optional, Tuple from models import WeatherHour, RiskScore from config import RiskConfig, get_config +from constants import SURFACE_COOLING_COEFFICIENTS, NIGHT_START_HOUR, NIGHT_END_HOUR +from constants import NIGHT_COOLING_MULTIPLIER, DAY_COOLING_MULTIPLIER logger = logging.getLogger(__name__) @@ -13,6 +15,21 @@ class RiskCalculator: def __init__(self, config: Optional[RiskConfig] = None): self.config = config or get_config().risk_config + + # Validate surface type is supported + if self.config.surface_type.lower() not in SURFACE_COOLING_COEFFICIENTS: + logger.warning(f"Surface type '{self.config.surface_type}' not recognized, using 'mixed' instead") + self.config.surface_type = "mixed" + + # Log enhanced recovery settings + if self.config.enable_graduated_recovery: + logger.info(f"Using graduated recovery scoring with max score of {self.config.surface_max_recovery_score}") + + if self.config.enable_time_of_day_factor: + logger.info(f"Time-of-day cooling factor enabled (night: {NIGHT_COOLING_MULTIPLIER}x, day: {DAY_COOLING_MULTIPLIER}x)") + + logger.info(f"Surface recovery config: threshold={self.config.surface_recovery_temp_threshold}°F, " + f"hours={self.config.surface_recovery_hours}, surface={self.config.surface_type}") def calculate_temperature_score(self, temperature_f: float) -> float: """Calculate risk score based on air temperature.""" @@ -78,25 +95,78 @@ class RiskCalculator: def calculate_surface_recovery_score(self, weather_hours: List[WeatherHour], current_index: int) -> float: - """Calculate surface recovery score (time since last peak temperature).""" + """Calculate surface recovery score with enhanced logic.""" if current_index < 2: return 0.0 - # Look back to find the last time temperature was ≥90°F + # Look back to find the last time temperature was above the threshold hours_since_peak = 0 + sun_exposure_hours = 0 + peak_temp = 0 + + current_hour = weather_hours[current_index].datetime + for i in range(current_index - 1, -1, -1): hours_since_peak += 1 - if weather_hours[i].temperature_f >= 90.0: + + # Check if temperature was above recovery threshold + if weather_hours[i].temperature_f >= self.config.surface_recovery_temp_threshold: + peak_temp = weather_hours[i].temperature_f break + + # Count sun exposure during recovery period + condition = weather_hours[i].condition.lower() + if 'sunny' in condition or 'clear' in condition: + sun_exposure_hours += 1 else: # No peak found in available data hours_since_peak = current_index + 1 - - # Give recovery credit if it's been >2 hours since last 90°F reading - if hours_since_peak > self.config.surface_recovery_hours: - return -1.0 - else: + + # No recovery needed if no peak was found + if peak_temp == 0: return 0.0 + + # Apply surface type coefficient + cooling_coefficient = SURFACE_COOLING_COEFFICIENTS.get( + self.config.surface_type.lower(), 1.0) + + # Apply time-of-day factor if enabled + if self.config.enable_time_of_day_factor: + hour_of_day = current_hour.hour + time_multiplier = (NIGHT_COOLING_MULTIPLIER + if hour_of_day >= NIGHT_START_HOUR or hour_of_day < NIGHT_END_HOUR + else DAY_COOLING_MULTIPLIER) + else: + time_multiplier = 1.0 + + # Calculate sun exposure percentage during recovery + sun_percentage = sun_exposure_hours / hours_since_peak if hours_since_peak > 0 else 0 + + # Sun slows cooling (reduce coefficient by up to 30%) + sun_factor = 1.0 - (sun_percentage * 0.3) + + # Final adjusted hours since peak temperature + adjusted_hours = hours_since_peak * cooling_coefficient * time_multiplier * sun_factor + + # Calculate recovery score + if adjusted_hours <= self.config.surface_recovery_hours: + # No recovery credit yet + return 0.0 + elif self.config.enable_graduated_recovery: + # Graduated recovery (more hours = more recovery credit) + # Normalize to range 0.0 to max_recovery_score + hours_over = adjusted_hours - self.config.surface_recovery_hours + max_additional_hours = self.config.surface_recovery_hours # Full credit after double the recovery time + factor = min(1.0, hours_over / max_additional_hours) + recovery_score = -factor * self.config.surface_max_recovery_score + + logger.debug(f"Graduated recovery: {hours_since_peak} hrs since {peak_temp}°F peak, " + f"adjusted to {adjusted_hours:.1f} hrs, score: {recovery_score}") + + return recovery_score + else: + # Original binary approach + return -1.0 def interpolate_missing_uv(self, weather_hours: List[WeatherHour]) -> List[WeatherHour]: """Interpolate missing UV values using nearby hours.""" diff --git a/test_real_data.py b/test_real_data.py new file mode 100644 index 0000000..642fffd --- /dev/null +++ b/test_real_data.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +Test script for enhanced surface recovery logic with real data +""" + +import os +import logging +import matplotlib.pyplot as plt +from datetime import datetime, timedelta +from models import WeatherHour, RiskScore +from config import RiskConfig, AppConfig, get_config +from risk_calculator import RiskCalculator +from constants import SURFACE_COOLING_COEFFICIENTS +from models import DatabaseManager + +# Set up logging +logging.basicConfig(level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +def load_db_data(): + """Load real weather data from the database.""" + config = get_config() + db_manager = DatabaseManager(config.database_path) + + # Get data from the past 24 hours + end_date = datetime.now() + start_date = end_date - timedelta(hours=24) + + try: + return db_manager.get_weather_data(start_date, end_date) + except Exception as e: + logger.error(f"Error loading data from database: {e}") + return [] + +def compare_recovery_settings(weather_data): + """Compare different recovery settings with real weather data.""" + if not weather_data: + logger.error("No weather data available for testing") + return + + print(f"Loaded {len(weather_data)} hours of weather data from database") + print(f"Temperature range: {min(h.temperature_f for h in weather_data):.1f}°F - {max(h.temperature_f for h in weather_data):.1f}°F") + + # Test configs + configs = [ + # Default settings + RiskConfig( + surface_recovery_temp_threshold=90.0, + enable_graduated_recovery=False, + enable_time_of_day_factor=False + ), + # All enhanced features + RiskConfig( + surface_recovery_temp_threshold=90.0, + enable_graduated_recovery=True, + enable_time_of_day_factor=True, + surface_max_recovery_score=2.0, + surface_type="asphalt" + ), + ] + + # Test each configuration + all_results = [] + + for i, config in enumerate(configs): + calculator = RiskCalculator(config) + risk_scores = calculator.calculate_risk_scores(weather_data) + all_results.append(risk_scores) + + label = "Default" if i == 0 else "Enhanced" + print(f"\n=== {label} Recovery Settings ===") + + high_risk = [s for s in risk_scores if s.recommend_shoes] + print(f"High Risk Hours: {len(high_risk)} out of {len(risk_scores)}") + + if high_risk: + times = [s.datetime.strftime("%H:%M") for s in high_risk] + print(f"High Risk Times: {', '.join(times)}") + + # Show recovery score impact + recovery_impact = sum(abs(s.surface_recovery_score) for s in risk_scores) + print(f"Total Recovery Impact: {recovery_impact:.2f}") + print(f"Average Recovery Score: {recovery_impact/len(risk_scores):.2f}") + + # Visualize the results + visualize_comparison(weather_data, all_results) + +def visualize_comparison(weather_data, result_sets): + """Create visualization comparing different recovery strategies with real data.""" + plt.figure(figsize=(14, 10)) + + # Extract time and temperatures for plotting + times = [hour.datetime for hour in weather_data] + temps = [hour.temperature_f for hour in weather_data] + + # Plot temperature + ax1 = plt.subplot(3, 1, 1) + ax1.plot(times, temps, 'r-', linewidth=2) + ax1.set_ylabel('Temperature (°F)') + ax1.set_title('Temperature Profile') + ax1.axhline(y=90, color='r', linestyle='--', alpha=0.7) + ax1.text(times[0], 91, "Recovery Threshold (90°F)", color='r') + ax1.grid(True, alpha=0.3) + + # Plot recovery scores + ax2 = plt.subplot(3, 1, 2, sharex=ax1) + + # Add labels for legend + labels = ['Default Recovery', 'Enhanced Recovery'] + linestyles = ['-', '--'] + colors = ['blue', 'green'] + + for i, results in enumerate(result_sets): + recovery_scores = [score.surface_recovery_score for score in results] + ax2.plot(times, recovery_scores, linestyle=linestyles[i], color=colors[i], linewidth=2, label=labels[i]) + + ax2.set_ylabel('Recovery Score') + ax2.set_title('Surface Recovery Scores Comparison') + ax2.grid(True, alpha=0.3) + ax2.legend() + + # Plot total risk scores + ax3 = plt.subplot(3, 1, 3, sharex=ax1) + + for i, results in enumerate(result_sets): + total_scores = [score.total_score for score in results] + ax3.plot(times, total_scores, linestyle=linestyles[i], color=colors[i], linewidth=2, label=labels[i]) + + # Add threshold line + ax3.axhline(y=6.0, color='red', linestyle='--', alpha=0.7, label='Shoe Threshold (6.0)') + + ax3.set_ylabel('Total Risk Score') + ax3.set_title('Total Risk Score Comparison (Real Weather Data)') + ax3.set_xlabel('Time') + ax3.grid(True, alpha=0.3) + ax3.legend() + + # Format x-axis + for ax in [ax1, ax2, ax3]: + ax.set_xlim(times[0], times[-1]) + plt.setp(ax.xaxis.get_majorticklabels(), rotation=45) + + plt.tight_layout() + plt.savefig('real_data_comparison.png') + plt.show() + +if __name__ == "__main__": + print("Testing enhanced surface recovery logic with real data") + weather_data = load_db_data() + if weather_data: + compare_recovery_settings(weather_data) + else: + print("No data available. Please ensure you've run the app to collect weather data first.") \ No newline at end of file diff --git a/test_recovery.py b/test_recovery.py new file mode 100644 index 0000000..ac26521 --- /dev/null +++ b/test_recovery.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +Test script for enhanced surface recovery logic +""" + +import os +import logging +import numpy as np +import matplotlib.pyplot as plt +from datetime import datetime, timedelta +from models import WeatherHour +from config import RiskConfig +from risk_calculator import RiskCalculator +from constants import SURFACE_COOLING_COEFFICIENTS + +# Set up logging +logging.basicConfig(level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +def create_test_data(): + """Create synthetic test data with a temperature peak followed by cooling.""" + now = datetime.now() + hours = [] + + # Create a 24-hour dataset + for i in range(24): + # Time starting from 24 hours ago to now + time = now - timedelta(hours=24-i) + + # Temperature pattern: starts low, peaks in afternoon, drops at night + if i < 8: # Early morning (cold) + temp = 75.0 + i * 1.5 + condition = "Clear" + uv = 0.0 + elif i < 14: # Mid-day heating (peak at 2pm / hour 14) + temp = 85.0 + (i - 8) * 3.0 + condition = "Sunny" + uv = 8.0 + else: # Afternoon cooling + temp = 95.0 - (i - 14) * 2.0 + condition = "Partly Cloudy" if i < 18 else "Clear" + uv = max(0.0, 8.0 - (i - 14) * 1.5) + + # Create a peak temperature that exceeds threshold + if i == 13: # 1pm + temp = 95.0 # Peak temperature + + hours.append(WeatherHour( + datetime=time, + temperature_f=temp, + uv_index=uv, + condition=condition, + is_forecast=False + )) + + return hours + +def visualize_results(test_data, all_results): + """Create visualization comparing different recovery strategies.""" + plt.figure(figsize=(14, 10)) + + # Extract time and temperatures for plotting + times = [hour.datetime for hour in test_data] + temps = [hour.temperature_f for hour in test_data] + + # Plot temperature + ax1 = plt.subplot(3, 1, 1) + ax1.plot(times, temps, 'r-', linewidth=2) + ax1.set_ylabel('Temperature (°F)') + ax1.set_title('Temperature Profile') + ax1.axhline(y=90, color='r', linestyle='--', alpha=0.7) + ax1.text(times[0], 91, "Recovery Threshold (90°F)", color='r') + ax1.grid(True, alpha=0.3) + + # Plot recovery scores + ax2 = plt.subplot(3, 1, 2, sharex=ax1) + + # Add labels for legend + labels = ['Default', 'Graduated', 'Time-of-day', 'All Features', 'Concrete', 'Grass'] + linestyles = ['-', '--', ':', '-.', '--', ':'] + colors = ['blue', 'green', 'purple', 'orange', 'brown', 'magenta'] + + for i, results in enumerate(all_results): + recovery_scores = [score.surface_recovery_score for score in results] + ax2.plot(times, recovery_scores, linestyle=linestyles[i], color=colors[i], linewidth=2, label=labels[i]) + + ax2.set_ylabel('Recovery Score') + ax2.set_title('Surface Recovery Scores Comparison') + ax2.grid(True, alpha=0.3) + ax2.legend() + + # Plot total risk scores + ax3 = plt.subplot(3, 1, 3, sharex=ax1) + + for i, results in enumerate(all_results): + total_scores = [score.total_score for score in results] + ax3.plot(times, total_scores, linestyle=linestyles[i], color=colors[i], linewidth=2, label=labels[i]) + + # Add threshold line + ax3.axhline(y=6.0, color='red', linestyle='--', alpha=0.7, label='Shoe Threshold (6.0)') + + ax3.set_ylabel('Total Risk Score') + ax3.set_title('Total Risk Score Comparison') + ax3.set_xlabel('Time') + ax3.grid(True, alpha=0.3) + ax3.legend() + + # Format x-axis + for ax in [ax1, ax2, ax3]: + ax.set_xlim(times[0], times[-1]) + plt.setp(ax.xaxis.get_majorticklabels(), rotation=45) + + plt.tight_layout() + plt.savefig('recovery_comparison.png') + plt.show() + +def test_recovery_settings(): + """Test different recovery settings and compare results.""" + test_data = create_test_data() + + # Test configs + configs = [ + # Default settings + RiskConfig( + surface_recovery_temp_threshold=90.0, + enable_graduated_recovery=False, + enable_time_of_day_factor=False + ), + # Graduated recovery + RiskConfig( + surface_recovery_temp_threshold=90.0, + enable_graduated_recovery=True, + enable_time_of_day_factor=False, + surface_max_recovery_score=2.0 + ), + # Time-of-day factor + RiskConfig( + surface_recovery_temp_threshold=90.0, + enable_graduated_recovery=False, + enable_time_of_day_factor=True + ), + # All features + RiskConfig( + surface_recovery_temp_threshold=90.0, + enable_graduated_recovery=True, + enable_time_of_day_factor=True, + surface_max_recovery_score=2.0 + ), + # Different surface types + RiskConfig( + surface_recovery_temp_threshold=90.0, + surface_type="concrete", + enable_graduated_recovery=True, + enable_time_of_day_factor=True + ), + RiskConfig( + surface_recovery_temp_threshold=90.0, + surface_type="grass", + enable_graduated_recovery=True, + enable_time_of_day_factor=True + ), + ] + + all_results = [] + + # Test each configuration + for i, config in enumerate(configs): + calculator = RiskCalculator(config) + risk_scores = calculator.calculate_risk_scores(test_data) + all_results.append(risk_scores) + + print(f"\n=== Test Config {i+1} ===") + print(f"Surface Type: {config.surface_type}") + print(f"Graduated Recovery: {config.enable_graduated_recovery}") + print(f"Time-of-Day Factor: {config.enable_time_of_day_factor}") + + print("\nHourly Surface Recovery Scores:") + print("Hour | Temp | Recovery Score") + print("-" * 30) + + for hour, score in enumerate(risk_scores): + temp = test_data[hour].temperature_f + time = test_data[hour].datetime.strftime("%H:%M") + print(f"{time} | {temp:4.1f}°F | {score.surface_recovery_score:5.2f}") + + # Show the highest risk hours + high_risk = [s for s in risk_scores if s.recommend_shoes] + print(f"\nHigh Risk Hours: {len(high_risk)} out of {len(risk_scores)}") + if high_risk: + times = [s.datetime.strftime("%H:%M") for s in high_risk] + print(f"Times: {', '.join(times)}") + + # Visualize comparison + visualize_results(test_data, all_results) + +if __name__ == "__main__": + print("Testing enhanced surface recovery logic") + test_recovery_settings() \ No newline at end of file