From 4bb86b603e1947219d571888b0b992964bbec52e Mon Sep 17 00:00:00 2001 From: sstent Date: Sun, 11 Jan 2026 06:06:43 -0800 Subject: [PATCH] many updates --- .../backend/__pycache__/main.cpython-311.pyc | Bin 5440 -> 6112 bytes .../backend/__pycache__/main.cpython-313.pyc | Bin 4743 -> 5453 bytes FitnessSync/backend/main.py | 19 +- .../__pycache__/activities.cpython-311.pyc | Bin 40861 -> 46164 bytes .../__pycache__/activities.cpython-313.pyc | Bin 36843 -> 37182 bytes .../__pycache__/bike_setups.cpython-311.pyc | Bin 7406 -> 9638 bytes .../api/__pycache__/discovery.cpython-311.pyc | Bin 0 -> 3579 bytes .../api/__pycache__/discovery.cpython-313.pyc | Bin 0 -> 3225 bytes .../__pycache__/scheduling.cpython-313.pyc | Bin 6967 -> 7501 bytes .../api/__pycache__/segments.cpython-311.pyc | Bin 13149 -> 18350 bytes .../api/__pycache__/segments.cpython-313.pyc | Bin 0 -> 12020 bytes .../api/__pycache__/status.cpython-313.pyc | Bin 9714 -> 10173 bytes FitnessSync/backend/src/api/activities.py | 94 +++- FitnessSync/backend/src/api/bike_setups.py | 59 ++- FitnessSync/backend/src/api/discovery.py | 83 ++++ FitnessSync/backend/src/api/segments.py | 108 ++++- .../backend/src/jobs/segment_matching_job.py | 66 ++- .../__pycache__/activity.cpython-311.pyc | Bin 3073 -> 3259 bytes .../__pycache__/activity.cpython-313.pyc | Bin 2314 -> 2440 bytes .../__pycache__/bike_setup.cpython-311.pyc | Bin 1292 -> 1500 bytes .../__pycache__/bike_setup.cpython-313.pyc | Bin 1083 -> 1225 bytes FitnessSync/backend/src/models/activity.py | 5 + FitnessSync/backend/src/models/bike_setup.py | 5 +- .../routers/__pycache__/web.cpython-311.pyc | Bin 3190 -> 3709 bytes .../routers/__pycache__/web.cpython-313.pyc | Bin 2173 -> 3278 bytes FitnessSync/backend/src/routers/web.py | 6 + .../__pycache__/discovery.cpython-311.pyc | Bin 0 -> 2185 bytes .../__pycache__/discovery.cpython-313.pyc | Bin 0 -> 1911 bytes FitnessSync/backend/src/schemas/discovery.py | 29 ++ .../__pycache__/bike_matching.cpython-311.pyc | Bin 5356 -> 10202 bytes .../__pycache__/bike_matching.cpython-313.pyc | Bin 4559 -> 9744 bytes .../__pycache__/discovery.cpython-311.pyc | Bin 0 -> 18344 bytes .../__pycache__/discovery.cpython-313.pyc | Bin 0 -> 15116 bytes .../__pycache__/job_manager.cpython-313.pyc | Bin 14294 -> 15429 bytes .../__pycache__/parsers.cpython-311.pyc | Bin 8525 -> 9279 bytes .../__pycache__/parsers.cpython-313.pyc | Bin 5090 -> 8439 bytes .../power_estimator.cpython-311.pyc | Bin 0 -> 5291 bytes .../__pycache__/scheduler.cpython-313.pyc | Bin 7535 -> 8485 bytes .../segment_matcher.cpython-311.pyc | Bin 10058 -> 10336 bytes .../__pycache__/sync_app.cpython-313.pyc | Bin 5832 -> 6541 bytes .../backend/src/services/bike_matching.py | 147 +++++- FitnessSync/backend/src/services/discovery.py | 453 ++++++++++++++++++ FitnessSync/backend/src/services/parsers.py | 14 +- .../backend/src/services/power_estimator.py | 160 +++++++ .../backend/src/services/segment_matcher.py | 9 +- .../sync/__pycache__/activity.cpython-311.pyc | Bin 17805 -> 18515 bytes .../sync/__pycache__/activity.cpython-313.pyc | Bin 14244 -> 16839 bytes .../backend/src/services/sync/activity.py | 10 + .../src/utils/__pycache__/geo.cpython-311.pyc | Bin 5647 -> 8201 bytes .../src/utils/__pycache__/geo.cpython-313.pyc | Bin 4770 -> 5750 bytes FitnessSync/backend/src/utils/geo.py | 65 ++- FitnessSync/backend/templates/activities.html | 71 ++- .../backend/templates/activity_view.html | 137 +++++- FitnessSync/backend/templates/base.html | 5 + .../backend/templates/bike_setups.html | 59 ++- FitnessSync/backend/templates/discovery.html | 444 +++++++++++++++++ FitnessSync/backend/templates/segments.html | 4 + ...est_discovery.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 6002 bytes ...ontend_assets.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 3215 bytes ...gle_discovery.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 6258 bytes .../backend/tests/api/test_discovery.py | 31 ++ .../backend/tests/api/test_frontend_assets.py | 12 + .../tests/api/test_single_discovery.py | 43 ++ ...est_discovery.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 11030 bytes .../backend/tests/services/test_discovery.py | 130 +++++ FitnessSync/pytest_output.txt | 42 ++ .../scratch/check_stormchaser_generic.py | 73 +++ FitnessSync/scratch/debug_match.py | 138 ++++++ FitnessSync/scratch/debug_specific_matches.py | 168 +++++++ FitnessSync/scratch/test_save.py | 28 ++ FitnessSync/scratch/trace_turns.py | 146 ++++++ FitnessSync/scratch/trigger_scan.py | 19 + FitnessSync/scripts/backfill_lat_lon.py | 58 +++ 73 files changed, 2881 insertions(+), 59 deletions(-) create mode 100644 FitnessSync/backend/src/api/__pycache__/discovery.cpython-311.pyc create mode 100644 FitnessSync/backend/src/api/__pycache__/discovery.cpython-313.pyc create mode 100644 FitnessSync/backend/src/api/__pycache__/segments.cpython-313.pyc create mode 100644 FitnessSync/backend/src/api/discovery.py create mode 100644 FitnessSync/backend/src/schemas/__pycache__/discovery.cpython-311.pyc create mode 100644 FitnessSync/backend/src/schemas/__pycache__/discovery.cpython-313.pyc create mode 100644 FitnessSync/backend/src/schemas/discovery.py create mode 100644 FitnessSync/backend/src/services/__pycache__/discovery.cpython-311.pyc create mode 100644 FitnessSync/backend/src/services/__pycache__/discovery.cpython-313.pyc create mode 100644 FitnessSync/backend/src/services/__pycache__/power_estimator.cpython-311.pyc create mode 100644 FitnessSync/backend/src/services/discovery.py create mode 100644 FitnessSync/backend/src/services/power_estimator.py create mode 100644 FitnessSync/backend/templates/discovery.html create mode 100644 FitnessSync/backend/tests/api/__pycache__/test_discovery.cpython-313-pytest-9.0.2.pyc create mode 100644 FitnessSync/backend/tests/api/__pycache__/test_frontend_assets.cpython-313-pytest-9.0.2.pyc create mode 100644 FitnessSync/backend/tests/api/__pycache__/test_single_discovery.cpython-313-pytest-9.0.2.pyc create mode 100644 FitnessSync/backend/tests/api/test_discovery.py create mode 100644 FitnessSync/backend/tests/api/test_frontend_assets.py create mode 100644 FitnessSync/backend/tests/api/test_single_discovery.py create mode 100644 FitnessSync/backend/tests/services/__pycache__/test_discovery.cpython-313-pytest-9.0.2.pyc create mode 100644 FitnessSync/backend/tests/services/test_discovery.py create mode 100644 FitnessSync/pytest_output.txt create mode 100644 FitnessSync/scratch/check_stormchaser_generic.py create mode 100644 FitnessSync/scratch/debug_match.py create mode 100644 FitnessSync/scratch/debug_specific_matches.py create mode 100644 FitnessSync/scratch/test_save.py create mode 100644 FitnessSync/scratch/trace_turns.py create mode 100644 FitnessSync/scratch/trigger_scan.py create mode 100644 FitnessSync/scripts/backfill_lat_lon.py diff --git a/FitnessSync/backend/__pycache__/main.cpython-311.pyc b/FitnessSync/backend/__pycache__/main.cpython-311.pyc index afc96633f87d38ebe36bc26ec59e3628d7b476f2..126f32fb490a80f3a8b164afd277f692bdcdb881 100644 GIT binary patch delta 1516 zcmb`H&rcIU6vt=j(k;~1Qnt_^fQ1%lD^Q9i#$X{-j4>P}!o>m8lh`mUR ziJEAF>BYngCxqC82`5kT7nm(+g4t7}R}Un3;^fYwh zRz7>eTK$Wz${=!k ze(aaJaktcid!%06D+O>s3gVy?!XZ72x~+@)R{X@b*jI@wxN`14`ko5l(Y6EBh4^*K(GEjxi-V_GGD(wJ- zakvT$l5iCeI(|Q5fG&uGQUph;P#1|*q2E?9O`)K7Z;~yVXM>E!P9~HE%_u7gC6&x{ zOqF`CkfJ21r0E0ia$51ur&pH+Z$j}-2kSI$B|*uuB5NkHo|2V#dMU$&%Q%E<_(5jx z+Ftm;m6Pc;LLX$T;lxTR{KE&W;XauQ4rRaEK8O%hSrAimGy>OIGsWZcDUrnE8b^to z7T1VoEU&7WVq;S`;^VjOXpK|TW7D^;m)?z~RAkzw)AiC4Bmo^{t!M^Wq4bJA&dD@6 z06AnoHn|pQ6o%-CZbtP8Z>#*+I}8SDo{bgQ zn99bAto4xP4_N-y^v<}-@_9B=7-AzT8z~~glZHnPPlKB?n=?C?;K}Tc=0;V=aNaRo za18GUHX8D1qJSn;G_kkR{F(uzT|HYrA;TfEA0YeY)k3pR?YpKTdmcp#D5|39QDfHP J2(z=W_3vq^T}%J~ delta 891 zcmaE$e?UuPIWI340}u$hBxc%jGcY^`abSQA%J{6pGEw6ZORCTU(a8rHg=HC1_?Z|| z*-`}72rgq}U|0>r5Rf9YMtHITlc>E2x=fT<3PXx$j(Dy_ltiv%lw__{lvJ*Clyt64 zluWK{lx(hClw7WSlsqE?&=v)dEvgKu5?P9Hlf~AEFJl7Q1;h}5qEeD*l~P2jlqXuH zG|?($h*l{}ZIy-$sdD(u1jdjAFou+(ln`MH)TKmFmn2Y^a+EScmo7u9GC~(>8ewDr z+9Q>tlB*h}iqOHzkgA%cI5~q=mQi|h8|wpZ%_?^N;*!LY%;YLgJv}H>lkpa3N@h`N za!GzskFdh7e&plh??)nXyCla;C_X{{Q`sg=7*xQ R8JRwCOkOLl&*Bf%3;-A%w*deE diff --git a/FitnessSync/backend/__pycache__/main.cpython-313.pyc b/FitnessSync/backend/__pycache__/main.cpython-313.pyc index 45ac20375ba3d2c5c783f24cabfeb2d81cc6b6bc..6adbf821cec0d0c2fccac9d082765130a6003add 100644 GIT binary patch delta 1630 zcmbu9O-~a+7{{lybPG$Nv{0ZeP$-4A5o*B~3PMxB7#9=GE{R6hYziz=c`12zLFH2P z0>(sX_5gkZj299=fSkOUbfczbPb3CU9yIFRnbM^gMT~CKd0v0>eq!9u)?`ER}P6rCiq#q&P#`YDr(7jf~RrBsj)KjAC;yK*xfLg1NvQn*I+Q+!={Sx#tY6rc4;%*_(*GYOh;@F|*H47+-;M9T6FQH)fS@m#Y%t9;a460x2Sy&H5Sv+oWrL&eAyG{ a^31^Za%Ave#ln6)8-1C+cc=+?eCi*qFfQZ( delta 965 zcmX@B)vl`XnU|M~0SJB^iOYNgWIYCPV1OOU_*}z0QR7&Bj8Kp^ObS5<^T)6$F$4<) z3tBP;3k3^XGAl5|umD9wKq5tA!3@Eo-r`;oMG{_;MUq}pMN(eUMbcg}MKWHpMY3LU zMRH#9Me;yBLP3rQQ(>$ai6AE^yB;P6q=UtR#Vwg|YLbL&B3_RaAw9_ULye7*C!`6< zewZFm?Rs7MTKh zMP?wv97I@v2ul!Q1tP3Lgw5pjLNXS1K;|uOkhY@y(vsAo;v##X$V!IKK$4+I1tiO1 zlbfGXnv-f*u?GIg%g57#LaFIU6}UG$%%{u)Zj2z9XZ7^CE-$7ngRfUz>vNG diff --git a/FitnessSync/backend/main.py b/FitnessSync/backend/main.py index 9fd25cd..31fed25 100644 --- a/FitnessSync/backend/main.py +++ b/FitnessSync/backend/main.py @@ -60,7 +60,20 @@ async def log_requests(request: Request, call_next): logger.error(f"Request Failed: {e}") raise -app.mount("/static", StaticFiles(directory="../static"), name="static") +from pathlib import Path + +# Resolve absolute path to static directory +BASE_DIR = Path(__file__).resolve().parent +STATIC_DIR = BASE_DIR.parent / "static" + +if not STATIC_DIR.exists(): + # Fallback or create? + # For now, just logging warning or ensuring it works in dev + logging.warning(f"Static directory not found at {STATIC_DIR}") + # Create it to prevent crash? + STATIC_DIR.mkdir(parents=True, exist_ok=True) + +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") templates = Jinja2Templates(directory="templates") from src.api import status, sync, auth, logs, metrics, activities, scheduling, config_routes @@ -82,6 +95,10 @@ app.include_router(segments.router, prefix="/api") from src.api import bike_setups app.include_router(bike_setups.router) +from src.api import discovery +app.include_router(discovery.router, prefix="/api/discovery") + + from src.routers import web diff --git a/FitnessSync/backend/src/api/__pycache__/activities.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/activities.cpython-311.pyc index 5fb4f4be6e53cd46503fe4d1f330bda90eecee42..d422953ee984664944836d67cc4688a0160a7c11 100644 GIT binary patch delta 7757 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 delta 3522 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 BS3v*( diff --git a/FitnessSync/backend/src/api/__pycache__/activities.cpython-313.pyc b/FitnessSync/backend/src/api/__pycache__/activities.cpython-313.pyc index fb99fa5056ad548c575d167c0594901d842113ca..68efc6ca93255aaedf49da50b3cd969408df12c4 100644 GIT binary patch delta 10400 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 delta 9502 zcmb7J3wTqKZqZMARNF}P=qamgOQw(2$(Dt zZE5ppx&&q$61rdFG}+eqcH5GwZD_mOLLea<$hS65E2^Zs$@leZw@o({3*Bs!cDv`y zwJcjWX?wx{J&&0)XXehFIWzj=(B-*~>-eOX0=iGb|*N7(IAtmmtZN2oy-G-g+vPOPE zSnkTmqL27VUES9Zwmu%Zb>l(9%jc$#* zLIe}8oEDi|26jy@vn$t?*CH?u9n1%fSj4$kCH!a*To!k=%L;!5?wkgN%a+I8C%=zh zbl#b;pJq?mr|3H?{OwZ_7i$glj6lXS1t6e&RyLgcdcgD*E0!x z8IaU7NgK9@-?_PAu?76bjjkqFbBnBjcdc4c>SV<)KQj3r!A#shpXMa(X|2Qj>f$StuM6D!FtvCG1`qx+l8$rMpt^{ z7<)H;tPjaq(wX8%_d-kQ?i%n!%e;^+_|nB%>q(ZNPSGT8PV|}HBW6@4XZzCmv^k}f zI~MD_AZC=WT&FjsPA~m;F;0K2_%`pL&nTZ3ZO~IQ-KdK4R+^^{^X2r6+Mc@#q&IO4 zNC84&0<3giU9G5NY($~SiV@Nfsu7$Bxavs_ zLKA(%R9?(lsYM0I+5z4$8=o*a41MA zLK#9izzZ6w=d7#P%QE^$)0qlnW2yid^%2jWDY6Dx`KT{ALWZ&C5b}a|VuGxt7tDEK z)_>MS6RMk0tsV~s#scI3^r!`etqAK7)+4a7T*0B3FyZsZ)Ln;1e3PN^fIlYs2ni7B zGbo^@L&KVv9C3pnrldo;>sGHtlJ^C1tFkXXYYm^!MK!-W^7V=?9tP)TWvOR{Q6XV8&E}9eHco;ef^HE%0{cwDUSCL>5EybK&lh{+ zP?FD~l=*vz9;$LWr+^PC0T#Zi^i}nvfkS>+GNTEfr^pjf=HDXoRV6an^Wv#be=BoX z3ljFYN@_WmoNhguqww%9aZpC5hYUz6fV@$|NF|W(+6;1#s_2H}IkcuOoxNv{8tB=- zQI*`uyVRi649d3Q$4+@blLi*-*#pSIvcC$c(ktpQ>O*UvSAf`N^F@V)~>)7ZRfjnHAFlo^jwum0Si7(-BI^moCvB6~BnrKAHc_GaEh2d=p$+khPH&8SvWN`Je z#D;I@hH>X$kE?iiM}i&{5+z(hi_8a;)-QAjWBeGmUa)avd^6CFQhKt{$v;HTH<~wN zgj>21Aj?7;ewr;y;u4D_IQE3?Tgh#z6J>0NQn2`bFFn1cEIfizw)s^hgJv>3kdvrq z!GUdI%eN4=?W6|AKjQ$}>qfv2M(E$_n!}&C-AM71#jwR7>X0uDKxl`>A30DMf281J zfDpDAcRZuMkav6{NS=Z+e}KQ>&tx3;EAAK<*YHj{uzNMlY0VIRt}CTqYW1bX)4802 zsIlytv5YF$_J`v+i+tQvlBtQClYB1nd7Qy=y8TppJRdnrg0mu5z?p2(oK@FyR>f_| z+mn1D@vc9mrR3+4-lPr=0Oh zkt z>nN<8ru7|baOe08-c-0O0rUl#?WRg6{}`n1C9*2+GWa785oeMKwuWk!Cn zzChrosn}6$Ws@#jnsjHtUQ8|R)R@kkq+J~?{3#ml$l35^)G0xr~z}F->wj5BSLV*pz3lmmC=P zdxE}T5V9Kb4JfxJvS#CX+*5zw)dibrd-p>MW_lmJ+HEO#5lY1LK_7Wwe8d-Y2B-WZ z9`EENiC|mbpo*T_Lgr0!+NzdJTdYkYbVrZX{B0!J3|0Zfd}Hp>$9jsye*w|fNTeqs zD9&QJZ_$aqp55OCGNzg&fxQI$b@1dzAUL9Tjd=a``vQCHV=&o$qpfy_mb`?uSZn8y zV%{wuC1PaOu#N{RlNze;uiNuOERAMkS)+Rr!{Ql(94$bmB!kQrIxeLGKS1iA0fK6P zL_ZdmV>&qqgA4w#CSHdEha=DT6Jb>96;{1~@G`&_hfGQ#(Ff=ijO_AWrTwM7BCnv$ z8hT`_&BZ!2g#5oENFM;Hzp4lLm2r^XfSM;Fzu9_RmU9`%m@*jhl8|ro1sP!teV2aT zRR{-L{7&A{QBTMl+)uFEBp(1G*eudGzEXJ$P{Cvbqwlz^;!i;K)rf5SG{5UA7QBWq z58zO-Z${okK^B4m0b3(KMnLbDHN^VO97}EJqrp7!r%?PmH1F>1d^8fc`xejNO-FY& z^5gW(PU{^kO;qhAfrFE{Hd&Uud;&Q{X#(iLOiYA<2nAz&P@2-1KeDMn3Qp~^ithq@ zffnt$DIkHcu!B#6rx)(qLEqg|8&(Xem!}FgsNq+4s0T6aU{o?8 za43&#({vpU5mL#(00V! zg=ancq?ZK8{d*VNqxrtha5p83+QC%fOV zmCoDh&W_DDZN6Ssbyho9);29Hq3mT}(>|-63C`U26MM@I6IW39fxu<00>~(XydXX6 z*uV$jLXiAeU;ZJJf}abDk8UZsbmL8qhhC^MPNYAQK5MEu%g+R7jrCDg!>p>|oizQi zfsf)!DDhzsKf&=FxMezH>Qo6Y7Hw)4FPJhA9R|Rcd4{>gKwso|MA42|I;?{77pqlJ z^{yBWYm>%oJ;1H!=E@PY!m7IHF@+?LAXYt)1S}5a#UK{ zYMqH&l5h_y=v}oIxnDleN}v9`nH7BQem$&zb=qQ9E)@#<#R1r-an%mW*?Nb)ZBU8< zux$?V+c37JJUh29TlEm4S$$Z&g_u_uHPFP4;>u!b^0KesPzS7 znf*h@B-Y#A>WBg{`3wiU!;f6D=cXA7!$$cs}np-)STRB_R z7R_y+&uw2)2+G_@;|eO`nVi8y+oyxig|8d(P7jdTJ2l(|T{Gaz3gf0$`OEosq}z?1GWkV= z2=qlOk64t0*ecu9uDrNT0rVxAtW&AHBxkfn)7dOsGMGD83zt@lKp!crN#4}eJm{r= zdN7^V4O#gydgss$Me9zE^X5?d(JI-Uo8dag%I8o&GDY<1quq3NSX-*B;an=G47NF? zOYPFQ(rWQGMsR7JGFOIEsDaxZr;PsmXd|zqd7sL!%N({skJvLPPdPd3{qhcZa#gUC za~3`BZi18Z58SKLMxmOtwLC)O?gKeWm?yEckpS#^<9=_*7jy_j$z=d#IcEoYCaJSi@2lvt; zLIz8LkPfKDa6aA9Y@AOkd)rt&voGr0H0RtjZ|r=e`?}T`)!OE?w&})s?W$BQOd2g@ zLA#&qt>cexvTthOE|&0}b^OKB45aHB-Jt1o3K!cdI~~F$hY0i~rvPP^g>F2Su+Uv1 zr{D6F(|_1zWa-)ecvyO7F5;5+8IYzya+b~Y@SFEj=e_?e4)QL!NNe|-^n=R8r-LX9 zH+)Pg9~7l@EniG%*YkKb#Ag%+0`ew~c6!T&g>rP5sP(rMp$~djB}(KiwsU*2NU~Uk z>f;+sXqcnw<&HYk3GHp>xrD|J{U7jC|DMl^s^J{?w6j@xwQwt`x-GnOepLr#k+2jP zm7K^BTQ`g}Rg#~>IK>QhN{-d)#o2CFtG~vYl4*k8)USJSA`0K{phczpvVDyax71TS{BwV)?(`U!5 z;d90x*}i9c%`)F|ceG{q?9lEvEgsNK_sm(UqL%tOOa0ldm+pM=&htI<4gJxE?Xz3A zziAo761{Vlny95|&eC-D;7j2b!{?98H*bqJ56#{^^rmI_FSBt+_ngHUwKUFI8qfM( z3cMINKR(~IHQKaucKgn#W!DYs^31J_OV2*G<%81H#I1}5_O9Jy?kVD)GxcN&-!JZN z7cUfMAl8=wzHAi{%Q}_tdbL^wWnOJmLGf4HH9cDOWwjC{moqgzHs$3!0pyo$42v|I zb;9K;^Jb0knnv8Lqxwk`KSLdpCG?H$Y^g@>o19RnvvHby03VM2;(?*OPn<{ThfPNKT>~@%93o-@Bo{GR2u_u><8UsV5odk*B4z;f^ufcc`9~w)IQ*C_ ztm!Y|F64GN;dN1I>~qR5S~^5vE|nVlniZE?%Yc5xZtUmeuQV0{{c3(a;N=Wszgls* z*bek-N@IVP;x$VK(zP3#`}1X2%F)CXr>w6*d8Jl~bhE5)z4}TE(pNcIze0IchIHdq zwX9#KzM94qS+ah!`l^A^`Lg~(@v4>4C7J=faMfuZNEhBn7YFo__aB^=86U!tz$At& z-Lang)W*MhTf&t_>Jj#$%}k`&cL*X|191D6#J&hyyKune?D!|$65`Lv@Mt1q+kUUG=kx}cz?b*zsluNO| z!&bf{^2LWgE93j9{tGVtE$aKi3t<-Jeu?&thYw6R;pZ!#&)GHMI{?Sv?LfoNlqia& zEs;r69Oo~bg98WQXF_&p?GNnT3%?jT&_y>Ji!zP9any96<=~?wb5O)ZXTTQwUI&4 z!hbGUNvpqDr+bUbe2Y{62Uj%975$8}Mn=E*W!|=$UpKQU&cW;Kj$8P;DR8o#d|U?P z?F^T~MEEn``R5g|hetdiL>0<#o`F{|9qa Bjz<6h diff --git a/FitnessSync/backend/src/api/__pycache__/bike_setups.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/bike_setups.cpython-311.pyc index 1f91ad21a4ab0a2f83d7003dc6792d8c374bde35..4aa8edf76781bd2db45d1bc3f70910dd240f224d 100644 GIT binary patch literal 9638 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 delta 2783 zcmb7FOKcNI7@qNC{feD9A$IJp$#bznh@TPLVyAxAiz>?F)?+JdMO zsT`sTwZMSNsm-BHML`9XTTgIpFHl8lx~Ep^ttxT~NNo>Q|9|Wx7}G;HnQv$R*ZlMS z|BPq$J?{2>>Giq^JRi-zo&DB##n(WmuCyIINr^@@CFf0fHHYTR`SR7NYLGcJSFR@S zPxnh03!UqFN&hYYj{pvT02h?K9wfYaswhwJfasCeb3S>2`3& z0(uz5=RYDwS!*d`tRi9pA|^54zW*CB&KhM@MQg5zx*Vdml^BWu2P>{xBWo+Sx|CRJ zMF@3)NFX@@Ayz^NTh zg{+|katO0&_KonR+4_m9h(DFp4%VB~rl5|;vX0jdvFE$PLfIUa2 z7nbEJ$`G)AI`I3hcen8Qn#DoiT18ev6;r% z^Y#0)x)IOha`6G^qqvTZq_?Y9(|&fG@pIe@uBH|AD&?KH65e$2;?ApwnUO}fbkU7> zgKjk%;Z`O|K_OMXkGnqXy59T96P@)$fAqBc;Awf}Nz8f@54&v%Nr`IV>%iRf=tjmE z*VBWe8Z+%O;^5*H1O^HWyg#2j6nBpVpK&T2B&0(1pmA z_$Ls!XN`2y;qvSbKs^`9ds%u&a*hF`GNrw^NEvbdqWbv~_kmGbd={Mm?qadav`XBK z<<|9MGM3^VaIRykF3?{Ccg659v! zo;>t?PH!K`=#2Zo)NF*wJJ~^|w~vkL#xQ64*&IY0$v{+&ze#4*xRC{y!Q$mpgCZAH zeg7yfEbkXn$rV3>pe+94$lDt)4O8KxaGCK|cO2fe z?Z~d3vd6i+p|B{Xb!^<=SR#H%eBT_}fc%)Al2=Fi;6Az87p|+nof~0~<2kmOp9Xup zMD%ka+H$%k@{pAMi%(mO2^)ReBB?GuO&d=?pfMZu%N?o@g#A_hG*elx_VR> diff --git a/FitnessSync/backend/src/api/__pycache__/discovery.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/discovery.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..36547b62dd7a5dd29b9060d72bb5f56f39f69b3d GIT binary patch literal 3579 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 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-313.pyc b/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-313.pyc index 6b5cbeb0044b4414136d999c629a00caa8485c51..3a9470bd895f6262f14e4b1b5b8aca93d8d3f682 100644 GIT binary patch delta 575 zcmdmPcGimTGcPX}0}x0jBxY_CnaC%>n6pvclF1@QJjfFyjtYY1gB5~hEt!JFViZl7 zf)%A0vRF)^N{Ten*))|lr!eV>F;&SMJVphEP{zpz1Vtu02(i?gLbYfB6@fq^Cj&z;b1+LN10O>$t1)vhn;w%2 zLo_=?7AVIa%n{6K$&|{d$yKH3o0wOcn3GefP*Rkco}OBykf>0coROMRnvdL zSHuIZgE1@q^4A{c;zQ4REYz1)q}MG6&IH#C#Mz{rYEJYC@wjhwW#SJw$9%x|^3rLo!$N?r>%mid9C=`hUsat}; zfQpAZ5TxF}C=V#n3dF@$K%#-+D;oo={tb44>+CX@*kxvfU1wLh$gXljN_B(F4zV2^ z7aYPb7)4y?<89?-BW(Fqvk6CQXgal~M|fljliG ca+LyAG6HdN#O8z2VT@cKnK&2)iu8aY0QZg&zyJUM diff --git a/FitnessSync/backend/src/api/__pycache__/segments.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/segments.cpython-311.pyc index 9eeeaddeaeaecbb6075b6ba1e4b8ebe7f4867250..ae8e55bd2c284cbfe8b846526638bc775c4a06a0 100644 GIT binary patch delta 5401 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 delta 1491 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 LVXR)33if{kV1IN3 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/api/__pycache__/status.cpython-313.pyc b/FitnessSync/backend/src/api/__pycache__/status.cpython-313.pyc index d149b9a7ae54a2688c692c947f2f7f8b896f3f08..5a1e3d60e124d5eeef313fd225f4b392810c2e40 100644 GIT binary patch delta 508 zcmez5z1N@bGcPX}0}w>UCuTN@PvnzeoVQUuj*(GwavS5m$$U)Wrpl&_AO#Ez!Ll(5 zCQQL9QVdxvrVt^9BK2T~U>0o@{5vFb+a>bawZo@ zh)w2{N>gAJzj3mMjI6NI29XVE8zL^)`d`owxX2%Pfg^Bph0J7T*6Bbcotp&|>X2-M0wAA(O)csNu|PVC=1ulik>y$qWP;+N zc){i(68{3CM5WyiT!>i37ycWGb3G z*+fO1btaHAdvbzG2&2~Ktt#@2qF@6XfTBfRAOfVgXy)X1s;G4=*ak)nSn`da;3Tp06)(=E&u=k diff --git a/FitnessSync/backend/src/api/activities.py b/FitnessSync/backend/src/api/activities.py index eb279ed..672fcb3 100644 --- a/FitnessSync/backend/src/api/activities.py +++ b/FitnessSync/backend/src/api/activities.py @@ -141,6 +141,7 @@ async def query_activities( start_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None), download_status: Optional[str] = Query(None), + bike_setup_id: Optional[int] = Query(None), db: Session = Depends(get_db) ): """ @@ -154,7 +155,21 @@ async def query_activities( # Apply filters based on parameters if activity_type: - query = query.filter(Activity.activity_type == activity_type) + if activity_type == 'cycling': + # Match outdoor cycling types + # Using OR filtering for various sub-types + from sqlalchemy import or_ + query = query.filter(or_( + Activity.activity_type == 'cycling', + Activity.activity_type == 'road_biking', + Activity.activity_type == 'mountain_biking', + Activity.activity_type == 'gravel_cycling', + Activity.activity_type == 'cyclocross', + Activity.activity_type == 'track_cycling', + Activity.activity_type == 'commuting' + )) + else: + query = query.filter(Activity.activity_type == activity_type) if start_date: from datetime import datetime @@ -168,6 +183,9 @@ async def query_activities( if download_status: query = query.filter(Activity.download_status == download_status) + + if bike_setup_id: + query = query.filter(Activity.bike_setup_id == bike_setup_id) # Execute the query activities = query.all() @@ -376,7 +394,20 @@ async def redownload_activity_endpoint(activity_id: str, db: Session = Depends(g success = sync_app.redownload_activity(activity_id) if success: - return {"message": f"Successfully redownloaded activity {activity_id}", "status": "success"} + # Trigger bike matching + try: + from ..services.bike_matching import process_activity_matching + + # Fetch fresh activity object using new session logic or flush/commit handled by sync_app + # Just query by garmin_id + act_obj = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first() + if act_obj: + process_activity_matching(db, act_obj.id) + logger.info(f"Retriggered bike match for {activity_id} after redownload") + except Exception as match_err: + logger.error(f"Error matching bike after redownload: {match_err}") + + return {"message": f"Successfully redownloaded and matched activity {activity_id}", "status": "success"} else: raise HTTPException(status_code=500, detail="Failed to redownload activity. Check logs for details.") @@ -389,6 +420,48 @@ async def redownload_activity_endpoint(activity_id: str, db: Session = Depends(g # New Sync Endpoints +class BikeMatchUpdate(BaseModel): + bike_setup_id: Optional[int] = None + manual_override: bool = True + +@router.put("/activities/{activity_id}/bike") +async def update_activity_bike(activity_id: str, update: BikeMatchUpdate, db: Session = Depends(get_db)): + """ + Manually update the bike setup for an activity. + Sets bike_match_confidence to 2.0 to indicate manual override. + """ + try: + activity = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first() + if not activity: + raise HTTPException(status_code=404, detail="Activity not found") + + # Verify bike setup exists if provided + if update.bike_setup_id: + from ..models.bike_setup import BikeSetup + setup = db.query(BikeSetup).filter(BikeSetup.id == update.bike_setup_id).first() + if not setup: + raise HTTPException(status_code=404, detail="Bike Setup not found") + + activity.bike_setup_id = setup.id + activity.bike_match_confidence = 2.0 # Manual Override + logger.info(f"Manual bike override for {activity_id} to setup {setup.id}") + else: + # Clear setup + activity.bike_setup_id = None + activity.bike_match_confidence = 2.0 # Manual Clear + logger.info(f"Manual bike override for {activity_id} to cleared") + + db.commit() + return {"message": "Bike setup updated successfully", "status": "success"} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating activity bike: {e}") + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + + def run_scan_job(job_id: str, days_back: int, db_session_factory): """Background task wrapper for scan""" try: @@ -685,6 +758,23 @@ async def get_activity_streams(activity_id: str, db: Session = Depends(get_db)): logger.error(f"Error getting streams: {e}") raise HTTPException(status_code=500, detail=str(e)) +@router.post("/activities/{activity_id}/estimate_power") +async def estimate_activity_power(activity_id: int, db: Session = Depends(get_db)): + """ + Trigger physics-based power estimation. + """ + from ..services.power_estimator import PowerEstimatorService + + try: + service = PowerEstimatorService(db) + result = service.estimate_power_for_activity(activity_id) + return {"message": "Power estimated successfully", "stats": result} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error estimating power: {e}") + raise HTTPException(status_code=500, detail=str(e)) + @router.get("/activities/{activity_id}/navigation") async def get_activity_navigation(activity_id: str, db: Session = Depends(get_db)): """ diff --git a/FitnessSync/backend/src/api/bike_setups.py b/FitnessSync/backend/src/api/bike_setups.py index dd880a2..d485911 100644 --- a/FitnessSync/backend/src/api/bike_setups.py +++ b/FitnessSync/backend/src/api/bike_setups.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from pydantic import BaseModel from typing import List, Optional -from datetime import datetime +from datetime import datetime, date import logging from ..models.bike_setup import BikeSetup @@ -22,12 +22,18 @@ class BikeSetupCreate(BaseModel): frame: str chainring: int rear_cog: int + weight_kg: Optional[float] = None + purchase_date: Optional[date] = None + retirement_date: Optional[date] = None name: Optional[str] = None class BikeSetupUpdate(BaseModel): frame: Optional[str] = None chainring: Optional[int] = None rear_cog: Optional[int] = None + weight_kg: Optional[float] = None + purchase_date: Optional[date] = None + retirement_date: Optional[date] = None name: Optional[str] = None class BikeSetupRead(BaseModel): @@ -35,9 +41,15 @@ class BikeSetupRead(BaseModel): frame: str chainring: int rear_cog: int + year: Optional[int] = None + weight_kg: Optional[float] = None + purchase_date: Optional[date] = None + retirement_date: Optional[date] = None name: Optional[str] = None created_at: Optional[datetime] updated_at: Optional[datetime] + activity_count: int = 0 + total_distance: float = 0.0 class Config: from_attributes = True @@ -46,8 +58,40 @@ router = APIRouter(prefix="/api/bike-setups", tags=["bike-setups"]) @router.get("/", response_model=List[BikeSetupRead]) def get_bike_setups(db: Session = Depends(get_db)): - """List all bike setups.""" - return db.query(BikeSetup).all() + """List all bike setups with usage stats.""" + from sqlalchemy import func + from ..models.activity import Activity + + # Query setups with aggregated activity stats + results = db.query( + BikeSetup, + func.count(Activity.id).label("count"), + func.sum(Activity.distance).label("dist") + ).outerjoin(Activity, BikeSetup.id == Activity.bike_setup_id)\ + .group_by(BikeSetup.id).all() + + response = [] + for setup, count, dist in results: + # Clone setup attributes to Pydantic model + # Assuming Pydantic v2 or mapped correctly. + # We can construct dict or let Pydantic handle it if we pass enriched object? + # Constructing explicitly is safer with Pydantic 1.x pattern + response.append(BikeSetupRead( + id=setup.id, + frame=setup.frame, + chainring=setup.chainring, + rear_cog=setup.rear_cog, + weight_kg=setup.weight_kg, + purchase_date=setup.purchase_date, + retirement_date=setup.retirement_date, + name=setup.name, + created_at=setup.created_at, + updated_at=setup.updated_at, + activity_count=count, + total_distance=dist if dist else 0.0 + )) + + return response @router.post("/", response_model=BikeSetupRead, status_code=status.HTTP_201_CREATED) def create_bike_setup(setup: BikeSetupCreate, db: Session = Depends(get_db)): @@ -56,6 +100,9 @@ def create_bike_setup(setup: BikeSetupCreate, db: Session = Depends(get_db)): frame=setup.frame, chainring=setup.chainring, rear_cog=setup.rear_cog, + weight_kg=setup.weight_kg, + purchase_date=setup.purchase_date, + retirement_date=setup.retirement_date, name=setup.name ) db.add(new_setup) @@ -85,6 +132,12 @@ def update_bike_setup(setup_id: int, setup_data: BikeSetupUpdate, db: Session = setup.chainring = setup_data.chainring if setup_data.rear_cog is not None: setup.rear_cog = setup_data.rear_cog + if setup_data.weight_kg is not None: + setup.weight_kg = setup_data.weight_kg + if setup_data.purchase_date is not None: + setup.purchase_date = setup_data.purchase_date + if setup_data.retirement_date is not None: + setup.retirement_date = setup_data.retirement_date if setup_data.name is not None: setup.name = setup_data.name diff --git a/FitnessSync/backend/src/api/discovery.py b/FitnessSync/backend/src/api/discovery.py new file mode 100644 index 0000000..7eba267 --- /dev/null +++ b/FitnessSync/backend/src/api/discovery.py @@ -0,0 +1,83 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from datetime import datetime + +from ..models import Base # Ensure models are loaded if needed +from ..services.postgresql_manager import PostgreSQLManager +from ..utils.config import config + +from ..services.discovery import SegmentDiscoveryService +from ..schemas.discovery import DiscoveryFilter, DiscoveryResult, CandidateSegmentSchema, SingleDiscoveryRequest + + +router = APIRouter() + +def get_db_session(): + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + yield session + + +@router.post("/segments", response_model=DiscoveryResult) +def discover_segments( + filter: DiscoveryFilter, + db: Session = Depends(get_db_session) +): + service = SegmentDiscoveryService(db) + + + # Defaults + start = filter.start_date or datetime.now().replace(year=datetime.now().year - 1) # Default 1 year? + + candidates, debug_paths = service.discover_segments( + activity_type=filter.activity_type, + start_date=start, + end_date=filter.end_date + ) + + + # Convert to schema + results = [] + for c in candidates: + results.append(CandidateSegmentSchema( + points=c.points, + frequency=c.frequency, + distance=c.distance, + activity_ids=c.activity_ids + )) + + return DiscoveryResult( + candidates=results, + generated_at=datetime.now(), + activity_count=len(debug_paths), + debug_paths=debug_paths + ) + + +@router.post("/single", response_model=DiscoveryResult) +def discover_single_activity( + request: SingleDiscoveryRequest, + db: Session = Depends(get_db_session) +): + service = SegmentDiscoveryService(db) + + candidates = service.analyze_single_activity(request.activity_id) + + # Convert to schema + results = [] + for c in candidates: + results.append(CandidateSegmentSchema( + points=c.points, + frequency=c.frequency, + distance=c.distance, + activity_ids=c.activity_ids + )) + + return DiscoveryResult( + candidates=results, + generated_at=datetime.now(), + activity_count=1, + debug_paths=None + ) + + diff --git a/FitnessSync/backend/src/api/segments.py b/FitnessSync/backend/src/api/segments.py index cd0eac7..5615b6b 100644 --- a/FitnessSync/backend/src/api/segments.py +++ b/FitnessSync/backend/src/api/segments.py @@ -44,7 +44,9 @@ class SegmentResponse(BaseModel): distance: float elevation_gain: Optional[float] activity_type: str + activity_type: str points: List[List[float]] + effort_count: int = 0 @router.post("/segments/create") def create_segment(payload: SegmentCreate, db: Session = Depends(get_db)): @@ -120,9 +122,17 @@ def create_segment(payload: SegmentCreate, db: Session = Depends(get_db)): @router.get("/segments", response_model=List[SegmentResponse]) def list_segments(db: Session = Depends(get_db)): - segments = db.query(Segment).all() + # Query segments with effort count + from sqlalchemy import func + + # Outer join to count efforts, grouping by Segment + # SQLAlchemy < 2.0 style + results = db.query(Segment, func.count(SegmentEffort.id)) \ + .outerjoin(SegmentEffort, Segment.id == SegmentEffort.segment_id) \ + .group_by(Segment.id).all() + res = [] - for s in segments: + for s, count in results: pts = json.loads(s.points) if isinstance(s.points, str) else s.points res.append(SegmentResponse( id=s.id, @@ -130,7 +140,8 @@ def list_segments(db: Session = Depends(get_db)): distance=s.distance, elevation_gain=s.elevation_gain, activity_type=s.activity_type, - points=pts + points=pts, + effort_count=count )) return res @@ -222,5 +233,94 @@ def scan_segments(db: Session = Depends(get_db)): # Run in background thread = threading.Thread(target=job_manager.run_serialized, args=(job_id, run_segment_matching_job)) thread.start() - return {"message": "Segment scan started", "job_id": job_id} + +@router.post("/segments/scan/{activity_id}") +def scan_activity_segments(activity_id: int, db: Session = Depends(get_db)): + """Scan a specific activity for segment matches.""" + from ..models.activity import Activity + from ..services.segment_matcher import SegmentMatcher + from ..services.parsers import extract_points_from_file + + # Resolve ID + activity = db.query(Activity).filter(Activity.id == activity_id).first() + if not activity: + activity = db.query(Activity).filter(Activity.garmin_activity_id == str(activity_id)).first() + + if not activity: + raise HTTPException(status_code=404, detail="Activity not found") + + if not activity.file_content: + raise HTTPException(status_code=400, detail="Activity has no file content") + + # Clear existing efforts + db.query(SegmentEffort).filter(SegmentEffort.activity_id == activity.id).delete() + db.commit() # Commit delete + + try: + points = extract_points_from_file(activity.file_content, activity.file_type) + if not points: + return {"message": "No points found in activity", "matches": 0} + + matcher = SegmentMatcher(db) + efforts = matcher.match_activity(activity, points) + + # matcher commits internally + + return {"message": "Scan complete", "matches": len(efforts), "segment_ids": [e.segment_id for e in efforts]} + + except Exception as e: + print(f"Error scanning activity {activity.id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class SegmentCreateCustom(BaseModel): + name: str + description: Optional[str] = None + activity_type: str + points: List[List[float]] # [[lon, lat], ...] or [[lon, lat, ele], ...] + +@router.post("/segments/save_custom") +def save_custom_segment(payload: SegmentCreateCustom, db: Session = Depends(get_db)): + """Save a segment from custom points (e.g. discovery results).""" + from ..utils.geo import calculate_bounds, haversine_distance, ramer_douglas_peucker + + if not payload.points or len(payload.points) < 2: + raise HTTPException(status_code=400, detail="Invalid points") + + # Simplify if needed? Discovery results are already simplified. + # But maybe we ensure consistency. + # payload.points is likely already simplified. + + # Calculate metadata + dist = 0.0 + elev_gain = 0.0 + + for i in range(len(payload.points)-1): + p1 = payload.points[i] + p2 = payload.points[i+1] + dist += haversine_distance(p1[1], p1[0], p2[1], p2[0]) + + # Elevation if present + if len(p1) > 2 and len(p2) > 2 and p1[2] is not None and p2[2] is not None: + diff = p2[2] - p1[2] + if diff > 0: + elev_gain += diff + + bounds = calculate_bounds(payload.points) + + segment = Segment( + name=payload.name, + description=payload.description, + distance=dist, + elevation_gain=elev_gain, + activity_type=payload.activity_type, + points=json.dumps(payload.points), + bounds=json.dumps(bounds) + ) + + db.add(segment) + db.commit() + db.refresh(segment) + + return {"message": "Segment saved", "id": segment.id} diff --git a/FitnessSync/backend/src/jobs/segment_matching_job.py b/FitnessSync/backend/src/jobs/segment_matching_job.py index 03afe52..a5abdfb 100644 --- a/FitnessSync/backend/src/jobs/segment_matching_job.py +++ b/FitnessSync/backend/src/jobs/segment_matching_job.py @@ -25,11 +25,37 @@ def run_segment_matching_job(job_id: str): activities = db.query(Activity).all() total_activities = len(activities) + # Optimization: Pre-fetch segment locations for coarse filtering + segments_list = db.query(Segment).all() + segment_locations = [] + + # Parse segment bounds once + import json + for s in segments_list: + try: + if s.bounds: + # bounds: [min_lat, min_lon, max_lat, max_lon] + # We just need a center point or use bounds directly + b = json.loads(s.bounds) if isinstance(s.bounds, str) else s.bounds + if b and len(b) == 4: + segment_locations.append(b) + except: pass + + has_segments = len(segment_locations) > 0 + job_manager.update_job(job_id, progress=0, message=f"Starting scan of {total_activities} activities...") matcher = SegmentMatcher(db) total_matches = 0 + skipped_far = 0 + + # APPROX 1000 miles in degrees + # 1 deg lat ~ 69 miles. 1000 miles ~ 14.5 degrees. + # Longitude varies but 14.5 is a safe upper bound (it's less distance at poles). + # Let's use 15 degrees buffer. + BUFFER_DEG = 15.0 + for i, activity in enumerate(activities): if job_manager.should_cancel(job_id): logger.info(f"Job {job_id} cancelled.") @@ -37,33 +63,57 @@ 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 activity {i+1}/{total_activities} ({activity.id})") + job_manager.update_job(job_id, progress=prog, message=f"Scanning {i+1}/{total_activities} (Matches: {total_matches}, Skipped: {skipped_far})") - # Check for content + # Check for content first if not activity.file_content: continue + + # OPTIMIZATION: Check Coarse Distance + # If activity has start location, check if it's "close" to ANY segment + if has_segments and activity.start_lat is not None and activity.start_lng is not None: + is_near_any = False + a_lat = activity.start_lat + a_lng = activity.start_lng + for b in segment_locations: + # b: [min_lat, min_lon, max_lat, max_lon] + # Expand bounds by buffer + # Check if point is inside expanded bounds + if (b[0] - BUFFER_DEG <= a_lat <= b[2] + BUFFER_DEG) and \ + (b[1] - BUFFER_DEG <= a_lng <= b[3] + BUFFER_DEG): + is_near_any = True + break + + if not is_near_any: + # Skip parsing! + skipped_far += 1 + continue + # Extract points - cache this? # For now, re-extract. It's CPU intensive but safe. try: points = extract_points_from_file(activity.file_content, activity.file_type) if points: - # Clear existing efforts for this activity to avoid duplicates? - # Or SegmentMatcher handles it? - # SegmentMatcher currently just ADDS. It doesn't check for existence. - # So we should delete existing efforts for this activity first. + # Clear existing efforts db.query(SegmentEffort).filter(SegmentEffort.activity_id == activity.id).delete() efforts = matcher.match_activity(activity, points) total_matches += len(efforts) - logger.info(f"Activity {activity.id}: {len(efforts)} matches") + if efforts: + logger.info(f"Activity {activity.id}: {len(efforts)} matches") except Exception as e: logger.error(f"Error processing activity {activity.id}: {e}") # Continue to next db.commit() # Final commit - job_manager.complete_job(job_id, result={"total_matches": total_matches, "activities_scanned": total_activities}) + job_manager.complete_job(job_id, result={ + "total_matches": total_matches, + "activities_scanned": total_activities, + "skipped_due_to_distance": skipped_far + }) + except Exception as e: logger.error(f"Job {job_id} failed: {e}") diff --git a/FitnessSync/backend/src/models/__pycache__/activity.cpython-311.pyc b/FitnessSync/backend/src/models/__pycache__/activity.cpython-311.pyc index e1a099681a95c2d8c1dfc335758d3ab04e860d8e..6f4394b386d71dcb5ae78e8039d82f69eb7aeb53 100644 GIT binary patch delta 470 zcmZpa*e%JooR^o20SH`ICTA9NZR8VYWYS{Utj@^HIQbi+$YeVvHW1AyH2ENtB%?5h zoZQ1?1r`&Z{E<;|@)stB$;X&P7^T1}q(NegGGLi9W{8YDSVjRXqX?F{$IQm4JXw%Q zm{DbNKBJ_fDicF0ODcN``x=gAObiUGffxd!)W9;_V72O#by;MYHL^4(`?9DrY9aB& zC$C`<;MNAKWd)KtlaI3~GwOou64wLr^;3A4F#^p6VyL+WlNni8F&a)@!#Y!xv$!O& zs3blovE&wvnU}uVh0T&t{FZ1^W_D_PZemGtMtpL9URq{KYF={c z35riX#G$%5mqU+9Q$}fq+XB-KCKqHZF3MP3k+HbIB6&klc7n`|?2Ce0R|K^hyg;zX zb@F&(q7*(?|*|6uoFdcZWJaAp%*)Hg00gcplQWCCHuCW^G8RmhW1OSSAIu!gZpl<66vL{-5Ch{0gL!OF zIS~kN@>51lewYdgi2BL0Ow#o*b51lewYdgi2BL0Ow#o*bDEbsoc#2@264VrBFpmSu zQ-Sa_f;oaYfnHDrb9tbWY7m|h1CY;U$rz&^6fjwzSyr4Is8SQFoT8x?Sez|J8>oxd zlo1qQ3?TjNmdr&uF}greKA32cUNC=={^Wk<^^69S?O9fB{?B5;IN6V_mvP(VJ8Ws2 zW7xeIxdmlsWM3B4YW6B}oxG2uQPdS^T#*})xW!?Uo1apelWJGwJvoF^m0z7vyQAbQ K1CS~LDFOh>(^y*o diff --git a/FitnessSync/backend/src/models/__pycache__/bike_setup.cpython-311.pyc b/FitnessSync/backend/src/models/__pycache__/bike_setup.cpython-311.pyc index 21b958f586d54fff4260224a02de48fdf41d0a44..0493970a395cc7ec903bc6191727f37c42bcab3c 100644 GIT binary patch delta 635 zcma)3&ubGw6rMLbo83*aakZOO#Dh?O5DZ>IM5$W5L_BB@1(CUwZD!iU?4)+GK|Gm5 zz4frm9IOYS9;^jD_@^WgFa-YrZxy`g$=L+)62ZrOkMF&2-kT3*KmWB*`rx{Gq@(jT zY!{8RbgubWSFaI63?d|eg1{6DgBpTRB1~#Rrr{_jENW?PFcR6qp$;I7?jvUIA(rFL zk%7=hjd{>Kv({0?{+5*$GkvveP0dEM6UOQBHHg#k;8ua*Pv#NUQyTN2MT|J%?gFLs zS5fc4SL;xAysW3;s$D1>r=OltBRH!L!E=@tC|C8=Pv}BfweaR0Y3V1Y&U4=0Zp9m& zZJEnz_gP;yTVaoHurTJ+Qj=bvFs}3Ga{PRiU;N~$K`n8c?8~e4v!%1)<**bXgnGA#aD5#Hv`e04HF*?Od zf0jO~6qH9UzMT6k^>(8$YPPs|aa~hs>uXc8sg26)GiNsBa>iy0E^GG7zAbiGKjL>~ pMVFw_OLaOi0APYvhyQh8RlO%y?zzx_6SO!d8 z1c@`{u;#Kwu>sl4DJ(hcxg1d(j0{W+?hGlcEet7asT|9g85mXrF$6?$rmzPyXmY#+ zaWz>cb}CQYtj(x4@qU9TdkWJU)@4jU)j$jZQQS-nsVqRu1C(Q1#t4)HVh8|=@@DZ( ze#EG*$WKs}7Lz)oz~msNJVwFE2bq$1Z?WVh=B5_00gWnRpPaz#FTf3Af(TY1EFs%p&JbH7a(YIJ*$i(7o!{F T2L|lq1eT9rnJ+lx!L9-TTMubz diff --git a/FitnessSync/backend/src/models/__pycache__/bike_setup.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/bike_setup.cpython-313.pyc index dd9e613cdcf71eb75886a917d9126d00dc97836e..d499ffefa1e9c8c7ecf1b9093c50293e93fd0a89 100644 GIT binary patch delta 518 zcmZuuJxIeq6u!$PO;VG#N*hI#Rt2?KTtra(n-ok@!3YW#O0-8??2o>rh=b78%@RZx z!BKE=cXJb5IyhJmXE*U@_pYsj;K6(MefPchy$5%vJg9+BUH2n)_Ac$bMrwhc?N!Fk zkcBLqL?#Ao3LuySh$(_-N+89!CM12P3^K#S5|fIlf*M0TXb@RaFS2|ob&_ybgutJb zsG8AlhrjZLuRy*-`>@Dhj&9_mDW-L=b&I0c5}3NG|nI>tgIpK zu&sdqRC5|cIBFV78JVU(v!7T6jUx7i4^iI$px3;y2=V G)8GdH0C?vB delta 375 zcmX@fxtoLUGcPX}0}veB9+w%+G?7mt+zZH?$`H&D%;?SF#aP7X#Z<)P#azVf#Zttg z01{{NX7yq#Vgs_7gIT=Uy*P?E6c{8Jlo*0pr5J+QVmM5hK_)UV6mh1rYjV5C%v?=x6jX0W)+Ab*!Z>CT(`FDlYw$%;Mzyvecr=WJZ`8Ak7ZM%|QIQ0!Vl>FfvSMNMTH2%3;W5 zsAbG$tYuApqzS zR=*+;SCi=$M@nKzYDs2p>Mc$XnUb1Ql32wDcb{HHNp6lNa}g&{^)2SS{PH4IkUrKT z6(9v>7qNj@JRm}ifuWuOsu-c5m>DRiprBC12c&NyjE*lzOiwLR2MU64u`iH#0fY?< z4_I_AU_*DgMXqzJUgB20pk{W!{X$60^^k;1Aqf{llCFd#T_~)&U{QTR*rNI(cg+>< znhPv7MS?&h{WOIp@8Qf=16#C`0W1b~$uAC@-29Z%oK(9aJ)k;JJQT-o_TZ9Z@?>LR r;%Jv@lIrle$S8S*QSt*56Jsso0`(6JAR3wc$jrbb1y_MA1GWeN7e$2T delta 104 zcmew>^G$+pIWI340}$MENzCkKoyaG_n6yznk%=uuJeWaKV)F$iZFXiqO~J{n+}VmN v8HxmfN{je_#4iq;-29Z%oK(9aO(2&Mh>O)Wi}A=Y2{14+<}rQ%lVJ4#Ru~r* diff --git a/FitnessSync/backend/src/routers/__pycache__/web.cpython-313.pyc b/FitnessSync/backend/src/routers/__pycache__/web.cpython-313.pyc index 2427de45b2ff59fd8d37fca01cea66ed6d310847..310871764d2d9e14eaf3b2ac3669d694d3e4d67b 100644 GIT binary patch delta 1055 zcmZ8fO-vI}5PoleTH59R$I=z02yJOFtszvHvu|+ z8rQZRg#1LKN@7JR4R9X^cc_`Ap5FE-EqfKm95=!#9Wr?$L@PknjF|q;T8z>)*D@j~ zSL$%BHqKLvgS)!xFl{MsNdEMm-r?M?h_0s6YR1sbtT@}yRI$uGzHh8*==0flN@ZP8 zN(-=l4a6U^M}A42$CCd@^5<6%T!*99=wv~PSwhS_;opcJ0Ckuf!e`M7n#YWa-`zc+ zT?J;c+3ENoUI(?;{NflI$$292grTiz#zthbs?BT3?V?m;B{)c^lEd5)hj(YG!iBkL zB``on`6U|Sr*iG*X=|Fg9$q%mEAKewf@Icq!~4fG3r8~xg_%X`S?*|Tvmk9*!q%CNU3qb9m`;V-i_WFE zZp5=1!>5>F^3HJ?_lx0l7%o(i*A2#SYAk5RoU%ItDh@*5H?vNoXR<)MPdQ2-(7dNa z@GS9!^V%-+hx6?sY7gnDgK!Nm<>#{1=r7O!Ix{q%I)#i3Js8K#jGY-QhtBb@Vg(Af VadeyJgC&9|J|O?KT`UZh{0Hc%>8=0( delta 170 zcmX>n`By;WGcPX}0}zNDjmuPEVPJR+;=llq4+fv>7&mIvF|wo!X$o%+V6tSI9LzeA zRTL=4Klvr=c2031OBjehPflbJp1g-`AD0x62NErknB2m?hE)d0l%5>RB0kxNLxkB+ vlW+1Su3$Ehq9U2e^4wBf%0MO~5ErvfcIGabe2=@2`y&%0V*q235>ONX4O}5D diff --git a/FitnessSync/backend/src/routers/web.py b/FitnessSync/backend/src/routers/web.py index 054eb41..79a0a66 100644 --- a/FitnessSync/backend/src/routers/web.py +++ b/FitnessSync/backend/src/routers/web.py @@ -36,3 +36,9 @@ async def bike_setups_page(request: Request): async def activity_view_page(request: Request, activity_id: str): return templates.TemplateResponse("activity_view.html", {"request": request, "activity_id": activity_id}) +@router.get("/discovery") +async def discovery_page(request: Request): + from datetime import datetime, timedelta + return templates.TemplateResponse("discovery.html", {"request": request, "now": datetime.now(), "timedelta": timedelta}) + + diff --git a/FitnessSync/backend/src/schemas/__pycache__/discovery.cpython-311.pyc b/FitnessSync/backend/src/schemas/__pycache__/discovery.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4af798dd4066840f334f6069b03504ed0825c7ef GIT binary patch literal 2185 zcmbtVy>Ht_6hBHwiTbi-$5vb?PMX@OK_sZ#0v!wl9onF2n^p#56ljD3?1jA8L@7!k zPl2^5K!*$+Gz5sJqC?RSbno9m0vd>0J9TqlPnr7OQ6HA%b}9Mz)8qZ_cz3^h@9Eb{ zrAXlV_K3M=mXN>jWpwF7=k!N#UJ{2mnn#*iL(|aDc$sFlk!{ijRqt6(Z|V(QBN=j! zIP`ns=v?~+zWE0{>D9<7E(ctGj>{{~0Io2{8Hy_cSDNDrPI;f0mA`QcQy?CAw{1m&H)9Kv*b&G`R1iUfyHEYP!Bm9d-mx%OAF&!;Z`aX;_xe zn%uHv(XyH?r{ketwyY-|<_(!_C<3`?S4>Kk)eYkRR|T9S-4L8lST3)J;98;yg$zTL?3ZBX)$hh28&oq;CkRT^(A2Z)^F@E>?L6iGpU&VyXwOg>uP!=8sD2i`) zA<-fAnQwDhKBrGuH}f+B1r+8FRuPm+SJ9i13dIQ!P|@%+fGMfU)&5GKzqm3eZ$uO= zrW?uPay=QyLa;+b}fWW%~7ApiNRTaNIrRuo$9sx6>>N!nENC$P)hMhO?kk>jwfW%9vwkgmnDZk>Ks^YCJsYcKHxqN z;A|X=3Ar#<-EMV!VU{kGT?wmXe;f9hRta?!s#IKb6kFI20b@9{0H%(jx)QD48dPsb z6fLH=lMi-#_xo$dTgT$3yT7gee1CBL-NE{MgQfQ)x(gQ5-DG(^qG&N)PgX8R6fIT= zPI^E%W8ExDquq6wFI+po{|uRf!U|cL#?Yk3sj8&%OKniCc{-$C8+Xzo`eASjKBU1D zV&OZ+}Es literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/schemas/__pycache__/discovery.cpython-313.pyc b/FitnessSync/backend/src/schemas/__pycache__/discovery.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a975ab37091b488ceaa8e908b67a95e22e76002 GIT binary patch literal 1911 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 J@V~(F`wu_*56u7o diff --git a/FitnessSync/backend/src/services/__pycache__/bike_matching.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/bike_matching.cpython-313.pyc index ca91540da0b3eb1a1347b863736a5765f4824c18..588349ea8c73910f2c6ed10e22c53c61162a8359 100644 GIT binary patch literal 9744 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 RVe8Z1i2lFbAXH?w{|6$RQ0f2x literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/__pycache__/discovery.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/discovery.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a0e99d377df9d87d7e184ab674a777ec160f28d3 GIT binary patch literal 15116 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 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/__pycache__/job_manager.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/job_manager.cpython-313.pyc index 9c0da88ca9a476d27a21d3694a4627d21356f110..4d32df5719e877b493e07ebce796a4130d9e4331 100644 GIT binary patch delta 797 zcmY*VUr19?7{BM<&EclkHBvYC?NmPpKpoitSYiIM$>z$o@r)^kT zK@>?kPGQ!AdyRSt3w(+|f*vI#h6MW9LxKH)K~J5#km@_|JKy*He&>9@b3SI?UE_b$ z)|wd_C?-l@T3_;40T?C=;12K-6MGIoGQ#$PX0pWkoW`gr#0u^vCT+k@!Gm29<}|?W zR>t45zR5l~PEJ@Gzz6c8b^~Ct+huQ33<8L7a+Q9+9s(ON@QlC(ITC9>TO}AMX(6xM z2fK|CK#8g#7Y2W%knu2rNnc_1pp!Bj^oXzH*nGZV9?0M;D4CVz9GnvIBotvf6NjP- zlcFS}1e6r0O(2*MH8C!#C}19&kW`q+AQdVZ4W_ap78MPliYlul5SDY2lAVgjmK zW?F^P)D%icqK4#Lr#YV0prk>JWC_t5)ym1pc`BuXfEnMWX7k>Ed`(KS?j)XrSruV; zbe6i?0sgVPVTfAId!gP){(Z;)`oQRbzm8lCo+4j6`1u{vtcVqA&~L;obP#USp-+c? z3Okl*jAmlGm8_~7X$R_o-8#otX2kT5iP2BmanSK2q9Udc4*XB;Qn~ty!UD5j!|=AH z=)LIT_+wlmpM#zAd!FXC^lEz9QgXK~a}UiWc;>BTA7Yr_g(;ERWg$>OE0uHh~3$hLK~)aWcXGQ8{V_+Fi@Y^2$~ zow3%J9W+}UujIef8tSOfR~_oNbDMT^{}FN_)C4?aGW3kakI13$5a?cyhFzfHCu1_Y z_H8y}eNiYgbo;IYxpndlSS7tZPOw5Q_t?R6GSf5Y5IIo)jj7;(iK%nkmv36ImyY@k Dngs6J delta 146 zcmX?FaV?+kGcPX}0}xo;i_a`m+{jnR$Y?jYfw7HIZZbQQE2I2mUnWOJg~|O)>b$Z= z(xD8tj72gk44R6YFEaHRPF`;z$tXMdq{Yg~<(BS@%A5CF$}n<$XJBWQ`2->+f3(`i tIC=60YiY)blh0U7FfN_^)Y@Irmzh!KBLkR>V`Jc#?9OTAE|Ld20stUdDhdDq diff --git a/FitnessSync/backend/src/services/__pycache__/parsers.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/parsers.cpython-311.pyc index c53514afce11412a3cafdec2fe0725e0bc562095..c1621f98742ba66e257662d41066c94970869943 100644 GIT binary patch delta 1576 zcmZuvZA@EL7(VCR-hSWSc2d@EE4zG^7RJZI$7Vs8jL#KD5M)Eru|hAx##+2BGEE)q zhkv%kV2)o)OonE{bKa7bf1|#v0o^v}LnVjA}&vTyl zxo_@0@BO;*cAM!2$LRrCU-k}-{j%?-=?ldApM(J5Z&3Q+0ln~W6@@26F;L_Y21dq1 z{>TBLtgOtptvu`xh{0hI-(-G(Hax5PNxhFW4e!O}YO-ao!dzUeZpaR(0eBlczwH-* z@2YW`r5Jypu4Ir8W%?`g_)=%O{Mo<$dTEqMi(hogJF6U+cD;J|t)J3HfK1(uyNl6U{;n`*m)}D@lHbq=_VX znzTOF2aA1R!cm2 z49QHvLSYwy6swFXrTKRxqT%%Jlt~PZ`pJL-gGyD(qw##aw%dV!G+csae8yPkP}~$l zZcL$3DaA}oNhv6XX@5b$pBWtkP9nqEDgb7{dbTZw;(1Gw+}kG)i?U&aTr*8+RK`|6 z;P@-gy<<#pg-Nb34T^Q1nB{?GPtsB_*PXBwB`rm1;O0EB%5{g31}=+d1Guo8tIo=^ zd*YL~r$3)wo=!9jCL0DfK;ELnnc(zs*aO!FzZ#T%=MtU4WM?o9fIEz0>^%Q5FCT78 zxZ0DhcG=ZV{BG2T{-kso{hlFW`q3!bpt~`&nIU3M!Z4B}FOl#hY@%UG#BW9&G&~7A zX_(M7b?KH+2!+ucyDk*Wow&~37pm?FRSVB1gxaJ~D+{%0HE4#%;N7k}U2?B4(R3== zbV_!P&WSfd?}rw~63!z@=MkA)fzdgJHtE04-xq4`2{jAh?M+%?2fQ1)6O#Le5-r2{ zHB)rD8cC=*g(gU)J`!XxiL<=kc}?fxIz z+WO-pp67dfm$STe;F{H2!F+nm=C!jcoRR1iyUOd(tvDE}1xR(Sq6>^9zEYv`R%uqM z7^)9Jst+r=PTx_=tg0OyE@sumc9h~l^NhKLe2V?_bF>ne0YBpzhiy?~Y0(T-5ksn` zP($Gm0XHvcsdf-ZsiaUSBDGP;Mx?V75vh*Wo}y4sp@G8F6dDPn*x}K!@j!}s(LbJI z#Pbp9D4w@Dp>Oe9+k^@><5q{89U{TE@T6lNUd4qu73vatcPSpoDTmMC%Q;734PMK! zdQMO~hk`)i2@3Q@q$~=w$wxr4QJqcTFKykqP+)Rgtd~MKXa5`ZX6S`6HVr7p^E13u zuoPZylDm6X+m|jR8~ZZu4p@NG`$x(TLUYWT280%>;!M1EDgRb~+?1?rRlLVx1ivaQ F{{wBlyA%Kb delta 1030 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 diff --git a/FitnessSync/backend/src/services/__pycache__/power_estimator.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/power_estimator.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..230a1284b3e0bc5c9f09c2ca9a5996dba9513cdc GIT binary patch literal 5291 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 UjE02ld35`oO8@U;M%5+%51$eKi~s-t literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc index 48a0fd682ef88f8773ac30f9a82f0369041b1a95..0fba0b19d22d2f0f48b97b127ed311a8e536996a 100644 GIT binary patch delta 1817 zcmZuyOKclO7~a`kdt-aoi63eFY_>_0c8Q#ZI4LM;i`< zloovL=oKQc2ao1GS$AF>;eO|#fX zWPaWmrl!U*@+F^g{?hxxdTUGl5&t_vGP&nWNS#z@IZC=(#5hjE!p_8QPzP!R zr8vX}?KVSw`#6rstJXyIt5?Q;tBk!c9Dmm94S_HpuYxC_nJzYo8LTCuI>#Hv}t zl@d$d^ajy2vgjIdusJs4ASr)TLCAySKbhcI_n9 z5exp$20Zz|6TN~Ca-GZ+QXH2PCySL@QP(fYCf3Sj70X50Xcq3IOiNwXW@gk$t!S$H zrT+BM%b_O?v`X35JFR%O1?O?*;MXZhtOL;7VC=YGX;jQO%nYhcG(M{Gl2y#05i#9wYk3d|p$ zJANlJxVV3D@14--ig;j6Y+DuMOJe+Hc}47769e<Hvkf87wvfh3@?1#g?!?e;$hNJuU?E#UU=c6!!=N%^j&H{{6 zusE$^TKq({s^c2D9*y@XAnFBxG_e65UIRS5$2?%Y4)+6@{1^=-9}M<7&ZGMoC-b_u1|Z=&oa zB##+cEeB?*@JTp?=coYQTjLyQjqe?8LF@)YVIAOpka7D~U6H%4$j#@MUAwP3H~0*T ULhFo;(>x=^m&Kj$xNOh=0)lUy(f|Me delta 1190 zcmZWoTWb?R6rR~zvPrgHNNuv(bX#lVq6xNGskPp!P`npYi%RJl(>1Qfte)K{=z|I> z2#S<>@elamlW2ZGMZrfO6cqMR@Iha+DdMBf*=@ZJ%r}?w&6)3h( zw}wwRuU5^rW$3%v`~$@xQ{vI$?DO_QW{{$t5Xfdo4=E*dQc4aAi+lQ%-a`hX1%cH} zpf##6m)`EEEN+Iwq6@OHgY^m9H$$?TFq6UZQst~Y)2P`{vf;8*u|IWic4di-Cu6*e zyf3_FkA=C85-lpnNUaS`*s8=74x0+QD>{O?jHRS=?3#2>$#9c5#x!}iIIqQ;6Y@t& zb8I+PINr%Efn2HCWq{QJaU7iV43mSE+=DHUH+4IZbLk~JZEe7;*gaM?l`d4x> zB?+MShvQvz!$xEm5W?F;&-NjQhoy6_Q~%2^yQLbmpFL6!@Wb=!Hi2eXA!X4K zc0V;i4fY{5phWJNt_`HuqL-Lwd!FOgVFa(($xdl~N+__%?rH1jQ1h8q5R-q1V4s(F z?>m6xgQ1EmWyd!KKxZ({!NX!2i&6F?MgK3#U)ed*oLJEm@&>2D9t5mTAlBV0uostc zTq&=e}$inAgD762v@ra1)dmc?I~?^LX^?}Kx?;oF{NJtmMxN?7-m zI77hS}ZnerQd5Lo%tOBwF)m!?xa2YMX#02q?3od@?9dMHyDDU2J_QF^{ v6r;j-!r|*ml34Lx)!(T4!=blo?+xX_fI*v`fwj@`V9Rhp!zNlIv_8mMeq2!>QtMTM}=Zrn{AyG%CD zgE2)yh#Wv|8LA?pNS2_XRZf*JJ#gp+rJ@pYgDWD7qVgZK5)$H2!OXf&Yy)Dn{$_sP z?=jyzJU@&*h`GL%Bs)V||7VK+Ti*>A$FX1Wc+KC7N4d`PV49^)+OiztR5rsvi)zVo zDS=oVu~?NS9%kB7j$)V`R~c3JhB--@SqBWWL4M_y6Dp(Xz8pVVrglZ3DnT=*wCciD zaa`HEEaG3d0S}*GvVzgLJAo?T(+=DtJP$;Ci+}26VpV)uD`Il)idEes3$m^g-5WdG3Lo2;+N!uq7>S6}4VAoF zHDu$fd|6w~s%*r!vr4iaJH96DJ0T()0*GBF2eYwrI9WTg!`G4h3{jGGWSup29CdY^ z$U&+}$cfv<{R3pzkOMjE>Pbj~J|yJ472VZ6lJJM(Uf{yl#7>WkRIlE3H*(=$MLF1z zb$#Hv2tJLf=4b&dFpHMc%mN_X8%V%jYxjZ1x)W zwRw%N-F0#Y$Q8#0YY^`dJuDe=wvoMZZQ2_9k0fE73Af<0w%PMxzT|*uMV*N2GkPjf za?hscQ--3aDugQmm5i30m|U7nngJOd8uQ97;nGuTIt_OM#3zwqwj>!)(UTK!K9!=W zjRw>dbyiRAQdaSa6W_C4041#1@3>2nGHK`;-B_A1mdIsE_Soa9&P70d%L54{@^6h)3mkh&=u$eM1QfQ?Rp%A5DrgExcC~ypGz94u9 zf9x9*hKO%DfcJOrdxff7GU?wkrgqGO=W(bqR*w(`y)JlU}y z$oNvn#tUE4BqxOv6zJOl>Do(NJe^L$gVd&v9^@%ZQ5d9vD2(If&d8x*KA2m4$dK-) z_ojMl;JcaPbH`2lwoy2#6km&*Mm;R%|Ln{H@GM^F*1F@g9DNQ;wuEN9l1?Nv2v;dN kOreRwI0ii};5u&Yd2}Sk0pCOB-|>;j|Nb9d=qI)Q2HkUnn*aa+ delta 1405 zcmZ`&O>7%Q6rSDkCI|N*oYUxzGaQ&=d34a!L$5$=|&9 z&3oUzdE@zM;`+GzqN+Lxx4-shi_`s|xJ8jpfM49Fl9CO1w3+~~*!==A9PqIifgN4J zUc=GaLM%yb-=z2bX5VO)w+31+1<#}B0k zlTyjMlIvL2A>+C$9rF$(_+3iwl}v|8P1^;L_sf4RtIDRVKV`~O{Jd3X)wN2;-l5|# zom@>eT~K2|sM^=WC9dc+T^*W=spvtI)dH=-n+jaFZ}h0fruJ9)rG#;BFUdLiH~u*- zm#Hq3GaXZ<;aTYXEE ztU9L`v*1;SFZAz3lmC+-D#zg@^GK9f;kcZpepr;d8GD*&0k|xeZ6{)I)fI#Ga#_^WpFOTEnmX;2*f@AGoFX8%lTWKqHa5sif+=-KT9~vLih@V*7f;nHgf&Qj$n<{)ecB{wCwtNHX0GD8Sagfzl1f>kQ!vPM?l1t&vsItk06aoP{RhSt-u zx*C4h79Pb!Y)4})p#l=m!Do@pGzqsO8hsi3(c_Y(pQKrBEv!|vF2Z>xl-wE8N;Vr)N6!_TqrX&-zZUr%4J--?e5+fYsuo9yUJC2Q!< zV}W%@5Ass0t2dBMacHt~arRJ7Uw~{+nCh_5(?`8wXysn=@auDGG1nc@E=;6KJb39V6 diff --git a/FitnessSync/backend/src/services/__pycache__/sync_app.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/sync_app.cpython-313.pyc index 2cb3aa80bc3381173a44b4489d48ccef88c782dd..7dbab2161092614ff9198f81029c61b3aeaf2bdc 100644 GIT binary patch delta 1348 zcmZ{kOKcNY6o%)HADPV9GvkbLo+jf2;<$N~5U1pUX`@Djq>#GY(1tP)xyB?z9ml=4 zBFci&bycD2P^qdav7k~zrAkEJuxxkfqS9DI3i1MV(GBXRv_ zHsP}SGGUFhlQvosl`WYnmSV|#*Mr64Uh1pG_^8RV_Kfk@V*FICR#4+^s&OCF_R4yr zR$8y6>9h*8tF@a3YWsoRc<_bTJpCL-LQJC;YtYF59wk_d3DPdQ4?#oLzSI4%AsW$U ztC_Z-rxouuOF5P+1$g8@sO^b)=*06`t#G z#wg!?;i!{vz;BY*@hrWt;=eeYz=)X0y3tgO>RVaPq_V~3#g$Bv+35ls+}&Ub6xAee zLciJrH}w##stJ;SAJk}&jl9%u8va&$$?;NHQ}&{ng$ccr%$CmSJ;Zo9`xM*^bdxzg zcqj+HJzRrTu?=fNi8bsD?``f#a(Rc#X)ct@Jbc%bBozK>I@w=MCXe=`AHU%_JoExS zqJ4CD$5y;cDYjzg=3;G-H)fJMpRB7;WQ>on`nuQ{K2_WwtgdF+G47n?GR0+@%Pf~u zTpGA=ri$lMb~ROG@4{1KgnR=d!JY-tlUO&N2xuNlqGyJz(?4)ALw+=NtdpE~&MY|Z zpLfojFDEbX$Qklj@_HuLF#^rK;i}m@uvu{C-`l+M^WYQiE?o~kBqR-sp%(c=?2Uhi WLSYg<4UGnl6S7AB6so5BYkCGFia0v} delta 981 zcmZ{jO-vI}5XblRbGz;Cw%xYSDzHtZun0z=fJK1_LW&@MJQFS(5)GimlEzmw#)DkE z8AIa4yNQ_K!2{{Vqb8EnTbqzH=|SOSJZUtVc=JuWmqy%Vf3x%c?>{s9Hv4q2)TjKE zWl7*y_1RMPkFu+5kx+&R!gIkC7@-2QQNrx>80?bh=5gvaMJk#e>MaDB3l!N+kjmh-?F4=M^w5?vbxvGOImafuvs+!{Fk*)pKCVx{Skq;HH zlmP8O`;qRTwI@h*s`1f7rgp6x&!hRGVKYn<5x${?->s&^Pr#E>XwVecxSIFRyAiAtULq=Z(8g@|5+$x;*MK_+w!T-pFl6 ztRlv$Voycv*-7tRpS*kURBc-HT^D9R?}rQQx-GuMSc=Yq0z>n>#Pgwjlg3? zCxgXTijQdhsM_nQC6B@fRVO2G80-ewzbp@P(Xg=u4>b*T{aTD?ojhYWT~p;T*!Jt> zEbRG{?wT`~UC(a7Hz@)s|7p16_1V}kOa!|0*Y<{g0*l}qHxF z@IKf%i?}d{m`7YfEFdmJU-$}{ zgGb@{NW=WKov$w#gi#(1+X5Vf!=x2{hi7MM_QD2Zcz$evqvp7o&*#_(=A(!)#CgOt zB7<-vuyf71n##{`z~O7JJ^V&6Qx4=`eNz9$5X%RLUF3) s6G<##ogM^!)|z;3J->V}w=&LFdCLD#p)dg#qG!}`a-aMVkoDbv0Tso|@Bjb+ diff --git a/FitnessSync/backend/src/services/bike_matching.py b/FitnessSync/backend/src/services/bike_matching.py index 77f8958..8d57179 100644 --- a/FitnessSync/backend/src/services/bike_matching.py +++ b/FitnessSync/backend/src/services/bike_matching.py @@ -4,12 +4,70 @@ from sqlalchemy.orm import Session from ..models.activity import Activity from ..models.bike_setup import BikeSetup +import statistics +from ..services.parsers import extract_activity_data + logger = logging.getLogger(__name__) # Constants WHEEL_CIRCUMFERENCE_M = 2.1 # Approx 700x23c/28c generic TOLERANCE_PERCENT = 0.15 +def calculate_ratio_from_streams(speed_stream: List[float], cadence_stream: List[int], window_size: int = 10) -> float: + """ + Calculate median gear ratio from steady-state segments in streams using a sliding window. + """ + if not speed_stream or not cadence_stream or len(speed_stream) != len(cadence_stream): + return 0.0 + + ratios = [] + + # Pre-clean streams to handle None values efficiently? + # Or just handle inside loop. + # Python Loop might be slow for very long rides (10k+ points). + # But usually < 20k points. + + n = len(speed_stream) + if n < window_size: + return 0.0 + + # Optimization: Skip if we don't have enough data + # Optimization: Step by window_size or half-window to avoid O(N*W)? + # User loop is O(N*W). For W=10, it's fine. + + for i in range(0, n - window_size, 5): # Step by 5 to speed up/reduce overlap redundancy + window_speeds = speed_stream[i:i+window_size] + window_cadences = cadence_stream[i:i+window_size] + + # Quick check for None before processing + if any(v is None for v in window_speeds) or any(c is None for c in window_cadences): + continue + + # Check thresholds + if all(c > 55 for c in window_cadences) and all(v > 2.5 for v in window_speeds): + # Check consistency (stdev) + # Catch potential low variance errors if all values identical (stdev=0) + try: + cad_std = statistics.stdev(window_cadences) + spd_std = statistics.stdev(window_speeds) + + if cad_std < 5 and spd_std < 0.5: + # Steady! + avg_speed = statistics.mean(window_speeds) + avg_cadence = statistics.mean(window_cadences) + + # Ratio + ratio = (avg_speed * 60) / (avg_cadence * WHEEL_CIRCUMFERENCE_M) + ratios.append(ratio) + except statistics.StatisticsError: + # Variance requires at least two data points, window_size=10 is safe. + pass + + if not ratios: + return 0.0 + + return statistics.median(ratios) + def calculate_observed_ratio(speed_mps: float, cadence_rpm: float) -> float: """ Calculate gear ratio from speed and cadence. @@ -25,7 +83,7 @@ def match_activity_to_bike(db: Session, activity: Activity) -> Optional[BikeSetu Match an activity to a bike setup based on gear ratio. """ if not activity.activity_type: - return None + return None, 0.0 type_lower = activity.activity_type.lower() @@ -45,23 +103,41 @@ def match_activity_to_bike(db: Session, activity: Activity) -> Optional[BikeSetu if not is_cycling: # Not cycling - return None + return None, 0.0 if 'indoor' in type_lower: # Indoor cycling - ignore - return None + return None, 0.0 - if not activity.avg_speed or not activity.avg_cadence: - # Not enough data - return None - - observed_ratio = calculate_observed_ratio(activity.avg_speed, activity.avg_cadence) + observed_ratio = 0.0 + + # helper to check if we can use streams + if activity.file_content: + try: + data = extract_activity_data(activity.file_content, activity.file_type) + speeds = data.get('speed') or [] + cadences = data.get('cadence') or [] + + # If explicit streams exist, use them + if speeds and cadences and len(speeds) > 0: + observed_ratio = calculate_ratio_from_streams(speeds, cadences) + logger.debug(f"Smart Match Ratio for {activity.id}: {observed_ratio:.2f}") + except Exception as e: + logger.warning(f"Failed to extract streams for Smart Matching activity {activity.id}: {e}") + + # Fallback to averages if Smart Matching failed or returned 0 if observed_ratio == 0: - return None + if not activity.avg_speed or not activity.avg_cadence: + # Not enough data + return None, 0.0 + observed_ratio = calculate_observed_ratio(activity.avg_speed, activity.avg_cadence) + + if observed_ratio == 0: + return None, 0.0 setups = db.query(BikeSetup).all() if not setups: - return None + return None, 0.0 best_match = None min_diff = float('inf') @@ -70,6 +146,33 @@ def match_activity_to_bike(db: Session, activity: Activity) -> Optional[BikeSetu if not setup.chainring or not setup.rear_cog: continue + # Check Date Constraints + # Ignore if activity date is before purchase or after retirement + # Start time is datetime with timezone + act_date = activity.start_time + + if setup.purchase_date: + p_date = setup.purchase_date + if p_date.tzinfo: + p_date = p_date.replace(tzinfo=None) + a_date = act_date + if a_date.tzinfo: + a_date = a_date.replace(tzinfo=None) + + if a_date < p_date: + continue + + if setup.retirement_date: + r_date = setup.retirement_date + if r_date.tzinfo: + r_date = r_date.replace(tzinfo=None) + a_date = act_date + if a_date.tzinfo: + a_date = a_date.replace(tzinfo=None) + + if a_date > r_date: + continue + mechanical_ratio = setup.chainring / setup.rear_cog diff = abs(observed_ratio - mechanical_ratio) @@ -80,7 +183,15 @@ def match_activity_to_bike(db: Session, activity: Activity) -> Optional[BikeSetu min_diff = diff best_match = setup - return best_match + if best_match: + match_ratio = best_match.chainring / best_match.rear_cog + error_pct = min_diff / match_ratio + confidence = max(0.0, 1.0 - error_pct) + return best_match, confidence + + return None, 0.0 + + return None, 0.0 def process_activity_matching(db: Session, activity_id: int): """ @@ -90,17 +201,20 @@ def process_activity_matching(db: Session, activity_id: int): if not activity: return - match = match_activity_to_bike(db, activity) + match, confidence = match_activity_to_bike(db, activity) if match: activity.bike_setup_id = match.id - logger.info(f"Matched Activity {activity.id} to Setup {match.frame} (Found Ratio: {calculate_observed_ratio(activity.avg_speed, activity.avg_cadence):.2f})") + activity.bike_match_confidence = confidence + logger.info(f"Matched Activity {activity.id} to Setup {match.frame} (Ratio: {calculate_observed_ratio(activity.avg_speed, activity.avg_cadence):.2f}, Confidence: {confidence:.2f})") else: # Implicitly "Generic" if None, but user requested explicit default logic. generic = db.query(BikeSetup).filter(BikeSetup.name == "GenericBike").first() if generic: activity.bike_setup_id = generic.id + activity.bike_match_confidence = 0.5 # Low confidence fallback else: activity.bike_setup_id = None # Truly unknown + activity.bike_match_confidence = 0.0 db.commit() @@ -111,7 +225,8 @@ def run_matching_for_all(db: Session): from sqlalchemy import or_ activities = db.query(Activity).filter( - Activity.bike_setup_id == None, + # Activity.bike_setup_id == None, + # Re-match everything to enforce new rules/constraints or_( Activity.activity_type.ilike('%cycling%'), Activity.activity_type.ilike('%road_biking%'), @@ -119,7 +234,9 @@ def run_matching_for_all(db: Session): Activity.activity_type.ilike('%mtb%'), Activity.activity_type.ilike('%cyclocross%') ), - Activity.activity_type.notilike('%indoor%') + Activity.activity_type.notilike('%indoor%'), + # Skip manual overrides (confidence >= 2.0) + or_(Activity.bike_match_confidence == None, Activity.bike_match_confidence < 2.0) ).all() count = 0 diff --git a/FitnessSync/backend/src/services/discovery.py b/FitnessSync/backend/src/services/discovery.py new file mode 100644 index 0000000..de5cc8f --- /dev/null +++ b/FitnessSync/backend/src/services/discovery.py @@ -0,0 +1,453 @@ +from typing import List, Dict, Optional, Tuple, Set +from datetime import datetime, timedelta +import logging +import math +from sqlalchemy.orm import Session +from sqlalchemy import func + +from ..models.activity import Activity +from ..models.segment import Segment +from ..utils.geo import haversine_distance, calculate_bounds, calculate_bearing, ramer_douglas_peucker_indices +from ..services.parsers import extract_points_from_file, extract_activity_data + + +logger = logging.getLogger(__name__) + +class CandidateSegment: + def __init__(self, points: List[List[float]], frequency: int, activity_ids: List[int]): + self.points = points # [[lon, lat], ...] + self.frequency = frequency + self.activity_ids = activity_ids + self.distance = self._calculate_distance() + self.uuid = None # To be assigned for frontend reference + + def _calculate_distance(self) -> float: + d = 0.0 + for i in range(len(self.points) - 1): + p1 = self.points[i] + p2 = self.points[i+1] + d += haversine_distance(p1[1], p1[0], p2[1], p2[0]) + return d + +class SegmentDiscoveryService: + def __init__(self, db: Session): + self.db = db + + def discover_segments(self, + activity_type: str, + start_date: Optional[datetime], + end_date: Optional[datetime] = None) -> Tuple[List[CandidateSegment], List[List[List[float]]]]: + + + + logger.info(f"Starting segment discovery for {activity_type} since {start_date}") + + # 1. Fetch activities + query = self.db.query(Activity).filter(Activity.activity_type == activity_type) + if start_date: + query = query.filter(Activity.start_time >= start_date) + if end_date: + query = query.filter(Activity.start_time <= end_date) + + activities = query.all() + logger.info(f"Analyzing {len(activities)} activities.") + + if len(activities) < 2: + return [], [] + + + # 2. Extract and Simplify Points (The "Cloud") + # Structure: { activity_id: [[lon, lat], ...] } + # Decimate to ~50m + activity_paths = {} + for act in activities: + if not act.file_content: + continue + try: + raw_points = extract_points_from_file(act.file_content, act.file_type) + # Reduced min_dist to 15m (smaller than grid 20m) to ensure connectivity + simplified = self._decimate_points(raw_points, min_dist=15.0) + if len(simplified) > 5: # Ignore tiny paths + + activity_paths[act.id] = simplified + except Exception as e: + logger.warning(f"Failed to process activity {act.id}: {e}") + + + + + # 3. Grid-Based Clustering + # We map every point to a grid cell (approx 20m x 20m). + # Count unique activities per cell. + grid_size_deg = 0.0002 # Approx 20m at equator + + # cell_key -> set(activity_ids) + grid: Dict[Tuple[int, int], Set[int]] = {} + + for act_id, points in activity_paths.items(): + for p in points: + lon, lat = p[0], p[1] + xi = int(lon / grid_size_deg) + yi = int(lat / grid_size_deg) + if (xi, yi) not in grid: + grid[(xi, yi)] = set() + grid[(xi, yi)].add(act_id) + + # 4. Filter Hotspots + # Keep cells with > 2 unique activities + min_freq = 2 + hotspot_cells = {k: v for k, v in grid.items() if len(v) >= min_freq} + + logger.info(f"Found {len(hotspot_cells)} hotspot cells.") + + + + if not hotspot_cells: + return [], list(activity_paths.values()) + + + # 5. Connect Hotspots (Stitching) + # Identify chains of adjacent hotspot cells. + # This is a graph traversal problem. + # Simple approach: Connected Components on grid. + + clusters = self._find_connected_components(hotspot_cells, grid_size_deg) + + + + # 6. Reconstruct Paths & Candidates + candidates = [] + for cluster_cells in clusters: + # Reconstruct a representative path for this cluster. + # We can take the center of each cell and sort them? + # Sorting is hard without knowing direction. + # Better: Pick one activity that traverses this cluster best? + + # Find activity that visits most cells in this cluster + best_act_id = self._find_representative_activity(cluster_cells, activity_paths, grid_size_deg) + if best_act_id: + # Extract the segment sub-path from this activity + path_points = self._extract_subpath_from_activity(activity_paths[best_act_id], cluster_cells, grid_size_deg) + + if path_points and self._calculate_path_length(path_points) > 200: # Min length 200m + # Calculate actual frequency for this specific path + # (Refined from grid count) + # For now, use the max cell count in the cluster as proxy or re-verify? + # Let's use the average cell count? Or max? + # Robust way: check how many other activities follow this path (Hausdorff check). + # For MVP, use the cell overlap count. + + freq = self._estimate_frequency(cluster_cells, hotspot_cells) + + # Collect all activity IDs involved in this cluster + cluster_activity_ids = set() + for cell in cluster_cells: + if cell in hotspot_cells: + cluster_activity_ids.update(hotspot_cells[cell]) + + cand = CandidateSegment(path_points, freq, list(cluster_activity_ids)) + candidates.append(cand) + + # 7. Deduplicate against DB + final_candidates = self._deduplicate_against_db(candidates, activity_type) + + return final_candidates, list(activity_paths.values()) + + + def analyze_single_activity(self, activity_id: int) -> List[CandidateSegment]: + act = self.db.query(Activity).filter(Activity.id == activity_id).first() + + # Fallback to Garmin ID if not found by primary key + if not act: + act = self.db.query(Activity).filter(Activity.garmin_activity_id == str(activity_id)).first() + + if not act or not act.file_content: + return [] + + # Parse data + data = extract_activity_data(act.file_content, act.file_type) + points = data.get('points', []) + timestamps = data.get('timestamps', []) + + if not points or not timestamps or len(points) != len(timestamps): + logger.warning(f"Analysis failed for {activity_id}: Mismatched points/timestamps") + return [] + + clean_points = [] + + # clean points loop + for p, ts in zip(points, timestamps): + if p and ts: + clean_points.append(p) + # Note: we drop timestamp alignment here if we just append p + # But we need timestamp for pause... + # Let's keep aligned struct or use index map? + # Actually clean_points is new list. We need aligned ts. + # Let's rebuild aligned lists + + # Re-build aligned lists + aligned_points = [] + aligned_ts = [] + for p, ts in zip(points, timestamps): + if p and ts: + aligned_points.append(p) + aligned_ts.append(ts) + + if len(aligned_points) < 10: + return [] + + # Step 1: Split by Pauses (> 10s) + sub_segments_indices = [] # List of [start_idx, end_idx] + + seg_start = 0 + for i in range(1, len(aligned_points)): + t1 = aligned_ts[i-1] + t2 = aligned_ts[i] + diff = (t2 - t1).total_seconds() + + if diff > 10.0: + # Pause detected, split + if i - seg_start > 5: + sub_segments_indices.append([seg_start, i]) # i is exclusive? + seg_start = i + + # Add last one + if len(aligned_points) - seg_start > 5: + sub_segments_indices.append([seg_start, len(aligned_points)]) + + final_segments = [] + + # Step 2: RDP Turn Detection on each sub-segment + for start_idx, end_idx in sub_segments_indices: + segment_points = aligned_points[start_idx:end_idx] + + # Get RDP simplified INDICES (relative to segment_points start) + # Use epsilon=10.0m for robust major turn detection + rdp_indices = ramer_douglas_peucker_indices(segment_points, 10.0) + + # Check turns at RDP vertices + split_points_relative = [] + + if len(rdp_indices) > 2: + last_bearing = None + + # Iterate simplified vertices + for k in range(1, len(rdp_indices)): + idx1 = rdp_indices[k-1] + idx2 = rdp_indices[k] + + p1 = segment_points[idx1] + p2 = segment_points[idx2] + + bearing = calculate_bearing(p1[1], p1[0], p2[1], p2[0]) + + if last_bearing is not None: + diff = abs(bearing - last_bearing) + if diff > 180: diff = 360 - diff + + if diff > 60: + # Turn detected at vertex k-1 (idx1) + # Convert relative idx1 to split point + split_points_relative.append(idx1) + + last_bearing = bearing + + # Split segment based on turns + current_rel_start = 0 + for split_idx in split_points_relative: + # Check min length (e.g. 5 points) + if split_idx - current_rel_start > 5: + abs_start = start_idx + current_rel_start + abs_end = start_idx + split_idx + 1 # Include the vertex point? + # Turns usually happen AT a point. + # Segment should end at turn, next starts at turn? Or gap? + # Continuous: End at k, next start at k. + + final_segments.append(aligned_points[abs_start : abs_end]) + current_rel_start = split_idx + + # Last piece + if len(segment_points) - current_rel_start > 5: + abs_start = start_idx + current_rel_start + abs_end = start_idx + len(segment_points) + final_segments.append(aligned_points[abs_start : abs_end]) + + # Step 3: Filter & Convert + candidates = [] + for path in final_segments: + d = self._calculate_path_length(path) + if d > 100: # Min 100m + # Simple decimation for display + simplified = self._decimate_points(path, min_dist=10.0) + cand = CandidateSegment(simplified, 1, [activity_id]) + candidates.append(cand) + + return candidates + + + + + def _decimate_points(self, points: List[List[float]], min_dist: float) -> List[List[float]]: + if not points: return [] + out = [points[0]] + last = points[0] + for p in points[1:]: + d = haversine_distance(last[1], last[0], p[1], p[0]) + if d >= min_dist: + out.append(p) + last = p + return out + + def _find_connected_components(self, cells: Dict[Tuple[int, int], Set[int]], grid_step: float) -> List[List[Tuple[int, int]]]: + # cells: map of (x,y) -> activity_ids + visited = set() + components = [] + + cell_keys = list(cells.keys()) + + for k in cell_keys: + if k in visited: + continue + + # BFS + q = [k] + visited.add(k) + cluster = [] + + while q: + curr = q.pop(0) + cluster.append(curr) + cx, cy = curr + + # Check 8 neighbors + for dx in [-1, 0, 1]: + for dy in [-1, 0, 1]: + if dx == 0 and dy == 0: continue + neighbor = (cx + dx, cy + dy) + if neighbor in cells and neighbor not in visited: + visited.add(neighbor) + q.append(neighbor) + + if len(cluster) > 5: # Min cluster size + components.append(cluster) + + return components + + def _find_representative_activity(self, cluster_cells: List[Tuple[int, int]], + activity_paths: Dict[int, List[List[float]]], + grid_step: float) -> Optional[int]: + # Count which activity ID appears most in these cells + counts = {} + cell_set = set(cluster_cells) + + # Optimization: We already stored act_ids in 'cells' dict in step 3. + # But I didn't pass 'cells' (just keys) to this func. + # Re-eval by looking at activity paths? No, too slow. + # Start scanning the paths against the cluster cells is slow. + # Better to pass the cell data. + + # Quick hack: Iterate all cells in cluster, tally votes for act_ids. + # Need access to the grid content. + # I'll rely on the caller logic or re-design slightly. + # For this draft, let's assume I can't access grid content easily without passing it. + # I'll pass it in next iter. + # Actually I can reconstruct quickly if I had the grid. + + # Let's iterate all paths (naive) - optimization for later. + best_id = None + max_overlap = 0 + + for act_id, points in activity_paths.items(): + overlap = 0 + for p in points: + xi = int(p[0] / grid_step) + yi = int(p[1] / grid_step) + if (xi, yi) in cell_set: + overlap += 1 + + if overlap > max_overlap: + max_overlap = overlap + best_id = act_id + + return best_id + + def _extract_subpath_from_activity(self, points: List[List[float]], + cluster_cells: List[Tuple[int, int]], + grid_step: float) -> List[List[float]]: + # Extract the contiguous sequence of points that lie within the cluster + cell_set = set(cluster_cells) + + subpath = [] + longest_subpath = [] + + for p in points: + xi = int(p[0] / grid_step) + yi = int(p[1] / grid_step) + + if (xi, yi) in cell_set: + subpath.append(p) + else: + if len(subpath) > len(longest_subpath): + longest_subpath = subpath + subpath = [] + + if len(subpath) > len(longest_subpath): + longest_subpath = subpath + + return longest_subpath + + def _estimate_frequency(self, cluster_keys: List[Tuple[int, int]], grid: Dict[Tuple[int, int], Set[int]]) -> int: + # Average unique visitors per cell in cluster + if not cluster_keys: return 0 + total = 0 + for k in cluster_keys: + if k in grid: + total += len(grid[k]) + return int(total / len(cluster_keys)) + + def _calculate_path_length(self, points: List[List[float]]) -> float: + d = 0.0 + for i in range(len(points) - 1): + d += haversine_distance(points[i][1], points[i][0], points[i+1][1], points[i+1][0]) + return d + + def _deduplicate_against_db(self, candidates: List[CandidateSegment], activity_type: str) -> List[CandidateSegment]: + # Load all segments of type + existing = self.db.query(Segment).filter(Segment.activity_type == activity_type).all() + + unique = [] + + for cand in candidates: + # Simple check: Do start and end points match any existing segment? + # Or bounding box overlap + Fréchet? + # Pure Python Fréchet is expensive O(N*M). + # Fast check: Hausdorff distance? + # Even faster: "Projected overlap". + + is_duplicate = False + for ex in existing: + # Check simple proximity of Start/End (e.g. 50m) + # Need to parse JSON points + import json + ex_points = json.loads(ex.points) if isinstance(ex.points, str) else ex.points + if not ex_points: continue + + ex_start = ex_points[0] + ex_end = ex_points[-1] + cand_start = cand.points[0] + cand_end = cand.points[-1] + + d_start = haversine_distance(ex_start[1], ex_start[0], cand_start[1], cand_start[0]) + d_end = haversine_distance(ex_end[1], ex_end[0], cand_end[1], cand_end[0]) + + if d_start < 50 and d_end < 50: + # Likely duplicate + # Check length similarity + if abs(cand.distance - ex.distance) < 200: + is_duplicate = True + break + + if not is_duplicate: + unique.append(cand) + + return unique diff --git a/FitnessSync/backend/src/services/parsers.py b/FitnessSync/backend/src/services/parsers.py index d864a45..e3d7e1f 100644 --- a/FitnessSync/backend/src/services/parsers.py +++ b/FitnessSync/backend/src/services/parsers.py @@ -14,7 +14,9 @@ def extract_activity_data(file_content: bytes, file_type: str) -> Dict[str, List 'points': [[lon, lat, ele], ...], 'timestamps': [datetime, ...], 'heart_rate': [int, ...], - 'power': [int, ...] + 'power': [int, ...], + 'speed': [float, ...], + 'cadence': [int, ...] } """ if file_type == 'fit': @@ -34,7 +36,7 @@ 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': []} + data = {'points': [], 'timestamps': [], 'heart_rate': [], 'power': [], 'speed': [], 'cadence': []} try: with io.BytesIO(file_content) as f: with fitdecode.FitReader(f) as fit: @@ -64,6 +66,14 @@ def _extract_data_from_fit(file_content: bytes) -> Dict[str, List[Any]]: 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) diff --git a/FitnessSync/backend/src/services/power_estimator.py b/FitnessSync/backend/src/services/power_estimator.py new file mode 100644 index 0000000..c4f98c7 --- /dev/null +++ b/FitnessSync/backend/src/services/power_estimator.py @@ -0,0 +1,160 @@ + +import math +import logging +from typing import List, Optional, Tuple, Dict + +from ..models.activity import Activity +from ..models.bike_setup import BikeSetup +from ..models.weight_record import WeightRecord +from ..services.parsers import extract_activity_data + +logger = logging.getLogger(__name__) + +class PowerEstimatorService: + def __init__(self, db_session): + self.db = db_session + + # Physics Constants + self.GRAVITY = 9.80665 + self.RHO = 1.225 # Air density at sea level, standard temp (kg/m^3) + + # Default Parameters if not provided/estimated + self.DEFAULT_CDA = 0.32 # Typical road cyclist + self.DEFAULT_CRR = 0.005 # Typical road tire on asphalt + self.DRIVETRAIN_LOSS = 0.03 # 3% loss + + def estimate_power_for_activity(self, activity_id: int) -> Dict[str, any]: + """ + Estimate power activity streams based on physics model. + Returns summary stats. + """ + activity = self.db.query(Activity).filter(Activity.id == activity_id).first() + if not activity: + raise ValueError("Activity not found") + + 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 + + # 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 + count = 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 + 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. + activity.avg_power = avg_power + # Max power? + activity.max_power = max(power_stream) if power_stream else 0 + + self.db.commit() + + return { + "avg_power": avg_power, + "max_power": activity.max_power, + "stream_sample": power_stream[:20] + } + diff --git a/FitnessSync/backend/src/services/segment_matcher.py b/FitnessSync/backend/src/services/segment_matcher.py index 06e5916..2270272 100644 --- a/FitnessSync/backend/src/services/segment_matcher.py +++ b/FitnessSync/backend/src/services/segment_matcher.py @@ -34,8 +34,15 @@ class SegmentMatcher: # without special extensions. We'll fetch all and filter in Python. # Ideally, we'd use PostGIS geometry types. + # Normalize activity type + act_type = activity.activity_type + if act_type in ['road_biking', 'mountain_biking', 'gravel_cycling', 'virtual_cycling', 'indoor_cycling']: + act_type = 'cycling' + elif act_type in ['trail_running', 'treadmill_running']: + act_type = 'running' + segments = self.db.query(Segment).filter( - Segment.activity_type == activity.activity_type + (Segment.activity_type == activity.activity_type) | (Segment.activity_type == act_type) ).all() matched_efforts = [] 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 edb828d77838938ccdf815392bbe6a8792531f91..496d334007f8b45c9e50d8649dbe60b74b719762 100644 GIT binary patch delta 787 zcmeC}X1qLsk#9LKFBbz4gsn`@Tx7M8FT{e?f{lS;`s74QsmZG?_%~0mXk(dt%tBI7 zVi_X?!)hRgfFke71~!s{l4zo4AW>m7wKa_KB9r%7D9WL!28xTq%uA682H7bng=Qec z8fi393$Qh4YGKxBqNzr+h5=|57;DOIma)xe48A2$T#{H+l9`w8lUR~jQks%_OAs!S zpO+315(Wzer6wolPj;{mWjfBZdAj{i z#`+I@3_Jq;7F`w>MAR;Ft6$+(|G>b;>CCvoWxdBLj|-+A7j-fb9VQ`zqSY delta 234 zcmcaSfw8xnk#9LKFBbz41h^z-?y%U%7h=J>g@u7(`s74QsmZG?_%~0mXk%eZkqBnc zl-#_)E}xOvPg7)alEbFST8^Pie;GG7I{sv2yTBm>M3cQ-rZAdszVE`pXr#!>wL#(o z1CTl(av=GH@qvsZa$qh_VzQ#!TgKy)`Q0VBE&v_G2*kw#lMUT(+UK({a(!UHPJRT7 Mf59PJBnLDF0Fd@TMgRZ+ 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 14114f2b70b1fb9d31a7fe327686e8d1d86fc79e..faa0ba569a0d6ef2d615a1d79e6262244c609bd7 100644 GIT binary patch delta 4572 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 agQDqpBs75EC;PjHjRj2M-%%83M&>^fx)EFe delta 2520 zcmZ`*YfK#16~1?7clJFC?6Ql&z}n`uuwJ0BPQWHWg9)|)w@jvS3+ZaSJFGX|ouzjN z637oXjZ)Q8RjE2k8ujrbYE-#Vln|{dx&NA|Zl%1^%@-<4|fLH0Gt==`iBCQ41 zJZt`O!Hpi{F2x7;XqNhG=Y%gw{gyAUZCA6GwcOWo0;gy^K5Rw&|LBTD$jbztcO_ss z?S+4N$EdJBy5Q>;L=}d+20KMnkcr|_MVXAbGPF*UAYDWyD?!*f*%!KWBGXvGaM>|g z_iA%vIil{zAd5669xy0A{`Xm)ia+f#B-IrmPtb)2gczRH_&%R!8*A-y$prlr`sjfs{|(~+zv-!@3=Q+_*CB%b@;U& zLJ~yv0S!jmmC)n@A%~1|-MG@n6CLv|Qp2zM_d_u?MZQRtun~AzdJqrx!e7#nPbCpG zf@;v=$-j-;YlsnQ8X?XZ)a7YzR$81HBjotOKPP-HLsn(@T_{cm;kLWATXyurl)OfG zHR3k9cX@w#yH-UqgFvCC$}5D^EU)j7M?iK+#sNs2VIC6p5rfgze&x!!#z_ zxpf?7%14nY{3)hGdBfA;%#mQJ&5;Om{~sX%UJ57)JQyBDoG&$`#?I++r?$vRE8!IvXHhJonw@Nsll8bu~H z41=w6Gy|2^>Bvr4$!SI+S1E6lbW@*9B-^1B>w(u|Dfl3!C#EsL@|CoznXT%&VM*v& zE}F?EhEfuif*-~okwaR}EN&Ie?QC8%HMk#tylaZb$yHNhX4Wj0^=#E->@l3I;-M1w zdfP?ePXfHxwgyv2ROxPK`rAu?t@X8^r{T92@5DM@U4Y8<=G(EsSKw;LTHyQP55GuB z*YMtx6dvgu5p)X0&e8UFGqd8NNN$P##ZmEAsCzLb-b%T#zg(+z-uFnuJjD^%?Op8M zkrRz1i+VMkPA3J+Q`W0ht)N@p4OS^I9Dvl9n!T6Ds2#7eZRCnZA;Iv*5{nK$5GE6B zocBE&x{*gy(#3PG%GF0cJyZO`E=TY{O9p{T4zr``}W7O4EdpfL&J`JP1bcwdz0G zO_XLsnw1Jty**U%Eif~x(s6iYbcvpVyQ6b(?Q@B%>$nKJg0P&79?~_!r|o016*~~@ zEH~h_CRTI4@~ly+X!)#D=4_E0zr*1I2fiydgJ6XmUmd+ohOeB4zO%SD!gdgnp8D0{ z`mh%`oIxMg z?QXpu*(2EO=5LJsQoQl#JI2R+_~GXTXqp*`oNynxUfm88Ju8dU|8G)3w0-^$G1(uP diff --git a/FitnessSync/backend/src/services/sync/activity.py b/FitnessSync/backend/src/services/sync/activity.py index 2574a51..81964e4 100644 --- a/FitnessSync/backend/src/services/sync/activity.py +++ b/FitnessSync/backend/src/services/sync/activity.py @@ -317,3 +317,13 @@ class GarminActivitySync: data.get('maxBikingCadenceInRevPerMinute') or data.get('maxSwimCadenceInStrokesPerMinute') ) + + # Location + if data.get('startingLatitude') and data.get('startingLongitude'): + activity.start_lat = data.get('startingLatitude') + activity.start_lng = data.get('startingLongitude') + elif data.get('startRecallLatitude') and data.get('startRecallLongitude'): + # Sometimes Garmin uses these + activity.start_lat = data.get('startRecallLatitude') + activity.start_lng = data.get('startRecallLongitude') + diff --git a/FitnessSync/backend/src/utils/__pycache__/geo.cpython-311.pyc b/FitnessSync/backend/src/utils/__pycache__/geo.cpython-311.pyc index 57e9c2430b18f2e74dca06110ed70b170f8df28b..dab611d7718c6ac1c10ed132451f0720a0012529 100644 GIT binary patch delta 2544 zcmaJ?U2Gf25#IYFkGvEA6-%;3FeV*}lET=LWY?CfShAGFZJ^q9oW@RDn%13|t7%Hv zJ4K0n1lhoWDMvubo((gAo$95oj3}rJqevR0fFD~R4R{G3hyx7xrRZBLpa~!ZXwliD z{%EvaV85Neo!y(Anfpcm;xW%}+S=R*#(P)O%2a#B(?O3>m7c@98JU$>m>f(8OsC9F zyCv5Z_MD?K`((E$(=yvhHrm5h^HLkAosy^W!EVZ1`SeMr4|v|@yS=s#T6S%3ns!Kj zFmT%jm45|<0C0jtokoeE(M7#Uq5Bo8pD}(wJwbhGOjA7*NT+mKXEN*^8lewiHph=y zgz_j)<>@@5Gdi^ZUzBGxW>b&Q&$eitHP-xHMuHC0dCusjcNrdf55*XL^nfws@f!d3 zi=gZ?dTDooy+gqn6L&$dRXmJhF=FmCa(R#7Gv&GV}W$A47EKv)f0t5^AdV1lE zI}(W$BFEF&skv-glOsw_(Uf#HazRdGC6|F!o0sKWM4O+9%+4q|O-+(5ksr$19L}i` zNzPzdRwJ+N?~e=|+@EM_mDk~85>70_X}FDmhk%!W55VHmS~~Zv<+Qi6vYOzHGPCRX zmu7Mn^|nR5Ww94Gf;$MI_*U5DM{v4=;8VU8SzlH<7lLiU=*D$5ZGc>;wmiohhe5avBjX zL@y%a7`MlmfTGeByLuW;#MxRk<{qRi+hL+DLS$1zRzL zm5h>un#d}u*355w818MkR4t9Qlp?)lIpv&02{xbkUE!<#klHOvM zw?u73lGQ<$p-H^*w8Z3@MNvoFQOL8vQ#*KWX9RR4v81SVsIt7YY#x>KhwLX(5%WpxKMIcJ_;is|&Cup!+CPvC~@GOdT@d0P%G@iDne z=OrX@@6ye43&geVBKJLwHorRIn8X4(@(6)u;u2~YAVK5r5}M?Aq7A=5=obk*N8lKM z<(y5~?_hDWDR>1uMl{EXhQ+daz{Y1SHie~GTsTCOq!d)rmF&J^L$O%2`wN}~@j@R? zUy^Z3nwiUF(`sr~o}0P|ypofYDOpV(&CWo{sv}9_bB;Ls0${y}8bY9a))d0a{x5~S zHDPZf)Losg1>-d_{>U3Bv5kVyU*gt0DA-x;tOuecXT$eIdH%Y$#C_vNfnDXdmk(6( z%Nf(3toxH|h;?<@P#S&ckFPjZ-dJ_rJM_tf=^v~6$4aLfK7V=qmwcITbas{78y%hH z&^l>*@oOWckw$P=d9dnQ9x{W8dN5HMCxXIYm8;I1!FW9wwWduIDzhhG6;il)Zc;{eECovB?$~=+C#cICOZ5wfJ(A=<5z`WF@6_JgqMl# zM+8VbA)lWibdt~zHDf*a4EGu^H;w+#8fNB^D&(?Iw*+5{kqK6T{|V_S=vj z;aR|$Ay`)0?d6_qFAs+7eARyRKRt(5zyJUM delta 453 zcmY*TyG{a85allW0K&2uh*@A4P*EiEFd7RDQL!LVOB-W@=q`o?Ul+8J&c=#trnc1( z6B|1JL2E;=#>QA%5NFoPN$$C4&Y9QEdM_WkSJi+AL#@`EhU$cL;mhO9E^B^Uu*DcT zZa7yHs$z=`pB20o_hd_TMhPioi;2X(%hMY%2d-8=@6P(`T_APi_oO*d-GN zZ!GT}-1;P0PvQK&CnpZs+0fsrcWUj!ddubpJcYJc79_2eOkfE&2uy^b!tF*4EBQ1@ zlhD&*@)XewJZg(l0cmvbuDyE0M@Ze^0)9LNFA<#;;3HznMG{I7h;9d#h!U(oE&4t9 zjCWXY0@J=Q6pHh>plMXtK0FwfLC13P_!>f+rl!+eC!qp!W-eJLx}$O$}4e1^D} z7lE54sfx)i+?nUh4|+1QK=-=qIXQpRso5R-<0~JZS{E&Qm2aZP@UCga-rM)S_ujYr*jqI} z8$I9MZW*u>pVU*Lx9ADMcg5O{5!)z=BX;fVI@lFQQL`L(R)CX;IO6iCFpBKF%JtV% z{(77%#oZQHjC)wT%(g1L#rdGqXCfC2&_3$xY3yPBAE15v*arte{^VA$y+*g`9XEffh+YQh8bE8NnD zTHtzN6BA&SI7Z5?02s91em%c|VK97TW0)cS!&T@U3{Hz1{o=5Rl`wPw5CNJn)hi#= z)8mD-p2up+Naa&{S{=hWNf}9I^Jg$N)cl!@n$4t)d@d>v;(UP^IduXj3C1~fB-W_z zX^m-7Idy_*)^_kuv$JO#5w-)4bh3RSQ2LO0K2&b1~cG?461hA4&14{S-FU)19_)Ey1eK2 z&2)c|lsRcux)`|NxgNL?x*D2~T&pTcwKMzwmw6C*Sa+{(vH5;W0Mr$T>otUmB3r2YJRi3q(l~M zC0D~LY9JL$mr>Xrs%28|0u7lkYdYBq5;^u>m_%sFc^J0RN;%zG%_tlca%X8tHR+kG++-)l0wRCR-<;H}Ls4J8Y&cjSbUI zbR&rg=a@mFpT;CHktrn8dM=U0h4E9EXin0`o3+!Vr%&$UE{EQwWsg_b$~$TTiSh7G z?xS^fJzev~?7b|Kq+9)yP^Opskv6^~YnIi5@HEjlRB*61D|MXCOcc_%i}bS?{|j;^ l{p4@2Q()tjL*D>13qi{)V#}RYFd0wVo8xINJwe2FP|iv zGKi@%Ifu`fRS(D%*}Q~Lmsv_4$n<;p9|$yAZZYSi=G|f~O3X`7Ez+C(T2PivAEe1( zGM|t)ml=o&BCIwi3-L2af^=#!6={Hk)j@2QBCu+u$=xCbtd2mDh{*>;Di}E@TZ*a} vfvg1UE&|CEDS`+o5Fr61ZgJS;=BJeAq}mm^0J)4nT)b)W6j6)GUqqV#+D%P0 diff --git a/FitnessSync/backend/src/utils/geo.py b/FitnessSync/backend/src/utils/geo.py index 14b7c97..6caf17c 100644 --- a/FitnessSync/backend/src/utils/geo.py +++ b/FitnessSync/backend/src/utils/geo.py @@ -7,7 +7,7 @@ def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> fl on the earth (specified in decimal degrees) """ # Convert decimal degrees to radians - lon1, lat1, lon2, lat2 = map(math.radians, [lon1, lat2, lon2, lat2]) + lon1, lat1, lon2, lat2 = map(math.radians, [lon1, lat1, lon2, lat2]) # Haversine formula dlon = lon2 - lon1 @@ -17,6 +17,21 @@ def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> fl r = 6371000 # Radius of earth in meters return c * r +def calculate_bearing(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """ + Calculate initial bearing between two points. + Returns degrees [0, 360). + """ + lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2]) + + dLon = lon2 - lon1 + y = math.sin(dLon) * math.cos(lat2) + x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dLon) + + brng = math.atan2(y, x) + return (math.degrees(brng) + 360) % 360 + + def perpendicular_distance(point: List[float], line_start: List[float], line_end: List[float]) -> float: """ Calculate perpendicular distance from point to line segment. @@ -85,6 +100,54 @@ def ramer_douglas_peucker(points: List[List[float]], epsilon: float) -> List[Lis else: return [points[0], points[end]] +def ramer_douglas_peucker_indices(points: List[List[float]], epsilon: float) -> List[int]: + """ + Simplify a list of [lon, lat] points using RDP algorithm. + Returns the INDICES of the simplified points in the original list. + """ + if len(points) < 3: + return list(range(len(points))) + + # Since recursion makes relative indexing hard, we can use an iterative approach + # or pass absolute start index? + # Actually, easiest is to pass (points, start_idx, end_idx) recursively? + # Or just use mask? + + # Recursive helper that takes absolute indices + def _rdp(start_idx: int, end_idx: int) -> List[int]: + if end_idx - start_idx < 2: + return [start_idx, end_idx] + + dmax = 0.0 + index = 0 + + # Points are derived from original list by slicing? No, need random access + # Optimized: access global 'points' + + # Find the point with the maximum distance + # Line from start_idx to end_idx + p_start = points[start_idx] + p_end = points[end_idx] + + # Pre-calc line params for speed? + # perpendicular_distance is expensive in loop. + + for i in range(start_idx + 1, end_idx): + d = perpendicular_distance(points[i], p_start, p_end) + if d > dmax: + index = i + dmax = d + + if dmax > epsilon: + res1 = _rdp(start_idx, index) + res2 = _rdp(index, end_idx) + return res1[:-1] + res2 + else: + return [start_idx, end_idx] + + return _rdp(0, len(points) - 1) + + def calculate_bounds(points: List[List[float]]) -> List[float]: """ Return [min_lat, min_lon, max_lat, max_lon] diff --git a/FitnessSync/backend/templates/activities.html b/FitnessSync/backend/templates/activities.html index 2be5582..4b68c62 100644 --- a/FitnessSync/backend/templates/activities.html +++ b/FitnessSync/backend/templates/activities.html @@ -35,9 +35,9 @@
+
+ +
+
+ +
@@ -315,7 +324,10 @@ detailsModal = new bootstrap.Modal(document.getElementById('activityDetailsModal')); + detailsModal = new bootstrap.Modal(document.getElementById('activityDetailsModal')); + loadActivities(); + fetchBikeSetups(); document.getElementById('prev-page-btn').addEventListener('click', () => changePage(-1)); document.getElementById('next-page-btn').addEventListener('click', () => changePage(1)); @@ -375,10 +387,19 @@ tbody.innerHTML = 'Loading...'; const typeFilter = document.getElementById('filter-type').value; + const bikeFilter = document.getElementById('filter-bike').value; + let url = `/api/activities/list?limit=${limit}&offset=${currentPage * limit}`; - if (typeFilter) { - url = `/api/activities/query?activity_type=${typeFilter}`; + // If any filter is active, force query mode + if (typeFilter || bikeFilter) { + url = `/api/activities/query?`; + const params = new URLSearchParams(); + if (typeFilter) params.append('activity_type', typeFilter); + if (bikeFilter) params.append('bike_setup_id', bikeFilter); + + url += params.toString(); + document.getElementById('prev-page-btn').disabled = true; document.getElementById('next-page-btn').disabled = true; } else { @@ -438,8 +459,8 @@ - @@ -577,6 +598,44 @@ } } + async function fetchBikeSetups() { + try { + const res = await fetch('/api/bike-setups'); + if (!res.ok) throw new Error("Failed to fetch bikes"); + const bikes = await res.json(); + const select = document.getElementById('filter-bike'); + bikes.forEach(bike => { + const opt = document.createElement('option'); + opt.value = bike.id; + opt.textContent = bike.name ? `${bike.name} (${bike.frame})` : bike.frame; + select.appendChild(opt); + }); + } catch (e) { + console.error(e); + } + } + + async function refreshActivity(garminId) { + if (!confirm("Are you sure you want to re-download this activity from Garmin and run bike matching?")) { + return; + } + + showToast("Processing...", "Refreshing activity data...", "info"); + try { + const res = await fetch(`/api/activities/${garminId}/redownload`, { method: 'POST' }); + const data = await res.json(); + + if (res.ok) { + showToast("Success", data.message, "success"); + loadActivities(); // Reload table + } else { + throw new Error(data.detail || "Refresh failed"); + } + } catch (e) { + showToast("Error", e.message, "error"); + } + } + window.showActivityDetails = async function (id) { // Reset fields document.querySelectorAll('[id^="det-"]').forEach(el => el.textContent = '-'); diff --git a/FitnessSync/backend/templates/activity_view.html b/FitnessSync/backend/templates/activity_view.html index 793c501..f726e68 100644 --- a/FitnessSync/backend/templates/activity_view.html +++ b/FitnessSync/backend/templates/activity_view.html @@ -56,6 +56,12 @@ + + @@ -228,7 +234,10 @@
-
Bike Setup
+
+ Bike Setup + +
No Setup
@@ -647,6 +656,132 @@ setupDrag(endMarker, false); } + async function refreshActivity() { + if (!confirm("Are you sure you want to re-download this activity from Garmin and run bike matching? This will overwrite any manual bike selection.")) { + return; + } + + const btn = document.getElementById('refresh-btn'); + const origHtml = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' Refreshing...'; + + // Helper toast fallback if showToast not defined in this view (it inherits from base.html usually?) + // base.html usually has showToast. + if (typeof showToast === 'function') showToast("Processing...", "Refreshing activity data...", "info"); + + try { + const res = await fetch(`/api/activities/${activityId}/redownload`, { method: 'POST' }); + const data = await res.json(); + + if (res.ok) { + if (typeof showToast === 'function') showToast("Success", data.message, "success"); + else alert(data.message); + + // Reload details + loadDetails(); + loadCharts(); + } else { + throw new Error(data.detail || "Refresh failed"); + } + } catch (e) { + console.error(e); + if (typeof showToast === 'function') showToast("Error", e.message, "error"); + else alert("Error: " + e.message); + } finally { + btn.disabled = false; + btn.innerHTML = origHtml; + } + } + + let allBikes = []; + async function fetchAllBikes() { + try { + const res = await fetch('/api/bike-setups'); + if (res.ok) allBikes = await res.json(); + } catch (e) { console.error(e); } + } + document.addEventListener('DOMContentLoaded', fetchAllBikes); + + function editBikeSetup() { + const container = document.getElementById('m-bike-info'); + if (container.querySelector('select')) return; + + // Current text + const currentHtml = container.innerHTML; + + let initialSelect = `
+ + + +
`; + + container.innerHTML = initialSelect; + } + + async function saveBikeSetup() { + const sel = document.getElementById('bike-select'); + const newId = sel.value ? parseInt(sel.value) : null; + + try { + const res = await fetch(`/api/activities/${activityId}/bike`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bike_setup_id: newId, manual_override: true }) + }); + + if (res.ok) { + if (typeof showToast === 'function') showToast("Success", "Bike setup updated", "success"); + else alert("Bike setup updated"); + loadDetails(); + } else { + const err = await res.json(); + alert("Error: " + err.detail); + } + } catch (e) { + console.error(e); + alert("Save failed"); + } + } + + async function estimatePower() { + if (!confirm("Estimate power for this activity using physics usage calculation? This will update average/max power stats.")) return; + + const btn = document.getElementById('estimate-power-btn'); + const origText = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' Estimating...'; + + try { + const res = await fetch(`/api/activities/${window.currentDbId}/estimate_power`, { + method: 'POST' + }); + + if (res.ok) { + const data = await res.json(); + alert("Power estimation complete! Avg: " + data.stats.avg_power + " W"); + loadDetails(); // Refresh stats + loadCharts(); // Refresh charts if stream updated (Service returns stream but we'd need to reload) + } else { + const err = await res.json(); + alert("Error: " + err.detail); + } + } catch (e) { + console.error(e); + alert("Estimate failed: " + e.message); + } finally { + btn.disabled = false; + btn.innerHTML = origText; + } + } + async function saveSegment() { if (startIndex >= endIndex) { alert("Start point must be before End point."); diff --git a/FitnessSync/backend/templates/base.html b/FitnessSync/backend/templates/base.html index 7d0ac8a..51cc6a3 100644 --- a/FitnessSync/backend/templates/base.html +++ b/FitnessSync/backend/templates/base.html @@ -75,6 +75,11 @@
+ +
- +
- +
- - + +
+
+
+
+ + +
+
+ + +
+
+ +
@@ -96,7 +117,7 @@ function renderTable() { const tbody = document.getElementById('setupsTableBody'); tbody.innerHTML = ''; - + currentSetups.forEach(setup => { const ratio = (setup.chainring / setup.rear_cog).toFixed(2); const tr = document.createElement('tr'); @@ -106,6 +127,10 @@ ${setup.chainring}t ${setup.rear_cog}t ${ratio} + ${setup.activity_count || 0} + ${setup.total_distance ? (setup.total_distance / 1000).toFixed(0) + ' km' : '-'} + ${setup.weight_kg ? setup.weight_kg + 'kg' : '-'} + ${setup.retirement_date ? 'Retired' : 'Active'} + + + +
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ + +
Enter the ID of the activity to slice into segments.
+
+
+ +
+
+
+
+
+ + + + + + +
+ +
+
+ +
+ Run a search to see recommendations. +
+
+
+ + +
+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/FitnessSync/backend/templates/segments.html b/FitnessSync/backend/templates/segments.html index c447233..cd9d240 100644 --- a/FitnessSync/backend/templates/segments.html +++ b/FitnessSync/backend/templates/segments.html @@ -30,7 +30,9 @@ Name Type Distance + Distance Elevation + Efforts Actions @@ -141,7 +143,9 @@ ${seg.name} ${seg.activity_type} ${(seg.distance / 1000).toFixed(2)} km + ${(seg.distance / 1000).toFixed(2)} km ${seg.elevation_gain ? seg.elevation_gain.toFixed(1) + ' m' : '-'} + ${seg.effort_count || 0}