This commit is contained in:
2025-10-12 06:38:44 -07:00
parent 9e0bd322d3
commit 3886dcb9ab
158 changed files with 2022 additions and 9699 deletions

View File

@@ -0,0 +1,45 @@
import httpx
from src.core.config import settings
from typing import Dict, Any
class CentralDBClient:
def __init__(self):
self.base_url = settings.CENTRALDB_API_BASE_URL
self.client = httpx.AsyncClient(base_url=self.base_url)
async def download_fit_file(self, activity_id: int) -> bytes:
response = await self.client.get(f"/activities/{activity_id}/file")
response.raise_for_status()
return response.content
async def get_analysis_artifact(self, activity_id: int) -> Dict[str, Any]:
response = await self.client.get(f"/activities/{activity_id}/analysis")
response.raise_for_status()
return response.json()
async def create_analysis_artifact(
self, activity_id: int, data: Dict[str, Any]
) -> Dict[str, Any]:
response = await self.client.post(
f"/activities/{activity_id}/analysis", json=data
)
response.raise_for_status()
return response.json()
async def upload_chart(self, activity_id: int, chart_type: str, chart_file: bytes):
files = {"file": (f"{chart_type}.png", chart_file, "image/png")}
response = await self.client.post(
f"/activities/{activity_id}/analysis/charts",
params={"chart_create": chart_type},
files=files,
)
response.raise_for_status()
return response.json()
async def retrieve_chart(self, activity_id: int, chart_type: str) -> bytes:
response = await self.client.get(
f"/activities/{activity_id}/analysis/charts/{chart_type}"
)
response.raise_for_status()
return response.content

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,198 +0,0 @@
import zipfile
import io
from typing import List, Dict, Any, Optional
from uuid import UUID, uuid4
from datetime import datetime
from sqlalchemy.orm import Session
from src.core.file_parser import FitParser, TcxParser, GpxParser
from src.core.workout_analyzer import WorkoutAnalyzer
from src.core.report_generator import ReportGenerator
from src.db.models import WorkoutAnalysis, User
from src.core.workout_data import WorkoutMetadata # Import WorkoutMetadata
class BatchProcessor:
def __init__(self, db_session: Session):
self.db_session = db_session
def process_zip_file(self, zip_file_content: bytes, user_id: Optional[UUID], ftp_value: Optional[float]) -> List[Dict[str, Any]]:
results = []
zip_buffer = io.BytesIO(zip_file_content)
analyses_to_add = []
# Optimize: Fetch user's FTP once if user_id is provided and ftp_value is not
effective_ftp = ftp_value
if user_id and not effective_ftp:
user = self.db_session.query(User).filter(User.id == user_id).first()
if user and user.ftp_value:
effective_ftp = user.ftp_value
with zipfile.ZipFile(zip_buffer, 'r') as zf:
for file_info in zf.infolist():
if not file_info.is_dir():
file_name = file_info.filename
file_extension = file_name.split(".")[-1].lower()
parser = None
if file_extension == "fit":
parser = FitParser()
elif file_extension == "tcx":
parser = TcxParser()
elif file_extension == "gpx":
parser = GpxParser()
else:
results.append({
"file_name": file_name,
"status": "failed",
"error_message": "Unsupported file type"
})
continue
try:
with zf.open(file_info.filename) as workout_file:
workout_data = parser.parse(io.BytesIO(workout_file.read()))
analyzer = WorkoutAnalyzer(workout_data)
analyzer.analyze_power_data(ftp=effective_ftp if effective_ftp else 0)
analyzer.analyze_heart_rate_data(max_hr=180) # TODO: Get max_hr from user settings
analyzer.analyze_speed_data(max_speed=50) # TODO: Get max_speed from user settings
analyzer.analyze_elevation_data()
summary_metrics = analyzer.calculate_summary_metrics()
# Generate report (placeholder)
report_generator = ReportGenerator(workout_data)
html_report_content = report_generator.generate_html_report()
# TODO: Save report to a file and get path
report_path = "/path/to/batch_report.html" # Placeholder
# Generate charts (placeholder)
chart_paths = {} # Placeholder
analysis_id = uuid4()
new_analysis = WorkoutAnalysis(
id=analysis_id,
user_id=user_id,
file_name=file_name,
analysis_date=datetime.utcnow(),
status="completed",
summary_metrics=summary_metrics,
report_path=report_path,
chart_paths=chart_paths
)
analyses_to_add.append(new_analysis)
results.append({
"analysis_id": analysis_id,
"file_name": file_name,
"status": "completed",
"summary_metrics": summary_metrics
})
except Exception as e:
results.append({
"file_name": file_name,
"status": "failed",
"error_message": str(e)
})
# Commit all analyses in a single transaction
if analyses_to_add:
self.db_session.add_all(analyses_to_add)
self.db_session.commit()
for analysis in analyses_to_add:
self.db_session.refresh(analysis)
return results

23
src/core/cache.py Normal file
View File

@@ -0,0 +1,23 @@
from collections import OrderedDict
class InMemoryCache:
def __init__(self, capacity: int = 5):
self.cache = OrderedDict()
self.capacity = capacity
def get(self, key: str):
if key not in self.cache:
return None
self.cache.move_to_end(key)
return self.cache[key]
def set(self, key: str, value):
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.capacity:
self.cache.popitem(last=False)
cache = InMemoryCache()

View File

@@ -1,19 +1,20 @@
import matplotlib.pyplot as plt
import pandas as pd
import io
from src.core.workout_data import WorkoutData
class ChartGenerator:
def __init__(self, workout_data: WorkoutData):
self.workout_data = workout_data
def generate_power_curve_chart(self, output_path: str):
def generate_power_curve_chart(self) -> bytes:
df = self.workout_data.time_series_data
if "power" not in df.columns or df["power"].empty:
return
return b""
power_series = df["power"].dropna()
# For simplicity, a basic power vs time plot
plt.figure(figsize=(10, 6))
plt.plot(power_series.index, power_series, label="Power")
plt.xlabel("Time")
@@ -22,13 +23,17 @@ class ChartGenerator:
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.savefig(output_path)
buf = io.BytesIO()
plt.savefig(buf, format='png')
plt.close()
buf.seek(0)
return buf.read()
def generate_elevation_profile_chart(self, output_path: str):
def generate_elevation_profile_chart(self) -> bytes:
df = self.workout_data.time_series_data
if "altitude" not in df.columns or df["altitude"].empty:
return
return b""
altitude_series = df["altitude"].dropna()
@@ -40,10 +45,14 @@ class ChartGenerator:
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.savefig(output_path)
plt.close()
def generate_zone_distribution_chart(self, data_type: str, output_path: str):
buf = io.BytesIO()
plt.savefig(buf, format='png')
plt.close()
buf.seek(0)
return buf.read()
def generate_zone_distribution_chart(self, data_type: str) -> bytes:
if data_type == "power":
zone_distribution = self.workout_data.power_data.zone_distribution
title = "Power Zone Distribution"
@@ -57,20 +66,24 @@ class ChartGenerator:
title = "Speed Zone Distribution"
ylabel = "Time (seconds)"
else:
return
return b""
if not zone_distribution:
return
return b""
zones = list(zone_distribution.keys())
times = list(zone_distribution.values())
plt.figure(figsize=(10, 6))
plt.bar(zones, times, color='skyblue')
plt.bar(zones, times, color="skyblue")
plt.xlabel("Zone")
plt.ylabel(ylabel)
plt.title(title)
plt.grid(axis='y')
plt.grid(axis="y")
plt.tight_layout()
plt.savefig(output_path)
plt.close()
buf = io.BytesIO()
plt.savefig(buf, format='png')
plt.close()
buf.seek(0)
return buf.read()

11
src/core/config.py Normal file
View File

@@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
CENTRALDB_API_BASE_URL: str = "http://localhost:8000"
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -5,46 +5,55 @@ import fitparse
from tcxparser import TCXParser
import gpxpy
import gpxpy.gpx
import io
from src.core.workout_data import (
WorkoutMetadata,
WorkoutData,
PowerData,
HeartRateData,
SpeedData,
ElevationData,
)
from src.core.workout_data import WorkoutMetadata, WorkoutData, PowerData, HeartRateData, SpeedData, ElevationData
class FileParser(ABC):
def __init__(self, file_path: str):
self.file_path = file_path
@abstractmethod
def parse(self) -> WorkoutData:
def parse(self, file: io.BytesIO) -> WorkoutData:
pass
class FitParser(FileParser):
def parse(self) -> WorkoutData:
fitfile = fitparse.FitFile(self.file_path)
def parse(self, file: io.BytesIO) -> WorkoutData:
fitfile = fitparse.FitFile(file)
metadata = WorkoutMetadata(
start_time=datetime.now(), # Placeholder, will be updated
duration=timedelta(seconds=0), # Placeholder, will be updated
device="Unknown", # Placeholder
file_type="FIT"
start_time=datetime.now(), # Placeholder, will be updated
duration=timedelta(seconds=0), # Placeholder, will be updated
device="Unknown", # Placeholder
file_type="FIT",
)
time_series_data = []
for record in fitfile.get_messages('record'):
for record in fitfile.get_messages("record"):
data = record.as_dict()
timestamp = data.get('timestamp')
power = data.get('power')
heart_rate = data.get('heart_rate')
speed = data.get('speed')
altitude = data.get('altitude')
timestamp = data.get("timestamp")
power = data.get("power")
heart_rate = data.get("heart_rate")
speed = data.get("speed")
altitude = data.get("altitude")
if timestamp:
time_series_data.append({
"timestamp": timestamp,
"power": power,
"heart_rate": heart_rate,
"speed": speed,
"altitude": altitude
})
time_series_data.append(
{
"timestamp": timestamp,
"power": power,
"heart_rate": heart_rate,
"speed": speed,
"altitude": altitude,
}
)
df = pd.DataFrame(time_series_data)
if not df.empty:
df = df.set_index("timestamp")
@@ -57,32 +66,35 @@ class FitParser(FileParser):
power_data=PowerData(),
heart_rate_data=HeartRateData(),
speed_data=SpeedData(),
elevation_data=ElevationData()
elevation_data=ElevationData(),
)
class TcxParser(FileParser):
def parse(self) -> WorkoutData:
tcx = TCXParser(self.file_path)
def parse(self, file: io.BytesIO) -> WorkoutData:
tcx = TCXParser(file)
metadata = WorkoutMetadata(
start_time=tcx.started_at,
duration=timedelta(seconds=tcx.duration),
device="Unknown",
file_type="TCX"
file_type="TCX",
)
time_series_data = []
# tcxparser provides trackpoints as a list of objects
# Each trackpoint object has attributes like time, hr_value, altitude, speed
if hasattr(tcx, 'trackpoints') and tcx.trackpoints:
if hasattr(tcx, "trackpoints") and tcx.trackpoints:
for tp in tcx.trackpoints:
time_series_data.append({
"timestamp": tp.time,
"heart_rate": tp.hr_value,
"altitude": tp.altitude,
"speed": tp.speed
})
time_series_data.append(
{
"timestamp": tp.time,
"heart_rate": tp.hr_value,
"altitude": tp.altitude,
"speed": tp.speed,
}
)
df = pd.DataFrame(time_series_data)
if not df.empty:
df = df.set_index("timestamp")
@@ -93,31 +105,35 @@ class TcxParser(FileParser):
power_data=PowerData(),
heart_rate_data=HeartRateData(),
speed_data=SpeedData(),
elevation_data=ElevationData()
elevation_data=ElevationData(),
)
class GpxParser(FileParser):
def parse(self) -> WorkoutData:
with open(self.file_path, 'r') as gpx_file:
gpx = gpxpy.parse(gpx_file)
def parse(self, file: io.BytesIO) -> WorkoutData:
gpx = gpxpy.parse(file)
metadata = WorkoutMetadata(
start_time=gpx.time if gpx.time else datetime.now(), # gpx.time can be None
duration=timedelta(seconds=gpx.get_moving_data().moving_time) if gpx.get_moving_data() else timedelta(0),
device="Unknown", # GPX usually doesn't contain device info
file_type="GPX"
start_time=gpx.time if gpx.time else datetime.now(), # gpx.time can be None
duration=timedelta(seconds=gpx.get_moving_data().moving_time)
if gpx.get_moving_data()
else timedelta(0),
device="Unknown", # GPX usually doesn't contain device info
file_type="GPX",
)
time_series_data = []
for track in gpx.tracks:
for segment in track.segments:
for point in segment.points:
time_series_data.append({
"timestamp": point.time,
"latitude": point.latitude,
"longitude": point.longitude,
"elevation": point.elevation
})
time_series_data.append(
{
"timestamp": point.time,
"latitude": point.latitude,
"longitude": point.longitude,
"elevation": point.elevation,
}
)
df = pd.DataFrame(time_series_data)
if not df.empty:
df = df.set_index("timestamp")
@@ -128,5 +144,5 @@ class GpxParser(FileParser):
power_data=PowerData(),
heart_rate_data=HeartRateData(),
speed_data=SpeedData(),
elevation_data=ElevationData()
)
elevation_data=ElevationData(),
)

View File

@@ -2,6 +2,7 @@ import logging
import json
from datetime import datetime
class JsonFormatter(logging.Formatter):
def format(self, record):
log_record = {
@@ -11,25 +12,52 @@ class JsonFormatter(logging.Formatter):
"message": record.getMessage(),
"pathname": record.pathname,
"lineno": record.lineno,
"funcName": record.funcName
"funcName": record.funcName,
}
if record.exc_info:
log_record["exc_info"] = self.formatException(record.exc_info)
if record.stack_info:
log_record["stack_info"] = self.formatStack(record.stack_info)
# Add any extra attributes passed to the log record
for key, value in record.__dict__.items():
if key not in log_record and not key.startswith('_') and key not in (
'args', 'asctime', 'created', 'exc_info', 'exc_text', 'filename',
'funcName', 'levelname', 'levelno', 'lineno', 'module', 'msecs',
'message', 'name', 'pathname', 'process', 'processName', 'relativeCreated',
'stack_info', 'thread', 'threadName', 'extra', 'msg', 'record', 'self'
if (
key not in log_record
and not key.startswith("_")
and key
not in (
"args",
"asctime",
"created",
"exc_info",
"exc_text",
"filename",
"funcName",
"levelname",
"levelno",
"lineno",
"module",
"msecs",
"message",
"name",
"pathname",
"process",
"processName",
"relativeCreated",
"stack_info",
"thread",
"threadName",
"extra",
"msg",
"record",
"self",
)
):
log_record[key] = value
return json.dumps(log_record)
def setup_logging():
logger = logging.getLogger("fittrack_api")
logger.setLevel(logging.INFO)
@@ -43,4 +71,5 @@ def setup_logging():
return logger
logger = setup_logging()

View File

@@ -1,6 +1,7 @@
from src.core.workout_data import WorkoutData
from jinja2 import Environment, FileSystemLoader
import pdfkit
import io
class ReportGenerator:
def __init__(self, workout_data: WorkoutData):
@@ -29,54 +30,51 @@ class ReportGenerator:
html_content = self.generate_html_report()
# Ensure wkhtmltopdf is installed and accessible in the system's PATH
# For more complex PDF generation, consider dedicated libraries or services
pdfkit.from_string(html_content, output_path)
pdfkit.from_string(html_content, output_path)
def generate_batch_summary_report_csv(self, batch_results: list[dict]) -> str:
if not batch_results:
return ""
def generate_batch_summary_report_csv(self, batch_results: list[dict]) -> str:
if not batch_results:
return ""
# Extract all unique keys for CSV header
all_keys = set()
for result in batch_results:
all_keys.update(result.keys())
if "summary_metrics" in result:
all_keys.update(result["summary_metrics"].keys())
# Extract all unique keys for CSV header
all_keys = set()
for result in batch_results:
all_keys.update(result.keys())
if "summary_metrics" in result:
all_keys.update(result["summary_metrics"].keys())
# Define a preferred order for common keys
preferred_order = ["analysis_id", "file_name", "status", "error_message", "total_duration",
"average_speed_kmh", "total_distance_km", "average_heart_rate",
"max_heart_rate", "average_power", "max_power", "normalized_power",
"intensity_factor", "training_stress_score", "min_altitude",
"max_altitude", "elevation_gain", "elevation_loss",
"efficiency_factor", "variability_index", "average_cadence",
"max_cadence", "average_virtual_gear_ratio", "max_virtual_gear_ratio",
"min_virtual_gear_ratio"]
# Sort keys, putting preferred ones first, then others alphabetically
sorted_keys = [k for k in preferred_order if k in all_keys]
remaining_keys = sorted(list(all_keys - set(sorted_keys)))
final_keys = sorted_keys + remaining_keys
csv_buffer = io.StringIO()
csv_buffer.write(",".join(final_keys) + "\n")
for result in batch_results:
row_values = []
for key in final_keys:
value = result.get(key)
if value is None and key in result.get("summary_metrics", {}):
value = result["summary_metrics"].get(key)
# Define a preferred order for common keys
preferred_order = ["analysis_id", "file_name", "status", "error_message", "total_duration",
"average_speed_kmh", "total_distance_km", "average_heart_rate",
"max_heart_rate", "average_power", "max_power", "normalized_power",
"intensity_factor", "training_stress_score", "min_altitude",
"max_altitude", "elevation_gain", "elevation_loss",
"efficiency_factor", "variability_index", "average_cadence",
"max_cadence", "average_virtual_gear_ratio", "max_virtual_gear_ratio",
"min_virtual_gear_ratio"]
# Sort keys, putting preferred ones first, then others alphabetically
sorted_keys = [k for k in preferred_order if k in all_keys]
remaining_keys = sorted(list(all_keys - set(sorted_keys)))
final_keys = sorted_keys + remaining_keys
csv_buffer = io.StringIO()
csv_buffer.write(",".join(final_keys) + "\n")
for result in batch_results:
row_values = []
for key in final_keys:
value = result.get(key)
if value is None and key in result.get("summary_metrics", {}):
value = result["summary_metrics"].get(key)
# Handle dictionary values (like zone distributions) by converting to string
if isinstance(value, dict):
row_values.append(f"\"{str(value).replace('"\', '''''')}\""
) # Escape quotes for CSV
elif isinstance(value, str) and ',' in value:
row_values.append(f"\"{value}\""
)
else:
row_values.append(str(value) if value is not None else "")
csv_buffer.write(",".join(row_values) + "\n")
return csv_buffer.getvalue()
# Handle dictionary values (like zone distributions) by converting to string
if isinstance(value, dict):
row_values.append(f'"{str(value)}"')
elif isinstance(value, str) and ',' in value:
row_values.append(f'"{value}"')
else:
row_values.append(str(value) if value is not None else "")
csv_buffer.write(",".join(row_values) + "\n")
return csv_buffer.getvalue()

View File

@@ -0,0 +1 @@
<html><body><h1>Workout Report</h1><p>This is a dummy report.</p></body></html>

View File

@@ -4,6 +4,7 @@ from datetime import timedelta
from src.core.workout_data import WorkoutData, PowerData, HeartRateData, SpeedData
from src.utils.zone_calculator import ZoneCalculator
class WorkoutAnalyzer:
def __init__(self, workout_data: WorkoutData):
self.workout_data = workout_data
@@ -19,14 +20,24 @@ class WorkoutAnalyzer:
return 0.0
# For a more accurate NP, consider a rolling average of 30 seconds
# Here, we'll just take the average of the 4th power and then the 4th root
return np.power(np.mean(np.power(power_data, 4)), 0.25) if not power_data.empty else 0.0
return (
np.power(np.mean(np.power(power_data, 4)), 0.25)
if not power_data.empty
else 0.0
)
def _calculate_intensity_factor(self, normalized_power: float, ftp: float) -> float:
if ftp == 0:
return 0.0
return normalized_power / ftp
def _calculate_training_stress_score(self, duration_seconds: float, normalized_power: float, ftp: float, if_value: float) -> float:
def _calculate_training_stress_score(
self,
duration_seconds: float,
normalized_power: float,
ftp: float,
if_value: float,
) -> float:
if ftp == 0:
return 0.0
# TSS = (duration_in_seconds * NP * IF) / (FTP * 3600) * 100
@@ -35,50 +46,66 @@ class WorkoutAnalyzer:
def _analyze_power_zones(self, power_data: pd.Series, ftp: float) -> dict:
if power_data.empty or ftp == 0:
return {}
zones = ZoneCalculator.calculate_power_zones(ftp)
zone_distribution = {zone_name: timedelta(seconds=0) for zone_name in zones.keys()}
zone_distribution = {
zone_name: timedelta(seconds=0) for zone_name in zones.keys()
}
# Assuming power_data is indexed by time and values are instantaneous power
# We need to calculate time spent in each zone
# This is a simplified approach, a more accurate one would consider time intervals between data points
for power_value in power_data:
for zone_name, (lower, upper) in zones.items():
if lower <= power_value < upper:
zone_distribution[zone_name] += timedelta(seconds=1) # Assuming 1 second interval for simplicity
zone_distribution[zone_name] += timedelta(
seconds=1
) # Assuming 1 second interval for simplicity
break
return {zone_name: td.total_seconds() for zone_name, td in zone_distribution.items()}
def _analyze_heart_rate_zones(self, heart_rate_data: pd.Series, max_hr: int) -> dict:
return {
zone_name: td.total_seconds() for zone_name, td in zone_distribution.items()
}
def _analyze_heart_rate_zones(
self, heart_rate_data: pd.Series, max_hr: int
) -> dict:
if heart_rate_data.empty or max_hr == 0:
return {}
zones = ZoneCalculator.calculate_heart_rate_zones(max_hr)
zone_distribution = {zone_name: timedelta(seconds=0) for zone_name in zones.keys()}
zone_distribution = {
zone_name: timedelta(seconds=0) for zone_name in zones.keys()
}
for hr_value in heart_rate_data:
for zone_name, (lower, upper) in zones.items():
if lower <= hr_value < upper:
zone_distribution[zone_name] += timedelta(seconds=1)
break
return {zone_name: td.total_seconds() for zone_name, td in zone_distribution.items()}
return {
zone_name: td.total_seconds() for zone_name, td in zone_distribution.items()
}
def _analyze_speed_zones(self, speed_data: pd.Series, max_speed: float) -> dict:
if speed_data.empty or max_speed == 0:
return {}
zones = ZoneCalculator.calculate_speed_zones(max_speed)
zone_distribution = {zone_name: timedelta(seconds=0) for zone_name in zones.keys()}
zone_distribution = {
zone_name: timedelta(seconds=0) for zone_name in zones.keys()
}
for speed_value in speed_data:
for zone_name, (lower, upper) in zones.items():
if lower <= speed_value < upper:
zone_distribution[zone_name] += timedelta(seconds=1)
break
return {zone_name: td.total_seconds() for zone_name, td in zone_distribution.items()}
return {
zone_name: td.total_seconds() for zone_name, td in zone_distribution.items()
}
def analyze_power_data(self, ftp: float = 0):
df = self.workout_data.time_series_data
@@ -97,7 +124,9 @@ class WorkoutAnalyzer:
normalized_power = self._calculate_normalized_power(power_series)
intensity_factor = self._calculate_intensity_factor(normalized_power, ftp)
duration_seconds = self.workout_data.metadata.duration.total_seconds()
training_stress_score = self._calculate_training_stress_score(duration_seconds, normalized_power, ftp, intensity_factor)
training_stress_score = self._calculate_training_stress_score(
duration_seconds, normalized_power, ftp, intensity_factor
)
power_zone_distribution = self._analyze_power_zones(power_series, ftp)
self.workout_data.power_data = PowerData(
@@ -106,21 +135,23 @@ class WorkoutAnalyzer:
normalized_power=normalized_power,
intensity_factor=intensity_factor,
training_stress_score=training_stress_score,
zone_distribution=power_zone_distribution
zone_distribution=power_zone_distribution,
)
def analyze_heart_rate_data(self, max_hr: int = 0):
df = self.workout_data.time_series_data
if "heart_rate" in df.columns and not df["heart_rate"].empty:
hr_series = df["heart_rate"].dropna()
heart_rate_zone_distribution = self._analyze_heart_rate_zones(hr_series, max_hr)
heart_rate_zone_distribution = self._analyze_heart_rate_zones(
hr_series, max_hr
)
self.workout_data.heart_rate_data = HeartRateData(
raw_hr_stream=hr_series.tolist(),
average_hr=hr_series.mean(),
max_hr=hr_series.max(),
zone_distribution=heart_rate_zone_distribution
zone_distribution=heart_rate_zone_distribution,
)
def analyze_speed_data(self, max_speed: float = 0):
@@ -134,7 +165,7 @@ class WorkoutAnalyzer:
raw_speed_stream=speed_series.tolist(),
average_speed=speed_series.mean(),
max_speed=speed_series.max(),
zone_distribution=speed_zone_distribution
zone_distribution=speed_zone_distribution,
)
def analyze_elevation_data(self):
@@ -144,7 +175,7 @@ class WorkoutAnalyzer:
min_altitude = altitude_series.min()
max_altitude = altitude_series.max()
# Calculate elevation gain and loss
elevation_diffs = altitude_series.diff().dropna()
elevation_gain = elevation_diffs[elevation_diffs > 0].sum()
@@ -155,7 +186,7 @@ class WorkoutAnalyzer:
total_ascent=elevation_gain,
total_descent=elevation_loss,
max_elevation=max_altitude,
min_elevation=min_altitude
min_elevation=min_altitude,
)
def calculate_summary_metrics(self) -> dict:
@@ -163,8 +194,10 @@ class WorkoutAnalyzer:
df = self.workout_data.time_series_data
if not df.empty:
summary["total_duration"] = self.workout_data.metadata.duration.total_seconds()
summary["total_duration"] = (
self.workout_data.metadata.duration.total_seconds()
)
# Calculate and add efficiency metrics
efficiency_metrics = self.calculate_efficiency_metrics()
summary.update(efficiency_metrics)
@@ -177,44 +210,67 @@ class WorkoutAnalyzer:
data_spikes = self.detect_data_spikes()
if data_spikes:
summary["data_spikes"] = data_spikes
if "speed" in df.columns:
summary["average_speed_kmh"] = df["speed"].mean() * 3.6
if len(df) > 1:
time_diffs = (df.index.to_series().diff().dt.total_seconds().fillna(0))
time_diffs = (
df.index.to_series().diff().dt.total_seconds().fillna(0)
)
distance_meters = (df["speed"] * time_diffs).sum()
summary["total_distance_km"] = distance_meters / 1000
if self.workout_data.speed_data.zone_distribution:
summary["speed_zone_distribution"] = self.workout_data.speed_data.zone_distribution
summary["speed_zone_distribution"] = (
self.workout_data.speed_data.zone_distribution
)
if "heart_rate" in df.columns:
summary["average_heart_rate"] = df["heart_rate"].mean()
summary["max_heart_rate"] = df["heart_rate"].max()
if self.workout_data.heart_rate_data.zone_distribution:
summary["heart_rate_zone_distribution"] = self.workout_data.heart_rate_data.zone_distribution
summary["heart_rate_zone_distribution"] = (
self.workout_data.heart_rate_data.zone_distribution
)
if "power" in df.columns:
summary["average_power"] = df["power"].mean()
summary["max_power"] = df["power"].max()
# Add power analysis metrics to summary
if self.workout_data.power_data.normalized_power:
summary["normalized_power"] = self.workout_data.power_data.normalized_power
summary["normalized_power"] = (
self.workout_data.power_data.normalized_power
)
if self.workout_data.power_data.intensity_factor:
summary["intensity_factor"] = self.workout_data.power_data.intensity_factor
summary["intensity_factor"] = (
self.workout_data.power_data.intensity_factor
)
if self.workout_data.power_data.training_stress_score:
summary["training_stress_score"] = self.workout_data.power_data.training_stress_score
summary["training_stress_score"] = (
self.workout_data.power_data.training_stress_score
)
if self.workout_data.power_data.zone_distribution:
summary["power_zone_distribution"] = self.workout_data.power_data.zone_distribution
summary["power_zone_distribution"] = (
self.workout_data.power_data.zone_distribution
)
if "altitude" in df.columns:
summary["min_altitude"] = df["altitude"].min()
summary["max_altitude"] = df["altitude"].max()
summary["elevation_gain"] = self.workout_data.elevation_data.total_ascent
summary["elevation_loss"] = self.workout_data.elevation_data.total_descent
summary["elevation_gain"] = (
self.workout_data.elevation_data.total_ascent
)
summary["elevation_loss"] = (
self.workout_data.elevation_data.total_descent
)
return summary
def detect_high_intensity_intervals(self, power_threshold_percentage: float = 0.9, min_duration_seconds: int = 60, ftp: float = 0) -> list:
def detect_high_intensity_intervals(
self,
power_threshold_percentage: float = 0.9,
min_duration_seconds: int = 60,
ftp: float = 0,
) -> list:
intervals = []
df = self.workout_data.time_series_data
@@ -225,7 +281,7 @@ class WorkoutAnalyzer:
time_series = df.index.to_series()
threshold_power = ftp * power_threshold_percentage
in_interval = False
interval_start_index = -1
@@ -238,27 +294,40 @@ class WorkoutAnalyzer:
if in_interval:
in_interval = False
interval_end_index = i - 1
duration = (time_series.iloc[interval_end_index] - time_series.iloc[interval_start_index]).total_seconds()
duration = (
time_series.iloc[interval_end_index]
- time_series.iloc[interval_start_index]
).total_seconds()
if duration >= min_duration_seconds:
intervals.append({
"start_time": time_series.iloc[interval_start_index],
"end_time": time_series.iloc[interval_end_index],
"duration_seconds": duration,
"average_power": power_series.iloc[interval_start_index:interval_end_index+1].mean()
})
intervals.append(
{
"start_time": time_series.iloc[interval_start_index],
"end_time": time_series.iloc[interval_end_index],
"duration_seconds": duration,
"average_power": power_series.iloc[
interval_start_index : interval_end_index + 1
].mean(),
}
)
# Check for an interval that extends to the end of the workout
if in_interval:
duration = (time_series.iloc[-1] - time_series.iloc[interval_start_index]).total_seconds()
duration = (
time_series.iloc[-1] - time_series.iloc[interval_start_index]
).total_seconds()
if duration >= min_duration_seconds:
intervals.append({
"start_time": time_series.iloc[interval_start_index],
"end_time": time_series.iloc[-1],
"duration_seconds": duration,
"average_power": power_series.iloc[interval_start_index:].mean()
})
intervals.append(
{
"start_time": time_series.iloc[interval_start_index],
"end_time": time_series.iloc[-1],
"duration_seconds": duration,
"average_power": power_series.iloc[
interval_start_index:
].mean(),
}
)
return intervals
@@ -268,10 +337,14 @@ class WorkoutAnalyzer:
heart_rate_data = self.workout_data.heart_rate_data
if power_data.normalized_power > 0 and heart_rate_data.average_hr > 0:
efficiency_metrics["efficiency_factor"] = round(power_data.normalized_power / heart_rate_data.average_hr, 2)
efficiency_metrics["efficiency_factor"] = round(
power_data.normalized_power / heart_rate_data.average_hr, 2
)
if power_data.normalized_power > 0 and power_data.average_power > 0:
efficiency_metrics["variability_index"] = round(power_data.normalized_power / power_data.average_power, 2)
efficiency_metrics["variability_index"] = round(
power_data.normalized_power / power_data.average_power, 2
)
return efficiency_metrics
@@ -304,22 +377,29 @@ class WorkoutAnalyzer:
if "speed" in df.columns and not df["speed"].empty:
speed_series = df["speed"].dropna()
gear_metrics["average_speed"] = speed_series.mean() * 3.6 # km/h
gear_metrics["max_speed"] = speed_series.max() * 3.6 # km/h
gear_metrics["average_speed"] = speed_series.mean() * 3.6 # km/h
gear_metrics["max_speed"] = speed_series.max() * 3.6 # km/h
if "cadence" in df.columns and "speed" in df.columns and not df["cadence"].empty and not df["speed"].empty:
if (
"cadence" in df.columns
and "speed" in df.columns
and not df["cadence"].empty
and not df["speed"].empty
):
# Simple virtual gear ratio: speed / cadence. Unitless, higher value means 'harder' gear.
# Filter out zero cadence to avoid division by zero
valid_data = df[(df["cadence"] > 0) & (df["speed"] > 0)]
if not valid_data.empty:
virtual_gear_ratio = (valid_data["speed"] / valid_data["cadence"])
virtual_gear_ratio = valid_data["speed"] / valid_data["cadence"]
gear_metrics["average_virtual_gear_ratio"] = virtual_gear_ratio.mean()
gear_metrics["max_virtual_gear_ratio"] = virtual_gear_ratio.max()
gear_metrics["min_virtual_gear_ratio"] = virtual_gear_ratio.min()
return gear_metrics
def detect_data_spikes(self, window_size: int = 5, threshold_multiplier: float = 3.0) -> dict:
def detect_data_spikes(
self, window_size: int = 5, threshold_multiplier: float = 3.0
) -> dict:
spikes = {}
df = self.workout_data.time_series_data
@@ -331,19 +411,26 @@ class WorkoutAnalyzer:
if series.empty:
continue
rolling_median = series.rolling(window=window_size, center=True).median()
rolling_median = series.rolling(
window=window_size, center=True
).median()
deviation = np.abs(series - rolling_median)
median_absolute_deviation = deviation.rolling(window=window_size, center=True).median()
median_absolute_deviation = deviation.rolling(
window=window_size, center=True
).median()
# Identify spikes as points where deviation is significantly higher than MAD
# Using a threshold multiplier to define 'significantly higher'
spike_indices = series[deviation > (threshold_multiplier * median_absolute_deviation)].index.tolist()
if spike_indices:
spikes[stream_name] = [{
"timestamp": df.loc[idx].name.isoformat(),
"value": df.loc[idx, stream_name]
} for idx in spike_indices]
return spikes
spike_indices = series[
deviation > (threshold_multiplier * median_absolute_deviation)
].index.tolist()
def calculate_summary_metrics(self) -> dict:
if spike_indices:
spikes[stream_name] = [
{
"timestamp": df.loc[idx].name.isoformat(),
"value": df.loc[idx, stream_name],
}
for idx in spike_indices
]
return spikes

View File

@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
from typing import List, Dict, Any
import pandas as pd
@dataclass
class WorkoutMetadata:
start_time: datetime
@@ -10,6 +11,7 @@ class WorkoutMetadata:
device: str
file_type: str
@dataclass
class PowerData:
raw_power_stream: List[float] = field(default_factory=list)
@@ -19,6 +21,7 @@ class PowerData:
training_stress_score: float = 0.0
zone_distribution: Dict[str, Any] = field(default_factory=dict)
@dataclass
class HeartRateData:
raw_hr_stream: List[int] = field(default_factory=list)
@@ -26,11 +29,14 @@ class HeartRateData:
max_hr: int = 0
zone_distribution: Dict[str, Any] = field(default_factory=dict)
@dataclass
class SpeedData:
raw_speed_stream: List[float] = field(default_factory=list)
average_speed: float = 0.0
max_speed: float = 0.0
zone_distribution: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ElevationData:
@@ -40,6 +46,7 @@ class ElevationData:
max_elevation: float = 0.0
min_elevation: float = 0.0
@dataclass
class WorkoutData:
metadata: WorkoutMetadata
@@ -47,4 +54,4 @@ class WorkoutData:
power_data: PowerData = field(default_factory=PowerData)
heart_rate_data: HeartRateData = field(default_factory=HeartRateData)
speed_data: SpeedData = field(default_factory=SpeedData)
elevation_data: ElevationData = field(default_factory=ElevationData)
elevation_data: ElevationData = field(default_factory=ElevationData)

Binary file not shown.

Binary file not shown.

View File

@@ -2,9 +2,9 @@ import uuid
from datetime import datetime
from sqlalchemy import Column, String, DateTime, Float, JSON, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import declarative_base, relationship
from sqlalchemy.orm import relationship
from src.db.session import Base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
@@ -14,6 +14,7 @@ class User(Base):
workout_analyses = relationship("WorkoutAnalysis", back_populates="user")
class WorkoutAnalysis(Base):
__tablename__ = "workout_analyses"
@@ -26,4 +27,4 @@ class WorkoutAnalysis(Base):
report_path = Column(String, nullable=True)
chart_paths = Column(JSON, nullable=True)
user = relationship("User", back_populates="workout_analyses")
user = relationship("User", back_populates="workout_analyses")

View File

@@ -3,15 +3,18 @@ from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
import os
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://user:password@localhost/fittrack")
DATABASE_URL = os.getenv(
"DATABASE_URL", "sqlite:///./test.db"
)
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
db.close()

Binary file not shown.

View File

@@ -11,7 +11,10 @@ class ZoneCalculator:
"Zone 4: Lactate Threshold": (0.90 * ftp, 1.05 * ftp),
"Zone 5: VO2 Max": (1.05 * ftp, 1.20 * ftp),
"Zone 6: Anaerobic Capacity": (1.20 * ftp, 1.50 * ftp),
"Zone 7: Neuromuscular Power": (1.50 * ftp, max_power if max_power else float('inf'))
"Zone 7: Neuromuscular Power": (
1.50 * ftp,
max_power if max_power else float("inf"),
),
}
return zones
@@ -25,6 +28,6 @@ class ZoneCalculator:
"Zone 2: Light": (0.50 * max_hr, 0.60 * max_hr),
"Zone 3: Moderate": (0.60 * max_hr, 0.70 * max_hr),
"Zone 4: Hard": (0.70 * max_hr, 0.80 * max_hr),
"Zone 5: Maximum": (0.80 * max_hr, max_hr)
"Zone 5: Maximum": (0.80 * max_hr, max_hr),
}
return zones
return zones