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 @@ + +
+ +
@@ -392,12 +451,26 @@ let url = `/api/activities/list?limit=${limit}&offset=${currentPage * limit}`; // If any filter is active, force query mode - if (typeFilter || bikeFilter) { + // If any filter is active, force query mode + const hasHr = document.getElementById('filter-has-hr').checked; + const hasPower = document.getElementById('filter-has-power').checked; + const hasCadence = document.getElementById('filter-has-cadence').checked; + const powerReal = document.getElementById('filter-power-real').checked; + const powerEst = document.getElementById('filter-power-est').checked; + + if (typeFilter || bikeFilter || hasHr || hasPower || hasCadence || powerReal || powerEst) { url = `/api/activities/query?`; const params = new URLSearchParams(); if (typeFilter) params.append('activity_type', typeFilter); if (bikeFilter) params.append('bike_setup_id', bikeFilter); + if (hasHr) params.append('has_hr', 'true'); + if (hasPower) params.append('has_power', 'true'); + if (hasCadence) params.append('has_cadence', 'true'); + + if (powerReal) params.append('is_estimated_power', 'false'); + if (powerEst) params.append('is_estimated_power', 'true'); + url += params.toString(); document.getElementById('prev-page-btn').disabled = true; diff --git a/FitnessSync/backend/templates/activity_view.html b/FitnessSync/backend/templates/activity_view.html index f726e68..17af5f0 100644 --- a/FitnessSync/backend/templates/activity_view.html +++ b/FitnessSync/backend/templates/activity_view.html @@ -65,6 +65,9 @@ + + Discovery + @@ -231,6 +234,19 @@ + +
+
+
Respiration
+
+
Avg: - + br/min
+
Max: - br/min +
+
+
+
+
@@ -249,11 +265,78 @@
-
- Activity Streams +
+ Activity Streams +
+
+ + +
+ +
-
- +
+
+
+

Cycling Workout Analysis

+

Toggle metrics to customize view

+
+
+
+ + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + + +
+
+
+
+ + +
- -
How often this job should run.
+ +
How often this job should run. Set to 0 for Manual Only (Adhoc).
@@ -261,13 +261,7 @@
@@ -277,7 +271,8 @@
- + +
Set to 0 for Manual Only.
@@ -366,6 +361,7 @@ loadJobs(); if (typeof updateParamsHelp === 'function') updateParamsHelp(); // Init helper text loadDashboardData(); + populateJobTypes(); // Auto-refresh dashboard data every 3 seconds setInterval(loadDashboardData, 3000); @@ -851,7 +847,7 @@ const paramsStr = document.getElementById('create-job-params').value; if (!name) { alert("Name is required"); return; } - if (interval < 1) { alert("Interval must be > 0"); return; } + if (interval < 0) { alert("Interval must be >= 0"); return; } let params = {}; try { @@ -992,8 +988,8 @@ const enabled = document.getElementById('edit-job-enabled').checked; const paramsStr = document.getElementById('edit-job-params').value; - if (interval < 1) { - alert("Interval must be at least 1 minute"); + if (interval < 0) { + alert("Interval must be at least 0 minutes"); return; } @@ -1029,5 +1025,32 @@ alert("Error: " + e.message); } } + async function populateJobTypes() { + const select = document.getElementById('create-job-type'); + try { + const response = await fetch('/api/scheduling/available-types'); + if (!response.ok) throw new Error('Failed to fetch types'); + const types = await response.json(); + + select.innerHTML = ''; + types.forEach(type => { + const option = document.createElement('option'); + option.value = type; + // Simple label transformation: underscores to spaces, Title Case + option.text = type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + select.appendChild(option); + }); + + // Trigger help update for first item + if (types.length > 0) { + select.selectedIndex = 0; + updateParamsHelp(); + } + + } catch (e) { + console.error("Error loading job types", e); + select.innerHTML = ''; + } + } {% endblock %} \ No newline at end of file diff --git a/FitnessSync/backend/templates/segments.html b/FitnessSync/backend/templates/segments.html index cd9d240..5ad1284 100644 --- a/FitnessSync/backend/templates/segments.html +++ b/FitnessSync/backend/templates/segments.html @@ -82,11 +82,14 @@ + + @@ -96,12 +99,45 @@
Rank Date Time Avg HR WattsMax Watts
+
+ + +
+ + + + {% endblock %} {% block scripts %} @@ -113,7 +149,7 @@ if (!confirm("This will rescan ALL activities for all segments. It may take a while. Continue?")) return; try { - const response = await fetch('/api/segments/scan', { method: 'POST' }); + const response = await fetch('/api/segments/scan?force=true', { method: 'POST' }); if (!response.ok) throw new Error("Scan failed"); const data = await response.json(); alert("Scan started! Background Job ID: " + data.job_id); @@ -181,6 +217,7 @@ let map = null; let elevationChart = null; + let comparisonChart = null; function viewSegment(seg) { const modal = new bootstrap.Modal(document.getElementById('viewSegmentModal')); @@ -312,14 +349,21 @@ const tr = document.createElement('tr'); tr.innerHTML = ` + + + ${rank} ${date} ${timeStr} ${effort.avg_hr || '-'} ${effort.avg_power || '-'} + ${effort.max_power || '-'} `; tbody.appendChild(tr); }); + updateActionButtons(); } catch (e) { console.error(e); @@ -327,6 +371,222 @@ } } + function toggleAllEfforts(source) { + document.querySelectorAll('.effort-checkbox').forEach(cb => { + cb.checked = source.checked; + }); + updateActionButtons(); + } + + function updateActionButtons() { + const selected = document.querySelectorAll('.effort-checkbox:checked').length; + document.getElementById('btn-compare').disabled = selected < 2; + document.getElementById('btn-export').disabled = selected < 1; + } + + async function compareSelectedEfforts() { + const checkboxes = document.querySelectorAll('.effort-checkbox:checked'); + const ids = Array.from(checkboxes).map(cb => parseInt(cb.value)); + + if (ids.length < 2) return; + + try { + const res = await fetch('/api/segments/efforts/compare', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ids) + }); + + if (!res.ok) throw new Error("Comparison failed"); + const data = await res.json(); + + renderComparisonTable(data); + new bootstrap.Modal(document.getElementById('compareModal')).show(); + + } catch (e) { + alert("Error: " + e.message); + } + } + + function renderComparisonTable(data) { + const table = document.getElementById('comparison-table'); + const efforts = data.efforts; + const winners = data.winners; + + // Define rows to display + const rows = [ + { key: 'date', label: 'Date', format: v => new Date(v).toLocaleDateString() }, + { key: 'elapsed_time', label: 'Time', format: v => new Date(v * 1000).toISOString().substr(11, 8) }, + { key: 'avg_power', label: 'Avg Power (W)' }, + { key: 'max_power', label: 'Max Power (W)' }, + { key: 'avg_hr', label: 'Avg HR (bpm)' }, + { key: 'watts_per_kg', label: 'Watts/kg' }, + { key: 'avg_speed', label: 'Speed (m/s)', format: v => v ? v.toFixed(2) : '-' }, + { key: 'avg_cadence', label: 'Cadence' }, + { key: 'avg_respiration_rate', label: 'Respiration (br/min)', format: v => v ? v.toFixed(1) : '-' }, + { key: 'avg_temperature', label: 'Temp (C)', format: v => v ? v.toFixed(1) : '-' }, + { key: 'body_weight', label: 'Body Weight (kg)', format: v => v ? v.toFixed(2) : '-' }, + { key: 'bike_weight', label: 'Bike Weight (kg)', format: v => v ? v.toFixed(2) : '-' }, + { key: 'total_weight', label: 'Total Weight (kg)', format: v => v ? v.toFixed(2) : '-' }, + { key: 'bike_name', label: 'Bike' } + ]; + + let html = 'Metric'; + efforts.forEach(e => { + html += `${e.activity_name}`; + }); + html += ''; + + rows.forEach(row => { + html += `${row.label}`; + efforts.forEach(e => { + let val = e[row.key]; + let displayVal = val; + if (val === null || val === undefined) displayVal = '-'; + else if (row.format) displayVal = row.format(val); + + // Highlight winner + let bgClass = ''; + if (winners[row.key] === e.effort_id) { + bgClass = 'table-success fw-bold'; + } + + html += `${displayVal}`; + }); + html += ''; + }); + + html += ''; + table.innerHTML = html; + } + + async function exportSelectedEfforts() { + const checkboxes = document.querySelectorAll('.effort-checkbox:checked'); + const ids = Array.from(checkboxes).map(cb => parseInt(cb.value)); + + if (ids.length === 0) return; + + try { + const res = await fetch('/api/segments/efforts/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ids) + }); + + if (!res.ok) throw new Error("Export failed"); + + const blob = await res.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = "efforts_analysis.json"; + document.body.appendChild(a); + a.click(); + a.remove(); + + } catch (e) { + alert("Error: " + e.message); + } + } + + function renderComparisonChart(efforts) { + if (comparisonChart) { + comparisonChart.destroy(); + comparisonChart = null; + } + + const ctx = document.getElementById('comparisonChart').getContext('2d'); + + // Prepare datasets + // We will plot formatted 'Watts/kg' and 'Avg Speed' side by side? + // Or normalized? + // Let's plot Power, HR, and Watts/kg as bars. + // Since scales are different, we might need multiple axes or just plot one metric? + // User asked for "charts" (plural?). + // Getting simple: Grouped Bar Chart for Power and HR (scale 0-300ish). + // Watts/kg is small (0-10). + + // Let's use two y-axes: Left for Power/HR, Right for W/kg + + const labels = efforts.map(e => e.activity_name); + + comparisonChart = new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: [ + { + label: 'Avg Power (W)', + data: efforts.map(e => e.avg_power), + backgroundColor: 'rgba(255, 99, 132, 0.6)', + yAxisID: 'y' + }, + { + label: 'Avg HR (bpm)', + data: efforts.map(e => e.avg_hr), + backgroundColor: 'rgba(54, 162, 235, 0.6)', + yAxisID: 'y' + }, + { + label: 'Watts/kg', + type: 'line', // Line overlay for W / kg + data: efforts.map(e => e.watts_per_kg), + borderColor: 'rgba(75, 192, 192, 1)', + borderWidth: 2, + yAxisID: 'y1' + }, + { + label: 'Avg Speed (m/s)', + data: efforts.map(e => e.avg_speed), + backgroundColor: 'rgba(153, 102, 255, 0.6)', + yAxisID: 'y2', + hidden: false + }, + { + label: 'Avg Cadence (rpm)', + data: efforts.map(e => e.avg_cadence), + backgroundColor: 'rgba(255, 159, 64, 0.6)', + yAxisID: 'y', + hidden: true + }, + { + label: 'Avg Respiration (br/min)', + data: efforts.map(e => e.avg_respiration_rate), + backgroundColor: 'rgba(201, 203, 207, 0.6)', + yAxisID: 'y', + hidden: true + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + type: 'linear', + display: true, + position: 'left', + title: { display: true, text: 'Power (W) / HR (bpm)' } + }, + y1: { + type: 'linear', + display: true, + position: 'right', + grid: { drawOnChartArea: false }, + title: { display: true, text: 'Watts/kg' } + }, + y2: { + type: 'linear', + display: true, + position: 'right', + grid: { drawOnChartArea: false }, + title: { display: true, text: 'Speed' } + } + } + } + }); + } + document.addEventListener('DOMContentLoaded', loadSegments); {% endblock %} \ No newline at end of file diff --git a/FitnessSync/backend/test_segment_optimization.py b/FitnessSync/backend/test_segment_optimization.py new file mode 100644 index 0000000..9523846 --- /dev/null +++ b/FitnessSync/backend/test_segment_optimization.py @@ -0,0 +1,87 @@ + +import sys +import os +import logging +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.sql import func +import datetime + +# Setup environment +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from src.models.activity import Activity +from src.models.segment import Segment +from src.services.postgresql_manager import PostgreSQLManager +from src.utils.config import config +from src.services.job_manager import job_manager + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def test_optimization(): + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as db: + # 1. Reset + activity = db.query(Activity).filter(Activity.id == 256).first() + if not activity: + print("Activity 256 not found") + return + + print("Resetting last_segment_scan_timestamp...") + activity.last_segment_scan_timestamp = None + db.commit() + + # 2. Get Max Segment Date + max_seg_date = db.query(func.max(Segment.created_at)).scalar() + print(f"Max Segment Date: {max_seg_date}") + + # 3. Simulate Logic Check (Pass 1) + last_scan = activity.last_segment_scan_timestamp + if last_scan and max_seg_date and last_scan >= max_seg_date: + print("PASS 1: Optimization INCORRECTLY skipped!") + else: + print("PASS 1: Optimization correctly signaled to scan.") + # Simulate scan completion + activity.last_segment_scan_timestamp = datetime.datetime.now(datetime.timezone.utc) + db.commit() + + # 4. Refresh & Check Pass 2 + db.refresh(activity) + last_scan = activity.last_segment_scan_timestamp + print(f"Activity Last Scan: {last_scan}") + + if last_scan and max_seg_date and last_scan >= max_seg_date: + print("PASS 2: Optimization CORRECTLY skipped.") + else: + print(f"PASS 2: Optimization FAILED to skip. last_scan={last_scan} max_seg={max_seg_date}") + + # 5. Add New Segment + print("Creating new segment...") + new_seg = Segment( + name="Future Segment", + points="[]", + bounds="[]", + distance=100, + activity_type="cycling" + # created_at defaults to now + ) + db.add(new_seg) + db.commit() # created_at set + + # Refresh max date logic + max_seg_date_new = db.query(func.max(Segment.created_at)).scalar() + print(f"New Max Segment Date: {max_seg_date_new}") + + if last_scan and max_seg_date_new and last_scan >= max_seg_date_new: + print("PASS 3: Optimization FAILED (Generated False Positive Skip).") + else: + print("PASS 3: Optimization CORRECTLY signaled re-scan (Partial).") + + # Cleanup + db.delete(new_seg) + db.commit() + +if __name__ == "__main__": + test_optimization() diff --git a/FitnessSync/backend/tests/__pycache__/test_analysis.cpython-311-pytest-7.4.3.pyc b/FitnessSync/backend/tests/__pycache__/test_analysis.cpython-311-pytest-7.4.3.pyc new file mode 100644 index 0000000..0f6865d Binary files /dev/null and b/FitnessSync/backend/tests/__pycache__/test_analysis.cpython-311-pytest-7.4.3.pyc differ diff --git a/FitnessSync/backend/tests/test_analysis.py b/FitnessSync/backend/tests/test_analysis.py new file mode 100644 index 0000000..bbefa61 --- /dev/null +++ b/FitnessSync/backend/tests/test_analysis.py @@ -0,0 +1,49 @@ + +import pytest +from unittest.mock import MagicMock, patch +from datetime import datetime +from src.api.analysis import compare_efforts, ComparisonResponse +from src.models.segment_effort import SegmentEffort +from src.models.activity import Activity +from src.models.bike_setup import BikeSetup +from src.models.health_metric import HealthMetric + +def test_compare_efforts_logic(): + # Mock DB Session + mock_db = MagicMock() + + # Setup Entities + bike = BikeSetup(id=1, name="S-Works", weight_kg=7.5) + + act1 = Activity(id=1, activity_name="Ride 1", start_time=datetime(2023, 6, 1, 10, 0), avg_cadence=90, avg_speed=10.0, avg_temperature=20.0, bike_setup=bike) + act2 = Activity(id=2, activity_name="Ride 2", start_time=datetime(2023, 6, 2, 10, 0), avg_cadence=95, avg_speed=11.0, avg_temperature=25.0, bike_setup=bike) + + eff1 = SegmentEffort(id=101, activity_id=1, elapsed_time=100, avg_power=200, avg_hr=150, activity=act1) + eff2 = SegmentEffort(id=102, activity_id=2, elapsed_time=90, avg_power=220, avg_hr=160, activity=act2) + + # Mock Query Results + # Filter efforts + mock_db.query.return_value.filter.return_value.all.return_value = [eff1, eff2] + + # Mock HealthMetric (Body Weight) + weight_metric = HealthMetric(metric_value=70.0, date=datetime(2023, 6, 1)) + mock_db.query.return_value.filter.return_value.order_by.return_value.first.return_value = weight_metric + + # Run Function + response = compare_efforts([101, 102], mock_db) + + # Assertions + assert len(response.efforts) == 2 + + e1_data = next(e for e in response.efforts if e.effort_id == 101) + e2_data = next(e for e in response.efforts if e.effort_id == 102) + + assert e1_data.avg_power == 200 + assert e2_data.avg_power == 220 + assert e1_data.total_weight == 77.5 # 70 + 7.5 + assert e1_data.watts_per_kg == round(200/70.0, 2) + + # Winners + assert response.winners['avg_power'] == 102 + assert response.winners['elapsed_time'] == 102 # Lower is better? Logic said min for time. + assert response.winners['avg_hr'] == 101 # Lower is better diff --git a/FitnessSync/backend/verify_filters.py b/FitnessSync/backend/verify_filters.py new file mode 100644 index 0000000..1370829 --- /dev/null +++ b/FitnessSync/backend/verify_filters.py @@ -0,0 +1,50 @@ +import requests +import os + +API_URL = "http://localhost:8000/api" + +def test_filters(): + print("Testing Activity Filters...") + + # 1. Test Has Power + print("\n1. Testing 'has_power=true'...") + response = requests.get(f"{API_URL}/activities/query?has_power=true") + if response.status_code == 200: + activities = response.json() + print(f"Found {len(activities)} activities with power.") + for a in activities[:5]: + print(f" - ID {a['id']}, Avg Power: {a.get('avg_power')}") + if a.get('avg_power') is None: + print(" !!! FAIL: Activity returned but has no avg_power") + else: + print(f"FAIL: Status {response.status_code}") + + # 2. Test Has Heart Rate + print("\n2. Testing 'has_hr=true'...") + response = requests.get(f"{API_URL}/activities/query?has_hr=true") + if response.status_code == 200: + activities = response.json() + print(f"Found {len(activities)} activities with HR.") + for a in activities[:5]: + print(f" - ID {a['id']}, Avg HR: {a.get('avg_hr')}") + if a.get('avg_hr') is None: + print(" !!! FAIL: Activity returned but has no avg_hr") + else: + print(f"FAIL: Status {response.status_code}") + + # 3. Test Estimated Power (False - Real Only) + print("\n3. Testing 'is_estimated_power=false' (Real Power Only)...") + response = requests.get(f"{API_URL}/activities/query?has_power=true&is_estimated_power=false") + if response.status_code == 200: + activities = response.json() + print(f"Found {len(activities)} activities with Real Power.") + for a in activities[:5]: + # is_estimated_power might not be in response model yet? Check. + # It was added to model, but Pydantic response model might default to False or missing if not updated. + # But the FILTER should work. + print(f" - ID {a['id']}, Is Est: {a.get('is_estimated_power')}") + else: + print(f"FAIL: Status {response.status_code}") + +if __name__ == "__main__": + test_filters() diff --git a/FitnessSync/backend/verify_fix.py b/FitnessSync/backend/verify_fix.py new file mode 100644 index 0000000..5cf9d6c --- /dev/null +++ b/FitnessSync/backend/verify_fix.py @@ -0,0 +1,86 @@ +import sys +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +import logging + +# Add curdir to path +sys.path.append(os.getcwd()) + +from src.models.activity import Activity +from src.services.sync.activity import GarminActivitySync + +# Configure logging to see the output from the service +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Using localhost:5433 as per docker-compose port mapping +DATABASE_URL = "postgresql://postgres:password@localhost:5433/fitbit_garmin_sync" + +def verify_fix(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"Before Backfill:") + print(f" Distance: {activity.distance}") + print(f" Avg Power: {activity.avg_power}") + print(f" Max Power: {activity.max_power}") + print(f" Avg Cadence: {activity.avg_cadence}") + + # Instantiate Sync Service with dummy client (not needed for this method) + sync_service = GarminActivitySync(session, None) + + # Run Backfill + print("\nRunning _backfill_metrics_from_file...") + sync_service._backfill_metrics_from_file(activity) + + # Check results (pending commit) + print(f"\nAfter Backfill (InMemory):") + print(f" Distance: {activity.distance}") + print(f" Avg Power: {activity.avg_power}") + print(f" Max Power: {activity.max_power}") + print(f" Avg Cadence: {activity.avg_cadence}") + + if activity.distance is not None and activity.avg_power is not None: + print("\nSUCCESS: Metrics were populated.") + + print("\nExtended Metrics Check:") + print(f" Pedal Smoothness (L/R): {activity.avg_left_pedal_smoothness} / {activity.avg_right_pedal_smoothness}") + print(f" Torque Effectiveness (L/R): {activity.avg_left_torque_effectiveness} / {activity.avg_right_torque_effectiveness}") + print(f" Grit/Flow: {activity.grit} / {activity.flow}") + print(f" Total Work: {activity.total_work}") + print(f" Respiration Rate (Avg/Max): {activity.avg_respiration_rate} / {activity.max_respiration_rate}") + + print("\nStandard Metrics Check:") + print(f" Max HR: {activity.max_hr}") + print(f" Max Speed: {activity.max_speed}") + print(f" Elevation Gain/Loss: {activity.elevation_gain} / {activity.elevation_loss}") + print(f" Training Effect (Aerobic/Anaerobic): {activity.aerobic_te} / {activity.anaerobic_te}") + print(f" TSS: {activity.tss}") + print(f" Normalized Power: {activity.norm_power}") + + # Commit to save changes + session.commit() + print("Changes committed to DB.") + else: + print("\nFAILURE: Metrics were NOT populated.") + + except Exception as e: + print(f"Error: {e}") + finally: + session.close() + +if __name__ == "__main__": + verify_fix("21517046364") diff --git a/FitnessSync/backend/verify_jobs.py b/FitnessSync/backend/verify_jobs.py new file mode 100644 index 0000000..dc6749e --- /dev/null +++ b/FitnessSync/backend/verify_jobs.py @@ -0,0 +1,27 @@ +from src.services.postgresql_manager import PostgreSQLManager +from src.utils.config import config +from src.models.scheduled_job import ScheduledJob +from src.services.scheduler import scheduler + +def verify_jobs(): + db = PostgreSQLManager(config.DATABASE_URL) + + # Run ensure_defaults to populate DB + scheduler.ensure_defaults() + + with db.get_db_session() as session: + jobs = session.query(ScheduledJob).all() + print("Scheduled Jobs in DB:") + for j in jobs: + print(f"- {j.name} (Type: {j.job_type}, Enabled: {j.enabled})") + + reparse = next((j for j in jobs if j.job_type == 'reparse_local_files'), None) + estimate = next((j for j in jobs if j.job_type == 'estimate_power'), None) + + if reparse and estimate: + print("\nSUCCESS: Both new jobs found.") + else: + print("\nFAILURE: Missing new jobs.") + +if __name__ == "__main__": + verify_jobs() diff --git a/FitnessSync/backend/verify_segment_power.py b/FitnessSync/backend/verify_segment_power.py new file mode 100644 index 0000000..42af36b --- /dev/null +++ b/FitnessSync/backend/verify_segment_power.py @@ -0,0 +1,124 @@ + +import sys +import os +import logging +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +# Setup environment +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from src.models.base import Base +from src.models.activity import Activity +from src.models.segment import Segment +from src.models.segment_effort import SegmentEffort +from src.services.postgresql_manager import PostgreSQLManager +from src.services.segment_matcher import SegmentMatcher +from src.services.power_estimator import PowerEstimatorService +from src.utils.config import config + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def verify_segment_power(): + db_url = config.DATABASE_URL + if not db_url: + print("DATABASE_URL not set") + return + + manager = PostgreSQLManager(db_url) + with manager.get_db_session() as session: + # 1. Get our test activity (ID 256) + activity = session.query(Activity).filter(Activity.id == 256).first() + if not activity: + print("Activity 256 not found") + return + + print(f"Activity {activity.id}: is_estimated_power={activity.is_estimated_power}, avg_power={activity.avg_power}") + + if not activity.is_estimated_power: + print("Activity does not have estimated power enabled. Please run verify_filters.py or backfill/estimate job first.") + return + + # 2. Ensure we have at least one segment that matches this activity + # We'll check if any segments exist, if not create a dummy one for the activity bounds + # Or better, just check if any exist. + segments_count = session.query(Segment).count() + print(f"Found {segments_count} segments in DB.") + + # 3. Clear existing efforts for this activity to test fresh matching + existing_efforts = session.query(SegmentEffort).filter(SegmentEffort.activity_id == activity.id).all() + print(f"Deleting {len(existing_efforts)} existing efforts.") + for e in existing_efforts: + session.delete(e) + session.commit() + + # 4. Trigger Matching + matcher = SegmentMatcher(session) + # We need points. Accessing parsers... + from src.services.parsers import extract_activity_data + data = extract_activity_data(activity.file_content, activity.file_type) + if not data: + print("Could not extract data.") + return + + print(f"Extracted data keys: {data.keys()}") + if 'position_lat' in data: + print(f"Lat points: {len(data['position_lat'])}") + + if 'points' in data: + points = data['points'] + # Ensure format is [lon, lat] + # print(f"Sample point: {points[0] if points else 'None'}") + else: + lats = data.get('position_lat') + longs = data.get('position_long') + if lats and longs: + for i in range(len(lats)): + if lats[i] is not None and longs[i] is not None: + points.append([longs[i], lats[i]]) + + print(f"Matching with {len(points)} points...") + efforts = matcher.match_activity(activity, points) + + if len(efforts) == 0 and len(points) > 100: + print("No segments matched. Creating a dummy segment to verify logic...") + # Create a simple segment from the middle of the activity + mid = len(points) // 2 + seg_points = points[mid:mid+50] # 50 points + + # Bounds + from src.utils.geo import calculate_bounds + import json + bounds = calculate_bounds(seg_points) + + dummy_segment = Segment( + name="Temp Verify Segment", + activity_type=activity.activity_type, + distance=1000.0, # Approximate + points=json.dumps(seg_points), + bounds=json.dumps(bounds) + ) + session.add(dummy_segment) + session.commit() + print(f"Created dummy segment {dummy_segment.id}") + + # Retry Match + efforts = matcher.match_activity(activity, points) + print(f"Retried Match: {len(efforts)} segments.") + + # 5. Verify Power + for effort in efforts: + print(f"Effort ID: {effort.id}, Segment ID: {effort.segment_id}") + print(f" Avg Power: {effort.avg_power}") + print(f" Avg HR: {effort.avg_hr}") + print(f" Elapsed: {effort.elapsed_time}s") + + if effort.avg_power and effort.avg_power > 0: + print("SUCCESS: Segment Effort has power!") + else: + print("FAILURE: Segment Effort missing power.") + +if __name__ == "__main__": + verify_segment_power() diff --git a/FitnessSync/docs/specs/fitbit-integration.md b/FitnessSync/docs/specs/fitbit-integration.md new file mode 100644 index 0000000..ec9dd69 --- /dev/null +++ b/FitnessSync/docs/specs/fitbit-integration.md @@ -0,0 +1,114 @@ +# Fitbit Integration Specification + +**Status**: Implemented +**Version**: 1.0.0 +**Last Updated**: 2026-01-12 + +## 1. Overview +This specification documents the current implementation of the Fitbit integration within the FitTrack2 ecosystem. The primary goal is to authenticate with the Fitbit API using OAuth 2.0 and synchronize health metrics—specifically body weight and BMI—into the local database for analysis and visualization. + +## 2. Authentication (OAuth 2.0) + +### 2.1 Credentials Management +- **Storage**: Client ID and Client Secret are stored in the `configuration` table. +- **Tokens**: Access and Refresh tokens are stored in the `api_tokens` table with `token_type='fitbit'`. + +### 2.2 Authorization Flow +1. **Initiation**: + - Endpoint: `POST /setup/fitbit` + - Input: `client_id`, `client_secret`, `redirect_uri` + - Process: Generates an Authorization URL using the `fitbit` Python library. + - Scopes Requested: `['weight', 'nutrition', 'activity', 'sleep', 'heartrate', 'profile']` +2. **Callback**: + - Endpoint: `POST /setup/fitbit/callback` + - Input: `code` (Authorization Code) + - Process: Exchanges code for Access and Refresh tokens. + - Output: Saves tokens to `api_tokens` table. + +### 2.3 Token Refresh +- **Mechanism**: The `FitbitClient` handles token expiration. +- **Note**: Current implementation of `refresh_access_token` in `FitbitClient` contains mock logic and requires finalization for production use. + +## 3. Data Synchronization + +### 3.1 Supported Metrics +Currently, only **Weight** and **BMI** are synchronized. + +### 3.2 Sync Strategy +- **On-Demand**: Triggered via API or UI. +- **Scopes**: + - `30d`: Syncs the last 30 days. + - `all`: Syncs all history starting from 2015-01-01. +- **Chunking**: Requests are chunked into 30-day intervals to respect API limits. +- **Rate Limiting**: Custom handling for `HTTP 429 Too Many Requests`. The system parses `Retry-After` headers and sleeps automatically before retrying a chunk. + +### 3.3 Data Storage +Data is stored in the `weight_records` table. + +| Column | Type | Description | +| :--- | :--- | :--- | +| `id` | Integer | Primary Key | +| `fitbit_id` | String | Unique Log ID from Fitbit | +| `weight` | Float | Weight in Kg | +| `bmi` | Float | Calculated BMI | +| `unit` | String | Unit (default 'kg') | +| `date` | DateTime | Timestamp of measurement | +| `sync_status` | String | Status for downstream sync (e.g. 'unsynced') | + +### 3.4 Logic (Deduplication) +- Incoming records are matched against existing records by `fitbit_id`. +- **Updates**: If `weight` differs by > 0.01 or `bmi` was missing, the record is updated. +- **Inserts**: New records are created if no match is found. + +## 4. API Endpoints + +### 4.1 Configuration & Auth +- `POST /setup/fitbit`: Save credentials and get Auth URL. +- `POST /setup/fitbit/callback`: Exchange code for token. +- `POST /setup/fitbit/test-token`: Verify current token validity and fetch user profile. + +### 4.2 Synchronization +- `POST /api/sync/fitbit/weight`: Trigger sync. + - Body: `{ "scope": "30d" | "all" }` +- `GET /api/jobs/{job_id}`: Check sync status (if run as background job). + +### 4.3 Data Access +- `GET /api/metrics/query`: + - Params: `source=fitbit`, `metric_type=weight`, `start_date`, `end_date`. + - Returns: serialized `WeightRecord` objects. +- `POST /api/sync/compare-weight`: Compares Fitbit weight dates vs Garmin weight dates to find gaps. + +## 5. Frontend UI +The UI is split between configuration (setup) and visualization (health dashboard). + +### 5.1 Authentication UI (`backend/templates/setup.html`) +The setup page handles the OAuth 2.0 flow: +1. **Credentials Form**: Inputs for `Client ID`, `Client Secret`, and `Redirect URI` (defaulting to `http://localhost:8000/fitbit_callback`). +2. **Actions**: + - `Test Fitbit Credentials`: Validates inputs and generates the Authorization URL. + - `Save Fitbit Credentials`: Persists credentials to the database. + - `Test Current Fitbit Token`: Checks if the stored token is valid. +3. **OAuth Flow**: + - After validation, a link "Authorize with Fitbit" is displayed. + - User clicks link, authorizes on Fitbit, and is redirected to the `fitbit_callback` URL (localhost). + - **Manual Step**: User copys the full URL (including code) and pastes it into the "Paste full callback URL" input. + - `Complete OAuth Flow` button sends the code to the backend. + +### 5.2 Health Dashboard (`backend/templates/fitbit_health.html`) +Located at `backend/templates/fitbit_health.html`. + +#### Features +- **Sync Controls**: Buttons to trigger "Sync Recent (30d)" and "Sync All". +- **Comparison**: "Compare vs Garmin" modal showing counts and missing dates. +- **Data Table**: Displays Date, Weight, BMI, and Source. +- **Filtering**: Date range pickers (Start/End) and quick toggles (30d, 1y, All). + +## 6. Dependencies +- **Libraries**: + - `fitbit` (Python client for interaction) + - `sqlalchemy` (ORM) + - `pydantic` (Data validation) + +## 7. Future Considerations +- **Token Refresh**: Implement real token refresh logic in `FitbitClient`. +- **Additional Metrics**: Expand sync to cover Heart Rate, Sleep, and Activities using the already authorized scopes.