Additional activity fields and fit parsing

[backend] added additional columns to the activities table (max_speed, max_power, normalized_power, average_hr, max_hr, average_cad, max_cad)
[backend] optimised fit file parse logic
[backend] moved backend log to specific logs folder
[README] updated README file with new logs folder
This commit is contained in:
João Vitória Silva
2024-09-17 16:59:55 +01:00
parent 3a0ce53df8
commit 57ffdfb73e
8 changed files with 402 additions and 237 deletions

View File

@@ -169,7 +169,7 @@ It is recommended to configure the following volumes for code and data persisten
| /app | <local_path>/endurain/backend/app:/app | Configure volume if you want to edit the code locally by cloning the repo and comment next line | | /app | <local_path>/endurain/backend/app:/app | Configure volume if you want to edit the code locally by cloning the repo and comment next line |
| /app/user_images | <local_path>/endurain/backend/app/user_images:/app/user_images | Necessary for user image persistence on container image updates | | /app/user_images | <local_path>/endurain/backend/app/user_images:/app/user_images | Necessary for user image persistence on container image updates |
| /app/bulk_import | <local_path>/endurain/backend/bulk_import:/app/bulk_import | Necessary to enable bulk import of activities. Place here your activities files | | /app/bulk_import | <local_path>/endurain/backend/bulk_import:/app/bulk_import | Necessary to enable bulk import of activities. Place here your activities files |
| /app/app.log | <local_path>/endurain/backend/app.log:/app/app.log | Log file for the backend | | /app/logs | <local_path>/endurain/backend/app.log:/app/logs | Log files for the backend |
## Bulk import ## Bulk import

View File

@@ -464,7 +464,14 @@ def create_activity(activity: activities_schema.Activity, db: Session):
elevation_loss=activity.elevation_loss, elevation_loss=activity.elevation_loss,
pace=activity.pace, pace=activity.pace,
average_speed=activity.average_speed, average_speed=activity.average_speed,
max_speed=activity.max_speed,
average_power=activity.average_power, average_power=activity.average_power,
max_power=activity.max_power,
normalized_power=activity.normalized_power,
average_hr=activity.average_hr,
max_hr=activity.max_hr,
average_cad=activity.average_cad,
max_cad=activity.max_cad,
calories=activity.calories, calories=activity.calories,
visibility=activity.visibility, visibility=activity.visibility,
gear_id=activity.gear_id, gear_id=activity.gear_id,

View File

@@ -14,11 +14,18 @@ class Activity(BaseModel):
town: str | None = None town: str | None = None
country: str | None = None country: str | None = None
created_at: str | None = None created_at: str | None = None
elevation_gain: int elevation_gain: int | None = None
elevation_loss: int elevation_loss: int | None = None
pace: float pace: float | None = None
average_speed: float average_speed: float | None = None
average_power: int max_speed: float | None = None
average_power: int | None = None
max_power: int | None = None
normalized_power: int | None = None
average_hr: int | None = None
max_hr: int | None = None
average_cad: int | None = None
max_cad: int | None = None
calories: int | None = None calories: int | None = None
visibility: int | None = None visibility: int | None = None
gear_id: int | None = None gear_id: int | None = None

View File

@@ -0,0 +1,82 @@
"""Added additional columns to activities table
Revision ID: 24e2676546b1
Revises: ab815ee3beae
Create Date: 2024-09-17 15:41:59.800993
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = '24e2676546b1'
down_revision: Union[str, None] = 'ab815ee3beae'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('activities', sa.Column('max_speed', sa.DECIMAL(precision=20, scale=10), nullable=True, comment='Max speed seconds per meter (s/m)'))
op.add_column('activities', sa.Column('max_power', sa.Integer(), nullable=True, comment='Max power (watts)'))
op.add_column('activities', sa.Column('normalized_power', sa.Integer(), nullable=True, comment='Normalized power (watts)'))
op.add_column('activities', sa.Column('average_hr', sa.Integer(), nullable=True, comment='Average heart rate (bpm)'))
op.add_column('activities', sa.Column('max_hr', sa.Integer(), nullable=True, comment='Max heart rate (bpm)'))
op.add_column('activities', sa.Column('average_cad', sa.Integer(), nullable=True, comment='Average cadence (rpm)'))
op.add_column('activities', sa.Column('max_cad', sa.Integer(), nullable=True, comment='Max cadence (rpm)'))
op.alter_column('activities', 'elevation_gain',
existing_type=mysql.INTEGER(display_width=11),
nullable=True,
existing_comment='Elevation gain in meters')
op.alter_column('activities', 'elevation_loss',
existing_type=mysql.INTEGER(display_width=11),
nullable=True,
existing_comment='Elevation loss in meters')
op.alter_column('activities', 'pace',
existing_type=mysql.DECIMAL(precision=20, scale=10),
nullable=True,
existing_comment='Pace seconds per meter (s/m)')
op.alter_column('activities', 'average_speed',
existing_type=mysql.DECIMAL(precision=20, scale=10),
nullable=True,
existing_comment='Average speed seconds per meter (s/m)')
op.alter_column('activities', 'average_power',
existing_type=mysql.INTEGER(display_width=11),
nullable=True,
existing_comment='Average power (watts)')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('activities', 'average_power',
existing_type=mysql.INTEGER(display_width=11),
nullable=False,
existing_comment='Average power (watts)')
op.alter_column('activities', 'average_speed',
existing_type=mysql.DECIMAL(precision=20, scale=10),
nullable=False,
existing_comment='Average speed seconds per meter (s/m)')
op.alter_column('activities', 'pace',
existing_type=mysql.DECIMAL(precision=20, scale=10),
nullable=False,
existing_comment='Pace seconds per meter (s/m)')
op.alter_column('activities', 'elevation_loss',
existing_type=mysql.INTEGER(display_width=11),
nullable=False,
existing_comment='Elevation loss in meters')
op.alter_column('activities', 'elevation_gain',
existing_type=mysql.INTEGER(display_width=11),
nullable=False,
existing_comment='Elevation gain in meters')
op.drop_column('activities', 'max_cad')
op.drop_column('activities', 'average_cad')
op.drop_column('activities', 'max_hr')
op.drop_column('activities', 'average_hr')
op.drop_column('activities', 'normalized_power')
op.drop_column('activities', 'max_power')
op.drop_column('activities', 'max_speed')
# ### end Alembic commands ###

View File

@@ -14,26 +14,20 @@ logger = logging.getLogger("myLogger")
def parse_activity_streams_from_fit_file(parsed_info: dict, activity_id: int): def parse_activity_streams_from_fit_file(parsed_info: dict, activity_id: int):
# Create a list of tuples containing stream type, is_set, and waypoints # Create a list of tuples containing stream type, is_set, and waypoints
stream_data_list = [ stream_mapping = {
(1, parsed_info["is_heart_rate_set"], parsed_info["hr_waypoints"]), 1: ("is_heart_rate_set", "hr_waypoints"),
(2, parsed_info["is_power_set"], parsed_info["power_waypoints"]), 2: ("is_power_set", "power_waypoints"),
(3, parsed_info["is_cadence_set"], parsed_info["cad_waypoints"]), 3: ("is_cadence_set", "cad_waypoints"),
(4, parsed_info["is_elevation_set"], parsed_info["ele_waypoints"]), 4: ("is_elevation_set", "ele_waypoints"),
(5, parsed_info["is_velocity_set"], parsed_info["vel_waypoints"]), 5: ("is_velocity_set", "vel_waypoints"),
(6, parsed_info["is_velocity_set"], parsed_info["pace_waypoints"]), 6: ("is_velocity_set", "pace_waypoints"),
( 7: ("lat_lon_waypoints", "lat_lon_waypoints"),
7, }
parsed_info["prev_latitude"] is not None
and parsed_info["prev_longitude"] is not None,
parsed_info["lat_lon_waypoints"],
),
]
# Filter the list to include only those with is_set True
stream_data_list = [ stream_data_list = [
(stream_type, is_set, waypoints) (stream_type, parsed_info[is_set_key], parsed_info[waypoints_key])
for stream_type, is_set, waypoints in stream_data_list for stream_type, (is_set_key, waypoints_key) in stream_mapping.items()
if is_set if parsed_info[is_set_key]
] ]
# Return activity streams as a list of ActivityStreams objects # Return activity streams as a list of ActivityStreams objects
@@ -51,20 +45,31 @@ def parse_activity_streams_from_fit_file(parsed_info: dict, activity_id: int):
def parse_fit_file(file: str, user_id: int) -> dict: def parse_fit_file(file: str, user_id: int) -> dict:
# Open the FIT file # Open the FIT file
try: try:
fit_data = fitdecode.FitReader(open(file, "rb"))
# Initialize default values for various variables # Initialize default values for various variables
activity_name = "Workout" initial_latitude = None
initial_longitude = None
activity_type = "Workout" activity_type = "Workout"
distance = 0 calories = None
distance = None
avg_hr = None
max_hr = None
avg_cadence = None
max_cadence = None
first_waypoint_time = None first_waypoint_time = None
last_waypoint_time = None last_waypoint_time = None
avg_power = None
max_power = None
ele_gain = None
ele_loss = None
np = None
avg_speed = None
max_speed = None
activity_name = "Workout"
city = None city = None
town = None town = None
country = None country = None
process_one_time_fields = 0
pace = 0 pace = 0
calories = 0
visibility = 0 visibility = 0
# Arrays to store waypoint data # Arrays to store waypoint data
@@ -86,218 +91,152 @@ def parse_fit_file(file: str, user_id: int) -> dict:
is_cadence_set = False is_cadence_set = False
is_velocity_set = False is_velocity_set = False
# Iterate over FIT messages with open(file, "rb") as fit_file:
for frame in fit_data: fit_data = fitdecode.FitReader(fit_file)
if isinstance(frame, fitdecode.FitDataMessage):
if frame.name == "session": # Iterate over FIT messages
# Extract calories for frame in fit_data:
try: if isinstance(frame, fitdecode.FitDataMessage):
calories = ( if frame.name == "session":
frame.get_value("total_calories") # Extract session data
if frame.get_value("total_calories") (
else None first_waypoint_time,
) initial_latitude,
except KeyError: initial_longitude,
calories = 0 activity_type,
calories,
# Extract activity type from sport field distance,
try: avg_hr,
activity_type = ( max_hr,
frame.get_value("sport") avg_cadence,
if frame.get_value("sport") max_cadence,
else "Workout" avg_power,
) max_power,
# Extract sub-sport ele_gain,
ele_loss,
np,
avg_speed,
max_speed,
) = parse_frame_session(frame)
# If initial latitude and longitude are set, use them to get city, town, and country
if ( if (
frame.get_value("sub_sport") initial_latitude is not None
and frame.get_value("sub_sport") != "generic" and initial_longitude is not None
): ):
if(activity_type == "cycling"): # Use geocoding API to get city, town, and country based on coordinates
if(frame.get_value("sub_sport") == "virtual_activity"): location_data = (
activity_type = "virtual_ride" activities_utils.location_based_on_coordinates(
else: initial_latitude, initial_longitude
activity_type = frame.get_value("sub_sport") )
else: )
activity_type = frame.get_value("sub_sport")
except KeyError:
activity_type = "Workout"
# Extract activity name
if frame.name == "workout":
activity_name = (
frame.get_value("wkt_name")
if frame.get_value("wkt_name")
else "Workout"
)
# Extract waypoint data # Extract city, town, and country from location data
if frame.name == "record": if location_data:
# for field in frame.fields: city = location_data["city"]
# print(f"{field.name}: {field.value}") town = location_data["town"]
# Extract latitude and longitude country = location_data["country"]
latitude = frame.get_value("position_lat") or 0
longitude = frame.get_value("position_long") or 0
# Extract elevation # Extract activity name
try: if frame.name == "workout":
elevation = frame.get_value("enhanced_altitude") activity_name = parse_frame_workout(frame)
except KeyError:
elevation = 0 # Extract waypoint data
if frame.name == "record":
# Extract timestamp # for field in frame.fields:
time = ( # print(f"{field.name}: {field.value}")
datetime.fromisoformat( # Extract latitude and longitude
frame.get_value("timestamp").strftime("%Y-%m-%dT%H:%M:%S") (
latitude,
longitude,
elevation,
time,
heart_rate,
cadence,
power,
) = parse_frame_record(frame)
# Check elevation
if elevation is not None:
is_elevation_set = True
# Check if heart rate, cadence, power are set
if heart_rate is not None:
is_heart_rate_set = True
if cadence is not None:
is_cadence_set = True
if power is not None:
is_power_set = True
instant_speed = None
# Calculate instant speed, pace, and update waypoint arrays
if latitude is not None and longitude is not None:
instant_speed = activities_utils.calculate_instant_speed(
last_waypoint_time,
time,
latitude,
longitude,
prev_latitude,
prev_longitude,
)
# Calculate instance pace
instant_pace = None
if instant_speed:
instant_pace = 1 / instant_speed
is_velocity_set = True
timestamp = time.strftime("%Y-%m-%dT%H:%M:%S")
# Append waypoint data to respective arrays
if latitude is not None and longitude is not None:
lat_lon_waypoints.append(
{
"time": timestamp,
"lat": latitude,
"lon": longitude,
}
)
append_if_not_none(ele_waypoints, timestamp, elevation, "ele")
append_if_not_none(hr_waypoints, timestamp, heart_rate, "hr")
append_if_not_none(cad_waypoints, timestamp, cadence, "cad")
append_if_not_none(power_waypoints, timestamp, power, "power")
append_if_not_none(
vel_waypoints, timestamp, instant_speed, "vel"
) )
if frame.get_value("timestamp") append_if_not_none(
else "" pace_waypoints, timestamp, instant_pace, "pace"
)
# Extract heart rate
try:
heart_rate = frame.get_value("heart_rate")
except KeyError:
heart_rate = 0
# Extract cadence
try:
cadence = frame.get_value("cadence")
except KeyError:
cadence = 0
# Extract power
try:
power = frame.get_value("power")
except KeyError:
power = 0
# Convert FIT coordinates to degrees if available
if latitude is not None and longitude is not None:
latitude = latitude * (180 / 2**31)
longitude = longitude * (180 / 2**31)
if prev_latitude is not None and prev_longitude is not None and latitude != 0 and longitude != 0 and prev_latitude != 0 and prev_longitude != 0:
distance += activities_utils.calculate_distance(
prev_latitude, prev_longitude, latitude, longitude
) )
# Check elevation # Update previous latitude, longitude, and last waypoint time
if elevation is not None and elevation != 0: prev_latitude, prev_longitude, last_waypoint_time = (
is_elevation_set = True latitude,
longitude,
if first_waypoint_time is None: time,
first_waypoint_time = time
if process_one_time_fields == 0 and latitude and longitude:
# Use geocoding API to get city, town, and country based on coordinates
location_data = activities_utils.location_based_on_coordinates(
latitude, longitude
) )
if location_data:
city = location_data["city"]
town = location_data["town"]
country = location_data["country"]
process_one_time_fields = 1
# Check if heart rate, cadence, power are set
if heart_rate != 0:
is_heart_rate_set = True
if cadence != 0:
is_cadence_set = True
if power != 0:
is_power_set = True
# Calculate instant speed, pace, and update waypoint arrays
instant_speed = activities_utils.calculate_instant_speed(
last_waypoint_time,
time,
latitude,
longitude,
prev_latitude,
prev_longitude,
)
# Calculate instance pace
instant_pace = 0
if instant_speed > 0:
instant_pace = 1 / instant_speed
is_velocity_set = True
# Append waypoint data to respective arrays
if latitude is not None and longitude is not None:
lat_lon_waypoints.append(
{
"time": time.strftime("%Y-%m-%dT%H:%M:%S"),
"lat": latitude,
"lon": longitude,
}
)
if elevation is not None:
ele_waypoints.append(
{
"time": time.strftime("%Y-%m-%dT%H:%M:%S"),
"ele": elevation,
}
)
if heart_rate is not None:
hr_waypoints.append(
{
"time": time.strftime("%Y-%m-%dT%H:%M:%S"),
"hr": heart_rate,
}
)
if cadence is not None:
cad_waypoints.append(
{"time": time.strftime("%Y-%m-%dT%H:%M:%S"), "cad": cadence}
)
if power is not None:
power_waypoints.append(
{"time": time.strftime("%Y-%m-%dT%H:%M:%S"), "power": power}
)
if instant_speed is not None and instant_speed != 0:
vel_waypoints.append(
{
"time": time.strftime("%Y-%m-%dT%H:%M:%S"),
"vel": instant_speed,
}
)
if instant_pace != 0:
pace_waypoints.append(
{
"time": time.strftime("%Y-%m-%dT%H:%M:%S"),
"pace": instant_pace,
}
)
# Update previous latitude, longitude, and last waypoint time
prev_latitude, prev_longitude, last_waypoint_time = (
latitude,
longitude,
time,
)
# Calculate elevation gain/loss, pace, average speed, and average power # Calculate elevation gain/loss, pace, average speed, and average power
elevation_data = activities_utils.calculate_elevation_gain_loss(ele_waypoints) if ele_gain is None and ele_loss is None:
elevation_gain = elevation_data["elevation_gain"] elevation_data = activities_utils.calculate_elevation_gain_loss(
elevation_loss = elevation_data["elevation_loss"] ele_waypoints
)
ele_gain = elevation_data["elevation_gain"]
ele_loss = elevation_data["elevation_loss"]
pace = activities_utils.calculate_pace( pace = activities_utils.calculate_pace(
distance, first_waypoint_time, last_waypoint_time distance, first_waypoint_time, last_waypoint_time
) )
average_speed = activities_utils.calculate_average_speed( if avg_speed is None:
distance, first_waypoint_time, last_waypoint_time avg_speed = activities_utils.calculate_average_speed(
) distance, first_waypoint_time, last_waypoint_time
)
average_power = activities_utils.calculate_average_power(power_waypoints) if avg_power is None:
avg_power = activities_utils.calculate_average_power(power_waypoints)
# Create an Activity object with parsed data # Create an Activity object with parsed data
activity = activities_schema.Activity( activity = activities_schema.Activity(
@@ -310,11 +249,18 @@ def parse_fit_file(file: str, user_id: int) -> dict:
city=city, city=city,
town=town, town=town,
country=country, country=country,
elevation_gain=elevation_gain, elevation_gain=ele_gain,
elevation_loss=elevation_loss, elevation_loss=ele_loss,
pace=pace, pace=pace,
average_speed=average_speed, average_speed=avg_speed,
average_power=average_power, max_speed=max_speed,
average_power=avg_power,
max_power=max_power,
normalized_power=np,
average_hr=avg_hr,
max_hr=max_hr,
average_cad=avg_cadence,
max_cad=max_cadence,
calories=calories, calories=calories,
visibility=visibility, visibility=visibility,
strava_gear_id=None, strava_gear_id=None,
@@ -339,6 +285,14 @@ def parse_fit_file(file: str, user_id: int) -> dict:
"prev_latitude": prev_latitude, "prev_latitude": prev_latitude,
"prev_longitude": prev_longitude, "prev_longitude": prev_longitude,
} }
except (FileNotFoundError, fitdecode.FitDecodeError) as err:
# Log the exception
logger.error(f"File error in parse_fit_file: {err}", exc_info=True)
# Raise an HTTPException with a 400 Bad Request status code
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Can't open FIT file",
) from err
except Exception as err: except Exception as err:
# Log the exception # Log the exception
logger.error(f"Error in parse_fit_file: {err}", exc_info=True) logger.error(f"Error in parse_fit_file: {err}", exc_info=True)
@@ -347,3 +301,107 @@ def parse_fit_file(file: str, user_id: int) -> dict:
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Can't parse FIT file", detail="Can't parse FIT file",
) from err ) from err
def parse_frame_session(frame):
# Extracting data using the helper function
start_time = get_value_from_frame(frame, "start_time")
initial_latitude = get_value_from_frame(frame, "start_position_lat")
initial_longitude = get_value_from_frame(frame, "start_position_long")
# Activity type logic
activity_type = get_value_from_frame(frame, "sport", "Workout")
sub_sport = get_value_from_frame(frame, "sub_sport")
if sub_sport and sub_sport != "generic":
if activity_type == "cycling" and sub_sport == "virtual_activity":
activity_type = "virtual_ride"
else:
activity_type = sub_sport
# Extracting other values with default fallback
calories = get_value_from_frame(frame, "total_calories")
distance = get_value_from_frame(frame, "total_distance")
avg_hr = get_value_from_frame(frame, "avg_heart_rate")
max_hr = get_value_from_frame(frame, "max_heart_rate")
avg_cadence = get_value_from_frame(frame, "avg_cadence")
max_cadence = get_value_from_frame(frame, "max_cadence")
avg_power = get_value_from_frame(frame, "avg_power")
max_power = get_value_from_frame(frame, "max_power")
ele_gain = get_value_from_frame(frame, "total_ascent")
ele_loss = get_value_from_frame(frame, "total_descent")
np = get_value_from_frame(frame, "normalized_power")
avg_speed = get_value_from_frame(frame, "enhanced_avg_speed")
max_speed = get_value_from_frame(frame, "enhanced_max_speed")
initial_latitude, initial_longitude = convert_coordinates_to_degrees(
initial_latitude, initial_longitude
)
# Return all extracted values
return (
start_time,
initial_latitude,
initial_longitude,
activity_type,
calories,
distance,
avg_hr,
max_hr,
avg_cadence,
max_cadence,
avg_power,
max_power,
ele_gain,
ele_loss,
np,
avg_speed,
max_speed,
)
def parse_frame_workout(frame):
# Extracting data using the helper function
name = get_value_from_frame(frame, "wkt_name", "Workout")
# Return the extracted name
return name
def parse_frame_record(frame):
# Extracting data using the helper function
latitude = get_value_from_frame(frame, "position_lat")
longitude = get_value_from_frame(frame, "position_long")
elevation = get_value_from_frame(frame, "enhanced_altitude")
time = get_value_from_frame(frame, "timestamp")
if time:
time = datetime.fromisoformat(time.strftime("%Y-%m-%dT%H:%M:%S"))
heart_rate = get_value_from_frame(frame, "heart_rate")
cadence = get_value_from_frame(frame, "cadence")
power = get_value_from_frame(frame, "power")
latitude, longitude = convert_coordinates_to_degrees(latitude, longitude)
# Return all extracted values
return latitude, longitude, elevation, time, heart_rate, cadence, power
def get_value_from_frame(frame, key, default=None):
try:
value = frame.get_value(key)
return value if value else default
except KeyError:
return default
def convert_coordinates_to_degrees(latitude, longitude):
# Convert FIT coordinates to degrees if available
if latitude is not None and longitude is not None:
latitude = latitude * (180 / 2**31)
longitude = longitude * (180 / 2**31)
return latitude, longitude
def append_if_not_none(waypoint_list, time, value, key):
if value is not None:
waypoint_list.append({"time": time, key: value})

View File

View File

@@ -78,7 +78,7 @@ def retrieve_strava_user_activities_for_last_day():
logger = logging.getLogger("myLogger") logger = logging.getLogger("myLogger")
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
file_handler = logging.FileHandler("app.log") file_handler = logging.FileHandler("logs/app.log")
file_handler.setLevel(logging.DEBUG) file_handler.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")

View File

@@ -241,19 +241,30 @@ class Activity(Base):
created_at = Column( created_at = Column(
DateTime, nullable=False, comment="Activity creation date (datetime)" DateTime, nullable=False, comment="Activity creation date (datetime)"
) )
elevation_gain = Column(Integer, nullable=False, comment="Elevation gain in meters") elevation_gain = Column(Integer, nullable=True, comment="Elevation gain in meters")
elevation_loss = Column(Integer, nullable=False, comment="Elevation loss in meters") elevation_loss = Column(Integer, nullable=True, comment="Elevation loss in meters")
pace = Column( pace = Column(
DECIMAL(precision=20, scale=10), DECIMAL(precision=20, scale=10),
nullable=False, nullable=True,
comment="Pace seconds per meter (s/m)", comment="Pace seconds per meter (s/m)",
) )
average_speed = Column( average_speed = Column(
DECIMAL(precision=20, scale=10), DECIMAL(precision=20, scale=10),
nullable=False, nullable=True,
comment="Average speed seconds per meter (s/m)", comment="Average speed seconds per meter (s/m)",
) )
average_power = Column(Integer, nullable=False, comment="Average power (watts)") max_speed = Column(
DECIMAL(precision=20, scale=10),
nullable=True,
comment="Max speed seconds per meter (s/m)",
)
average_power = Column(Integer, nullable=True, comment="Average power (watts)")
max_power = Column(Integer, nullable=True, comment="Max power (watts)")
normalized_power = Column(Integer, nullable=True, comment="Normalized power (watts)")
average_hr = Column(Integer, nullable=True, comment="Average heart rate (bpm)")
max_hr = Column(Integer, nullable=True, comment="Max heart rate (bpm)")
average_cad = Column(Integer, nullable=True, comment="Average cadence (rpm)")
max_cad = Column(Integer, nullable=True, comment="Max cadence (rpm)")
calories = Column( calories = Column(
Integer, Integer,
nullable=True, nullable=True,