mirror of
https://github.com/sstent/FitTrack_ReportGenerator.git
synced 2026-03-15 09:25:40 +00:00
sync
This commit is contained in:
BIN
src/clients/__pycache__/centraldb_client.cpython-313.pyc
Normal file
BIN
src/clients/__pycache__/centraldb_client.cpython-313.pyc
Normal file
Binary file not shown.
45
src/clients/centraldb_client.py
Normal file
45
src/clients/centraldb_client.py
Normal 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.
BIN
src/core/__pycache__/cache.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/cache.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/core/__pycache__/config.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/core/__pycache__/logger.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/logger.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/report_generator.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/report_generator.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/workout_analyzer.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/workout_analyzer.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -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
23
src/core/cache.py
Normal 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()
|
||||
@@ -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
11
src/core/config.py
Normal 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()
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
1
src/core/templates/workout_report.html
Normal file
1
src/core/templates/workout_report.html
Normal file
@@ -0,0 +1 @@
|
||||
<html><body><h1>Workout Report</h1><p>This is a dummy report.</p></body></html>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
BIN
src/db/__pycache__/models.cpython-313.pyc
Normal file
BIN
src/db/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/db/__pycache__/session.cpython-313.pyc
Normal file
BIN
src/db/__pycache__/session.cpython-313.pyc
Normal file
Binary file not shown.
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
BIN
src/utils/__pycache__/zone_calculator.cpython-313.pyc
Normal file
BIN
src/utils/__pycache__/zone_calculator.cpython-313.pyc
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user