diff --git a/FitnessSync/backend/__pycache__/main.cpython-311.pyc b/FitnessSync/backend/__pycache__/main.cpython-311.pyc index 126f32f..95f86ce 100644 Binary files a/FitnessSync/backend/__pycache__/main.cpython-311.pyc and b/FitnessSync/backend/__pycache__/main.cpython-311.pyc differ diff --git a/FitnessSync/backend/__pycache__/main.cpython-313.pyc b/FitnessSync/backend/__pycache__/main.cpython-313.pyc index 6adbf82..71544e2 100644 Binary files a/FitnessSync/backend/__pycache__/main.cpython-313.pyc and b/FitnessSync/backend/__pycache__/main.cpython-313.pyc differ diff --git a/FitnessSync/backend/alembic/versions/09c17c0f0e9e_add_extended_fit_metrics.py b/FitnessSync/backend/alembic/versions/09c17c0f0e9e_add_extended_fit_metrics.py new file mode 100644 index 0000000..08c71f4 --- /dev/null +++ b/FitnessSync/backend/alembic/versions/09c17c0f0e9e_add_extended_fit_metrics.py @@ -0,0 +1,58 @@ +"""add_extended_fit_metrics + +Revision ID: 09c17c0f0e9e +Revises: b43006af329e +Create Date: 2026-01-12 07:34:07.760775 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '09c17c0f0e9e' +down_revision: Union[str, None] = 'b43006af329e' +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('total_work', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('intensity_factor', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('threshold_power', sa.Integer(), nullable=True)) + op.add_column('activities', sa.Column('avg_left_pedal_smoothness', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_right_pedal_smoothness', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_left_torque_effectiveness', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_right_torque_effectiveness', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('grit', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('flow', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_respiration_rate', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('max_respiration_rate', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_stress', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_spo2', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('total_strokes', sa.Integer(), nullable=True)) + op.add_column('activities', sa.Column('avg_stroke_distance', sa.Float(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('activities', 'avg_stroke_distance') + op.drop_column('activities', 'total_strokes') + op.drop_column('activities', 'avg_spo2') + op.drop_column('activities', 'avg_stress') + op.drop_column('activities', 'max_respiration_rate') + op.drop_column('activities', 'avg_respiration_rate') + op.drop_column('activities', 'flow') + op.drop_column('activities', 'grit') + op.drop_column('activities', 'avg_right_torque_effectiveness') + op.drop_column('activities', 'avg_left_torque_effectiveness') + op.drop_column('activities', 'avg_right_pedal_smoothness') + op.drop_column('activities', 'avg_left_pedal_smoothness') + op.drop_column('activities', 'threshold_power') + op.drop_column('activities', 'intensity_factor') + op.drop_column('activities', 'total_work') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/0c82944705e9_add_is_estimated_power.py b/FitnessSync/backend/alembic/versions/0c82944705e9_add_is_estimated_power.py new file mode 100644 index 0000000..6377f54 --- /dev/null +++ b/FitnessSync/backend/alembic/versions/0c82944705e9_add_is_estimated_power.py @@ -0,0 +1,32 @@ +"""Add is_estimated_power + +Revision ID: 0c82944705e9 +Revises: 09c17c0f0e9e +Create Date: 2026-01-12 17:52:22.138566 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0c82944705e9' +down_revision: Union[str, None] = '09c17c0f0e9e' +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('is_estimated_power', sa.Boolean(), nullable=True)) + op.drop_column('activities', 'avg_left_pedal_smoothness') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('activities', sa.Column('avg_left_pedal_smoothness', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True)) + op.drop_column('activities', 'is_estimated_power') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/1136125782ec_add_last_segment_scan_timestamp_v3.py b/FitnessSync/backend/alembic/versions/1136125782ec_add_last_segment_scan_timestamp_v3.py new file mode 100644 index 0000000..d2317b3 --- /dev/null +++ b/FitnessSync/backend/alembic/versions/1136125782ec_add_last_segment_scan_timestamp_v3.py @@ -0,0 +1,30 @@ +"""Add last_segment_scan_timestamp_v3 + +Revision ID: 1136125782ec +Revises: 87cc6ed8df63 +Create Date: 2026-01-12 18:52:58.854314 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1136125782ec' +down_revision: Union[str, None] = '87cc6ed8df63' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/52a16d820129_add_streams_json.py b/FitnessSync/backend/alembic/versions/52a16d820129_add_streams_json.py new file mode 100644 index 0000000..80bc0fa --- /dev/null +++ b/FitnessSync/backend/alembic/versions/52a16d820129_add_streams_json.py @@ -0,0 +1,24 @@ +"""add streams_json to activity + +Revision ID: 52a16d820129 +Revises: cc3b223773cb +Create Date: 2026-01-13 09:40:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '52a16d820129' +down_revision = 'e9b8841a1234' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('activities', sa.Column('streams_json', sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('activities', 'streams_json') diff --git a/FitnessSync/backend/alembic/versions/87cc6ed8df63_add_last_segment_scan_timestamp_v2.py b/FitnessSync/backend/alembic/versions/87cc6ed8df63_add_last_segment_scan_timestamp_v2.py new file mode 100644 index 0000000..a13d2af --- /dev/null +++ b/FitnessSync/backend/alembic/versions/87cc6ed8df63_add_last_segment_scan_timestamp_v2.py @@ -0,0 +1,30 @@ +"""Add last_segment_scan_timestamp_v2 + +Revision ID: 87cc6ed8df63 +Revises: 8cc7963c8db0 +Create Date: 2026-01-12 18:52:47.577657 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '87cc6ed8df63' +down_revision: Union[str, None] = '8cc7963c8db0' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/8cc7963c8db0_add_last_segment_scan_timestamp.py b/FitnessSync/backend/alembic/versions/8cc7963c8db0_add_last_segment_scan_timestamp.py new file mode 100644 index 0000000..4bbbc21 --- /dev/null +++ b/FitnessSync/backend/alembic/versions/8cc7963c8db0_add_last_segment_scan_timestamp.py @@ -0,0 +1,30 @@ +"""Add last_segment_scan_timestamp + +Revision ID: 8cc7963c8db0 +Revises: 0c82944705e9 +Create Date: 2026-01-12 18:52:29.081798 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8cc7963c8db0' +down_revision: Union[str, None] = '0c82944705e9' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/__pycache__/09c17c0f0e9e_add_extended_fit_metrics.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/09c17c0f0e9e_add_extended_fit_metrics.cpython-311.pyc new file mode 100644 index 0000000..0b3a8a9 Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/09c17c0f0e9e_add_extended_fit_metrics.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/09c17c0f0e9e_add_extended_fit_metrics.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/09c17c0f0e9e_add_extended_fit_metrics.cpython-313.pyc new file mode 100644 index 0000000..0091640 Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/09c17c0f0e9e_add_extended_fit_metrics.cpython-313.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/0c82944705e9_add_is_estimated_power.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/0c82944705e9_add_is_estimated_power.cpython-311.pyc new file mode 100644 index 0000000..5d456db Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/0c82944705e9_add_is_estimated_power.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/1136125782ec_add_last_segment_scan_timestamp_v3.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/1136125782ec_add_last_segment_scan_timestamp_v3.cpython-311.pyc new file mode 100644 index 0000000..5b99a66 Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/1136125782ec_add_last_segment_scan_timestamp_v3.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/52a16d820129_add_streams_json.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/52a16d820129_add_streams_json.cpython-311.pyc new file mode 100644 index 0000000..ca36434 Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/52a16d820129_add_streams_json.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/87cc6ed8df63_add_last_segment_scan_timestamp_v2.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/87cc6ed8df63_add_last_segment_scan_timestamp_v2.cpython-311.pyc new file mode 100644 index 0000000..54af18e Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/87cc6ed8df63_add_last_segment_scan_timestamp_v2.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/8cc7963c8db0_add_last_segment_scan_timestamp.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/8cc7963c8db0_add_last_segment_scan_timestamp.cpython-311.pyc new file mode 100644 index 0000000..6167835 Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/8cc7963c8db0_add_last_segment_scan_timestamp.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/a9c00e495f5e_add_segments_tables.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/a9c00e495f5e_add_segments_tables.cpython-313.pyc new file mode 100644 index 0000000..9eda1ea Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/a9c00e495f5e_add_segments_tables.cpython-313.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/b43006af329e_add_avg_temperature_to_activity.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/b43006af329e_add_avg_temperature_to_activity.cpython-311.pyc new file mode 100644 index 0000000..6db4564 Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/b43006af329e_add_avg_temperature_to_activity.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/b43006af329e_add_avg_temperature_to_activity.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/b43006af329e_add_avg_temperature_to_activity.cpython-313.pyc new file mode 100644 index 0000000..1a7ee39 Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/b43006af329e_add_avg_temperature_to_activity.cpython-313.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/cc3b223773cb_add_max_power_to_segmenteffort.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/cc3b223773cb_add_max_power_to_segmenteffort.cpython-311.pyc new file mode 100644 index 0000000..e0748a2 Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/cc3b223773cb_add_max_power_to_segmenteffort.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/dbb13b0ba015_add_last_segment_scan_timestamp.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/dbb13b0ba015_add_last_segment_scan_timestamp.cpython-311.pyc new file mode 100644 index 0000000..01a5a33 Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/dbb13b0ba015_add_last_segment_scan_timestamp.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/e9b8841a1234_add_segment_effort_metrics.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/e9b8841a1234_add_segment_effort_metrics.cpython-311.pyc new file mode 100644 index 0000000..7bc0cd5 Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/e9b8841a1234_add_segment_effort_metrics.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/b43006af329e_add_avg_temperature_to_activity.py b/FitnessSync/backend/alembic/versions/b43006af329e_add_avg_temperature_to_activity.py new file mode 100644 index 0000000..a88ed2a --- /dev/null +++ b/FitnessSync/backend/alembic/versions/b43006af329e_add_avg_temperature_to_activity.py @@ -0,0 +1,46 @@ +"""Add avg_temperature_to_activity + +Revision ID: b43006af329e +Revises: a9c00e495f5e +Create Date: 2026-01-11 16:32:06.147407 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'b43006af329e' +down_revision: Union[str, None] = 'a9c00e495f5e' +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('avg_temperature', sa.Float(), nullable=True)) + op.alter_column('bike_setups', 'purchase_date', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.DateTime(), + existing_nullable=True) + op.alter_column('bike_setups', 'retirement_date', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.DateTime(), + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('bike_setups', 'retirement_date', + existing_type=sa.DateTime(), + type_=postgresql.TIMESTAMP(timezone=True), + existing_nullable=True) + op.alter_column('bike_setups', 'purchase_date', + existing_type=sa.DateTime(), + type_=postgresql.TIMESTAMP(timezone=True), + existing_nullable=True) + op.drop_column('activities', 'avg_temperature') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/cc3b223773cb_add_max_power_to_segmenteffort.py b/FitnessSync/backend/alembic/versions/cc3b223773cb_add_max_power_to_segmenteffort.py new file mode 100644 index 0000000..80c814f --- /dev/null +++ b/FitnessSync/backend/alembic/versions/cc3b223773cb_add_max_power_to_segmenteffort.py @@ -0,0 +1,30 @@ +"""Add max_power to SegmentEffort + +Revision ID: cc3b223773cb +Revises: dbb13b0ba015 +Create Date: 2026-01-12 20:13:23.975345 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'cc3b223773cb' +down_revision: Union[str, None] = 'dbb13b0ba015' +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('segment_efforts', sa.Column('max_power', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('segment_efforts', 'max_power') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/dbb13b0ba015_add_last_segment_scan_timestamp.py b/FitnessSync/backend/alembic/versions/dbb13b0ba015_add_last_segment_scan_timestamp.py new file mode 100644 index 0000000..55c7c8e --- /dev/null +++ b/FitnessSync/backend/alembic/versions/dbb13b0ba015_add_last_segment_scan_timestamp.py @@ -0,0 +1,30 @@ +"""Add last_segment_scan_timestamp + +Revision ID: dbb13b0ba015 +Revises: 1136125782ec +Create Date: 2026-01-12 19:46:33.893542 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'dbb13b0ba015' +down_revision: Union[str, None] = '1136125782ec' +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('last_segment_scan_timestamp', sa.DateTime(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('activities', 'last_segment_scan_timestamp') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/e9b8841a1234_add_segment_effort_metrics.py b/FitnessSync/backend/alembic/versions/e9b8841a1234_add_segment_effort_metrics.py new file mode 100644 index 0000000..d3e9fe9 --- /dev/null +++ b/FitnessSync/backend/alembic/versions/e9b8841a1234_add_segment_effort_metrics.py @@ -0,0 +1,25 @@ +"""add_segment_effort_metrics + +Revision ID: e9b8841a1234 +Revises: 1136125782ec +Create Date: 2026-01-13 14:45:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'e9b8841a1234' +down_revision = 'cc3b223773cb' +branch_labels = None +depends_on = None + +def upgrade(): + op.add_column('segment_efforts', sa.Column('avg_speed', sa.Float(), nullable=True)) + op.add_column('segment_efforts', sa.Column('avg_cadence', sa.Integer(), nullable=True)) + op.add_column('segment_efforts', sa.Column('avg_respiration_rate', sa.Float(), nullable=True)) + +def downgrade(): + op.drop_column('segment_efforts', 'avg_respiration_rate') + op.drop_column('segment_efforts', 'avg_cadence') + op.drop_column('segment_efforts', 'avg_speed') diff --git a/FitnessSync/backend/backfill_estimated_power.py b/FitnessSync/backend/backfill_estimated_power.py new file mode 100644 index 0000000..f881de1 --- /dev/null +++ b/FitnessSync/backend/backfill_estimated_power.py @@ -0,0 +1,18 @@ +from src.services.postgresql_manager import PostgreSQLManager +from src.utils.config import config +from src.models.activity import Activity +from sqlalchemy import text + +def backfill(): + print("Backfilling is_estimated_power defaults...") + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + # Update all NULLs to False + result = session.execute( + text("UPDATE activities SET is_estimated_power = false WHERE is_estimated_power IS NULL") + ) + session.commit() + print(f"Updated {result.rowcount} rows.") + +if __name__ == "__main__": + backfill() diff --git a/FitnessSync/backend/check_db_status.py b/FitnessSync/backend/check_db_status.py new file mode 100644 index 0000000..c6b47ca --- /dev/null +++ b/FitnessSync/backend/check_db_status.py @@ -0,0 +1,22 @@ +from src.services.postgresql_manager import PostgreSQLManager +from src.utils.config import config +from src.models.activity import Activity + +def check(): + print("Checking ID 256...") + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + act = session.query(Activity).filter(Activity.id == 256).first() + if act: + print(f"ID: {act.id}") + print(f"Avg Power: {act.avg_power}") + print(f"Is Estimated: {act.is_estimated_power}") + + # Also check if it's NULL + if act.is_estimated_power is None: + print("Is Estimated is NULL/None") + else: + print("Activity 256 not found") + +if __name__ == "__main__": + check() diff --git a/FitnessSync/backend/debug_activity.py b/FitnessSync/backend/debug_activity.py new file mode 100644 index 0000000..b357797 --- /dev/null +++ b/FitnessSync/backend/debug_activity.py @@ -0,0 +1,77 @@ +import sys +import os +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker +import fitdecode +import io + +# Add curdir to path to allow imports from src +sys.path.append(os.getcwd()) + +# Import models - adjust import paths if needed strictly relative or absolute +from src.models.activity import Activity +from src.services.parsers import _extract_data_from_fit + +# Using localhost:5433 as per docker-compose port mapping for host access +DATABASE_URL = "postgresql://postgres:password@localhost:5433/fitbit_garmin_sync" + +def debug_activity(garmin_id): + print(f"Connecting to DB: {DATABASE_URL}") + try: + engine = create_engine(DATABASE_URL) + Session = sessionmaker(bind=engine) + session = Session() + except Exception as e: + print(f"Failed to connect to DB: {e}") + return + + try: + activity = session.query(Activity).filter(Activity.garmin_activity_id == garmin_id).first() + if not activity: + print(f"Activity {garmin_id} not found in DB.") + return + + print(f"Activity Found: ID={activity.id}, GarminID={activity.garmin_activity_id}") + print(f" Name: {activity.activity_name}") + print(f" Type: {activity.activity_type}") + print(f" Distance: {activity.distance}") + print(f" Avg Power: {activity.avg_power}") + print(f" Avg Speed: {activity.avg_speed}") + print(f" File Type: {activity.file_type}") + print(f" File Content Size: {len(activity.file_content) if activity.file_content else 0} bytes") + + if activity.file_content and activity.file_type == 'fit': + print("\n--- Parsing FIT File Content ---") + try: + data = _extract_data_from_fit(activity.file_content) + print(f" Data Keys: {data.keys()}") + print(f" Points Count: {len(data.get('points', []))}") + + power_data = data.get('power', []) + print(f" Power Count: {len(power_data)}") + valid_power = [p for p in power_data if p is not None] + if valid_power: + print(f" Avg Power (Calculated from stream): {sum(valid_power)/len(valid_power)}") + print(f" Max Power (Calculated from stream): {max(valid_power)}") + else: + print(f" Avg Power (Calculated from stream): None") + + none_power = sum(1 for x in power_data if x is None) + print(f" Power None values: {none_power} / {len(power_data)}") + + # Check lat/long + points = data.get('points', []) + if points: + print(f" First point: {points[0]}") + print(f" Last point: {points[-1]}") + + except Exception as e: + print(f" Error parsing FIT file: {e}") + else: + print(f" No FIT file content or file type is not 'fit' (Type: {activity.file_type})") + + finally: + session.close() + +if __name__ == "__main__": + debug_activity("21517046364") diff --git a/FitnessSync/backend/debug_estimated_power.py b/FitnessSync/backend/debug_estimated_power.py new file mode 100644 index 0000000..870644f --- /dev/null +++ b/FitnessSync/backend/debug_estimated_power.py @@ -0,0 +1,50 @@ +from src.services.postgresql_manager import PostgreSQLManager +from src.utils.config import config +from src.models.activity import Activity +from src.services.power_estimator import PowerEstimatorService +from sqlalchemy import text, or_ + +def debug_power(): + print("Debugging Estimated Power...") + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + # 1. Total Cycling + cycling_count = session.query(Activity).filter(Activity.activity_type.ilike('%cycling%')).count() + print(f"Total Cycling Activities: {cycling_count}") + + # 2. Missing Power + missing_power = session.query(Activity).filter( + Activity.activity_type.ilike('%cycling%'), + (Activity.avg_power == None) | (Activity.avg_power == 0) + ).count() + print(f"Cycling with Missing Power: {missing_power}") + + # 3. Already Estimated + estimated_count = session.query(Activity).filter(Activity.is_estimated_power == True).count() + print(f"Activities with is_estimated_power=True: {estimated_count}") + + # 4. Candidates for Job + candidates = session.query(Activity).filter( + Activity.activity_type.ilike('%cycling%'), + (Activity.avg_power == None) | (Activity.avg_power == 0), + Activity.file_content != None + ).all() + print(f"Job Candidates (Cycling + No Power + Has File): {len(candidates)}") + + if len(candidates) > 0: + print(f"First candidate ID: {candidates[0].id}") + # Try to estimate one + print("Attempting to estimate power for first candidate...") + try: + estimator = PowerEstimatorService(session) + res = estimator.estimate_power_for_activity(candidates[0].id) + print(f"Test Estimation Result: {res}") + except Exception as e: + print(f"Test Estimation FAILED: {e}") + import traceback + traceback.print_exc() + else: + print("No candidates found for estimation.") + +if __name__ == "__main__": + debug_power() diff --git a/FitnessSync/backend/inspect_fit_fields.py b/FitnessSync/backend/inspect_fit_fields.py new file mode 100644 index 0000000..8b7242d --- /dev/null +++ b/FitnessSync/backend/inspect_fit_fields.py @@ -0,0 +1,72 @@ +import sys +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +import fitdecode +import io + +# Add curdir to path +sys.path.append(os.getcwd()) + +from src.models.activity import Activity + +# Using localhost:5433 as per docker-compose port mapping +DATABASE_URL = "postgresql://postgres:password@localhost:5433/fitbit_garmin_sync" + +def inspect_fit_fields(garmin_id): + print(f"Connecting to DB: {DATABASE_URL}") + try: + engine = create_engine(DATABASE_URL) + Session = sessionmaker(bind=engine) + session = Session() + except Exception as e: + print(f"Failed to connect to DB: {e}") + return + + try: + activity = session.query(Activity).filter(Activity.garmin_activity_id == garmin_id).first() + if not activity: + print(f"Activity {garmin_id} not found in DB.") + return + + if not activity.file_content or activity.file_type != 'fit': + print("No FIT content found.") + return + + print(f"Parsing FIT file for Activity {garmin_id}...") + + record_fields = set() + lap_fields = set() + session_fields = set() + + with io.BytesIO(activity.file_content) as f: + with fitdecode.FitReader(f) as fit: + for frame in fit: + if frame.frame_type == fitdecode.FIT_FRAME_DATA: + if frame.name == 'record': + for field in frame.fields: + record_fields.add(field.name) + elif frame.name == 'lap': + for field in frame.fields: + lap_fields.add(field.name) + elif frame.name == 'session': + for field in frame.fields: + session_fields.add(field.name) + + print("\n--- Available 'record' Fields (Time Series) ---") + for field in sorted(record_fields): + print(f" - {field}") + + print("\n--- Available 'lap' Fields ---") + for field in sorted(lap_fields): + print(f" - {field}") + + print("\n--- Available 'session' Fields (Summary) ---") + for field in sorted(session_fields): + print(f" - {field}") + + finally: + session.close() + +if __name__ == "__main__": + inspect_fit_fields("21517046364") diff --git a/FitnessSync/backend/main.py b/FitnessSync/backend/main.py index 31fed25..2312e19 100644 --- a/FitnessSync/backend/main.py +++ b/FitnessSync/backend/main.py @@ -98,6 +98,9 @@ app.include_router(bike_setups.router) from src.api import discovery app.include_router(discovery.router, prefix="/api/discovery") +from src.api import analysis +app.include_router(analysis.router, prefix="/api") + @@ -107,3 +110,5 @@ app.include_router(web.router) + +# Trigger reload diff --git a/FitnessSync/backend/requirements.txt b/FitnessSync/backend/requirements.txt index d98fd38..e4d45b5 100644 --- a/FitnessSync/backend/requirements.txt +++ b/FitnessSync/backend/requirements.txt @@ -15,3 +15,4 @@ aiofiles==23.2.1 pytest==7.4.3 pytest-asyncio==0.21.1 alembic==1.13.1 +fitdecode>=0.10.0 diff --git a/FitnessSync/backend/src/api/__pycache__/activities.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/activities.cpython-311.pyc index d422953..ca80274 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/activities.cpython-311.pyc and b/FitnessSync/backend/src/api/__pycache__/activities.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/activities.cpython-313.pyc b/FitnessSync/backend/src/api/__pycache__/activities.cpython-313.pyc index 68efc6c..157060e 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/activities.cpython-313.pyc and b/FitnessSync/backend/src/api/__pycache__/activities.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/analysis.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/analysis.cpython-311.pyc new file mode 100644 index 0000000..4b6a557 Binary files /dev/null and b/FitnessSync/backend/src/api/__pycache__/analysis.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-311.pyc index cb4f24e..e481cb3 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-311.pyc and b/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/segments.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/segments.cpython-311.pyc index ae8e55b..f5a0c24 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/segments.cpython-311.pyc and b/FitnessSync/backend/src/api/__pycache__/segments.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/api/activities.py b/FitnessSync/backend/src/api/activities.py index 672fcb3..e83c4f7 100644 --- a/FitnessSync/backend/src/api/activities.py +++ b/FitnessSync/backend/src/api/activities.py @@ -13,6 +13,7 @@ from ..services.job_manager import job_manager from ..models.activity_state import GarminActivityState from datetime import datetime from ..services.parsers import extract_points_from_file +import fitdecode router = APIRouter() @@ -43,6 +44,13 @@ class ActivityResponse(BaseModel): download_status: Optional[str] = None downloaded_at: Optional[str] = None bike_setup: Optional[BikeSetupInfo] = None + avg_power: Optional[int] = None + avg_hr: Optional[int] = None + avg_cadence: Optional[int] = None + is_estimated_power: bool + + class Config: + from_attributes = True class ActivityDetailResponse(ActivityResponse): distance: Optional[float] = None @@ -63,6 +71,8 @@ class ActivityDetailResponse(ActivityResponse): norm_power: Optional[int] = None tss: Optional[float] = None vo2_max: Optional[float] = None + avg_respiration_rate: Optional[float] = None + max_respiration_rate: Optional[float] = None @router.get("/activities/list", response_model=List[ActivityResponse]) @@ -125,7 +135,11 @@ async def list_activities( chainring=activity.bike_setup.chainring, rear_cog=activity.bike_setup.rear_cog, name=activity.bike_setup.name - ) if (activity and activity.bike_setup) else None + ) if (activity and activity.bike_setup) else None, + avg_power=activity.avg_power if activity else None, + avg_hr=activity.avg_hr if activity else None, + avg_cadence=activity.avg_cadence if activity else None, + is_estimated_power=activity.is_estimated_power if activity else False ) ) @@ -142,6 +156,10 @@ async def query_activities( end_date: Optional[str] = Query(None), download_status: Optional[str] = Query(None), bike_setup_id: Optional[int] = Query(None), + has_power: Optional[bool] = Query(None), + has_hr: Optional[bool] = Query(None), + has_cadence: Optional[bool] = Query(None), + is_estimated_power: Optional[bool] = Query(None), db: Session = Depends(get_db) ): """ @@ -186,6 +204,30 @@ async def query_activities( if bike_setup_id: query = query.filter(Activity.bike_setup_id == bike_setup_id) + + if has_power is not None: + if has_power: + query = query.filter(Activity.avg_power != None) + else: + query = query.filter(Activity.avg_power == None) + + if has_hr is not None: + if has_hr: + query = query.filter(Activity.avg_hr != None) + else: + query = query.filter(Activity.avg_hr == None) + + if has_cadence is not None: + if has_cadence: + query = query.filter(Activity.avg_cadence != None) + else: + query = query.filter(Activity.avg_cadence == None) + + if is_estimated_power is not None: + if is_estimated_power: + query = query.filter(Activity.is_estimated_power == True) + else: + query = query.filter(Activity.is_estimated_power == False) # Execute the query activities = query.all() @@ -210,7 +252,11 @@ async def query_activities( chainring=activity.bike_setup.chainring, rear_cog=activity.bike_setup.rear_cog, name=activity.bike_setup.name - ) if activity.bike_setup else None + ) if activity.bike_setup else None, + avg_power=activity.avg_power, + avg_hr=activity.avg_hr, + avg_cadence=activity.avg_cadence, + is_estimated_power=activity.is_estimated_power ) ) @@ -322,6 +368,9 @@ async def get_activity_details(activity_id: str, db: Session = Depends(get_db)): norm_power=val('norm_power', 'normalized_power'), tss=val('tss', 'training_stress_score'), vo2_max=activity.vo2_max, # Usually not in simple session msg directly but maybe + avg_respiration_rate=val('avg_respiration_rate', 'avg_respiration_rate'), + max_respiration_rate=val('max_respiration_rate', 'max_respiration_rate'), + is_estimated_power=activity.is_estimated_power or False, bike_setup=BikeSetupInfo( id=activity.bike_setup.id, frame=activity.bike_setup.frame, @@ -597,9 +646,13 @@ def _extract_streams_from_fit(file_content: bytes) -> Dict[str, List[Any]]: "power": [], "altitude": [], "speed": [], - "cadence": [] + "cadence": [], + "respiration_rate": [] } try: + import fitdecode + import io + start_time = None with io.BytesIO(file_content) as f: with fitdecode.FitReader(f) as fit: @@ -626,8 +679,68 @@ def _extract_streams_from_fit(file_content: bytes) -> Dict[str, List[Any]]: streams["altitude"].append(get_val(frame, ['enhanced_altitude', 'altitude'])) streams["speed"].append(get_val(frame, ['enhanced_speed', 'speed'])) # m/s (enhanced is also m/s) streams["cadence"].append(get_val(frame, ['cadence'])) + streams["respiration_rate"].append(get_val(frame, ['respiration_rate', 'enhanced_respiration_rate'])) except Exception as e: logger.error(f"Error extracting streams from FIT: {e}") + + # Apply LTTB Downsampling + try: + from ..utils.algorithms import lttb + target_points = 1500 # Plenty for 4k screens, but much smaller than raw 1s data + + # We need a primary axis to sample against, typically Time. + # But LTTB is 2D (x,y). We have multiple Ys for one X (time). + # Strategy: Use Time vs Power (or HR/Speed) to pick key indices? + # Or simpler: Just LTTB each stream independently against Time? + # Independent LTTB might misalign peaks across streams (e.g. HR peak might slightly shift vs Power peak). + # Better: Pick 'Power' (most volatile) as the driver for indices? + # Or Simple Decimation for speed? + # Actually, let's just LTTB each one. The slight misalignment is negligible for visualization. + + # Check if we have enough points to warrant sampling + count = len(streams["time"]) + if count > target_points: + # Create (time, index) pairs to find which indices to keep? + # No, standard LTTB takes (x,y). + + # Helper to LTTB a specific stream + def sample_stream(name): + if not streams.get(name) or len(streams[name]) != count: return + + # Filter out Nones for LTTB? No, preserve index? + # LTTB requires values. If we have gaps, it's tricky. + # Let's replace None with 0 (or prev value) for sampling purposes? + # Or just use simple uniform sampling (decimation) which is "good enough" and keeps perfect alignment. + pass + + # CHANGING STRATEGY: + # LTTB is great for one line. For aligned multi-series, simple bucket averaging or decimation is safer to keep alignment. + # However, decimation loses peaks. + # + # Let's try: "Bucket Max/Avg". + # Or simplified: Use LTTB on the "most interesting" metric (Power) to select the timestamps, then sample others at those timestamps. + + # Implementation: Use simple N-th sampling for now to guarantee alignment and speed improvement. + # It's an order of magnitude faster than full LTTB and robust for "Loading Speed" requests. + + step = count / target_points + indices = [int(i * step) for i in range(target_points)] + # Ensure last point included + if indices[-1] != count - 1: indices[-1] = count - 1 + + sampled_streams = {k: [] for k in streams} + + for idx in indices: + for k in streams: + if idx < len(streams[k]): + sampled_streams[k].append(streams[k][idx]) + + return sampled_streams + + except Exception as e: + logger.error(f"Error during downsampling: {e}") + # Return original if sampling fails + return streams def _extract_summary_from_fit(file_content: bytes) -> Dict[str, Any]: @@ -745,18 +858,71 @@ async def get_activity_streams(activity_id: str, db: Session = Depends(get_db)): if not activity or not activity.file_content: raise HTTPException(status_code=404, detail="Activity or file content not found") + # 1. Check DB Cache + if activity.streams_json: + return activity.streams_json + + if not activity.file_content: + raise HTTPException(status_code=404, detail="Activity file content not found") + + # 2. Extract streams + try: streams = {} if activity.file_type == 'fit': streams = _extract_streams_from_fit(activity.file_content) elif activity.file_type == 'tcx': - streams = _extract_streams_from_tcx(activity.file_content) + # Need to define or import this. Let's use parsers definition if possible, + # but _extract_streams_from_fit is local. + # Let's inspect parsers.py again. It returns data dict which is similar to streams. + # We can map it. + from ..services.parsers import extract_activity_data + data = extract_activity_data(activity.file_content, 'tcx') + # Mapping parsers format to streams format + streams = { + "time": [(t - data['timestamps'][0]).total_seconds() for t in data['timestamps']] if data['timestamps'] else [], + "heart_rate": data['heart_rate'], + "power": data['power'], + "altitude": [p[2] if len(p) > 2 else None for p in data['points']], + "speed": data.get('speed', []), + "cadence": data.get('cadence', []), + "respiration_rate": data.get('respiration_rate', []) + } else: + # Try FIT as fallback or empty logger.warning(f"Unsupported file type for streams: {activity.file_type}") + streams = {} + + # 3. Cache the result (downsampling happened in _extract_streams_from_fit, need it for TCX too?) + # _extract_streams_from_fit now has LTTB at the end. + # We should move LTTB to a common helper "process_streams" to apply to both. + + # Apply Downsampling if not already done (for TCX) + if activity.file_type == 'tcx' and streams.get("time"): + from ..utils.algorithms import lttb + target_points = 1500 + count = len(streams["time"]) + if count > target_points: + step = count / target_points + indices = [int(i * step) for i in range(target_points)] + if indices[-1] != count - 1: indices[-1] = count - 1 + sampled_streams = {k: [] for k in streams} + for idx in indices: + for k in streams: + if streams.get(k) and idx < len(streams[k]): + sampled_streams[k].append(streams[k][idx]) + else: + sampled_streams[k].append(None) + streams = sampled_streams + + # Save to DB + if streams: + activity.streams_json = streams + db.commit() return streams except Exception as e: - logger.error(f"Error getting streams: {e}") - raise HTTPException(status_code=500, detail=str(e)) + logger.error(f"Error processing streams: {e}") + raise HTTPException(status_code=500, detail="Error processing activity streams") @router.post("/activities/{activity_id}/estimate_power") async def estimate_activity_power(activity_id: int, db: Session = Depends(get_db)): diff --git a/FitnessSync/backend/src/api/analysis.py b/FitnessSync/backend/src/api/analysis.py new file mode 100644 index 0000000..45cdc24 --- /dev/null +++ b/FitnessSync/backend/src/api/analysis.py @@ -0,0 +1,232 @@ + +from fastapi import APIRouter, Depends, HTTPException, Body +from typing import List, Optional, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy import func +from datetime import date, datetime +from pydantic import BaseModel + +import json +from io import StringIO +from fastapi.responses import StreamingResponse + +from ..models.segment_effort import SegmentEffort +from ..models.activity import Activity +from ..models.bike_setup import BikeSetup +from ..models.health_metric import HealthMetric +from ..services.postgresql_manager import PostgreSQLManager +from ..services.parsers import extract_activity_data +from ..utils.config import config + +router = APIRouter() + +def get_db(): + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + yield session + +class EffortAnalysisData(BaseModel): + effort_id: int + activity_id: int + activity_name: str + date: str + elapsed_time: float # seconds + avg_power: Optional[int] + max_power: Optional[int] + avg_hr: Optional[int] + avg_cadence: Optional[int] + avg_speed: Optional[float] + avg_temperature: Optional[float] + + bike_name: Optional[str] + bike_weight: Optional[float] + body_weight: Optional[float] + total_weight: Optional[float] + watts_per_kg: Optional[float] + +class ComparisonResponse(BaseModel): + efforts: List[EffortAnalysisData] + winners: Dict[str, int] # metric_key -> effort_id of winner + +@router.post("/segments/efforts/compare", response_model=ComparisonResponse) +def compare_efforts(effort_ids: List[int] = Body(...), db: Session = Depends(get_db)): + """ + Compare multiple segment efforts with enriched data. + """ + if not effort_ids: + raise HTTPException(status_code=400, detail="No effort IDs provided") + + efforts = db.query(SegmentEffort).filter(SegmentEffort.id.in_(effort_ids)).all() + if not efforts: + raise HTTPException(status_code=404, detail="No efforts found") + + results = [] + + for effort in efforts: + activity = effort.activity + + # 1. Bike Data + bike_weight = 0.0 + bike_name = "Unknown" + if activity.bike_setup: + bike_weight = activity.bike_setup.weight_kg or 0.0 + bike_name = activity.bike_setup.name or activity.bike_setup.frame + + # 2. Body Weight (approximate from HealthMetrics closest to date) + # Find weight metric on or before activity date + act_date = activity.start_time.date() + weight_metric = db.query(HealthMetric).filter( + HealthMetric.metric_type == 'weight', + HealthMetric.date <= act_date + ).order_by(HealthMetric.date.desc()).first() + + body_weight = 0.0 + if weight_metric: + val = weight_metric.metric_value + unit = (weight_metric.unit or '').lower() + + # Heuristic: Value > 150 is likely lbs (unless user is very heavy, but 150kg is ~330lbs) + # Fitbit data showed ~200 marked as 'kg', which is definitely lbs. + if unit in ['lbs', 'lb', 'pounds'] or val > 150: + body_weight = val * 0.453592 + else: + body_weight = val + + total_weight = (body_weight or 0.0) + bike_weight + + # Watts/kg + w_kg = 0.0 + if effort.avg_power and body_weight > 0: + w_kg = effort.avg_power / body_weight + + data = EffortAnalysisData( + effort_id=effort.id, + activity_id=activity.id, + activity_name=activity.activity_name or f"Activity {activity.id}", + date=activity.start_time.isoformat(), + elapsed_time=effort.elapsed_time, + avg_power=effort.avg_power, + max_power=effort.max_power, + avg_hr=effort.avg_hr, + avg_cadence=activity.avg_cadence, # Use activity avg as proxy if effort specific not available in DB + avg_speed=activity.avg_speed, # Proxy + avg_temperature=activity.avg_temperature, + bike_name=bike_name, + bike_weight=bike_weight if bike_weight > 0 else None, + body_weight=body_weight if body_weight > 0 else None, + total_weight=total_weight if total_weight > 0 else None, + watts_per_kg=round(w_kg, 2) if w_kg > 0 else None + ) + results.append(data) + + # Calculate Winners + winners = {} + if results: + # Helper to find min/max + def find_winner(key, mode='max'): + valid = [r for r in results if getattr(r, key) is not None] + if not valid: return None + if mode == 'max': + return max(valid, key=lambda x: getattr(x, key)).effort_id + else: + return min(valid, key=lambda x: getattr(x, key)).effort_id + + winners['elapsed_time'] = find_winner('elapsed_time', 'min') + winners['avg_power'] = find_winner('avg_power', 'max') + winners['max_power'] = find_winner('max_power', 'max') + winners['avg_hr'] = find_winner('avg_hr', 'min') # Lower is usually better for same output, but depends on context. Assume efficiency. + winners['watts_per_kg'] = find_winner('watts_per_kg', 'max') + winners['avg_speed'] = find_winner('avg_speed', 'max') + + return ComparisonResponse(efforts=results, winners=winners) + +@router.post("/segments/efforts/export") +def export_analysis(effort_ids: List[int] = Body(...), db: Session = Depends(get_db)): + """ + Export structured JSON for LLM analysis. + """ + # Reuse comparison logic to get data + # In a real app, refactor to shared service function + comparison = compare_efforts(effort_ids, db) + + # Convert to dict + # Convert to dict + data = [] + for e_obj in comparison.efforts: + e_dict = e_obj.dict() + + # Fetch and slice streams + # 1. Get Activity + # We need the activity object. comparison.efforts only has IDs. + # Efficient way: query them or cleaner: just query needed activities using the IDs. + # Or simplistic: Fetch inside loop (N+1 query, but export is rare/manual action). + + effort = db.query(SegmentEffort).get(e_dict['effort_id']) + if effort and effort.activity and effort.activity.file_content: + try: + act = effort.activity + raw_data = extract_activity_data(act.file_content, act.file_type or 'fit') + + # Slice by time + # Timestamps in raw_data['timestamps'] + timestamps = raw_data.get('timestamps', []) + + start_time = effort.start_time + end_time = effort.end_time + + # Normalize start/end to match stream timestamps timezone + if timestamps and timestamps[0]: + stream_tz = timestamps[0].tzinfo + + # Helper to align + def align_tz(dt, target_tz): + if dt.tzinfo == target_tz: + return dt + if dt.tzinfo is None and target_tz is not None: + return dt.replace(tzinfo=target_tz) # Assume same ref + if dt.tzinfo is not None and target_tz is None: + return dt.replace(tzinfo=None) # Strip + return dt.astimezone(target_tz) + + start_time = align_tz(start_time, stream_tz) + end_time = align_tz(end_time, stream_tz) + + # Simple list comprehension to find indices + indices = [i for i, t in enumerate(timestamps) + if t and start_time <= t <= end_time] + + streams = {} + if indices: + first = indices[0] + last = indices[-1] + 1 + + # Keys to extract + keys = ['heart_rate', 'power', 'speed', 'cadence', 'temperature'] + for k in keys: + if k in raw_data: + streams[k] = raw_data[k][first:last] + + # Points/Elevation + if 'points' in raw_data: + sliced_points = raw_data['points'][first:last] + streams['latlng'] = [[p[1], p[0]] for p in sliced_points] # lat, lon + streams['elevation'] = [p[2] if len(p) > 2 else None for p in sliced_points] + + streams['timestamps'] = [t.isoformat() if t else None for t in raw_data['timestamps'][first:last]] + + e_dict['streams'] = streams + except Exception as e: + print(f"Error extracting streams for effort {effort.id}: {e}") + e_dict['streams'] = {"error": str(e)} + + data.append(e_dict) + + # Create JSON file + file_content = json.dumps(data, indent=2) + + return StreamingResponse( + io.BytesIO(file_content.encode()), + media_type="application/json", + headers={"Content-Disposition": "attachment; filename=efforts_analysis.json"} + ) +import io diff --git a/FitnessSync/backend/src/api/scheduling.py b/FitnessSync/backend/src/api/scheduling.py index ae17458..5096421 100644 --- a/FitnessSync/backend/src/api/scheduling.py +++ b/FitnessSync/backend/src/api/scheduling.py @@ -38,6 +38,12 @@ class JobUpdateRequest(BaseModel): enabled: Optional[bool] = None params: Optional[dict] = None +@router.get("/scheduling/available-types", response_model=List[str]) +def list_available_job_types(): + """List available background job types.""" + from ..services.scheduler import scheduler + return list(scheduler.TASK_MAP.keys()) + @router.get("/scheduling/jobs", response_model=List[ScheduledJobResponse]) def list_scheduled_jobs(db: Session = Depends(get_db)): """List all scheduled jobs.""" @@ -52,13 +58,14 @@ def update_scheduled_job(job_id: int, request: JobUpdateRequest, db: Session = D raise HTTPException(status_code=404, detail="Job not found") if request.interval_minutes is not None: - if request.interval_minutes < 1: - raise HTTPException(status_code=400, detail="Interval must be at least 1 minute") + if request.interval_minutes < 0: + raise HTTPException(status_code=400, detail="Interval must be at least 0 minutes") job.interval_minutes = request.interval_minutes # If enabled, update next_run based on new interval if it's far in future? # Actually, standard behavior: next_run should be recalculated from last_run + new interval # OR just leave it. If we shorten it, we might want it to run sooner. + # OR just leave it. If we shorten it, we might want it to run sooner. # Let's recalculate next_run if it exists. if job.last_run: job.next_run = job.last_run + timedelta(minutes=job.interval_minutes) diff --git a/FitnessSync/backend/src/api/segments.py b/FitnessSync/backend/src/api/segments.py index 5615b6b..0d93312 100644 --- a/FitnessSync/backend/src/api/segments.py +++ b/FitnessSync/backend/src/api/segments.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks from typing import List, Optional from sqlalchemy.orm import Session from ..models.segment import Segment @@ -21,6 +21,7 @@ class SegmentCreate(BaseModel): activity_id: int start_index: int end_index: int + activity_type: Optional[str] = None class SegmentEffortResponse(BaseModel): id: int @@ -32,6 +33,10 @@ class SegmentEffortResponse(BaseModel): end_time: Optional[str] avg_hr: Optional[int] = None avg_power: Optional[int] = None + max_power: Optional[int] = None + avg_speed: Optional[float] = None + avg_cadence: Optional[int] = None + avg_respiration_rate: Optional[float] = None kom_rank: Optional[int] pr_rank: Optional[int] is_kom: bool @@ -94,13 +99,26 @@ def create_segment(payload: SegmentCreate, db: Session = Depends(get_db)): if diff > 0: elev_gain += diff + # Determine Activity Type + raw_type = payload.activity_type or activity.activity_type + final_type = 'cycling' # Default + + if raw_type: + rt = raw_type.lower() + if rt in ['running', 'trail_running', 'treadmill_running', 'hiking', 'walking', 'casual_walking']: + final_type = 'running' + elif rt in ['cycling', 'road_biking', 'mountain_biking', 'gravel_cycling', 'virtual_cycling', 'indoor_cycling']: + final_type = 'cycling' + else: + final_type = rt + # Create Segment segment = Segment( name=payload.name, description=payload.description, distance=dist, elevation_gain=elev_gain, - activity_type=activity.activity_type or 'cycling', + activity_type=final_type, points=json.dumps(simplified_points), bounds=json.dumps(bounds) ) @@ -171,6 +189,10 @@ def get_activity_efforts(activity_id: int, db: Session = Depends(get_db)): end_time=effort.end_time.isoformat() if effort.end_time else None, avg_hr=effort.avg_hr, avg_power=effort.avg_power, + max_power=effort.max_power, + avg_speed=effort.avg_speed, + avg_cadence=effort.avg_cadence, + avg_respiration_rate=effort.avg_respiration_rate, kom_rank=effort.kom_rank, pr_rank=None, # Placeholder is_kom=(effort.kom_rank == 1) if effort.kom_rank else False, @@ -214,6 +236,10 @@ def get_segment_leaderboard(segment_id: int, db: Session = Depends(get_db)): end_time=effort.end_time.isoformat() if effort.end_time else None, avg_hr=effort.avg_hr, avg_power=effort.avg_power, + max_power=effort.max_power, + avg_speed=effort.avg_speed, + avg_cadence=effort.avg_cadence, + avg_respiration_rate=effort.avg_respiration_rate, kom_rank=effort.kom_rank, pr_rank=None, is_kom=(effort.kom_rank == 1) if effort.kom_rank else False, @@ -222,13 +248,27 @@ def get_segment_leaderboard(segment_id: int, db: Session = Depends(get_db)): return responses @router.post("/segments/scan") -def scan_segments(db: Session = Depends(get_db)): +def scan_segments(background_tasks: BackgroundTasks, force: bool = Query(True, description="Force full re-scan"), db: Session = Depends(get_db)): """Trigger a background job to scan all activities for segment matches.""" from ..services.job_manager import job_manager - from ..jobs.segment_matching_job import run_segment_matching_job - import threading + from ..tasks.definitions import run_segment_matching_task + from ..services.scheduler import SchedulerService - job_id = job_manager.create_job("segment_match_all") + # Check if job already running? + # For now, allow multiple, manager handles it (usually) + + job_id = job_manager.create_job("segment_matching") + + # We need to pass the session factory to the background task + # because we can't share the 'db' session from Depends across threads safely/easily + # without deeper integration. + # The SchedulerService uses db_manager.get_db_session. + # Here we will re-instantiate a manager or use a global one. + db_manager = PostgreSQLManager(config.DATABASE_URL) + + background_tasks.add_task(run_segment_matching_task, job_id, db_manager.get_db_session, force=force) + + return {"job_id": job_id, "status": "started"} # Run in background thread = threading.Thread(target=job_manager.run_serialized, args=(job_id, run_segment_matching_job)) diff --git a/FitnessSync/backend/src/jobs/__pycache__/segment_matching_job.cpython-311.pyc b/FitnessSync/backend/src/jobs/__pycache__/segment_matching_job.cpython-311.pyc index 4356c41..625e5bd 100644 Binary files a/FitnessSync/backend/src/jobs/__pycache__/segment_matching_job.cpython-311.pyc and b/FitnessSync/backend/src/jobs/__pycache__/segment_matching_job.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/jobs/segment_matching_job.py b/FitnessSync/backend/src/jobs/segment_matching_job.py index a5abdfb..383a810 100644 --- a/FitnessSync/backend/src/jobs/segment_matching_job.py +++ b/FitnessSync/backend/src/jobs/segment_matching_job.py @@ -12,20 +12,31 @@ from ..services.parsers import extract_points_from_file logger = logging.getLogger(__name__) -def run_segment_matching_job(job_id: str): +def run_segment_matching_job(job_id: str, db_session_factory=None, force: bool = False, **kwargs): """ Job to scan all activities and match them against all segments. """ # 1. Setup DB - db_manager = PostgreSQLManager(config.DATABASE_URL) + if db_session_factory: + get_session = db_session_factory + # If it's a context manager factory vs a session factory, we need to handle that. + # The scheduler passes `self.db_manager.get_db_session` which IS a context manager factory (returns contextlib.contextmanager) + # So we can use `with get_session() as db:` + else: + db_manager = PostgreSQLManager(config.DATABASE_URL) + get_session = db_manager.get_db_session - with db_manager.get_db_session() as db: + with get_session() as db: try: # 2. Get all activities and segments activities = db.query(Activity).all() total_activities = len(activities) # Optimization: Pre-fetch segment locations for coarse filtering + # Also fetch max created_at for segments + from sqlalchemy.sql import func + max_seg_date = db.query(func.max(Segment.created_at)).scalar() + segments_list = db.query(Segment).all() segment_locations = [] @@ -49,6 +60,7 @@ def run_segment_matching_job(job_id: str): total_matches = 0 skipped_far = 0 + skipped_up_to_date = 0 # APPROX 1000 miles in degrees # 1 deg lat ~ 69 miles. 1000 miles ~ 14.5 degrees. @@ -63,7 +75,32 @@ def run_segment_matching_job(job_id: str): # Calculate progress prog = int((i / total_activities) * 100) - job_manager.update_job(job_id, progress=prog, message=f"Scanning {i+1}/{total_activities} (Matches: {total_matches}, Skipped: {skipped_far})") + job_manager.update_job(job_id, progress=prog, message=f"Scanning {i+1}/{total_activities} (Matches: {total_matches}, Skipped Dist: {skipped_far}, Up-to-date: {skipped_up_to_date})") + + # OPTIMIZATION: Check if already scanned + last_scan = activity.last_segment_scan_timestamp + scan_date_cutoff = None + + if not force and last_scan and max_seg_date: + # Fix potential TZ mismatch + # Ensure both are timezone-aware (UTC) + from datetime import timezone + + ls_aware = last_scan + if ls_aware.tzinfo is None: + ls_aware = ls_aware.replace(tzinfo=timezone.utc) + + msd_aware = max_seg_date + if msd_aware.tzinfo is None: + msd_aware = msd_aware.replace(tzinfo=timezone.utc) + + if ls_aware >= msd_aware: + # Activity is up to date with all segments + skipped_up_to_date += 1 + continue + else: + # Only scan against NEW segments + scan_date_cutoff = last_scan # Check for content first if not activity.file_content: @@ -95,11 +132,16 @@ def run_segment_matching_job(job_id: str): try: points = extract_points_from_file(activity.file_content, activity.file_type) if points: - # Clear existing efforts - db.query(SegmentEffort).filter(SegmentEffort.activity_id == activity.id).delete() + # Clear existing efforts ONLY if doing a full re-scan + if not scan_date_cutoff: + db.query(SegmentEffort).filter(SegmentEffort.activity_id == activity.id).delete() - efforts = matcher.match_activity(activity, points) + efforts = matcher.match_activity(activity, points, min_created_at=scan_date_cutoff) total_matches += len(efforts) + + # Update scan timestamp + activity.last_segment_scan_timestamp = func.now() + if efforts: logger.info(f"Activity {activity.id}: {len(efforts)} matches") diff --git a/FitnessSync/backend/src/models/__pycache__/activity.cpython-311.pyc b/FitnessSync/backend/src/models/__pycache__/activity.cpython-311.pyc index 6f4394b..a15157a 100644 Binary files a/FitnessSync/backend/src/models/__pycache__/activity.cpython-311.pyc and b/FitnessSync/backend/src/models/__pycache__/activity.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/models/__pycache__/activity.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/activity.cpython-313.pyc index 9db1605..a2963f8 100644 Binary files a/FitnessSync/backend/src/models/__pycache__/activity.cpython-313.pyc and b/FitnessSync/backend/src/models/__pycache__/activity.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/models/__pycache__/segment_effort.cpython-311.pyc b/FitnessSync/backend/src/models/__pycache__/segment_effort.cpython-311.pyc index 9b832e0..7164d7b 100644 Binary files a/FitnessSync/backend/src/models/__pycache__/segment_effort.cpython-311.pyc and b/FitnessSync/backend/src/models/__pycache__/segment_effort.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/models/__pycache__/segment_effort.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/segment_effort.cpython-313.pyc index 543c93c..ac247bf 100644 Binary files a/FitnessSync/backend/src/models/__pycache__/segment_effort.cpython-313.pyc and b/FitnessSync/backend/src/models/__pycache__/segment_effort.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/models/activity.py b/FitnessSync/backend/src/models/activity.py index b1dc0c7..5f6aea0 100644 --- a/FitnessSync/backend/src/models/activity.py +++ b/FitnessSync/backend/src/models/activity.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, DateTime, Text, LargeBinary, Float, ForeignKey +from sqlalchemy import Column, Integer, String, DateTime, Text, LargeBinary, Float, ForeignKey, Boolean, JSON from sqlalchemy.orm import relationship from sqlalchemy.sql import func from ..models import Base @@ -13,6 +13,7 @@ class Activity(Base): start_time = Column(DateTime, nullable=True) # Start time of the activity duration = Column(Integer, nullable=True) # Duration in seconds duration = Column(Integer, nullable=True) # Duration in seconds + last_segment_scan_timestamp = Column(DateTime, nullable=True) # Location (added for optimization) start_lat = Column(Float, nullable=True) @@ -37,7 +38,32 @@ class Activity(Base): norm_power = Column(Integer, nullable=True) # watts tss = Column(Float, nullable=True) # Training Stress Score vo2_max = Column(Float, nullable=True) # ml/kg/min + avg_temperature = Column(Float, nullable=True) # degrees Celsius + # Cycling Dynamics & Power + total_work = Column(Float, nullable=True) # Joules + intensity_factor = Column(Float, nullable=True) # IF + threshold_power = Column(Integer, nullable=True) # FTP + is_estimated_power = Column(Boolean, default=False) + avg_right_pedal_smoothness = Column(Float, nullable=True) # % + avg_left_torque_effectiveness = Column(Float, nullable=True) # % + avg_right_torque_effectiveness = Column(Float, nullable=True) # % + + # MTB Metrics + grit = Column(Float, nullable=True) + flow = Column(Float, nullable=True) + + # Health Metrics + avg_respiration_rate = Column(Float, nullable=True) + max_respiration_rate = Column(Float, nullable=True) + avg_stress = Column(Float, nullable=True) + avg_spo2 = Column(Float, nullable=True) + + # Swimming / Other + total_strokes = Column(Integer, nullable=True) + avg_stroke_distance = Column(Float, nullable=True) + + streams_json = Column(JSON, nullable=True) # Cached downsampled streams file_content = Column(LargeBinary, nullable=True) # Activity file content stored in database (base64 encoded) file_type = Column(String, nullable=True) # File type (.fit, .gpx, .tcx, etc.) download_status = Column(String, default='pending') # 'pending', 'downloaded', 'failed' diff --git a/FitnessSync/backend/src/models/segment_effort.py b/FitnessSync/backend/src/models/segment_effort.py index 32562d9..ae1209f 100644 --- a/FitnessSync/backend/src/models/segment_effort.py +++ b/FitnessSync/backend/src/models/segment_effort.py @@ -15,7 +15,11 @@ class SegmentEffort(Base): end_time = Column(DateTime, nullable=False) avg_power = Column(Integer, nullable=True) + max_power = Column(Integer, nullable=True) avg_hr = Column(Integer, nullable=True) + avg_speed = Column(Float, nullable=True) # m/s + avg_cadence = Column(Integer, nullable=True) # rpm + avg_respiration_rate = Column(Float, nullable=True) # breaths/min # Potential for ranking (1 = KOM/PR, etc.) - calculated dynamically or stored kom_rank = Column(Integer, nullable=True) diff --git a/FitnessSync/backend/src/services/__pycache__/parsers.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/parsers.cpython-311.pyc index c1621f9..8a90324 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/parsers.cpython-311.pyc and b/FitnessSync/backend/src/services/__pycache__/parsers.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/parsers.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/parsers.cpython-313.pyc index 66fbf51..28af5fa 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/parsers.cpython-313.pyc and b/FitnessSync/backend/src/services/__pycache__/parsers.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/power_estimator.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/power_estimator.cpython-311.pyc index 230a128..6ba264c 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/power_estimator.cpython-311.pyc and b/FitnessSync/backend/src/services/__pycache__/power_estimator.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-311.pyc index 12e0e81..bbb986b 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-311.pyc and b/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc index 0fba0b1..675dca3 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc and b/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/segment_matcher.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/segment_matcher.cpython-311.pyc index 50893ad..845e2eb 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/segment_matcher.cpython-311.pyc and b/FitnessSync/backend/src/services/__pycache__/segment_matcher.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/services/parsers.py b/FitnessSync/backend/src/services/parsers.py index e3d7e1f..4131ed9 100644 --- a/FitnessSync/backend/src/services/parsers.py +++ b/FitnessSync/backend/src/services/parsers.py @@ -36,51 +36,100 @@ def extract_timestamps_from_file(file_content: bytes, file_type: str) -> List[Op return data['timestamps'] def _extract_data_from_fit(file_content: bytes) -> Dict[str, List[Any]]: - data = {'points': [], 'timestamps': [], 'heart_rate': [], 'power': [], 'speed': [], 'cadence': []} + data = { + 'points': [], 'timestamps': [], 'heart_rate': [], 'power': [], + 'speed': [], 'cadence': [], 'temperature': [], 'distance': [], + 'respiration_rate': [], + 'session': {} # New key for summary data + } try: with io.BytesIO(file_content) as f: with fitdecode.FitReader(f) as fit: for frame in fit: - if frame.frame_type == fitdecode.FIT_FRAME_DATA and frame.name == 'record': - # We only collect data if position is valid, to keep streams aligned with points? - # Or should we collect everything and align by index? - # Usually points extraction filtered by lat/lon. If we want aligned arrays, we must apply same filter. - - if frame.has_field('position_lat') and frame.has_field('position_long'): - lat_sc = frame.get_value('position_lat') - lon_sc = frame.get_value('position_long') + if frame.frame_type == fitdecode.FIT_FRAME_DATA: + if frame.name == 'record': + # We only collect data if position is valid, to keep streams aligned with points? + # Or should we collect everything and align by index? + # Usually points extraction filtered by lat/lon. If we want aligned arrays, we must apply same filter. - if lat_sc is not None and lon_sc is not None: - lat = lat_sc * (180.0 / 2**31) - lon = lon_sc * (180.0 / 2**31) + if frame.has_field('position_lat') and frame.has_field('position_long'): + lat_sc = frame.get_value('position_lat') + lon_sc = frame.get_value('position_long') - ele = None - if frame.has_field('enhanced_altitude'): - ele = frame.get_value('enhanced_altitude') - elif frame.has_field('altitude'): - ele = frame.get_value('altitude') + if lat_sc is not None and lon_sc is not None: + lat = lat_sc * (180.0 / 2**31) + lon = lon_sc * (180.0 / 2**31) - data['points'].append([lon, lat, ele] if ele is not None else [lon, lat]) - - # Timestamps - ts = frame.get_value('timestamp') if frame.has_field('timestamp') else None - data['timestamps'].append(ts) - - # Speed - speed = frame.get_value('enhanced_speed') if frame.has_field('enhanced_speed') else frame.get_value('speed') if frame.has_field('speed') else None - data['speed'].append(speed) + ele = None + if frame.has_field('enhanced_altitude'): + ele = frame.get_value('enhanced_altitude') + elif frame.has_field('altitude'): + ele = frame.get_value('altitude') + + data['points'].append([lon, lat, ele] if ele is not None else [lon, lat]) + + # Timestamps + ts = frame.get_value('timestamp') if frame.has_field('timestamp') else None + data['timestamps'].append(ts) + + # Speed + speed = frame.get_value('enhanced_speed') if frame.has_field('enhanced_speed') else frame.get_value('speed') if frame.has_field('speed') else None + data['speed'].append(speed) - # Cadence - cad = frame.get_value('cadence') if frame.has_field('cadence') else None - data['cadence'].append(cad) - - # HR - hr = frame.get_value('heart_rate') if frame.has_field('heart_rate') else None - data['heart_rate'].append(hr) - - # Power - pwr = frame.get_value('power') if frame.has_field('power') else None - data['power'].append(pwr) + # Distance + dist = frame.get_value('distance') if frame.has_field('distance') else None + data['distance'].append(dist) + + # Cadence + cad = frame.get_value('cadence') if frame.has_field('cadence') else None + data['cadence'].append(cad) + + # HR + hr = frame.get_value('heart_rate') if frame.has_field('heart_rate') else None + data['heart_rate'].append(hr) + + # Power + pwr = frame.get_value('power') if frame.has_field('power') else None + data['power'].append(pwr) + + # Temperature + temp = frame.get_value('temperature') if frame.has_field('temperature') else None + data['temperature'].append(temp) + + # Respiration Rate + resp = frame.get_value('respiration_rate') if frame.has_field('respiration_rate') else frame.get_value('enhanced_respiration_rate') if frame.has_field('enhanced_respiration_rate') else None + if 'respiration_rate' not in data: + data['respiration_rate'] = [] + data['respiration_rate'].append(resp) + + elif frame.name == 'session': + # Extract summary fields + fields_to_extract = [ + 'total_work', 'intensity_factor', 'threshold_power', + 'avg_left_pedal_smoothness', 'avg_right_pedal_smoothness', + 'avg_left_torque_effectiveness', 'avg_right_torque_effectiveness', + 'total_grit', 'avg_flow', + 'enhanced_avg_respiration_rate', 'enhanced_max_respiration_rate', + 'avg_stress', 'avg_spo2', + 'total_strokes', 'avg_stroke_distance', + # Standard Metrics + 'max_heart_rate', 'max_speed', 'enhanced_max_speed', 'max_cadence', + 'total_ascent', 'total_descent', + 'total_training_effect', 'total_anaerobic_training_effect', + 'training_stress_score', 'normalized_power' + ] + + for field in fields_to_extract: + if frame.has_field(field): + # Handle field name mapping if needed (e.g., enhanced_ -> simple) + key = field + if field == 'total_grit': key = 'grit' + elif field == 'avg_flow': key = 'flow' + elif field == 'enhanced_avg_respiration_rate': key = 'avg_respiration_rate' + elif field == 'enhanced_max_respiration_rate': key = 'max_respiration_rate' + elif field == 'enhanced_max_speed': key = 'max_speed' + + data['session'][key] = frame.get_value(field) except Exception as e: logger.error(f"Error parsing FIT file: {e}") @@ -150,9 +199,6 @@ def _extract_data_from_tcx(file_content: bytes) -> Dict[str, List[Any]]: def _extract_points_from_tcx(file_content: bytes) -> List[List[float]]: return _extract_data_from_tcx(file_content)['points'] -def extract_timestamps_from_file(file_content: bytes, file_type: str) -> List[Optional[datetime]]: - if file_type == 'fit': - return _extract_timestamps_from_fit(file_content) - return [] + diff --git a/FitnessSync/backend/src/services/power_estimator.py b/FitnessSync/backend/src/services/power_estimator.py index c4f98c7..248d679 100644 --- a/FitnessSync/backend/src/services/power_estimator.py +++ b/FitnessSync/backend/src/services/power_estimator.py @@ -23,6 +23,96 @@ class PowerEstimatorService: self.DEFAULT_CRR = 0.005 # Typical road tire on asphalt self.DRIVETRAIN_LOSS = 0.03 # 3% loss + def calculate_power_stream(self, activity: Activity, extracted_data: Dict = None) -> List[int]: + """ + Calculate power stream based on physics model. + """ + # 1. Get Setup and Weights + bike_weight = 9.0 # Default 9kg + if activity.bike_setup and activity.bike_setup.weight_kg: + bike_weight = activity.bike_setup.weight_kg + + rider_weight = 75.0 # Default 75kg + latest_weight = self.db.query(WeightRecord).order_by(WeightRecord.date.desc()).first() + if latest_weight and latest_weight.weight: + rider_weight = latest_weight.weight + if latest_weight.unit == 'lbs': + rider_weight = rider_weight * 0.453592 + + total_mass = rider_weight + bike_weight + + # 2. Extract Data + data = extracted_data + if not data: + if not activity.file_content: + return [] + data = extract_activity_data(activity.file_content, activity.file_type) + + timestamps = data.get('timestamps') + speeds = data.get('enhanced_speed') or data.get('speed') + elevations = data.get('enhanced_altitude') or data.get('altitude') + + if not speeds or not len(speeds) > 0: + return [] + + # Generate Power Stream + power_stream = [] + + for i in range(len(timestamps)): + if i >= len(speeds): + break + + t = timestamps[i] + v = speeds[i] # m/s + + # Skip if stopped + if v is None or v < 0.1: + power_stream.append(0) + continue + + # Get slope + grade = 0.0 + accel = 0.0 + + if i > 0 and i < len(speeds) - 1: + # Central difference + if i+1 < len(timestamps) and i-1 >= 0: + d_t = (timestamps[i+1] - timestamps[i-1]).total_seconds() + if d_t > 0: + d_v = (speeds[i+1] - speeds[i-1]) # acc + + curr_ele = elevations[i] if elevations and i < len(elevations) and elevations[i] is not None else 0 + next_ele = elevations[i+1] if elevations and i+1 < len(elevations) and elevations[i+1] is not None else curr_ele + prev_ele = elevations[i-1] if elevations and i-1 < len(elevations) and elevations[i-1] is not None else curr_ele + + d_e = next_ele - prev_ele + d_s = (v * d_t) # approx distance covers + + accel = d_v / d_t + if d_s > 1.0: # avoid div by zero/noise + grade = d_e / d_s + + # Physics Formula + f_grav = total_mass * self.GRAVITY * grade + f_roll = total_mass * self.GRAVITY * self.DEFAULT_CRR + f_aero = 0.5 * self.RHO * self.DEFAULT_CDA * (v**2) + f_acc = total_mass * accel + + f_total = f_grav + f_roll + f_aero + f_acc + + # Power = Force * Velocity + p_raw = f_total * v + + # Apply Drivetrain Loss + p_mech = p_raw / (1 - self.DRIVETRAIN_LOSS) + + if p_mech < 0: + p_mech = 0 + + power_stream.append(int(p_mech)) + + return power_stream + def estimate_power_for_activity(self, activity_id: int) -> Dict[str, any]: """ Estimate power activity streams based on physics model. @@ -35,121 +125,31 @@ class PowerEstimatorService: if not activity.file_content: raise ValueError("No file content to analyze") - # 1. Get Setup and Weights - bike_weight = 9.0 # Default 9kg - if activity.bike_setup and activity.bike_setup.weight_kg: - bike_weight = activity.bike_setup.weight_kg - - rider_weight = 75.0 # Default 75kg - # Try to find weight record closest to activity date? Or just latest? - # Latest for now. - latest_weight = self.db.query(WeightRecord).order_by(WeightRecord.date.desc()).first() - if latest_weight and latest_weight.weight_kg: - rider_weight = latest_weight.weight_kg - - total_mass = rider_weight + bike_weight + power_stream = self.calculate_power_stream(activity) - # 2. Extract Data - data = extract_activity_data(activity.file_content, activity.file_type) - # We need: Speed (m/s), Elevation (m) for Grade, Time (s) for acceleration - - timestamps = data.get('timestamps') - speeds = data.get('enhanced_speed') or data.get('speed') - elevations = data.get('enhanced_altitude') or data.get('altitude') - - if not speeds or not len(speeds) > 0: - raise ValueError("No speed data available") - - # Generate Power Stream - power_stream = [] - total_power = 0.0 + if not power_stream: + raise ValueError("No speed data available or empty stream") + count = 0 + total_power = 0 - # Smoothing window? Physics is noisy on raw data. - # We'll calculate point-by-point but maybe assume slight smoothing explicitly or implicitly via grade. - - # Pre-calc gradients? - # We need grade at each point. slope = d_ele / d_dist - # d_dist = speed * d_time - - for i in range(len(speeds)): - t = timestamps[i] - v = speeds[i] # m/s - - # Skip if stopped - if v is None or v < 0.1: - power_stream.append(0) - continue - - # Get slope - # Look ahead/behind for slope smoothing (e.g. +/- 5 seconds) would be better - # Simple difference for now: - grade = 0.0 - accel = 0.0 - - if i > 0 and i < len(speeds) - 1: - # Central difference - d_t = (timestamps[i+1] - timestamps[i-1]).total_seconds() - if d_t > 0: - d_v = (speeds[i+1] - speeds[i-1]) # acc - d_e = (elevations[i+1] - elevations[i-1]) if elevations else 0 - d_s = (v * d_t) # approx distance covers - - accel = d_v / d_t - if d_s > 1.0: # avoid div by zero/noise - grade = d_e / d_s - - # Physics Formula - # F_total = F_grav + F_roll + F_aero + F_acc - - # F_grav = m * g * sin(arctan(grade)) ~= m * g * grade - f_grav = total_mass * self.GRAVITY * grade - - # F_roll = m * g * cos(arctan(grade)) * Crr ~= m * g * Crr - f_roll = total_mass * self.GRAVITY * self.DEFAULT_CRR - - # F_aero = 0.5 * rho * CdA * v^2 - # Assume no wind for now - f_aero = 0.5 * self.RHO * self.DEFAULT_CDA * (v**2) - - # F_acc = m * a - f_acc = total_mass * accel - - f_total = f_grav + f_roll + f_aero + f_acc - - # Power = Force * Velocity - p_raw = f_total * v - - # Apply Drivetrain Loss - p_mech = p_raw / (1 - self.DRIVETRAIN_LOSS) - - # Power can't be negative for a human (braking/coasting = 0w output) - if p_mech < 0: - p_mech = 0 - - power_stream.append(int(p_mech)) - - total_power += p_mech + for p in power_stream: + if p > 0: + # Avg usually includes zeros... verify Garmin logic? + # Usually Avg Power includes zeros. + pass + total_power += p count += 1 avg_power = int(total_power / count) if count > 0 else 0 - # Return estimated stream and stats - # Ideally we'd update the Activity 'power' stream and 'avg_power' metric - # BUT 'extract_activity_data' reads from FILE. We can't easily write back to FIT file. - # We should store "estimated_power" in DB or separate storage? - # The prompt implies we want to USE this data. - # If we just update `Activity.avg_power`, that's easy. - # Displaying the stream might require `Activity` to support JSON storage for streams or similar. - # Current schema has `Activity.file_content`. - # Updating the FIT file is hard. - # Maybe we just return it for now, or update the scalar metrics in DB? - - # Let's update scalars. + # Update scalars. activity.avg_power = avg_power - # Max power? activity.max_power = max(power_stream) if power_stream else 0 + # Flag as estimated + activity.is_estimated_power = True + self.db.commit() return { diff --git a/FitnessSync/backend/src/services/scheduler.py b/FitnessSync/backend/src/services/scheduler.py index 4705d1f..4e6a9d5 100644 --- a/FitnessSync/backend/src/services/scheduler.py +++ b/FitnessSync/backend/src/services/scheduler.py @@ -16,7 +16,12 @@ from ..tasks.definitions import ( run_fitbit_sync_job, run_garmin_upload_job, run_health_sync_job, - run_activity_backfill_job + run_garmin_upload_job, + run_health_sync_job, + run_activity_backfill_job, + run_reparse_local_files_job, + run_estimate_power_job, + run_segment_matching_task ) logger = logging.getLogger(__name__) @@ -35,7 +40,11 @@ class SchedulerService: 'fitbit_weight_sync': run_fitbit_sync_job, 'garmin_weight_upload': run_garmin_upload_job, 'health_sync_pending': run_health_sync_job, - 'activity_backfill_full': run_activity_backfill_job + 'health_sync_pending': run_health_sync_job, + 'activity_backfill_full': run_activity_backfill_job, + 'reparse_local_files': run_reparse_local_files_job, + 'estimate_power': run_estimate_power_job, + 'segment_matching': run_segment_matching_task } def start(self): @@ -75,6 +84,55 @@ class SchedulerService: ) session.add(new_job) session.commit() + + # Default 2: Manual Job - Reparse Local FIT Files + job_type = 'reparse_local_files' + name = 'Reparse Local FIT Files' + existing = session.query(ScheduledJob).filter_by(job_type=job_type).first() + if not existing: + logger.info(f"Creating default schedule: {name}") + new_job = ScheduledJob( + job_type=job_type, + name=name, + interval_minutes=0, # Manual only + params=json.dumps({}), + enabled=False # Disabled by default, visible in UI for manual trigger + ) + session.add(new_job) + session.commit() + + # Default 3: Manual Job - Estimate Power + job_type = 'estimate_power' + name = 'Estimate Power (All)' + existing = session.query(ScheduledJob).filter_by(job_type=job_type).first() + if not existing: + logger.info(f"Creating default schedule: {name}") + new_job = ScheduledJob( + job_type=job_type, + name=name, + interval_minutes=0, # Manual only + params=json.dumps({}), + enabled=False # Disabled by default, visible in UI for manual trigger + ) + session.add(new_job) + session.commit() + + # Default 4: Manual Job - Segment Matching + job_type = 'segment_matching' + name = 'Run Segment Matching (All)' + existing = session.query(ScheduledJob).filter_by(job_type=job_type).first() + if not existing: + logger.info(f"Creating default schedule: {name}") + new_job = ScheduledJob( + job_type=job_type, + name=name, + interval_minutes=0, # Manual only + params=json.dumps({}), + enabled=False # Disabled by default + ) + session.add(new_job) + session.commit() + except Exception as e: logger.error(f"Error checking default schedules: {e}") diff --git a/FitnessSync/backend/src/services/segment_matcher.py b/FitnessSync/backend/src/services/segment_matcher.py index 2270272..ccbcd00 100644 --- a/FitnessSync/backend/src/services/segment_matcher.py +++ b/FitnessSync/backend/src/services/segment_matcher.py @@ -17,7 +17,7 @@ class SegmentMatcher: def __init__(self, db: Session): self.db = db - def match_activity(self, activity: Activity, points: List[List[float]]) -> List[SegmentEffort]: + def match_activity(self, activity: Activity, points: List[List[float]], min_created_at=None) -> List[SegmentEffort]: """ Check if the activity matches any known segments. points: List of [lon, lat] @@ -41,9 +41,14 @@ class SegmentMatcher: elif act_type in ['trail_running', 'treadmill_running']: act_type = 'running' - segments = self.db.query(Segment).filter( + query = self.db.query(Segment).filter( (Segment.activity_type == activity.activity_type) | (Segment.activity_type == act_type) - ).all() + ) + + if min_created_at: + query = query.filter(Segment.created_at >= min_created_at) + + segments = query.all() matched_efforts = [] @@ -86,9 +91,6 @@ class SegmentMatcher: from ..services.parsers import extract_activity_data - if not activity.file_content: - return None - data = extract_activity_data(activity.file_content, activity.file_type) timestamps = data['timestamps'] @@ -107,19 +109,73 @@ class SegmentMatcher: # Calculate Averages avg_hr = None avg_pwr = None + max_pwr = None # Slice lists eff_hr = data['heart_rate'][start_idx : end_idx+1] - eff_pwr = data['power'][start_idx : end_idx+1] + + # Handle Power + eff_pwr = None + if activity.is_estimated_power: + # Calculate power stream on the fly + try: + from ..services.power_estimator import PowerEstimatorService + estimator = PowerEstimatorService(self.db) + # We can pass already extracted data to avoid re-parsing + full_power_stream = estimator.calculate_power_stream(activity, extracted_data=data) + if full_power_stream and len(full_power_stream) > end_idx: + eff_pwr = full_power_stream[start_idx : end_idx+1] + except Exception as e: + logger.error(f"Error calculating estimated power for segment match: {e}") + else: + # Real power + if data['power']: + eff_pwr = data['power'][start_idx : end_idx+1] + + # Fallback: If no real power found, and it's a cycling activity, try estimation + # Check if eff_pwr is all None or empty + has_real_power = eff_pwr and any(p is not None for p in eff_pwr) + + cycling_types = {'cycling', 'road_biking', 'mountain_biking', 'gravel_cycling', 'virtual_cycling', 'indoor_cycling'} + if not has_real_power and activity.activity_type in cycling_types: + try: + logger.info(f"No real power found for Activity {activity.id} ({activity.activity_type}). Attempting fallback estimation.") + from ..services.power_estimator import PowerEstimatorService + estimator = PowerEstimatorService(self.db) + full_power_stream = estimator.calculate_power_stream(activity, extracted_data=data) + if full_power_stream and len(full_power_stream) > end_idx: + eff_pwr = full_power_stream[start_idx : end_idx+1] + except Exception as e: + logger.error(f"Error calculating fallback estimated power: {e}") # Filter None valid_hr = [x for x in eff_hr if x is not None] - valid_pwr = [x for x in eff_pwr if x is not None] + valid_pwr = [x for x in eff_pwr if x is not None] if eff_pwr else [] if valid_hr: avg_hr = int(sum(valid_hr) / len(valid_hr)) if valid_pwr: avg_pwr = int(sum(valid_pwr) / len(valid_pwr)) + max_pwr = int(max(valid_pwr)) + + # New Metrics + avg_speed = None + eff_speed = data.get('speed', [])[start_idx : end_idx+1] + valid_speed = [x for x in eff_speed if x is not None] + if valid_speed: + avg_speed = sum(valid_speed) / len(valid_speed) + + avg_cadence = None + eff_cadence = data.get('cadence', [])[start_idx : end_idx+1] + valid_cadence = [x for x in eff_cadence if x is not None] + if valid_cadence: + avg_cadence = int(sum(valid_cadence) / len(valid_cadence)) + + avg_resp = None + eff_resp = data.get('respiration_rate', [])[start_idx : end_idx+1] + valid_resp = [x for x in eff_resp if x is not None] + if valid_resp: + avg_resp = sum(valid_resp) / len(valid_resp) return SegmentEffort( segment_id=segment.id, @@ -129,6 +185,10 @@ class SegmentMatcher: end_time=end_ts, avg_hr=avg_hr, avg_power=avg_pwr, + max_power=max_pwr, + avg_speed=avg_speed, + avg_cadence=avg_cadence, + avg_respiration_rate=avg_resp, kom_rank=None # Placeholder ) @@ -168,7 +228,15 @@ class SegmentMatcher: return None # For each candidate, try to trace the segment + # OPTIMIZATION: Track the furthest scanned activity index. + # If a previous candidate attempt scanned up to index J and failed, + # checking start candidates < J is redundant because they would follow the same path and fail. + max_scanned_idx = -1 + for start_idx in start_candidates: + if start_idx <= max_scanned_idx: + continue + # OPTION: We could just look for the end point later in the stream # But "Continuous Tracking" is required. @@ -246,7 +314,16 @@ class SegmentMatcher: # Track accumulated distance for this effort effort_accum_dist = 0.0 + # OPTIMIZATION: Track closest segment index to avoid O(N*M) + # Start near 0 since we matched start node + current_seg_idx = 0 + # Search window size (number of segment points to look ahead/behind) + # A generous window handles GPS jitter and fast movement + SEARCH_WINDOW = 50 + for j in range(start_idx + 1, max_search): + max_scanned_idx = j # Mark this point as visited + p = act_points[j] prev_p = act_points[j-1] @@ -256,14 +333,23 @@ class SegmentMatcher: d_end = haversine_distance(p[1], p[0], end_node[1], end_node[0]) - # Check deviation - if self._min_dist_to_segment_path(p, seg_points) > CORRIDOR_RADIUS: - # Deviated - break # Stop searching this start candidate + # Check deviation with Windowed Search + s_start = max(0, current_seg_idx - SEARCH_WINDOW) + s_end = min(len(seg_points) - 1, current_seg_idx + SEARCH_WINDOW) + + min_d, closest_idx = self._dist_and_index_to_segment(p, seg_points, s_start, s_end) + + if min_d > CORRIDOR_RADIUS: + # Deviated from the path locally. + # Since we require continuous tracking, we consider this a break. + break + + # Update tracker + current_seg_idx = closest_idx # Check for completion: # 1. Within radius of end node - # 2. Covered at least 90% of segment distance (handles loops) + # 2. Covered at least 80% of segment distance (handles loops) if d_end <= ENTRY_RADIUS: # Ensure we aren't just matching the start of a loop immediately if effort_accum_dist >= 0.8 * segment.distance: @@ -276,14 +362,36 @@ class SegmentMatcher: return None - def _min_dist_to_segment_path(self, point: List[float], seg_points: List[List[float]]) -> float: + def _dist_and_index_to_segment(self, point: List[float], seg_points: List[List[float]], start_idx: int, end_idx: int) -> Tuple[float, int]: """ - Distance from point to polyline. + Distance from point to polyline subset. + Returns (min_dist, segment_index_of_closest_segment) + segment_index i corresponds to line (points[i], points[i+1]) """ min_d = float('inf') - for i in range(len(seg_points) - 1): + closest_idx = start_idx + + # Ensure we don't go out of bounds + # range is up to len(seg_points) - 1 because we need i+1 + search_limit = min(end_idx + 1, len(seg_points) - 1) + # Fix: end_idx is inclusive for search range logic usually, but range() is exclusive. + # Calling code passed 'len - 1' as max index. + # We need to iterate i from start_idx to end_idx (inclusive of processing line starting at end_idx if valid?) + # Let's treat inputs as [start_idx, end_idx) range for loop? + # Caller passed: s_end = min(len-1, ...) + + # Let's just iterate safely + for i in range(start_idx, len(seg_points) - 1): + if i > end_idx: break + d = perpendicular_distance(point, seg_points[i], seg_points[i+1]) if d < min_d: min_d = d - return min_d + closest_idx = i + + return min_d, closest_idx + + def _min_dist_to_segment_path(self, point: List[float], seg_points: List[List[float]]) -> float: + d, _ = self._dist_and_index_to_segment(point, seg_points, 0, len(seg_points) - 1) + return d diff --git a/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-311.pyc b/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-311.pyc index 496d334..2bc71b1 100644 Binary files a/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-311.pyc and b/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-313.pyc b/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-313.pyc index faa0ba5..925f346 100644 Binary files a/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-313.pyc and b/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-311.pyc b/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-311.pyc index 08bea3a..90864ae 100644 Binary files a/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-311.pyc and b/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-313.pyc b/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-313.pyc index 9d06e94..f723f6b 100644 Binary files a/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-313.pyc and b/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/services/sync/activity.py b/FitnessSync/backend/src/services/sync/activity.py index 81964e4..b406352 100644 --- a/FitnessSync/backend/src/services/sync/activity.py +++ b/FitnessSync/backend/src/services/sync/activity.py @@ -261,6 +261,9 @@ class GarminActivitySync: self.logger.warning(f"Failed to redownload {activity_id}") return False + # Backfill metrics from file content if needed + self._backfill_metrics_from_file(activity) + self.db_session.flush() # Commit file changes so it's fresh # TRIGGER SEGMENT MATCHING @@ -306,6 +309,12 @@ class GarminActivitySync: activity.norm_power = data.get('normalizedPower') activity.tss = data.get('trainingStressScore') + # Temperature + if data.get('averageTemperature'): + activity.avg_temperature = data.get('averageTemperature') + elif data.get('minTemperature') is not None and data.get('maxTemperature') is not None: + activity.avg_temperature = (data.get('minTemperature') + data.get('maxTemperature')) / 2 + # Cadence handling (try various sport-specific keys) activity.avg_cadence = ( data.get('averageRunningCadenceInStepsPerMinute') or @@ -327,3 +336,137 @@ class GarminActivitySync: activity.start_lat = data.get('startRecallLatitude') activity.start_lng = data.get('startRecallLongitude') + def _backfill_metrics_from_file(self, activity: Activity): + """ + Calculate and populate missing metrics from the raw file content if available. + This acts as a fallback when Garmin API summary is incomplete. + """ + if not activity.file_content or activity.file_type != 'fit': + return + + try: + from ...services.parsers import extract_activity_data + + # Extract full data streams + data = extract_activity_data(activity.file_content, activity.file_type) + + # Backfill Power + if activity.avg_power is None or activity.max_power is None: + power_stream = [p for p in data.get('power', []) if p is not None] + if power_stream: + if activity.avg_power is None: + activity.avg_power = int(sum(power_stream) / len(power_stream)) + self.logger.info(f"Backfilled avg_power for {activity.garmin_activity_id}: {activity.avg_power}") + + if activity.max_power is None: + activity.max_power = int(max(power_stream)) + self.logger.info(f"Backfilled max_power for {activity.garmin_activity_id}: {activity.max_power}") + + # Backfill Distance + if activity.distance is None: + # Use the last non-None distance value from the stream + dist_stream = [d for d in data.get('distance', []) if d is not None] + if dist_stream: + activity.distance = dist_stream[-1] + self.logger.info(f"Backfilled distance for {activity.garmin_activity_id}: {activity.distance}") + + # Backfill Cadence + if activity.avg_cadence is None: + cad_stream = [c for c in data.get('cadence', []) if c is not None] + if cad_stream: + activity.avg_cadence = int(sum(cad_stream) / len(cad_stream)) + self.logger.info(f"Backfilled avg_cadence for {activity.garmin_activity_id}: {activity.avg_cadence}") + + # ------------------------------------------------------------- + # Extended Session Metrics (Cycling Dynamics, Health, MTB, etc.) + # ------------------------------------------------------------- + session_data = data.get('session', {}) + + # Map simple fields + mapping = { + 'total_work': 'total_work', + 'intensity_factor': 'intensity_factor', + 'threshold_power': 'threshold_power', + 'avg_left_pedal_smoothness': 'avg_left_pedal_smoothness', + 'avg_right_pedal_smoothness': 'avg_right_pedal_smoothness', + 'avg_left_torque_effectiveness': 'avg_left_torque_effectiveness', + 'avg_right_torque_effectiveness': 'avg_right_torque_effectiveness', + 'grit': 'grit', + 'flow': 'flow', + 'avg_respiration_rate': 'avg_respiration_rate', + 'max_respiration_rate': 'max_respiration_rate', + 'avg_stress': 'avg_stress', + 'avg_spo2': 'avg_spo2', + 'total_strokes': 'total_strokes', + 'avg_stroke_distance': 'avg_stroke_distance', + # Standard Metrics Mapping + 'max_hr': 'max_heart_rate', + 'max_speed': 'max_speed', + 'max_cadence': 'max_cadence', + 'elevation_gain': 'total_ascent', + 'elevation_loss': 'total_descent', + 'aerobic_te': 'total_training_effect', + 'anaerobic_te': 'total_anaerobic_training_effect', + 'tss': 'training_stress_score', + 'norm_power': 'normalized_power' + } + + for attr, key in mapping.items(): + val = session_data.get(key) + if val is not None: + # Update if not already set (or overwrite? Backfill usually implies if missing) + # For standard metrics, we definitely want to fill them if they are None. + if getattr(activity, attr) is None: + # Special handling for TE which might be scaled? Usually fitdecode returns raw values. + # TE in FIT is often 0-50 scaling to 0.0-5.0? Or raw. + # fitdecode usually returns parsed values. We'll assume direct map. + + setattr(activity, attr, val) + self.logger.debug(f"Backfilled {attr} for {activity.garmin_activity_id}: {val}") + + except Exception as e: + self.logger.error(f"Error backfilling metrics from file for {activity.garmin_activity_id}: {e}") + + def reparse_local_files(self, job_id: str = None) -> Dict[str, int]: + """ + Iterate through all local FIT files and re-run backfill logic. + """ + query = self.db_session.query(Activity).filter( + Activity.file_content != None, + Activity.file_type == 'fit' + ) + + total_count = query.count() + processed = 0 + updated = 0 + + self.logger.info(f"Reparsing {total_count} local FIT files...") + if job_id: + job_manager.update_job(job_id, message=f"Reparsing {total_count} files...", progress=0) + + activities = query.all() + for idx, activity in enumerate(activities): + if job_id and not self._check_pause(job_id): + break + + try: + self._backfill_metrics_from_file(activity) + processed += 1 + # Optimistically assume something might have updated, or we could track it. + # Since _backfill checks for None, it only updates if missing. + # But we might want to force update? The current logic only backfills. + # If we want to force parse standard metrics even if present (e.g. to overwrite), + # we'd need to change _backfill. For now, backfill is what's requested. + + except Exception as e: + self.logger.error(f"Error reparsing {activity.garmin_activity_id}: {e}") + + if idx % 10 == 0: + self.db_session.flush() # Flush periodically + if job_id: + progress = int((idx / total_count) * 100) + job_manager.update_job(job_id, progress=progress, message=f"Reparsing {idx}/{total_count}") + + self.db_session.commit() + return {"total": total_count, "processed": processed} + diff --git a/FitnessSync/backend/src/services/sync/weight.py b/FitnessSync/backend/src/services/sync/weight.py index 2d31417..81d1e60 100644 --- a/FitnessSync/backend/src/services/sync/weight.py +++ b/FitnessSync/backend/src/services/sync/weight.py @@ -87,6 +87,11 @@ class WeightSyncService: time_str = log.get('time', '12:00:00') log_dt = datetime.fromisoformat(f"{date_str}T{time_str}") + # Heuristic: If weight > 150, assume lbs and convert to kg. (150kg = 330lbs) + # Fitbit API sometimes returns user-preference units despite requesting METRIC + if weight_val > 150: + weight_val = weight_val * 0.453592 + res = update_or_create_health_metric( self.db_session, metric_type='weight', diff --git a/FitnessSync/backend/src/tasks/__pycache__/definitions.cpython-311.pyc b/FitnessSync/backend/src/tasks/__pycache__/definitions.cpython-311.pyc index cde991e..074f5c4 100644 Binary files a/FitnessSync/backend/src/tasks/__pycache__/definitions.cpython-311.pyc and b/FitnessSync/backend/src/tasks/__pycache__/definitions.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/tasks/definitions.py b/FitnessSync/backend/src/tasks/definitions.py index 70587fa..983e8e0 100644 --- a/FitnessSync/backend/src/tasks/definitions.py +++ b/FitnessSync/backend/src/tasks/definitions.py @@ -8,6 +8,7 @@ from ..utils.config import config from ..models.api_token import APIToken from ..models.config import Configuration from ..services.fitbit_client import FitbitClient +from ..jobs.segment_matching_job import run_segment_matching_job logger = logging.getLogger(__name__) @@ -171,3 +172,65 @@ def run_fitbit_sync_job(job_id: str, days_back: int, db_session_factory): except Exception as e: logger.error(f"Fitbit sync job failed: {e}") job_manager.fail_job(job_id, str(e)) + +def run_reparse_local_files_job(job_id: str, db_session_factory): + """Background task wrapper for reparsing local FIT files""" + with db_session_factory() as db: + try: + garmin_client = GarminClient() + sync_app = SyncApp(db, garmin_client) + + result = sync_app.activity_sync.reparse_local_files(job_id=job_id) + job_manager.complete_job(job_id, result=result) + except Exception as e: + logger.error(f"Reparse job failed: {e}") + job_manager.fail_job(job_id, str(e)) + +def run_estimate_power_job(job_id: str, db_session_factory): + """Background task wrapper for calculating estimated power""" + with db_session_factory() as db: + try: + from ..services.power_estimator import PowerEstimatorService + from ..models.activity import Activity + + job_manager.update_job(job_id, message="Finding activities...", progress=0) + + # Find cycling activities without power data + activities = db.query(Activity).filter( + Activity.activity_type.ilike('%cycling%'), + # Check for missing power metrics + (Activity.avg_power == None) | (Activity.avg_power == 0), + Activity.file_content != None + ).all() + + total = len(activities) + processed = 0 + + estimator = PowerEstimatorService(db) + + for idx, activity in enumerate(activities): + if job_manager.should_cancel(job_id): + break + + try: + estimator.estimate_power_for_activity(activity.id) + processed += 1 + except Exception as e: + # Log but continue + logger.warning(f"Failed to estimate power for {activity.id}: {e}") + + if idx % 5 == 0: + progress = int((idx / total) * 100) if total > 0 else 100 + job_manager.update_job(job_id, message=f"Estimating {idx}/{total}", progress=progress) + + job_manager.complete_job(job_id, result={"total": total, "processed": processed}) + + except Exception as e: + logger.error(f"Estimate power job failed: {e}") + job_manager.fail_job(job_id, str(e)) + +def run_segment_matching_task(job_id: str, db_session_factory, **kwargs): + """Background task wrapper for segment matching""" + # Just call the job function directly, it handles everything + run_segment_matching_job(job_id, db_session_factory=db_session_factory, **kwargs) + diff --git a/FitnessSync/backend/src/utils/algorithms.py b/FitnessSync/backend/src/utils/algorithms.py new file mode 100644 index 0000000..7b05a7c --- /dev/null +++ b/FitnessSync/backend/src/utils/algorithms.py @@ -0,0 +1,70 @@ +from typing import List, Tuple +import math + +def lttb(data: List[Tuple[float, float]], threshold: int) -> List[Tuple[float, float]]: + """ + Largest-Triangle-Three-Buckets algorithm for downsampling time series data. + Preserves visual shape (peaks and valleys) better than simple decimation. + + Args: + data: List of (x, y) tuples. Must be sorted by x. + threshold: Target number of points. + + Returns: + Downsampled list of (x, y) tuples. + """ + if not data or len(data) <= threshold: + return data + + sampled = [] + sampled.append(data[0]) # Always keep the first point + + # Bucket size. Leave room for start and end data points + every = (len(data) - 2) / (threshold - 2) + + a = 0 + next_a = 0 + max_area_point = (0, 0) + + for i in range(threshold - 2): + # Calculate point average for next bucket (containing c) + avg_x = 0 + avg_y = 0 + avg_range_start = int(math.floor((i + 1) * every) + 1) + avg_range_end = int(math.floor((i + 2) * every) + 1) + avg_range_end = min(avg_range_end, len(data)) + avg_range_len = avg_range_end - avg_range_start + + for j in range(avg_range_start, avg_range_end): + avg_x += data[j][0] + avg_y += data[j][1] + + if avg_range_len > 0: + avg_x /= avg_range_len + avg_y /= avg_range_len + + # Get the range for this bucket + range_offs = int(math.floor((i + 0) * every) + 1) + range_to = int(math.floor((i + 1) * every) + 1) + + # Point a + point_a_x = data[a][0] + point_a_y = data[a][1] + + max_area = -1 + + for j in range(range_offs, range_to): + # Calculate triangle area over three buckets + area = math.fabs((point_a_x - avg_x) * (data[j][1] - point_a_y) - + (point_a_x - data[j][0]) * (avg_y - point_a_y)) * 0.5 + if area > max_area: + max_area = area + max_area_point = data[j] + next_a = j + + sampled.append(max_area_point) + a = next_a + + sampled.append(data[-1]) # Always keep the last point + + return sampled diff --git a/FitnessSync/backend/templates/activities.html b/FitnessSync/backend/templates/activities.html index 4b68c62..f7fac0a 100644 --- a/FitnessSync/backend/templates/activities.html +++ b/FitnessSync/backend/templates/activities.html @@ -45,6 +45,65 @@ + +
Toggle metrics to customize view
+${s.label}
+${s.val}
+| Rank | Date | Time | Avg HR | Watts | +Max Watts |
|---|