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

This commit is contained in:
Chris Sewell
2025-06-13 14:08:37 -04:00
parent 88d6b76392
commit a2c25e603a
7 changed files with 497 additions and 10 deletions

View File

@ -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

View File

@ -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

17
constants.py Normal file
View File

@ -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

View File

@ -25,4 +25,11 @@ UV_THRESHOLD_HIGH=9
# Risk assessment parameters (Optional)
RISK_THRESHOLD_SHOES=5
ROLLING_WINDOW_HOURS=2
SURFACE_RECOVERY_HOURS=2
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

View File

@ -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."""

154
test_real_data.py Normal file
View File

@ -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.")

199
test_recovery.py Normal file
View File

@ -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()