mirror of
https://github.com/sstent/Garmin_Analyser.git
synced 2026-01-25 16:42:40 +00:00
288 lines
12 KiB
Python
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() |