Files
Garmin_Analyser/tests/test_power_estimate.py
2025-10-06 12:54:15 -07:00

288 lines
12 KiB
Python

import unittest
import pandas as pd
import numpy as np
import logging
from unittest.mock import patch, MagicMock
from analyzers.workout_analyzer import WorkoutAnalyzer
from config.settings import BikeConfig
from models.workout import WorkoutData, WorkoutMetadata
class TestPowerEstimation(unittest.TestCase):
def setUp(self):
# Patch BikeConfig settings for deterministic tests
self.patcher_bike_mass = patch.object(BikeConfig, 'BIKE_MASS_KG', 8.0)
self.patcher_bike_crr = patch.object(BikeConfig, 'BIKE_CRR', 0.004)
self.patcher_bike_cda = patch.object(BikeConfig, 'BIKE_CDA', 0.3)
self.patcher_air_density = patch.object(BikeConfig, 'AIR_DENSITY', 1.225)
self.patcher_drive_efficiency = patch.object(BikeConfig, 'DRIVE_EFFICIENCY', 0.97)
self.patcher_indoor_aero_disabled = patch.object(BikeConfig, 'INDOOR_AERO_DISABLED', True)
self.patcher_indoor_baseline = patch.object(BikeConfig, 'INDOOR_BASELINE_WATTS', 10.0)
self.patcher_smoothing_window = patch.object(BikeConfig, 'POWER_ESTIMATE_SMOOTHING_WINDOW_SAMPLES', 3)
self.patcher_max_power = patch.object(BikeConfig, 'MAX_POWER_WATTS', 1500)
# Start all patches
self.patcher_bike_mass.start()
self.patcher_bike_crr.start()
self.patcher_bike_cda.start()
self.patcher_air_density.start()
self.patcher_drive_efficiency.start()
self.patcher_indoor_aero_disabled.start()
self.patcher_indoor_baseline.start()
self.patcher_smoothing_window.start()
self.patcher_max_power.start()
# Setup logger capture
self.logger = logging.getLogger('analyzers.workout_analyzer')
self.logger.setLevel(logging.DEBUG)
self.log_capture = []
self.handler = logging.Handler()
self.handler.emit = lambda record: self.log_capture.append(record.getMessage())
self.logger.addHandler(self.handler)
# Create analyzer
self.analyzer = WorkoutAnalyzer()
def tearDown(self):
# Stop all patches
self.patcher_bike_mass.stop()
self.patcher_bike_crr.stop()
self.patcher_bike_cda.stop()
self.patcher_air_density.stop()
self.patcher_drive_efficiency.stop()
self.patcher_indoor_aero_disabled.stop()
self.patcher_indoor_baseline.stop()
self.patcher_smoothing_window.stop()
self.patcher_max_power.stop()
# Restore logger
self.logger.removeHandler(self.handler)
def _create_mock_workout(self, df_data, metadata_attrs=None):
"""Create a mock WorkoutData object."""
workout = MagicMock(spec=WorkoutData)
workout.raw_data = pd.DataFrame(df_data)
workout.metadata = MagicMock(spec=WorkoutMetadata)
# Set default attributes
workout.metadata.is_indoor = False
workout.metadata.activity_name = "Outdoor Cycling"
workout.metadata.duration_seconds = 240 # 4 minutes
workout.metadata.distance_meters = 1000 # 1 km
workout.metadata.avg_heart_rate = 150
workout.metadata.max_heart_rate = 180
workout.metadata.elevation_gain = 50
workout.metadata.calories = 200
# Override with provided attrs
if metadata_attrs:
for key, value in metadata_attrs.items():
setattr(workout.metadata, key, value)
workout.power = None
workout.gear = None
workout.heart_rate = MagicMock()
workout.heart_rate.heart_rate_values = [150, 160, 170, 180] # Mock HR values
workout.speed = MagicMock()
workout.speed.speed_values = [5.0, 10.0, 15.0, 20.0] # Mock speed values
workout.elevation = MagicMock()
workout.elevation.elevation_values = [0.0, 10.0, 20.0, 30.0] # Mock elevation values
return workout
def test_outdoor_physics_basics(self):
"""Test outdoor physics basics: non-negative, aero effect, no NaNs, cap."""
# Create DataFrame with monotonic speed and positive gradient
df_data = {
'speed': [5.0, 10.0, 15.0, 20.0], # Increasing speed
'gradient_percent': [2.0, 2.0, 2.0, 2.0], # Constant positive gradient
'distance': [0.0, 5.0, 10.0, 15.0], # Cumulative distance
'elevation': [0.0, 10.0, 20.0, 30.0] # Increasing elevation
}
workout = self._create_mock_workout(df_data)
result = self.analyzer._estimate_power(workout, 16)
# Assertions
self.assertEqual(len(result), 4)
self.assertTrue(all(p >= 0 for p in result)) # Non-negative
self.assertTrue(result[3] > result[0]) # Higher power at higher speed (aero v^3 effect)
self.assertTrue(all(not np.isnan(p) for p in result)) # No NaNs
self.assertTrue(all(p <= BikeConfig.MAX_POWER_WATTS for p in result)) # Capped
# Check series name
self.assertIsInstance(result, list)
def test_indoor_handling(self):
"""Test indoor handling: aero disabled, baseline added, gradient clamped."""
df_data = {
'speed': [5.0, 10.0, 15.0, 20.0],
'gradient_percent': [2.0, 2.0, 2.0, 2.0],
'distance': [0.0, 5.0, 10.0, 15.0],
'elevation': [0.0, 10.0, 20.0, 30.0]
}
workout = self._create_mock_workout(df_data, {'is_indoor': True, 'activity_name': 'indoor_cycling'})
indoor_result = self.analyzer._estimate_power(workout, 16)
# Reset for outdoor comparison
workout.metadata.is_indoor = False
workout.metadata.activity_name = "Outdoor Cycling"
outdoor_result = self.analyzer._estimate_power(workout, 16)
# Indoor should have lower power due to disabled aero
self.assertTrue(indoor_result[3] < outdoor_result[3])
# Check baseline effect at low speed
self.assertTrue(indoor_result[0] >= BikeConfig.INDOOR_BASELINE_WATTS)
# Check unrealistic gradients clamped
df_data_unrealistic = {
'speed': [5.0, 10.0, 15.0, 20.0],
'gradient_percent': [15.0, 15.0, 15.0, 15.0], # Unrealistic for indoor
'distance': [0.0, 5.0, 10.0, 15.0],
'elevation': [0.0, 10.0, 20.0, 30.0]
}
workout_unrealistic = self._create_mock_workout(df_data_unrealistic, {'is_indoor': True})
result_clamped = self.analyzer._estimate_power(workout_unrealistic, 16)
# Gradients should be clamped to reasonable range
self.assertTrue(all(p >= 0 for p in result_clamped))
def test_inputs_and_fallbacks(self):
"""Test input fallbacks: speed from distance, gradient from elevation, missing data."""
# Speed from distance
df_data_speed_fallback = {
'distance': [0.0, 5.0, 10.0, 15.0], # 5 m/s average speed
'gradient_percent': [2.0, 2.0, 2.0, 2.0],
'elevation': [0.0, 10.0, 20.0, 30.0]
}
workout_speed_fallback = self._create_mock_workout(df_data_speed_fallback)
result_speed = self.analyzer._estimate_power(workout_speed_fallback, 16)
self.assertEqual(len(result_speed), 4)
self.assertTrue(all(not np.isnan(p) for p in result_speed))
self.assertTrue(all(p >= 0 for p in result_speed))
# Gradient from elevation
df_data_gradient_fallback = {
'speed': [5.0, 10.0, 15.0, 20.0],
'distance': [0.0, 5.0, 10.0, 15.0],
'elevation': [0.0, 10.0, 20.0, 30.0] # 2% gradient
}
workout_gradient_fallback = self._create_mock_workout(df_data_gradient_fallback)
result_gradient = self.analyzer._estimate_power(workout_gradient_fallback, 16)
self.assertEqual(len(result_gradient), 4)
self.assertTrue(all(not np.isnan(p) for p in result_gradient))
# No speed or distance - should return zeros
df_data_no_speed = {
'gradient_percent': [2.0, 2.0, 2.0, 2.0],
'elevation': [0.0, 10.0, 20.0, 30.0]
}
workout_no_speed = self._create_mock_workout(df_data_no_speed)
result_no_speed = self.analyzer._estimate_power(workout_no_speed, 16)
self.assertEqual(result_no_speed, [0.0] * 4)
# Check warning logged for missing speed
self.assertTrue(any("No speed or distance data" in msg for msg in self.log_capture))
def test_nan_safety(self):
"""Test NaN safety: isolated NaNs handled, long runs remain NaN/zero."""
df_data_with_nans = {
'speed': [5.0, np.nan, 15.0, 20.0], # Isolated NaN
'gradient_percent': [2.0, 2.0, np.nan, 2.0], # Another isolated NaN
'distance': [0.0, 5.0, 10.0, 15.0],
'elevation': [0.0, 10.0, 20.0, 30.0]
}
workout = self._create_mock_workout(df_data_with_nans)
result = self.analyzer._estimate_power(workout, 16)
# Should handle NaNs gracefully
self.assertEqual(len(result), 4)
self.assertTrue(all(not np.isnan(p) for p in result)) # No NaNs in final result
self.assertTrue(all(p >= 0 for p in result))
def test_clamping_and_smoothing(self):
"""Test clamping and smoothing: spikes capped, smoothing reduces jitter."""
# Create data with a spike
df_data_spike = {
'speed': [5.0, 10.0, 50.0, 20.0], # Spike at index 2
'gradient_percent': [2.0, 2.0, 2.0, 2.0],
'distance': [0.0, 5.0, 10.0, 15.0],
'elevation': [0.0, 10.0, 20.0, 30.0]
}
workout = self._create_mock_workout(df_data_spike)
result = self.analyzer._estimate_power(workout, 16)
# Check clamping
self.assertTrue(all(p <= BikeConfig.MAX_POWER_WATTS for p in result))
# Check smoothing reduces variation
# With smoothing window of 3, the spike should be attenuated
self.assertTrue(result[2] < (BikeConfig.MAX_POWER_WATTS * 0.9)) # Not at max
def test_integration_via_analyze_workout(self):
"""Test integration via analyze_workout: power_estimate added when real power missing."""
df_data = {
'speed': [5.0, 10.0, 15.0, 20.0],
'gradient_percent': [2.0, 2.0, 2.0, 2.0],
'distance': [0.0, 5.0, 10.0, 15.0],
'elevation': [0.0, 10.0, 20.0, 30.0]
}
workout = self._create_mock_workout(df_data)
analysis = self.analyzer.analyze_workout(workout, 16)
# Should have power_estimate when no real power
self.assertIn('power_estimate', analysis)
self.assertIn('avg_power', analysis['power_estimate'])
self.assertIn('max_power', analysis['power_estimate'])
self.assertTrue(analysis['power_estimate']['avg_power'] > 0)
self.assertTrue(analysis['power_estimate']['max_power'] > 0)
# Should have estimated_power in analysis
self.assertIn('estimated_power', analysis)
self.assertEqual(len(analysis['estimated_power']), 4)
# Now test with real power present
workout.power = MagicMock()
workout.power.power_values = [100, 200, 300, 400]
analysis_with_real = self.analyzer.analyze_workout(workout, 16)
# Should not have power_estimate when real power exists
self.assertNotIn('power_estimate', analysis_with_real)
# Should still have estimated_power (for internal use)
self.assertIn('estimated_power', analysis_with_real)
def test_logging(self):
"""Test logging: info for indoor/outdoor, warnings for missing data."""
df_data = {
'speed': [5.0, 10.0, 15.0, 20.0],
'gradient_percent': [2.0, 2.0, 2.0, 2.0],
'distance': [0.0, 5.0, 10.0, 15.0],
'elevation': [0.0, 10.0, 20.0, 30.0]
}
# Test indoor logging
workout_indoor = self._create_mock_workout(df_data, {'is_indoor': True})
self.analyzer._estimate_power(workout_indoor, 16)
self.assertTrue(any("indoor" in msg.lower() for msg in self.log_capture))
# Clear log
self.log_capture.clear()
# Test outdoor logging
workout_outdoor = self._create_mock_workout(df_data, {'is_indoor': False})
self.analyzer._estimate_power(workout_outdoor, 16)
self.assertTrue(any("outdoor" in msg.lower() for msg in self.log_capture))
# Clear log
self.log_capture.clear()
# Test warning for missing speed
df_data_no_speed = {'gradient_percent': [2.0, 2.0, 2.0, 2.0]}
workout_no_speed = self._create_mock_workout(df_data_no_speed)
self.analyzer._estimate_power(workout_no_speed, 16)
self.assertTrue(any("No speed or distance data" in msg for msg in self.log_capture))
if __name__ == '__main__':
unittest.main()