diff --git a/README.md b/README.md index b7250dd2b..cd8711cc4 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ It is recommended to configure the following volumes for code and data persisten | /app | /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 | /endurain/backend/app/user_images:/app/user_images | Necessary for user image persistence on container image updates | | /app/bulk_import | /endurain/backend/bulk_import:/app/bulk_import | Necessary to enable bulk import of activities. Place here your activities files | -| /app/app.log | /endurain/backend/app.log:/app/app.log | Log file for the backend | +| /app/logs | /endurain/backend/app.log:/app/logs | Log files for the backend | ## Bulk import diff --git a/backend/app/activities/crud.py b/backend/app/activities/crud.py index 370765458..8bc9406c5 100644 --- a/backend/app/activities/crud.py +++ b/backend/app/activities/crud.py @@ -464,7 +464,14 @@ def create_activity(activity: activities_schema.Activity, db: Session): elevation_loss=activity.elevation_loss, pace=activity.pace, average_speed=activity.average_speed, + max_speed=activity.max_speed, 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, visibility=activity.visibility, gear_id=activity.gear_id, diff --git a/backend/app/activities/schema.py b/backend/app/activities/schema.py index 2fc653175..9384bb167 100644 --- a/backend/app/activities/schema.py +++ b/backend/app/activities/schema.py @@ -14,11 +14,18 @@ class Activity(BaseModel): town: str | None = None country: str | None = None created_at: str | None = None - elevation_gain: int - elevation_loss: int - pace: float - average_speed: float - average_power: int + elevation_gain: int | None = None + elevation_loss: int | None = None + pace: float | None = None + average_speed: float | None = None + 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 visibility: int | None = None gear_id: int | None = None diff --git a/backend/app/alembic/versions/24e2676546b1_added_additional_columns_to_activities_.py b/backend/app/alembic/versions/24e2676546b1_added_additional_columns_to_activities_.py new file mode 100644 index 000000000..780a2762b --- /dev/null +++ b/backend/app/alembic/versions/24e2676546b1_added_additional_columns_to_activities_.py @@ -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 ### diff --git a/backend/app/fit/utils.py b/backend/app/fit/utils.py index 4f152252e..ed0706211 100644 --- a/backend/app/fit/utils.py +++ b/backend/app/fit/utils.py @@ -14,26 +14,20 @@ logger = logging.getLogger("myLogger") 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 - stream_data_list = [ - (1, parsed_info["is_heart_rate_set"], parsed_info["hr_waypoints"]), - (2, parsed_info["is_power_set"], parsed_info["power_waypoints"]), - (3, parsed_info["is_cadence_set"], parsed_info["cad_waypoints"]), - (4, parsed_info["is_elevation_set"], parsed_info["ele_waypoints"]), - (5, parsed_info["is_velocity_set"], parsed_info["vel_waypoints"]), - (6, parsed_info["is_velocity_set"], parsed_info["pace_waypoints"]), - ( - 7, - parsed_info["prev_latitude"] is not None - and parsed_info["prev_longitude"] is not None, - parsed_info["lat_lon_waypoints"], - ), - ] + stream_mapping = { + 1: ("is_heart_rate_set", "hr_waypoints"), + 2: ("is_power_set", "power_waypoints"), + 3: ("is_cadence_set", "cad_waypoints"), + 4: ("is_elevation_set", "ele_waypoints"), + 5: ("is_velocity_set", "vel_waypoints"), + 6: ("is_velocity_set", "pace_waypoints"), + 7: ("lat_lon_waypoints", "lat_lon_waypoints"), + } - # Filter the list to include only those with is_set True stream_data_list = [ - (stream_type, is_set, waypoints) - for stream_type, is_set, waypoints in stream_data_list - if is_set + (stream_type, parsed_info[is_set_key], parsed_info[waypoints_key]) + for stream_type, (is_set_key, waypoints_key) in stream_mapping.items() + if parsed_info[is_set_key] ] # 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: # Open the FIT file try: - fit_data = fitdecode.FitReader(open(file, "rb")) - # Initialize default values for various variables - activity_name = "Workout" + initial_latitude = None + initial_longitude = None 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 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 town = None country = None - process_one_time_fields = 0 pace = 0 - calories = 0 visibility = 0 # Arrays to store waypoint data @@ -86,218 +91,152 @@ def parse_fit_file(file: str, user_id: int) -> dict: is_cadence_set = False is_velocity_set = False - # Iterate over FIT messages - for frame in fit_data: - if isinstance(frame, fitdecode.FitDataMessage): - if frame.name == "session": - # Extract calories - try: - calories = ( - frame.get_value("total_calories") - if frame.get_value("total_calories") - else None - ) - except KeyError: - calories = 0 - - # Extract activity type from sport field - try: - activity_type = ( - frame.get_value("sport") - if frame.get_value("sport") - else "Workout" - ) - # Extract sub-sport + with open(file, "rb") as fit_file: + fit_data = fitdecode.FitReader(fit_file) + + # Iterate over FIT messages + for frame in fit_data: + if isinstance(frame, fitdecode.FitDataMessage): + if frame.name == "session": + # Extract session data + ( + first_waypoint_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, + ) = parse_frame_session(frame) + + # If initial latitude and longitude are set, use them to get city, town, and country if ( - frame.get_value("sub_sport") - and frame.get_value("sub_sport") != "generic" + initial_latitude is not None + and initial_longitude is not None ): - if(activity_type == "cycling"): - if(frame.get_value("sub_sport") == "virtual_activity"): - activity_type = "virtual_ride" - else: - 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" - ) + # Use geocoding API to get city, town, and country based on coordinates + location_data = ( + activities_utils.location_based_on_coordinates( + initial_latitude, initial_longitude + ) + ) - # Extract waypoint data - if frame.name == "record": - # for field in frame.fields: - # print(f"{field.name}: {field.value}") - # Extract latitude and longitude - latitude = frame.get_value("position_lat") or 0 - longitude = frame.get_value("position_long") or 0 + # Extract city, town, and country from location data + if location_data: + city = location_data["city"] + town = location_data["town"] + country = location_data["country"] - # Extract elevation - try: - elevation = frame.get_value("enhanced_altitude") - except KeyError: - elevation = 0 - - # Extract timestamp - time = ( - datetime.fromisoformat( - frame.get_value("timestamp").strftime("%Y-%m-%dT%H:%M:%S") + # Extract activity name + if frame.name == "workout": + activity_name = parse_frame_workout(frame) + + # Extract waypoint data + if frame.name == "record": + # for field in frame.fields: + # print(f"{field.name}: {field.value}") + # Extract latitude and longitude + ( + 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") - else "" - ) - - # 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 + append_if_not_none( + pace_waypoints, timestamp, instant_pace, "pace" ) - # Check elevation - if elevation is not None and elevation != 0: - is_elevation_set = True - - if first_waypoint_time is None: - 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 + # Update previous latitude, longitude, and last waypoint time + prev_latitude, prev_longitude, last_waypoint_time = ( + latitude, + longitude, + time, ) - - 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 - elevation_data = activities_utils.calculate_elevation_gain_loss(ele_waypoints) - elevation_gain = elevation_data["elevation_gain"] - elevation_loss = elevation_data["elevation_loss"] + if ele_gain is None and ele_loss is None: + elevation_data = activities_utils.calculate_elevation_gain_loss( + ele_waypoints + ) + ele_gain = elevation_data["elevation_gain"] + ele_loss = elevation_data["elevation_loss"] + pace = activities_utils.calculate_pace( distance, first_waypoint_time, last_waypoint_time ) - average_speed = activities_utils.calculate_average_speed( - distance, first_waypoint_time, last_waypoint_time - ) + if avg_speed is None: + 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 activity = activities_schema.Activity( @@ -310,11 +249,18 @@ def parse_fit_file(file: str, user_id: int) -> dict: city=city, town=town, country=country, - elevation_gain=elevation_gain, - elevation_loss=elevation_loss, + elevation_gain=ele_gain, + elevation_loss=ele_loss, pace=pace, - average_speed=average_speed, - average_power=average_power, + average_speed=avg_speed, + 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, visibility=visibility, strava_gear_id=None, @@ -339,6 +285,14 @@ def parse_fit_file(file: str, user_id: int) -> dict: "prev_latitude": prev_latitude, "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: # Log the exception 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, detail="Can't parse FIT file", ) 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}) diff --git a/backend/app/logs/__init__.py b/backend/app/logs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/main.py b/backend/app/main.py index 29b48f227..07f2a3524 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -78,7 +78,7 @@ def retrieve_strava_user_activities_for_last_day(): logger = logging.getLogger("myLogger") logger.setLevel(logging.DEBUG) -file_handler = logging.FileHandler("app.log") +file_handler = logging.FileHandler("logs/app.log") file_handler.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") diff --git a/backend/app/models.py b/backend/app/models.py index 409ac1c26..eaf50da62 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -241,19 +241,30 @@ class Activity(Base): created_at = Column( DateTime, nullable=False, comment="Activity creation date (datetime)" ) - elevation_gain = Column(Integer, nullable=False, comment="Elevation gain in meters") - elevation_loss = Column(Integer, nullable=False, comment="Elevation loss in meters") + elevation_gain = Column(Integer, nullable=True, comment="Elevation gain in meters") + elevation_loss = Column(Integer, nullable=True, comment="Elevation loss in meters") pace = Column( DECIMAL(precision=20, scale=10), - nullable=False, + nullable=True, comment="Pace seconds per meter (s/m)", ) average_speed = Column( DECIMAL(precision=20, scale=10), - nullable=False, + nullable=True, 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( Integer, nullable=True,