From f50006d100725170e00c758f1485ddda55d4652e Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Mon, 24 Nov 2025 14:09:23 +0200 Subject: [PATCH] component decomposition is done --- .../commands/2.planning/2.1_gen_components.md | 1 + .../astral_next_components_diagram.drawio | 306 ++++++++++ .../astral_next_components_diagram.png | Bin 0 -> 341132 bytes docs/02_components/decomposition_plan.md | 364 ++++++++++++ .../gps_denied_rest_api_spec.md | 387 ++++++++++++ .../flight_manager_spec.md | 358 ++++++++++++ .../route_api_client_spec.md | 331 +++++++++++ .../satellite_data_manager_spec.md | 551 ++++++++++++++++++ .../image_input_pipeline_spec.md | 450 ++++++++++++++ .../image_rotation_manager_spec.md | 410 +++++++++++++ .../sequential_visual_odometry_spec.md | 316 ++++++++++ .../global_place_recognition_spec.md | 310 ++++++++++ .../metric_refinement_spec.md | 308 ++++++++++ .../factor_graph_optimizer_spec.md | 362 ++++++++++++ .../failure_recovery_coordinator_spec.md | 404 +++++++++++++ .../coordinate_transformer_spec.md | 333 +++++++++++ .../result_manager_spec.md | 267 +++++++++ .../sse_event_streamer_spec.md | 242 ++++++++ .../model_manager_spec.md | 224 +++++++ .../configuration_manager_spec.md | 172 ++++++ .../gps_denied_database_layer_spec.md | 193 ++++++ .../helpers/h01_camera_model_spec.md | 101 ++++ .../helpers/h02_gsd_calculator_spec.md | 78 +++ .../helpers/h03_robust_kernels_spec.md | 74 +++ .../helpers/h04_faiss_index_manager_spec.md | 84 +++ .../helpers/h05_performance_monitor_spec.md | 93 +++ .../helpers/h06_web_mercator_utils_spec.md | 94 +++ .../helpers/h07_image_rotation_utils_spec.md | 92 +++ .../helpers/h08_batch_validator_spec.md | 329 +++++++++++ .../route_rest_api_spec.md | 289 +++++++++ .../route_data_manager_spec.md | 338 +++++++++++ .../waypoint_validator_spec.md | 294 ++++++++++ .../route_database_layer_spec.md | 475 +++++++++++++++ docs/_metodology/tutorial.md | 7 + 34 files changed, 8637 insertions(+) create mode 100644 docs/02_components/astral_next_components_diagram.drawio create mode 100644 docs/02_components/astral_next_components_diagram.png create mode 100644 docs/02_components/decomposition_plan.md create mode 100644 docs/02_components/gps_denied_01_gps_denied_rest_api/gps_denied_rest_api_spec.md create mode 100644 docs/02_components/gps_denied_02_flight_manager/flight_manager_spec.md create mode 100644 docs/02_components/gps_denied_03_route_api_client/route_api_client_spec.md create mode 100644 docs/02_components/gps_denied_04_satellite_data_manager/satellite_data_manager_spec.md create mode 100644 docs/02_components/gps_denied_05_image_input_pipeline/image_input_pipeline_spec.md create mode 100644 docs/02_components/gps_denied_06_image_rotation_manager/image_rotation_manager_spec.md create mode 100644 docs/02_components/gps_denied_07_sequential_visual_odometry/sequential_visual_odometry_spec.md create mode 100644 docs/02_components/gps_denied_08_global_place_recognition/global_place_recognition_spec.md create mode 100644 docs/02_components/gps_denied_09_metric_refinement/metric_refinement_spec.md create mode 100644 docs/02_components/gps_denied_10_factor_graph_optimizer/factor_graph_optimizer_spec.md create mode 100644 docs/02_components/gps_denied_11_failure_recovery_coordinator/failure_recovery_coordinator_spec.md create mode 100644 docs/02_components/gps_denied_12_coordinate_transformer/coordinate_transformer_spec.md create mode 100644 docs/02_components/gps_denied_13_result_manager/result_manager_spec.md create mode 100644 docs/02_components/gps_denied_14_sse_event_streamer/sse_event_streamer_spec.md create mode 100644 docs/02_components/gps_denied_15_model_manager/model_manager_spec.md create mode 100644 docs/02_components/gps_denied_16_configuration_manager/configuration_manager_spec.md create mode 100644 docs/02_components/gps_denied_17_gps_denied_database_layer/gps_denied_database_layer_spec.md create mode 100644 docs/02_components/helpers/h01_camera_model_spec.md create mode 100644 docs/02_components/helpers/h02_gsd_calculator_spec.md create mode 100644 docs/02_components/helpers/h03_robust_kernels_spec.md create mode 100644 docs/02_components/helpers/h04_faiss_index_manager_spec.md create mode 100644 docs/02_components/helpers/h05_performance_monitor_spec.md create mode 100644 docs/02_components/helpers/h06_web_mercator_utils_spec.md create mode 100644 docs/02_components/helpers/h07_image_rotation_utils_spec.md create mode 100644 docs/02_components/helpers/h08_batch_validator_spec.md create mode 100644 docs/02_components/route_01_route_rest_api/route_rest_api_spec.md create mode 100644 docs/02_components/route_02_route_data_manager/route_data_manager_spec.md create mode 100644 docs/02_components/route_03_waypoint_validator/waypoint_validator_spec.md create mode 100644 docs/02_components/route_04_route_database_layer/route_database_layer_spec.md diff --git a/.cursor/commands/2.planning/2.1_gen_components.md b/.cursor/commands/2.planning/2.1_gen_components.md index 93211c2..660dbee 100644 --- a/.cursor/commands/2.planning/2.1_gen_components.md +++ b/.cursor/commands/2.planning/2.1_gen_components.md @@ -44,5 +44,6 @@ - Generate draw.io components diagram shows relations between components. ## Notes + Components should be semantically coherents. Do not spread similar functionality across multiple components Do not put any code yet, only names, input and output. Ask as many questions as possible to clarify all uncertainties. \ No newline at end of file diff --git a/docs/02_components/astral_next_components_diagram.drawio b/docs/02_components/astral_next_components_diagram.drawio new file mode 100644 index 0000000..d7090b7 --- /dev/null +++ b/docs/02_components/astral_next_components_diagram.drawio @@ -0,0 +1,306 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/02_components/astral_next_components_diagram.png b/docs/02_components/astral_next_components_diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..95279541bc9bbdffd5d434a1b3fa3410e67e332c GIT binary patch literal 341132 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%0A}9acV;)VH z7@~tq>Kz8;cA6HZXBa%viOU~)kbX`;hCOSoLnnO%(*Du3ht|Pgp1u+(}#5B8JAXQ`(vl_ zyxz#~I;*xel{VL{CcP4PPMv_JkKkNs=Cn&)kmE%LnYYKJS6Hnj)U8r2b*8rU2q_Y+ zJWN7?!)(dRS9g@8Tk`BvZhrY#=|DVTuHXggmyXx(CO=z777&DnZl&t22r6dR$|r5g zL|5ptA62`KbD&@@*}u2j2)r-nA;CTgX4lR1QhLPNJ8$jfqWPBRL&9gqz;}Q;&xi`c zitC*;wkdE)t4mPS4}Ix^9Uc0=j{YA-|BpxUR`}NolMM5|kt!#B&$zuj&1q(%-&t<# zGq7_@F?mzcdGYiE@uim2Q+lqWeV&G>*QwwFOguZIH@XH@CBZ&!eyA-L_ZcamOD8P4$&`-ZqP-8UxK_p)I} ziodc-IF@{G9@|@#v^rcr!|m4AGat8zxnUVx;dg8UkDVWHL7I7d>eVyPP7W%~Pr7tU zu-8-y-cqdpVO>X~+Y!0pF>Orm_dUYW4d+PKjN>v?i7;*~zgl-)N6G7mtl{DA(c(N7 zHurDu>71H9YAu%zZRC=Bn_cyy%);!>cdn7^Qe`bi?Dn?dcDPJ>@D4u1wS?*RXNiJG-~W$~ep*-g zP&h;KTX7hAx+c1scirQ+o2l=7VPuIs;Z({H*(Ljixed35OSx$68@IkXulG&Iy!p!7 zuluF0Zr9m8@m_>v7O1?3fZ?xX-+r?0tV|cP>o_8KV)gL8XU@lqMD=M65witSYnT zM{n1R?`?WvefrI^qxGNHtT=)_Q_|d`)-dqNs3pBW3?X=kzU=#92a^w0%!6W3`k-Ys z1|b!U%kIn(7#@u8e3X_?avE8+H>pqHov40 z=KR*8Yf0hnzIWF@uu73AZQpphezk$hY%qq z(AgdJ`zCu;d3JyKklz`5m?r0IdpS0}wX{6>P`XoTzNQjat|NaWK8KJe!`wFbEjR2P zkA`&q?Mq9QDC!`e0KSVJ73U7o5c0X?ovAGo;CSuliw|Cq_(op`12Exx5F;y0`rBw(&v`(8cRR@ zt1ib2zIRuc9J(7@+P-|HHyWSz7c9c-AzM2j<+DvA2v_L+}&N;zIM%e)xeDaJS8U+MV4p}58>?_*OGH( z92MJv^U6lYrFW58dD1AE9?>=~$akf3-_v6^$`Utk4%Jh$32Bt_jNP9yR`?=664N)) z>SN!$`|yo-Z{8L4T~F3?A1MYwV^M@dY?H&Jt4Vin?JIh_m%0FfA;i?{wbKsiOiF!a67xj<|!7YK$xzbagCYt<`G|E63IyJ>)J`G-9mX z_z?7!hxrmkU)e!lrvi6M#%{Yj{#u{?r3z!0b#}{>)t~;@RusO=OdpfJfASmF?yWGh ze{Pvvn3UjG2?=vtK3XkVHoU6fwp3|;UGTYsZJjlZQ<_JlrfJ8TZ+cp-6YVl|$9AGS zs!s)-HjyggRy7Md*f!`6pN_cmr6Jy=J6igtD{9>9j{6(t))jsc0(OJ;;<=%{M~_98 zKdFwDa_MTK-_4GH*E`S_ z6O_N7x{*D<-$kq=^k%AnyMKQb=-%<9gYB_nzUQU%%?(m)I=dWW{enx|zm@B`N{xQe zJ;2|e0$56g-Sn9%c!fSy5N&Mtr-H-4CAK)AiEQE;GXu>)dq% z<}c~Np0Qm;&1^&1x2E*b7lKqDHa8}v?EvzhB>G_{neGi$@K>_3oUFMA34}YfAeQNB zo>q2(3i|X_wa48MaD8xoHPy>tQb*CfRUL&H_un=$L_ZRAQ7tvAWGUJBP%uf)@*p6J zUh0Fy8KPK#aO;s6Us@az5|8n9rsHQf<*uK+J8ZUZ|J{@X)2vwR;afLv;MPQ7M*+9S zsfvTDmL9*ZLbgmb{PS;LduNty>XL-Z021^_u<<+Z!O8D?9HblkX2>$*1rW)i(yQ+p z_Wvk*>#!=jXAPKA5Tq0YB?SXSN~BW-K{^CP1f;t=HX#y%lysMbbV--AbazSjrjgj} zZ$0>XSb%-p^XIX6}1t*33LU9IzHL@f+!7+gCG3b*Yv#a>noN?bpV- zF`rVa@n%p8JW1>!i_S>ynaV9ge2Y6-n+b={I2|M{3HG4)B8W73RjCj57+&K1g;^BP zK@R3m5HB3W6J0#$mrSOxJJV*0n>}(wu##Xj&Z3)F_h&zM;ri>?Pa;v%XR4C)2TX*= zsVQ^`yPGxmd(29vUmUZR)1#{ncdWA6_}5wDkI&9FVs!-8Ys#&8$|@$kVqX*{nih{U zffG`xu=rQJeGVmk^&hXk!i6Mhh0SJsUDcdJirQuJKF!G<5_v1+s4AIfFP~Gv^bv)o zfQ(nPr{t^m!+Vb6Qzn}%fOWnPTiRV-^8^O~UE+8lC2INyw@&#r^eKkv(#}(T&!}jg zFw=AhFW_&=#vhNsk4J3aQ9V-rMRh9JolXKhMQ|x{Ua-CgCv5_kuE8!8HaedbZ^AAp z2$2v&|B+q%+a`@wuk71MXBDiW#j3jU`AXw__z`t)RSQN`Pk>xeDh7spRL?b*&r=@( zw6<)KGXIT3354E*Tf6SpZ2b-EWrOA`UTLsH*#|*RW6I^RSBp za5g!~TlNHS73E;IqzJYvHO%|(X(jbRMoYV%LUtDnm44(bo=z~##W2AByO?J8F5xj9 z!@%i(p*o_ay~n45tA!Bvm$+45HoIL+N)h_Qbl`j5cJt&E8l~qKy|CG%mS57STh!{_l+yHxvS)o0>ytQgm1<7OhPEY9(zzV55dI=D=_0IQbJgvod zT@Ec;^@o!>Pae1`=cqi?s5;qLfyMm``2(u;mbLAfHXaU67nxGJ&2~VDD{g>1d9O!S zR!F9C%Btela^DJQ<)fp?eGe}UWM^7gTTG)g*_exKk95U+Wrr@{3Ey+XR{X@(*7ybe zVa)(}qcY#jUoXT2jh@>KWV~8R?6E-B^84qkp9E;bW626l`}XDsm63Ta$l+|!B{7vM zeC{ni?!A<3U9$sC{}baS2LtKh12Mx>fgb(D;`1}Eji!+{)5N;h38OBlQI;bUmB_>W zd3%{NM?$vYbljk+qocIMo#Y-1ggW=u{m7q7=IZQEW?mr8#=p9`ZKy=HtBur&;!STC z(=zFiA=G&g9~g8eC=wY>V{4cZj=b54Dm}|82UXwZ0i_PT7W{kb4)Q^@z#lpegbnk{ zAhgWCuqfc?;7O`mtkC292t4PcK*{Ar;Ntw)=DPWQl4T0laMGfAM7;`ja)g;H=$yQ{ zbRanC)FCFT_?@aohJq&^P|(#XH@(xPkdgIzCFCe4&W1qH&6Sh53wxQqPvf@Wy^@|A z+F2Kz%uBW2U1zxgd-fi5L`)>SYS>TfP-+kKLF#InMRS+A9Q>N=9;?KoH=9{L-q^Ey zp~0q%O|R3D>-H22H2weC;Ra{m%m)j;AF zzSh>a^$7zwpt1Kl2U%)SnsDtm6sr!`jO~Zgu4$AaXA>8qNa}QF&6GZ`n|9T0IvK5t zIhbFiD=F&36U|189d$1?60DS}CgGFPtkhFg;*Bo_YYrH zG*5t(p%-^@t(zZVin1q#Q(WZSU!iXn6P08>HW}kp0c)M9F)nCuSwvlL&e3sO`s9q6 zZM?ZTX>JfDWHo|c$ILKHez(kRr|3t!F5B`xY9}uFjOD@k=b`iCDbM+Js#CK2hhi->Wu*E*^Xa5)gUA^bgjOE9ET7ct^g#b;H~G)_n0^l&o|ZN%DgvyYj= z#HhAHtL0q}Du3YCB&&6&`m4Md-VQFKICxIC5Y>UPZwrld=xMtCnr7F%z5KNnpKG_)yZI+vzf@$j zHcnEHodskslqXh7b{f5NuH)h3)K%|1J2cggy!PbRyuw5+BPPXOzZc6yC$}2SE1%q> zsWWHKBY$(ZN?7IN{?M5^N6o--xYX&y{jJHt{2`BOf$1NuwI;GgEt425_ce%TbQ_SUK zk|rtjPgQvym<)5GQ;qLO|A_1VRej!g1$4RU7sz;rr#A0HXcYuc%&5=Ab43 zT%>QOLl2zP>3f>#l(HJQR?fuP=4l1=%_+*v;|K09G%Ly8RWXcrHDM1uEi)GNF-o6c zu#%EBR9ZjzZ~!jo!X`#5S9>tiNIU)HiyKCj;S&OQnob9;B8pSTmdom6oVVMT67unE z44h9V$n2ubFqhs^ziwz$|C#+lv8MfvPjqEYaDugH!ioR1ImhVfz-ie8xly)b;(Gnz zx!YNr7|h<;?F39xH~4#(gE&@?-`4~Yc|U$v^<-MK zzIBs$nXf*=$tpzvk60*bm4uJDnt5Iu-wfY!lH9Pwg?U%O_3WErbPo2Xv)-#q55iS! zHI#a$5@zh8^2~{%5Nokl?0Vtdt7|f8PO2alT|dw}Q3df7JB%>U_pd9}%LyF(+5kGl zryG!m^ND3DPi`~smR;i=P8WaNfhnK3%0;aG_vP6XulycpN7uhGNGz#DX z?_bwF*mqZK*k3D)74K82W@ZqThYT4ds048>90f1Yt)9DT~WTN#xxvJE0hg- z%s&rJ%AW*MWb{t41o$I6JCQ6@A+R zRV|_XkKa2;KYq|>>vPdn-;lq_v(E%3A`LC*)zuPLDJHvQe)d;cT|KQqc;A)GKmTok z+l70bqF`0A@JNxY9sE$*Sstgun1 ziNrvR@Z|8?@y>+hhqW7=FDW0Jd>G6MU$rYRat_aR%BNgiE)(N-iQC_Ms}|m1;(u^@BtV;uCtyiX5L@&zy)%&4e*dsTIWN3|gq)RbN2_HL@5?5cEa($# z#U1D>Oqg^DB`?@&#Z0SfKS?qL4FDq(RWLTw+~OzvPczmblYCHe3r-8XLp!Ige~ovr zg7OQ7C9CTf38_$T!O&qz&I1D}-*V$tPk%HU&P*Hh-6ED6v#$MK&z;FTo4VjIBnJde z*(af;DK!WgiEt9lYtQ^~}ST47Bi^79dk-L$Y)?MnqHoGaTi&vu`!c~7yvS!zBI%*3C6p; zmo#w#YOkit2-5`RAgA;@VR~%-cr3~|ljB^3KI`42^CSI)a(zwR+-r_DhSw;#CNvO7 z;SPPCgl{QKe4elOoUvPW*sY9=AGA+ZuBGE1x<`V>;N2(buoI6Up7KFmZma>32hnb| zEK^5aSf;f)t8q=;{A}kBX8q%xm$>v2$`@5O9KR!exP1x7q~LY%O?9-GpImFaioZLE z@Bh?#aJoD1(`Gg|toNjNF>sxUsxx2q+Z_N}r~%1Om>&pfaFprIE~)b8xRq&7lwl%% zpXVy$w|^dm+g2fSB!f<@2`A~6ezl)8Cv&GS2j(Y~Q%2HCS6Em~Rw()ey5Oz36?c3E zjI63fN0eTb_^Y;i6_GFSyXi+YC#U~&Siy2O%=#sKB58k<) z)vjaWuh*=f99@q|G3Q&MLL{)_zp)j37!y;L>>-6%>eVSOMJNMs8>&SSz4WM};W^5uYg99ha7|Nrepo;#xgipT7T8g{PNj$r|(Orw1=_0N$oG~de%*>k2qNUW*2ZAxz_e|glWmzN&C5f3&~1@3uT@CfTJ#u`GDLk z-Q|X1z_)_$t@M|5d#@S|G*dauCNS1Hj-*#bvVBI};If^kcMcLXZ1nLD7^!6*N?=9G zQ$+8NH@iriOB(e3MpFX@c4Wqre@`+oknLY7^{99dx^HZ7?_R%1sn0kfzFpo~I7R;w zHvHXN`SI_aMW(~f@|KaqQNU*4@d&AIzI~I+*1@*kNJU=f@*w5|z(0cs1FFmG?-L;C z)gVGflV?ddB+h$fYQHfjC|wr1%G`_x)^1^qE?&pz{{1PSCx>^*SB8;y563+5$1h`w zFUQLM3>^W{Jt}z4=DbsB$8o#VHowju=?aejt+o8`w@`U%HH)=f8B@+j2u8Ys4nyqf z?Xa6Vjh|ZYt1(O+yg!$+_O@An)46OGK$6QyvNGo)Z7W%2}wh-n`~@#_abs-bP(#gv&?baczE9NaQ|iYb@*h*{@8j z4>L3F@G&tt>a1&_b1tS`vNrer66h-yxon+g>L70BS|RO77^?+QBnYbID6%ljDAmG^;wSk6@w<&LwW~VLZi2**$U+~IQerrEGpeWN zwm1(B4J@vQ9@m5fB;|Kg883D8GAWPBbof??!a7p?8@lkqBf^RtI=_sTNnGX^pl>BD z?2+Gxy4YL^YZQP4S)dL=je>jpa>6YEgAg47L$h7Yjae<`_+KteNoS0N$5MpH+t$w( ziC0TLM^Ljs-L#(}ifO;XRj1{Bx!-+gqzjx@5?PJ*ZX=DA)jPYN>Q;y%&2Ek_`Omfe zr!C$}kD(|<3Hk^CfxnQ5@}H_5q=~akR66?pG9`HfcJN*)zX?%^2>rhoXfb z7Q`MlsQ;Mw#QHA$Jj!?DW(er(w6dvmF#iy@M&2mgcjJ-Y^R%HSZ#^&j?*H5x{bK~dWk6`t^NFVL zYXbv!oYDqe301_S5{AorWBz|WWsORBfn7wdGvojAm@vFPWg?MeDzSEav{!&d9&$f^ z3MK;GxG{V7DEUVc{iQnRLetV3+NBjpfFkA+%8TdT3gOR@lres1(3J^9$_9z_e?6Sm zCU8er3G~dic@(5Ft!{#6lo4js{qqbVSMYzw0o?qz2$>F}u3}NkLi1DdNGE`*n4!gK zEON~4|DF2QCIhgkjx_@`*!}E6O&j#2v9gjQ>Ul82b?0L}n1d1j4I;-da52XA;$M_> zS!l&{*dIeG{w?3HjTwwX6MM^;%8tbc@S)oTvLtYG^S8zuloCNb5&Y+4hY2kFxyj1n z2wCAkFd#zo;zQsGnBWN>Z&17QwbDlMfEfO`dr8?~Kae%^hk$aYn>+01T4{q|ev+&l z(FEBHH?MHgT~wuXP7J8NJ*fAsLd`|-u{nKv;R%l2y zu(^NFx%TJ;mjY~wC>3^z{x$DJ6I^LQDo*p{w_P)hOxKujJS$WP5=InXu| z3}6hZ^SZpO2E|}3HN;AGF_mCDh5^CyYp#TjB639dU;nGNf(hb#40>39g&_1#d1em- zz>>E&U(g>sA#^OK^K%|Bx}6J3_Mw|sG^{3U3s+)*lK<=w^lDrtC(B1;eS=77fg~9& z%E1A+vIFGF@@P)K{QY<^aRk0l(6{pH3i$ok!6UT6iNGRVwWWgyg_07$h7PJnF9)k6 zIRg<+k+HpM(Hd8vpm zAq^u?0gx3rT)>c4d!L4i(Qj_R@cZXs>dRKv|J9r#hnr-@rykS5ANkEu{okaKzeB>1 z)_pUiMnrB!Hl7OO3(GVm!Vr5fxLiXjDlhCLh-O!l(9v9uh$SWs5HBoz8h{#Tr;#Zn4jA4MRcTvBmCMlMdbLx#jFjXibxh~ zurNIVjHiELRwzjqSVV-!7%T>&H+ClRZ*@09kV-_&-yu0JFeQam$_u-y3ELEyAp%WGXw6dtTiFVvU`!MOOSrcE*5RA{8o63%C7qwD>gb`% ztoc8+0Q^oX(EyZCcF z!ea`fw4kmw9H7z55Z<`BuTuV9KNNUT%YY-^CAW79s4qur99mWW)TTQqPNf_&au?bk zpzBkt0l|L&4h6s$8|KAhay6C3VPe9R%zkr?knl-S58_7>AN_at>P9>t#^5=k>Tqic zSwHEgn;7nRusYYASj7;O09BO!e>)Qao5J)5B$XSl<%p1#h4DqHEf`DYRonR3iq*Cq z2>e??X3jdtffT&Du5cIaQ+-iGoX4uQlKoZ0o0v1gan*sUD{AlwQ8yv3~|C=1osxDafozEsoKh6dKgxoF1_R|>a7dc@5F$!lPaFJ6 zQcK96fXU&aS|Ps4w6VL&fMLWkHu z46N)6<#wvK@ zVfashFwn?Jqab{52SxTa39AGt*jH zu=W)|wX|y>L(OIQ1s$#q6QI4No9D1e zbbci1Lq&Y5FQO8b%Y!D28}=<19VpNcz-`NN(-sf9B(n==2@BFmbPPOy>)ohO-s(?2EJe=|1xh1^MDJhH-DOnzEwT2dWF+)!uS z=_qK8qlyhAq3PuX@HmZ2PjM+73s61P5Y{FS;PSCgytF8gkLdHlF)01>2>|j-2%ORW z?(i=-Lw*G1O>~a}C<;go1k6UkSSpe<1IfhMWtHOZC)~k&#$sv-008~uc4>n+K+AZ# zh8G7OHSD{zRDMdhksMDqT|8wyJgy{r?IsTZ2nb}WE!YQ{STZG$d zb-kxtSh{XjTm|GVb%O2rzbl%Zo~7+JEs@VvCG;AJ8rIZiTr7*i5frV)Dp_%uitu?4 z=uhw6&Kpyc@*r>AH??Q=>67pZGe~k-P2nhN`M56!Xx7^iyQAj?Hu*gy&qR`KmZ2Y%c6ah zR(qEwl2{}e8Jl<;djWJYpA<76f5>@LY*x_zE?->mFh^oBZ+(VV)x1t`yNZO|slB1y zmZCJ#<($})Fm|TKGkldTt_qrCrw5|TArk~Q&);(qg6a%6$*ezO07p&2TsBPJ=TlpE z3Y}frNM-fgFz2YPJjs#lZhYPycAQ@o=g4(wH_u7SsYOolh7aU|NV-mrWH@Wz~%-ya8pZ-RCNGI{{XwB+Q`$ZdlT&BBwhv##ug zgjfLJKT%%0AMZ zu4Drc(GV?-cP0EYPJAT=K!O@M`}k#{ldLFkdjoGx9KHl_weKxYe{csR6H@40nV@5% zY1prBaqgeKkuLM7ZJlr>2!fQ`5>A&c_$)LRPVWMrAxLq`_C4znk&6peU*9z%)O1px z=QlLJc2O0EwJ!>!50yt=245FmLIE{|N1@N?ZDV}Tf>P+DW%6wuzx%TA=kFwxlzm(c z#ol$oIH3hHa%=gLaG6882ya2x06f_K`e+ zDl4jo@uvdZ|H6X@<%pIMS4lizzBdo@i&(p_wm5=Dg&4Q?M&6ShPqRf1%eaP(-;vD;ic0uJ&2jj>)E~OU_ zLCYGdl!Mv$lR9Tn-6ZKKfnix(b>S$1T8ez(By)%jOodnfnV~`L?hY)YJMLd>BY7iz z{RLDEs?!`}i-THPqjk1}S<8ntCe@1=g}Dj7F*QZoZ{0#v{eUY`7N{S>Cukx41~8w0 z9nA@0ezN=n?$TfNT$QR7Z{PqqeD~Orxk8e|Z7=J`Bm0`7GpK0h*>m_hC^TgG|y7j~OKlOhVA+WI*ilF4f znab(x*fWx}nAkY*j>@zApkmi~vc*GYb&k>QWy74#!tXD}9>Iz!G{Gxw{JRL+gn;^7 zzZ%W-*Bg!o!Gyj5s@0|dVEDjldGUM;=-FdOuSSpCw!@nuPb>h-N8Xq;1<)g!_)ys+~a{R8R4v(rf|;k6%}| zXfb$Ry;gQfq-+3hc5^du{C9_E@YEy`OKbH!e44$8W(IF(^Sx?M5}UenxXIN1vZw3X z6K*}-y6KT3Voat-cNK1>8wc5D)M?IUt4TR;AzOn!ldcJ@P{C+v7rwcfw`53j-v;i8 z0LjXIG*)oF&2)vAmmorsAb(QD=TrhS0fgU@ZhYV+{>_}Pto#rmB6nt*jj6Sqh6YAn zmg!Yi(KsJhuReG**t_T{kktUXCSMK)02Ys_rYg91KW}8Y@|~0-}0vuH+5c zdAG96j_#%{X?`<{q8tZ(l!wp&tVu>(ZSlMn;(a^%TLX4w3!k_A5qAd4z-XbC(Xjfy zd{$1j{qqq@guLVLyt=n^J1J#*{7;q23{UZEDGvygj?@65wJxL82vY;+Dx38Fze?a& ziU0;iiUySiG1fOvkJ{wc%F-W%((LNU5gmPZqPA0i#9|heXKxf~%c?7?1}JeHklZ?LJt z=(-tePX0|fU&DU)Sh+1BgDRw^suaJLLiW&NWYnkNM*1EqfK4cg5k>aDu{!7vwlz-g zflsqK$bQ4~!WTHy^?bXc%RbPB&TA_Rk1z#edIxS?zW#CZc0j zm+ofT1z1%P`A;Q&7fmeQ7E+o@m;Fh6^z!5Fhi!$*%($LLs z#_d$j!)aC{2k=i$+nI(`3#QM)EFGdnXo52;BLOvR-$;tt$4j`;=Y^>cX%tj zN*#}9Da~{H4RVF$9nuAvh?E7jO*i^lt;+_URjEt;gCq)I)C{)Z{_j1^)6w%;LEMwn zj?pNe+>>#0vftv>E3jymDMs5=bi~6l7u#f$j_=Ytxv)N@OT zMp7BjYVDF*z>S9W7!+LS%U^QIf4z_3W(|`^iU=Qq{4?YzUTgARWiz7m$-SFo&N8ZN zXH@;vNkJ)HRH5)LS`B0xM=vBX|C+`IFpc6wG)FYx2Q8H|q@4>32QvB1u^X1M1St`w z+q=betyxdqRBLI;5jRiO|F~+JStNr|Ne}pqvo%18lOgH+S;Wr)s~9@&S7GGLUgWei zqTd|Hs81KM?ibnpl#dR59^v2ig#N3n_*a6lj6*PZc8pOxLH=}cH6_?b@oo@fbf~lN zPbI=Jn$rbT?&n+d>JNH2CaezsiB(yF$HyveGQfKgLCB{KstPX<12Bobz)cX6kuIk@ zy^5L+yLb95=@bBxt=sk0XE4+QZf;TG?vUr*N>vMITH!e}WeRp1&f-hU_HDbzRP7$8 ziG*Wy5;{mT!tbIwNOi(7=9EL^N}cT&CJA9|L}D6EvsK7_YZ?h9JY=!B&KTZykvJ)6!i6Q)mt zAI~5P$XSq4ZPO1$gcafX{fywnFGVJ~ZM&W#5&5B_5&7Zl5=uY&pD)}}q0zV%lK&IC z;#;{;Lc;!=zqQVPve6fw4>Ws)792Zm@~8=P)h`Uv7Z(PUoR793R+<$a@vVVsz;bxGqhzL)h>oV?;QKjJ+I&n zFYW)&gz!pmn>IZZk~(!0>L(_oV&19%*w%wt{%eaco%4G87QspZ*fs?vU(6PecAkx= z-5+)h{sO*QJoTAs`U3!)gPFz@V)V9AzLUU*{qEl zfyZi1wCW-MAmTohjAy2OC1?b;sO)cSM8=R7;oAm{#?~^zTom$$lG8-C;O}*VLPj*i zEns}0eeeZ*@cXVEJ2TSp{F=({VA*1Ubnf$fZXpR}{~SJbodf#a#x%_KEv;zqau5N< zclYdvCyD>l1Q#3*HmcMpr5^zD6RX`<(gwlnF?sOs#!rzFruTH$`inl0gwJL6>0yi$ zf`?HkACA|$GcBhac3>`3g(4Gccs)Y1c=lS5&RJR6M^_B&2qO!8c|RBv5x$l+AQ{Yb zoa*J2V~gp`{PgNP)bs(H-k@aItHCB#Y4S+Y$libytY=TnQ16x#gp{Kc*$W94Ihx^U%fXk$<>#ZPr=dt5rej z4e4qMKYw^nO7sF{{p|2YKnj`=iaS0b#;hnA5ru4tIA!TGStkUNN zw*^c-%qehV#(!&RJ*EG1ST)xQqPmwGLP0<}Wo&Ix+#k2yxbT9VwnE1 ze?Erepnp{>PxwYBA@e|w0ONuJw|HQldZM^$btD>*1(Z($=gg^}Jmm1znyh;of#zMw z-6Oda;f5m1K`eECBPRsKEc8*%hKyS!LF#OyB>_23vz+go!EcL9J5A-Uw?p#u#J=A4 z>cbOGVI(ETMZI%=hJ#IQ6h458+JrAgIg5iJt~fz6o}vpK zYBes^vrMxywyx(M)r>|g@SM@`ZEszs)+%RsGMrC`x^mG#!d z`B*o@jOw0i$Z4^zH##BL+-T#(OLCSCdkM)kXp1H~s(eYfTKfvY@gz%f>HX=_zfg;G2Ur-yp#Mj8}%TWg66&T6aZF(MD zoJ@T)+T-*p2aD+OT97oFA9&`0z=!u_|ae{ zn+o=Z2CyIm*^b{NmI3Y-wrT5-ZI5cJ&vxho)MAF}GaZu-ssLY%9bDmFJV8BC-W}qd zN7SSs{^I7<<2uYA&+w$kcW0-P><$E&HZr@!f|FXqCl!ial1ig{0cT6M!U?j zQ19NpL;LINlV~oCbk@KdEquC_k7UV% z#v4idW|k+Pbf9FL(qre5?5yZEJdzOn;t>YtHHmuiW12^MoUq;KjqzHx{BPf`JkD`m zj%K~GUsQDCIt6Radc7yUEHTViw8}yMESmnx7Xa^%ZZoRK;-aeg(i2oGs^V&pNTiNY z+uKkbYrCgh83H7|G2&?ymBSrP z*hglvgGt)@kMzCfKqF72nsJKnCG}`2&Z~0e1?2zi{!-3c$=dpZC;(kNP>6rGpgGdV zE%XuJR^Ud|l?%lG`Uv1-)`#ku5Xj7(M_(Thqv-pIWz^8>+Nj@K-t+8c_wsG88U#;m-Us+4@^RQ6{uVNinX_z1IedH{s)=#F0vDL5=Z|F-m+! zJ-Jf25N-AnqqAZ=O`&i=<3mt$5WM|ebzDf`DBT~nQU`1`flr9u`4LDlT2?of$f68b z_ZFpwo+v8zS0>;duX&r?cpNZAn@BPz+7RnZ_qQb)QY z--U9haR@^Sd>gEbS38bWHwMxYHoDkUcTAu-NA=WfBG|Ij_rX%-=jOW%Z}f_AVaVmo z?2w|#QIbYIP~^DDOzoPqFZ`6)?Ha}Qub1B%28dEFcmNLZKzyNIx+NrJq^0c851OKh z^N=Uc^P&F<57A2?N~#wE$>=+ojSppYd?Gu zSt4*5hk}ZFMj*CcB{U#jDNkLDc8rPQDc!hbKqP}%`ypSql(SU&f^O{y0eLgg{@^Nl z1kz9 z%!^Vy&vyrH(8UWiz~!7_aOu+%zL9HUlq?2U?XLRF!BCkZaO9RN??n?_oYBVpdMzF* zEE!N6pbytn9(srhxOeq1Fs#q<6*xWS01oRklq*+hKw?~Iu-Y+5!1FHF>|?BM6pI*d z)~swoTs2j>8{=Mn7X0-jK2CdM7DmHqt*FhSbLiJ*`M61_c-ZkeJOeHW2k`&eTRgQrd-ol$i;$8u`s}g$t*U(= z9p9lB=MijtufVtLgCnG*=KH{Bk9XCH!i_FDSYH$E4K5V=oMdwjP+mF5` zW%gm8yNw~(Gi{1yzCs6HI<>6))6a3a>LvfKWmabc2mBg~Hsl^{fh|lSv^%dmgA{I15U^12UH+>-{dSyyVj2T?+0nG= zv|Bmb(#!6bbD#8eQ#H&InD5AM?IrxpAiC!Px1LGpd?ZG&rg zjZ|kEKU{*+x@{otDTgd>1fTWhh7`C5I29zxszk5vFGG=@V8ZxYZY5_*t95X4! z$f|uVh_$oKxbX`buszQzSYKc&I2)%O$;kENjUlupb|EZSaq$dj#2CLZQxsS5Rb# zp%loZI25y`Re2mvg}w03y?H+l9~B*l%LHIZco!xe`ma@*cm8x+rpF9^F(W!j=p3(5 zeQ>TPc#WU+{r=SX1I;I+Vf%uelT?9-1h5l8xkAr*V_wz^5uHc`t?oy4$QBuByZGpZ zJUl=io?Vt(e>%f|TL@I~v!C5gQ{#<~Mo&!_O26`<(c_1{R)#sHw|*sa=^wQl1E1#S z?cQkCgOuAuh!pP%IW-Uv`qfW&0WxzF10u>eQ(1p!%)&GNY5DmCOU}6rY4rENF~Te4 zk7QN6ysLkdD|I)r;YoRD{-K~a$PXgF@PpXA)f8bgH*8;)eD2=_id+Tz7)=F~4=UjN zhCPmSh2hPAzbWx{x_LCBZd_3Pr5GwN7LgQ2dwSA1C;sx-UCNd}F5@@gGA6`O!R<03 zTBG7pjK4NpMf0Tbyzj|#W=P~-a(aP){vLq?OH0v5IvMt5TFMRSGdsOQEXmjRK@|Z@cc>3$Ogs3)Kqu1 zE05BX)3znbMV4kq>;gh8FQN|cKCw5F@Wh+>R?$EV-J}qU%IOPt&`mLcBbyM7ItIV? z4d_64RuVsa#HiEXZmyOhNHde4R_8TQL7mV~)=cL3&ok}sCbhsy?p^@tJ_MwR_z;jT z|J2m)ClV7EQtZ-N&OoW9r8#QyTg^I=LP4U=I@4I$%LnXgL3qLhVO_juHcnwJ(1w}9 zpYg}64A?p^k_`BaI_f?*KOs@pOBFnPc7bfsAkyQvE;SizfmF=FV(z(uB9%M3$by2t za3|f=y`-!(6gyPkE&4bglrVJgN-d08Oj&xGF0=ON3h*o-`PKe5Qu)Y7;xf zj;$uuYSi!k) z>t&MCU9n;cF5r!?>bDl4xej(&kPYnAjw0Hlnb;A#&RpF~Vtg2X!*yB&725^}i)HtY z&E3zMR;YJYKp+jI(tQiq%h-JD3ML)M)1drvAiKi%sJDg}L5AYmUfj=rLcn4QFuV>3 zVKcH#jzl=|N8)*$>x=R~yu< zOQVVQwZ%C-4u}4IuxBol0d@9>xS?+{P_CMX)~M@WPb+s}hBp{0 zC!HDBeI(jKIK^x)*^VA`HENz$Z^R^~ZZ@Cxp8dv`UlUa3BnfED+hx`cfBh@&U30zb z1w2#30KR4Pd|~7k117(GJtB+p`|_3GSdp*O+;VL4W!G%r(nw|$WPNe}6h~zz;3Ba| zAl=n3C8E41kCldNeChA*EfGYzXo;*IMp8Y?F_={KC@Zs3pigflNj@Er)q)9Gio#pP*L{!F%Labfs=sEfZD?n9qmR(Gh=6V*g=^a7r5;let_Gna-Y7*^ph zc;78N>MEpBG63$jR(~Ct?SdoM-%vcrN68}$VekGF6EZHBl%AfRnUwTCr)QZA{e4(g zAW?{BpMj0Ty&Jc$zTZxuSXrcze@wH~T=p!nheTGfu4kDI>1h38oTk6*V+%kw|-}-dT-PWxjqi-yLne%dwAr+TuL@sqWE<0 z?Rw?tmfhZItzEh(nH@De_~$S`?0aarL~WUtlgp2>5`DWMT4t)gbhY7bc!L5n^0~_r zn4+_r9uy_IKBtkgz0Z(Caj-;nd(OTgmSXO_9kAfQX}jt!_ACR%htf3Ulmcl`K(u85 z?9B7!+p6x|@+y095CdID+Q^mO+A}wDHA(zHl&gsWGO*?O+1H60(|-u{5F!oA`Fgsd z$G|m?_@S^OtvG|R?M!&wLlpBXm-5D&T9;$gK7)NR#dsn-pCCM+M$s#19_Z*ELWU9& z62{=Cys(f}RzpK$aC|&wWO&&7@X*f2#)g^*`@qGkQB#iHo32(O*LnM}R+2K_8RxM_Nyf&scc`lAziF!<4oTbseTa>pOU8983=C#6 zweX(!-24o8T*Hy*S1y^XQ*&MFaR~fvYsePJl~e1 zhQ8NlxK49ZeP$$jwJ`p4jHmm-O?-!)dxH`2zW6#!(p|!*CZQMJuoW!`)6FKwi~~VX z+HsMG{YqWna=d%p<^r;4m%~jvYg;l^Vxs;Rkygcl@+TBKD^Iq*n zaasK8C+;SynJ;g?$~^Xsom!zV7noAG7qn>Vx4Lu_+0fVItSvgb!2oX}iHccw>R>y+ zlKM$My>)qMP^+>IgVc%etq6-m*0%o9Ij)u-#QD_^f!lP{5_cONii%LL#N98jS?ym< zmR)&5Sdmmy!(ZTvIGGitg1s)WoPW7acdHEMB0z=5vqG)##+*#h)g?1Cvn{UYytSdB zp;cNCvA<~65qS)la0~Pjv{THJCW`DEJ~nYA(J)h44@_0pTDEJ2Cj>GN#}xO?+s7S! zXX8Jggh#2IaW5el(~pLw=R(6wSYaBx15zQ=wiazLY)#vx!Ph3k;+IMiUu{%fdT?A&3l8FMas?-~r1t12@26+Xmne8=eu4gaaRL--88H^qu zA1H-!T-lo~pU)*RX`#~Avdf62G5>03ov5E4JFnT9XG_l{a}pKVW|y>{Wu#%XdcYoHqthVU&5cZn$wLclAmSo2EJ z+~sJ>K6b|SbW`5?BaUDqPE_jqK;EtLVcI~hl!EN+_2jd&v#M^c`OgRYkUJj?Rjpa^ zqeZW37#AeL(DRHOh|{81?^fkLNDwp&)c-`0eZ$SQ?6}tPr`~jOXl`+z#1agD$v1RV zWjbB#9Uv=)-*3ovTGKX+^_|?tS$P}g1%GZy1`&^H??ji+;q31q-QS=1F{j6BIaJGy zv}XUxCY|1E=tV5XzmW`b`(*4QT}TR$@VZxq8hE8%LP@u*%o*2aROFY`!m0&QlMn*w z2#MY=QyH-}f5HR~5GM3^KRGRk2Vp`f*%twH`JpZp>6;-kam3uIQZL^h?=BK&o%8?} zN*_^oW`yUKO+ObE6@4P*ur}V%@I20@|5eCMX|87tzqXkr#Km15*1ly7J7atN1R-H5 zXnJZ_Y78lTzyAs&74~c(!0Hcz0;d+_K8Qzt4xMiKc=1er_uhnOcFt>kiVQH z1m(|Mh%TbSVh|O&;m*DyR9VYuyKU-rC4x!K$&ia&y})d$@eC|C$GKd7Z^u~o@E`dR#m;X+O8o`uGNWe_RnK9M!f6hbV(1$` zq4ef@a_y>=$5Wio)mQrrFkP3?Ywt%Uv37M8b6*4~1<^p>9ApIEp%mb`Me}ia>aPzT zqs$90M6MczZZ4?5|M}&&t<9TY3z}t{hgGu5aippw37WJr@XId?vZwuNf>ve*= zje=q*2%wDLZ%$|DdfVv0RdQ1om%&1;gdb*Fd>@-jj@q0>*6MocVo-CE7_Tb@U9(^Z zw}F)w)92_5hB;)e*E%^5aPYzb3}ZcG; z$+Et(hPUYG>3;-pPLuAQA|56$^&a zoe^jnL|9>{_gghpf4m7ZB-`52@P-e3x_JI11oEdXVYd%nfP9ZD_1MY{MRuGLb{ebT zfCeo(dMv)9No%)vg<8#y*-E+M$;8v+k@Y_xCVmD6@GGRkdRhGyJtWdGs6ULX{cx>u zLmtzj%I;TO#1WyPGrngE9)fdsmyAl-qTv+?7#5Dq!k|bd9)=#{dMaa}v4izNG{M7< z%+2#qnNnnLUQo=a_r}EdNY{h^hpVput8(eura`*91SCXhrBg~lKxyd)Y3bgGbc+&_ zl1g`{fOLz}sdP&BKM$zq``>eM_NB7-Gc#*euY0XMbKX@p_~Ig!x$soxPc$QJk0c#F z$(r5xI;efJVpj)=Ddlw~$@ZgV5B7|O#HKTeI>shn&*!^TvOYES9UCg_!qj!I9}V3V zpQ3Zvk!M>pn7z@1y70s!BkNt?DM52-$|ID!Z1SpJ#LWeCzoYlk&Dw$y*z0#B7B7xw z^}c^=ONI4)8{u@_(eF(aZ8`)VI@yQMB4cb2_d?~#`ZFPOtO(Q{C%7MuR$n42v2b%5 zG96chx9OiZVb%HYaR2h%uobVbia7PKwQb-3s;Bc~#Moz&leevK({<5;Fo|d2Zg$5N zy%?AECI)+QBMXjFEIBhDF0&}IU|S?F1I)J5ZR|r<;W!o)p`aTpfh-y3LaD_Wl-ivw zF)*IN)u^;exZKZK%4t36%4|Mry7JoK=S{xSN_}ix36it`=Ih-aD4yEF@pGOFj>H0~ z!`bGIvz6rI<%hHr270M&$v^#k$_Y=ip+o&DUpj$qtNWvDZ(3WKXK#Y~9f$|Q2hUiQE-UmT(B(dE_L-j_@aj9q zlYnqFuW9V4cWVa(B8lm)^A>SOc~k_j(eFdt@IG+BrzuDh)IDpcXce*V@W|iR*odCi z8nhdDkJfO;l$Shqv}p9yB&X3U^~0r%n!6BrD3f?wAcYAV_2yM_{o8B)-C%A;uB4Lj z=QPm<{2NA4hKRn;q93w~L3|I+CEILEoQ0LO^(pUko1yzs&^Q;+jG*n2h$h$-9x4yM z)+GPphBjxsDJHk9AX!^UPdX9NT{wNX%hkT~Wu!4AK<`42FvXo8U9qO^u5sd*5|)5O z#YI0}&5o2~^q$Krx}zHloi7?Gy3{^J05tu9mjhua%fm#)jTMr;vxO~>K5RSXdK^lQ zp<1@9pb?J=t{({t?q`;YUp54IH=o$oI3)*{HFebQk4CV(?pS!xeqL(nJ+sWou4dmP zcnB(46B9wPW?L&YysyoZrkoO&avB%&>N&1u}l${8Az$LNKJ>F^HOK%dSL1HPW- zdfQ9S0@T{uh0^x+Tz7ofEsy@EK4Zl2oI&r91R6JD1Mh$Ob} z_u`eA0Mj52a?>LCUbj|g>=8V_|DX(?%iP4tAcoza2{Z~BzZ9qU5M~!XG%GQ5KQ6;@ zk9ZwSr88V{dC-79SMs{tcJNb*p}U^c&oQ<=K<}L$3lE~hMVID8(REwSh9c+Q`1S=X z+v(z2L-+apJjh+2USW)Y!ug@z^+r3%FpDO3J@$(NJQ^i?6lcP)FEVIz_z9u+b-Poc zIvAl{DA3wAWRI7k-vP`0*nWJ$P*r%Oe(s5+$W?-tH|2eB?|prJ)mN8icXJuu9vX^TTDLL{KBgg|yO7k?Ftk7v^Z5})Gghdx@8z@bph}8{Gc8F<( zZuUN`Ybv0B46-Qe)iGlolmqyz*@;GqhGsEGYT(=Ksuo=gI%voU1c|?vQ}~>Ro@|fV zv+M%56i?`qnbbjNV=O`AW1}cL?Pea;i0M7un7vED{)X-wXi4#kwlc$QG*|fBf`go| zM!fEHeEbMd8zDF(oo*V7*;DZtkd*(++3MkfbhaCge{Kre93CFoa{UUKeL9RKyJ`0; z1r&N+?gWbBGWQ{n=y`GiBW*YQn5iT_#Z>DdsKae&L3c^jaLXL4aER@k7L*y5nhcee z89n%k6zUMN)XxP!(3})H<;$H%vzjg+01@p7*)q*>Kks)`wtAS_Mw0Ngkhk464@Twk zP9AvOMjdSoTUOiaz5Cg-moZM?-8WVe#@G(W#+P>K^?!%9Ti!cWmY@B>cG>3K=d^Sj z=c?xs?!A@H@&WE$GK?56>HhNK!IadOl$(T*CScVVqQ!$k=!glepK?dx3JIf8%uJg< z86lLAMD-^Nk{}}^yDlY|;RZ{hy-SD9Zg=>T=mteZV1N7e4aQFU6z^RX$f@6yN8{Gn zzmq5Vb6nn^3VQTG4V+WWBb!pMGlFQqXgMx1d19&ex9sLpO7bdW=$QSzlCsvmBl-DS z>s3Eyp$qi&tYp(8_w!xGUG{mNx^AO($1=akjW?0DylP0iv!SBg)8!TAYZbC|&M;hv<>%DMJ@93jt#?Q!KIGZ0nE2iln5{SitGYad$!=Ymw!TS(V+9@+DE z7ZY3WLqQfQ&z+X|2bRPl=R1$?X;t>Fy=&j!7%L+|SDJ%5T8|k>fjT=0nkzI%Q|D>$ z{e2j@v5DWrJt_GTy*nFr-#?3^BQ8G9YKcmKtYlH8x91_E5u@hgiu)I~dFIrDJ@t#> zInkm{3Il9MZS+Ql9rbNKvy_v=HA_-ROi9K>>3tphwj$Z)Hbh12$Jh+G8~h9Hge6B+ z+zq0e@!Z3UF5w*uzu#j?byzl4MbvX%ao`rr*8adydpcXPXl5u=--fyva z<-MF$j`w-Z_R?8N@=nPY&rI)066b@-vs+K@3zR)-IA^euAkFC%@{E09S)X{V;!8mp z(f#4O@xV<8OEewDK^u7sgg;N$Vi=#6hOALMv}z6^VjY~B+2V}$&C$}(2(lQ-Hkhim zSHts~u5}U)F99_S^_j=;=nU+jH2vGurtVRmeY*6nQitg~-o~(JoyexE}VT52NID^oWPyFt_7 z0~o_H_xxkPIZ6B*x$Y?^EU)kq)_XBYo%GuG$5K_*)C{}dvG8qGFBY5jk>~5w5o=Xi z-PWyYUKz@;-yf1$95ae+pR{cXSItxVSXo)APD4C$G6uPEpv(Mj;>6*aT%P?cSm3&@ zn1Oj>gP{=*q1|g35^O#$UHtWNZg2aZLQn!Hq?31Yflk(?a*JHcDb(135`Zli~vJ(majCvH6nreIT=nEKHSCDjeX299d z^t?U-WThcw)vA)CF2#%R(eNT8`AK&b0d{w3d3cXoDk**w^?1i6z3o2ZtX$IIasfd( zftowH=L$Lug%z;{V=AM?O56932XXl3;3ypj#lOd~@PROcEY<5U!HKZ9(t2|6+c)D3 z`7@Fynwn(N5tO$a-h_79_QrFn4V{lDidHnrdn2BJq8#pn8L$AR3g8~!ZvG>Yf|jbf zH9M(I3Cj7Yd!+ox8}zu8*q~4f>n=TV6kDkO{GTvthBTKhAt8KRzW3X6o- z*9+wxTQ$maPI#Vpifl~P5Y#zumNo*D0y3?UOG`^h81G_Z4F}W2uM>XQR4=`-C>~-C znSVPCgxfPgUtY?~%VYiJswuCS5QPPf4eGnz?~!8V*uvS`zSok6ZR9#UmXP;Dc!*2t ze%l$Td)Mjn7YT|tt++Sa5{I^hf4!|5P`o99!kll z<#HpC$}m|B@4CImVO?H4VIX&fcMUbeRy^2J8H%l( zKj)@k%+W|1SN2I5@jXT}E{$(2;*g)OtFNCrDg3l&LVE297qYXmIWMwHnV*o_8(w6$J(G~7f`WqY7!rNzMn)M2CWQk_f$jTN zFU!~glkKtE3M1B{&02cV%-XmU{F>=b8i(*HzFcX)J-?l>&3T}4vG#q3>@KD}2p@cF zcG&_!pXKrtyvk@ya>@PuzgPfxA>iQi@nT4)gc{b8i~?lA!Y6Js^q&fV&8Ehe)0aDy zT^&0zs)b0r$`sM7b@ba*&7+yI-(!@FY}RmzLJY3}Ay+Nvzq*b4Jh{^1f-d}aXRZIuq_Qf;1i`Sxgp z)qB~gAg-=HSxPJ@8(0)w`jz4`WsmwjUz3}beq;-acM7()_EPA4r}e^S@nazM7obv( zYP}b~hR2KgG#)g}EIcs#EP8po`1oKX6$VPuk@!U*$1;2e7@t~(nEx^vHJPrDxFrqG#PIRwL}&$48yT*7rCT z(93118N0QuO6=n?!+|k|F#*eS^s|`T0$834%uj2}`y zw!6?S+tM+-pPb;0@Ti`R0#TsK}gBT!7dpCj-0~wX0GeB z73+%^wL3bLhqAbL(Cp#sG4T4kDZ6fOn#azBtd_XS56uw%A_!d#_P=rzCIhujxwo5O zp$iC!F8XAvR{AQaGI=14V|b&mJ{}AC6^jxNJP7e4yRD| z1n9vy_+!wd*>^JT%I+smI6^QWA1RtMMc9<1kUZfK7ILx;IXd%S{MDWb*_*i=f}Qia zdD;s(m&;obo@vQPMphLRU>(hP*uHSdN_2%FNwfD&<$bjw420ap)PBK(>YM{F?eTqL zm%$}dq_I2-`U~N>7FQx$$8_)Sy8#d*)?=Ly(m&K=GOI3mr z+&&n3l6U!}W?4Wd=oWf?-QKOAQi4-4B-|gx3X#yU5$-Cq)-__-sT3OOH(lpz2Cvhj z64&WbroiOKld`li?T#_oEGddfd?jX8Ej_RPlRYFJ zQ65@s014Joz#w;6mm=0H$X%R0!-{?siG3F2G9)JK3I7^u&OOq1vbwiCP!+NxL*?|&iP&8a z6jV}M8k>xC4NKZigJ!d+>*54Q8jf6GWXjFqU<)<&Do3d?BDb1OOFpi$Gh3hGrZ#0I zLP=Q*-yLUb=HJJ^hSZC>IX-ztKyXSLIqalHk6bdFV(@VXM4wRv8&n%>*{8hCh zFI-H1ErYL2k8tIjlq_tAGj_ znYorw3>{Tb6b?B_5l0#6nJ&8IcE{~ccT!mZ#4d3c8y{SM@Ue%~0LCCd|!JZ^}tv33M zu5^@Y+?5rBc%+Pb=3cg0M?wRTt5g7MZf6oFSqLyE*_ybZ3#om%3bElGTuOMAq!2Zq zTi*1l$1*Rk%L6cOP#$pk%E#Hw19l&0s`OCCmjPM}-wGC27B3>kHv=35Owf&y2VYXN z#aPhtfV>?Mt0($L$&k}3=WT$0okM)WgxDT4NasDptm?{eAU%$7le?1xbg_C|MxzD# z63p`EWm!l8L}}<9`Fx)yfFKGB{`{XU-)2l3Fo<a(3xNMqtZ^krEb7RYvP?{obZ~9=)hE=JginJf>R@ zn|n0nX=o{JLB<$`0l+_DW7-}uYKzUXOqaNyTk)`pGMI-qCR7qgkxvP6MU@;$m0rE8 zAh>YaU1WMitxHdFeG)!pps36uC^8hD0hK@qZ|zu@TL`y9L-#ug${L^HbxLwxK&G+Q zY2EIJj?4U7UeQ;RIGq47T79YKT3Vr?o`eXh2VJXU@uce@aWQFKWpW1_T-%pk1#r1G zP*BUf&zKgvBP_YQxKuUt?Kd`xaxj7}9*0?a9A)sqNrt^_CdR-ChFwl?%ern?s@rvj zC=bxUhDvj3GzX~mEjKZh1A92oT;;OW)qRnWSr_lUUQlH?ocS17YH!8c7oen2ioU*N z6fy?W${~=W{G6an|lC9y1DJ2_3Nazb4IgG6(#fKyAgE@JRAx^*do~Zg_sS~ z<-=azlhOZlTtXxwgg(dl86nmE2#cQ^U^j#Sy1P~eb!Oedp=3FQNlcX~xKrTB_-DBM zu%^7|;h2$q&;2U>)!{7HzY~GZ@NByfkIXUZCvYdb*n9QJ5g?QF&li zqO-nqR5j}@=)5uJdy9Zkqi#aINO!s{bgG=9u0y-db=NRYgJzwb6f^zf$CkTedfq3$ z;)UEwN4@zdzb;0KMzrFH)-QXMI4@52wr#m>$sc{4WiovXUk7}2lep+&Tgn%HMdqYe zYP^}2L zzTqs~Yfx{|h1kL!!p<}?fzZ`#B@u@u`6;iX>F=CuW+`rrHjO8y@e7qKhKUFs=S+>o zjn11d@?bL)6CubL#2QWPckh;>?`FtEZm~2co-Ky%y3DvSVGHeu<=KfCeZ|Zh(9jER z_bj8B_UtV@IZj?Z3u)DkE5r$1U43gT2o$^zGzSuRTAT>Axat;2v*G3XD(eF8DzB&p z(X5%H=;7fR;Nr{r3W|i93487Uv?H9)M^(P7UcHdwCfqxXhJ9pl zY9wS}j@`cz+h;&*?-`b{o`K4kS!#)_Iz#Rn2_9SPF!G0aXKFKtdw^Yrl@St z*LVHxls$Qm6q5yr6on56*=kqltR~7ooAH9mv`O$Z0c$qX)*Hnzqt2+?dd%6|tPzeW zoBf9F0W`)B0Y(M@w?TS3$QZetyo}!ZxUvW>$JsmNu(BzGfuwHq>3sPPZrU;0H-Bfk zCIjRrZ~Odz2+Gj%;gkM8!C}rl@Oh1vj`nCMON=7je1v(TEim~l>fRoKAiNc7vB|(? z(9@lH0{$PTwg6QJ{Get(pb|Xn1{1hI6{Ic&K~)ENjuFqTRVSf_H_?%JKPPcXK9L!P(&SOag0 zQqah`T3t=;P`l24@vCm=YKftbC*^L(NaiUL$b03|Zi6h?-#!iscA(~v=&BjWzj*E% zJvYaYph|o;RYMiTWAe3ogRfr{j`;&Dyv2Y|sjgTFeTQ}Pi{gvfjRoIOFRC(s)L8y?-ZxbdU1r*|Qr1J62+?Uv2ZSbSr9%6*gZ#<$+ zZr^W9Ud^ty5viJX)>7Qgmi2DM;!Ufqoe-$2uUhcPN28uhpuU=2qC7aBUJkx?O@jsd z7eg<01*gyUBH=!ZhyhpcZYSv7@->Dqu+pJFw&cV}3<*8?1c$?Vyu>Mso*~sa-^QN^ zUMLhuv*g9KR-9p1Iy^b~o*nK-l-hP0SeaXG7=X@n2a*Lm7 zdV?#HXrwQ~v20JsSqIquW5e2-fuz|+OE6*s_??k{9rX++9J{Xo+dKryaom(ynWwE9 z4pe)My;%`IF+h8EAVw{1sXC_w5|C?d{3vgeAy%;JI#CCB2|O0~TXowa zc`MXS+`@+lDtmOwBb9DltJU_4`3M)k;`D5&ILq7*URo}D+S=FezdaET-S@2K78Z`M z0-@*4Xex&V)z28_Sa;`J#e?*DJU_ijrwoxeo_j=ycRYw9j+j0c%q;SVNd&?q$Pd(_ z1zI2tRMh*0#Ez1_>usq2u8SZ=0Q(&?gx%i$iaMoC-SJP(FNqQI!llD@v!#H^E$1l3 z1KpW`gB=Ol8g(T;$iqrryPYSjc1_uKSZc~wEq>x)M6ZUX3m!!jsB@3xaH@G}j2*gA zPVEbH*RRd}tnX2fG#cXj28L6;b144WRm0(YP3Pi3K<7hJROx6FAT_0!DOp=8PUk5Z zx+1;LT{akzeH}sSm3gT;)@l9wLA?;q_zv)m)WUv1==QPek^`O%;ovW3F^o&za*U22 zO>a1duz5{a8zb`o3h{wBDvitWTXbSE%G^G(VtZXBE zB8=9Wic3D@>Xzi3_ImQ!42bGyic(|{?Og!|JeUnp7f=L!q3lDrHZR?Ys5U7I;@ zQua-6RZ{oH4>_t=JPhO_xiT2xAS1qBgHHw6sz7YYRt3;T(eUkj!?h~c8>1PSj;k+X zmovKoczFZ;1TBW{&Pn5-4zp7O{9aG|DYmo+e0mwxMb^UTi8Ep~lY34tyiIxC$Ky&^ zeUL7(+eIG}%{acm4g-ysz%|_f1^wEKjvzc!Xb2-_+EVpnRV;)tc>T?Q#f{S6p|7XpKPeP*I?2BzBCHc8gQ5Xgu+ zRX8ttxd+<%cxS`X)9F|*D=QyPd2Bbhu4cs@CJcxJj$-koxp_43k2#x?*$s_+Lv4N5Ki&Chb)Lj zZ8whz>$H9t8vn2{6^5}O7%4?6G+VxR2)KKXdvYB(CQ0n0Gb?RV? z5=tG+DdVv?nFVZh-6d#uR3=R2wWUt+KDB`IL&R$N&42SW!dKSLg&ocXk<`Ky6A6tD z%L-u>yx8-0q#y_Fsz^~aLtEDA`GDmWW`eI$+qo$ zfltRq+UAv+mTL=lOoHb&)1WCJT1KMl`f>O2Y@6Nk<&)U~2^x=$5;fmC(5(rA$I6i9 z-PEyE;2sR0a+g_76E^9&{UXu^*%a61WS4xZ1svgh+>p@Fg7lLi8JcP3G`%|Kf>Ccv zOIG508u}MAp1n@oroF}JTiI!sph}_XQwfqB4c@jBTLp8rr&?7u(=?dah(7y^i+EJQ2x{E}hfAs9R0!Tb|;!hrH46Elf91T6&vmqt8;XsQRgaEX$}+fUZS zD1Lg8;kjE@Zci%!jINwe)V!>>p zL!Xh);WuT)`Seh>QuL(~I=Azh^=vU+b@e0vMXg@80}7jJcz`_?7My+hY}Rww>s!w| z7G)0WiE{tt6pyVF3mH>Ci2vO|Dk%gsr!_LCjmBfru#K>3lUeu`EgBt9ihfPAL#)6@ zMlcb(gcoaZwZc(LLwY^?XeYC`9see*1&WCdC?*~0*Y?J$Kr!XND&t1?6xRp&tsEyu zN1-PDme&hP%jGRWhw8BocB%oe#Yv>_p@vkG?b@P*a(N1m#q$BTpG(8X89Kdg%e!?QMo%}3 z;%xv(A=#d~j%l|u-iv4??w;oBMB7HPU9(CvBePTWrqj^?fSeO@{H|#>{T5pb%1+$9^GkG(MhHM;vyx|9z{ z9(VLIrMRZk;Rk2Fb6e<z*KzOp$e7z&2>_N?aSH?B^r_I=r;J1Q77Kv^`J=Oo>iuCk3V_aTbnH*!C&@wT zPJ~JT4AA(RxBB%-2-66sbO9eR%)~gK)^T8XhoNmtXf0zOO9(#0D@3NV?qB>J2k++R zTmJ+EfR)JIoIK=T)b!wUZ@VIk)ZbgykK5xiYP-?QCg7kALZ{UI$)Un7IE}e%a7yfS zqINFXTb(u_vwQSL;v*;!<@Jc1{ET_`HiYOKsNm!~<43A+3U>DONF(~f_1fPDiAr|q zNa}_k5eLTrERp26_v+bR@U)S0O=4Ww7P!fNabD#8v1gpyO;;ii=-eSS_^5Whu+vJ6MY>byRCEN@{03E)-Gbaxs-~QN4f?ONA&V;}xG8|CmDIVtc zg{Dx3ubkr2?tb@|r($iM6-ydQa1P&VngCu-t9@^2j-&kK$+HMFc}H70YF&T*JGDEp z+Hfhvsf)Lbj;@a$1tsV{pX}NMLLFj*zUyXVxi2Fs`E(?g4)=(4V~uzL(#UH^eIGwXtzYpM~pthG`0#XuGzG8FP3EXBP(a9^2`yuAr0CMvS1meEE z0FJ|nB0>pI!zd-dp-LF|m|8j*$B*YtfGoG?yxJSO0_LFdG@%K2NkRBcq;wrv0Q`d{ zX?V$6pV2MXr>bIbyYLI!m*{YBk51y)X?BP_FY}r(pElAtF1;7?YVVALdy;YcTL_L% zj8MM6p)e9?dth>SmdKA+3CS?C(P5!VDqe(N!;&&3W8M zEQ=aqkG^Z!e$2?A4;LQGV=0sKUg}M#KHI7*$S{3-K}bkA_l7{BnI$J__}AVEaZqOy zKusstHN5rzc>v=02`G%BxI>XH)`aZHk3Q&I5~PnwM`|4rCL(b zaE;0OL&GdvydvZ)%CD4xrd^!-|5@t`1UguCYVVjd=n0tsk`w>XxqY z0o~HXu^3Y}~NS0wi0Iq2^5@@7_ z!ACgOi5vzJpNfk)@^c4J{8~E9`jcv=oNjU71|c93P>?l-q{1<8a{I>}o*zfT%ft9D zk(hC#qs-vqZ{`GmjUa+*pO=_beb*|~MCXNcMAMWTi|C5iOo_KUqqcNGz<(G5zBMJ4 zG+46#f4eE{9RAkBe^yU{))J0aKV{d^;y^lK^HY94<;?ha4HqdU8-T&eWhwwSHAW!)&uV7gyxO!)@ z;NGtvbJ^jm)g@nYO%iAgyl>7`%?lwXCs)`#@nmv#aso}zgsOc-1qGW^mDXC9hnth3 zAaVJpJg)6OK4NQO!GeNC{sz>)^xJ~)3g~4bHuCfHdr6}Eg|BwX?y|FQLy+>mkdTlc z+42d)v3oGG>r6HS#PJBBai%(;vy^SW2ObkqDHgZ+DyVZ*6DNcU%4Ro8WT|+K zG5M$fF|!^GU0Q!yQ^C`;xRNC^5I~M)*Yg(RAs2qG%H-cVpV2zM^9(4IW-(f&3DE2W z{>QhmvcmlK;|EDaW#v*uMa9R41}g9$n!kjw-n`wIfCM9XxP+>Q1c*~TMdC9w5PacY zx9AV3|JO9Nu&_`l?uSzGJ}xd0;NB7L+_}>}Z&%GviEWDxnx>Etd@JZm`F3~@&ZNnvv=*aSR)Wla5^Ut)O%TD4*{Uo`t4*hYRe20#y z1KAS6!<7Cc0GYrMjXC-x_Vnq~XX4^58mR-kD9y~OR4jC@=|d|!vJ8gKpRt~ea^bNM z<2l&899-T*RvF#iJL$dB{lFeBopF~g+MMTQKPS%Tmw~yJA+n1pB=MVvXBUE?-ws=K znuTuiuzt|Wl81bHYG|m$eru{`%6e;PInqM883Qo5o58!})QsE@+{3o0b_Om6)92eq zCv?9{_sWcJ`YFKaj9-A{pgcyImllM_?J?-mQ1Wo5qoSjuG0R81c7m?24=D0S`Y!OQ z2k^%Nur4?eF2P~=_-!nbXq38+f#W}JH(_T3&_^{ih|*Drz}tBROUYoX^T|AVfagXF zGCEq<${5sOAr5cj5ESUzii_06n1kO_pi+I1c>k^x!!D+i%yQSN~1Bgplp_|&<)MWe)T!^qEXc;D(IV8li8nE zT*93Q@QfKUOD2=rehqF1ky;N54c7wB+%PEA4g>{WI!WB)4ZNwB_6-E9LgfMGXehcN7qR8d1aZ9Jsf z&HvoY7Vy2hg^gBYM(9~Y+L5HcVTwSy|OFAyrj*noX*bUuSp*E#o#R6O`stpLu{L}vX zaa0LNeh{1(;*924aI(o)8l)=OPzzjn@x_GtC<5MUv7T{Q&>zJFHL6Z=MkowOs19t* zyuU0D6587LB!xn<>)Sa5R$y9pvBexr1p`f=X@8*XVEq(`uH28TCnZ%6I=3WFWu^fH z*NWmEBIG__FCPt-7ko8F>X-{Dy}K*DXQ~yX%{6qtGa-c8wPF;52UvJ-li8A83FBOW z^13M92_4z1F$_0ohQN*TEM*1YJ?Dn;#HSF1uuIICA5&Eru33ZHIJ*_V>RTx*qQUtTRa9 zzi;^3DmV0Ltp*$id{>rP{X0Qu+{*QdXzRdJ%zh*`j6=f@5-}yQzbK8`B66_PUp&UH zHH-Umq#sZ};KauYC&OHKZI|0#_hDyJ42J74GRnVHjeyd>L#-Wis9?&>jMy(%FgoIf z-Vlxe#9d_LK!k^;{T<5+y~HF%dYmygBLDKdE#UdBf?dgR?XMgz^hOi2av}j1N zDzF903W#guCR=7vI@!NRca}pch1m405}LFT+xoU5P37PvoJ>$>f#&)8=BwI zgF0rq`gsi*ZV5bxWc;2`z(0|c1IE{7wVA5`8l|;&NRXb}0p}_$iM?BiRCW!I1!II1 zf!WyCEX`^}RyoMr(pmK(jz(R*$$UdJDg}cX+$%H3BMYD_9ku((K&tA6+I&Fw+g$-c zKj#k_<3OtCS1Ejjg+Q*moy+Zp3J>*Dg~WjF;=iP_`v|#!lq%WfK0x~%+WtIbRXJdU z8~81yYC~hO1aQ=VH^!kJBdy_?O&L`Tpm-L60y1?#68*CV5PxV4jam2ys`1C*P6fkJ zjQ+@}QZp7L^9F>Q$$q)I=yMBz$MHR6dRG1|p8=M)p;TQe*MpXq`V|y95w)&{QoNN7iTjwgI>N-&M4s5~>_(ti=Jqb5f_` z{{466C|w9H26}h$Q|W{Vnh)SEY3URPq4^Ov0`?O!$l8%`X9K~&vEw%|`^yUG)nJ$* zD+!Jb_H*S?`npJ$6Az%t4=LZYu>G}{X}~=P**)K#%x0(1iMU7mj*5%}nN75IQ)r@+ zFx5%AKVxh_3NsDiF1sjSj@_x4?XnkP(5+8vgIf~r7DpH|q93vUNlURGxL#*cWN1#y zPZcrMWyDYTczY7hZ|ZM1^nAsfmiq&qufjv-be$j5U+0e=f#N@4(v}fHLcOjQP9 ztlVsQ1P$-=o)8-RU;arGivV1q01h%6(#g$_?l=9{T@>@x1wyv$2jw&1zGD2Vw?_PHRt@MH3AfWk_bNA#N;LM<$w_x^3=uTjVYk79v^0?M*?yptP%O2D1t_tmpRJM5o0fDcd_YbuB4j`zd#Z;S6=I-IuFejY3-c;D3_u1u% zxo}>{uyt?C03Bq`lkkTRXfeP(%-|Drz?=FBltzW{R$uA<42%DFOk-(4q2B6JFrVRO zxg^lEU+eHSB>JBy+^}S40xVYqa}Mxe)MZ2sx9~-_)gNNby#z6bj&Qv^gz%OUQ$>ZZ zR+HR%l0`Km_m7+Z0nGm%Dk=oxgq2-gK;8p{tH=g!-iBZWGJMSWTpXB#(3g{ zBq~bU_To|1ZA2N>^+GZAD|)Hy!NKw7VVXPxZ9i+$Zf$AdN=>yRBjYKxn2h1Y`x;Q` z!^XEobK)mwSaNIgJZzlQRj*F>x|oJ_oVNLAJ=XdQ^pXYC(KrKxV#xyREmxBL{b=D3 z{`}zj!?iiSK}CbXUH^#H62Rh9w(#Jt#Nm+A@o^=l{`*K&KN4+r&fZF^ z3oab|%?~3SCzbv6Gda=FZWnT3F51Ho{`?LX&eswAUN{+i-M!z#!l92Af8SMXZKd4@15%f25pvYm ze@~Jr3?H;=L4yDXC^8od-pfyS+RPEwR4a_y;rsVZFfk241)Lt)FF1eqPOc9CQNjf`|!**5a{F zR^Z;b?GEWT6aIM{8XVXkJSjsw8rUBVI4DNY3R8eV(h5zU{;mIxnHd1XHKfGTQIsqq z3*e-Gpq8fo-#W6`;2B->VY-0i5|UzQ&lDuDcrX{YaBGm&Z*Bp4rDX$8zkOfgl#$MT zkRwdT{sGMI-!%bf7a%yQRkJ=D8cb~M7GRv)>5sua65TEJ;s4tQ10Z*Lv6L*k4c>4Z z(=2FD{qOR-?f}Bn)DKaBC&&WUJEaIHVPx29#`ED(3s^9_c^>9q<4#LHOHkJ_JK{tsI4e zGkoO#Ed!s5hbuu?fwsD-^=7|9gd1<^KY4-W0K$2Mkc5MMzexbpp~vgHlb(mwdQ8{b z_=Wesv*Su=%SkeUP3(yy{der)aJaU8M+*C63R*l0LQngKFE^{c=WT5M{t_s`hW>Zq zX`5i*Rmrzs{4d}7DgjlqZ-qB36&j_B_e5r%@W1P?2J$_P%s`0%k#-0Wl`ClFF?;@~kU);Mm?3*2W8=mUl&CZAT zcf(QMNC9!w(lLRG=>vBg)6^T2lD*;PIIoW__WV{DsmN<;feYI~mY640$#*2+#6O@_ zz$GJc3XAz6DgXT;`& zuOACCoq%g1NU6+$HqjxFZF-2vIbB4LSofd20+DU@LHb?#d!-(a;B$U)nC=UKP&Hev zMNxVKyqmqPKw4`$D&YI=pw*956NnMw@;E1Ot-^Zv@vnM`c#P&38_Thz|BssELCC=o z30Dv?rfev;BEElqsNDR=H27vhl0?|MB8dnjf}HlW=2pG*Nw#;wlD*$^c^VzJm_Mrck+{m_)#a0Hq&wB_6!AHOh36m zijBxG#_oYGQMB3ggV5Cu_RI3oBgLm~NvBSm6ekh9+h?0vM||m3)a*FgbzW3D0*mO@ zkDNDml~+SD*fQ2GM)%i$Y-dVQ#I}uA8jp(Pc(ICnmsZhr7CHZQE;{n8L5AvERkzMW zZAtByXkVeF(2%VC0e|in;TZyKxx!davG$MCnO5f`b7T%03i21i6RJ_@+EIIU%R;@T zvo(#RTvDe*eDZ%3V~hSgc&=QfzV4LY`mpZdspIM+z)_h69#unfa(dYf2ObS;=$n6x zMzc7$e|qNE56SbO z?-W&#MVLPFeMsA4$aa|2vaDoe@bnO$DTu$^8?n8Uf_h@aq$|j|u$YxLQ|6$kd>qM` z&0V#CEtOq7kn}B(E;op#hniiZoq$_+pC;hpsJ-&V(49|Aiyz^R>|Mvv)N$;d^P3{q zAoIAjF|56v^*3cpmRffyRLtUEp}5vqv{;Z)Y!%1()Pp;MC}3yP7ZAS{OBiuuBV~CJ zUP{;At)DPa?b(ix{!qf!F5=c`RaUHHASo2{TBrP;150+^>$;bLF&ef+xh8@=cbhVA zxBCUt;E$Pjtk@4mme=9Z0-bm^xt)yGozZDs71 z&dn%9ED!9OPb-o3N+xW*^fOMag=gIh4?cz;_y&?Uecz3_iufuWZnm|)7n0?8I;V^i zg5^YAs!K9#DspvTJCnADbCu#&UC&t1TY#f+tkKoEmb#xhtMqJEqQrx_)#!apf^}mb zT@Ce+gYi%Jx9(UqZmnN%9Ja>;Q=~G^sEyE9Q?1XZa2Q)qt}TKu5AchG`%r|Mw8y(VW?LRy*tVF>#DXm5DD?nr_4Sn%;iN8&Hrgmgz|(+CU0vn0oNMZ@H zvkp7f#G=c`S^AnHO{I?R6m7C*S&Qvhw*#4w)y`@ zJi{UJTx`LKUkBp(P=EIW2B)yDAs;o;KK}79GSM8i&AP#GZrksZ94~;#nsdWj1{2#1 ze%?U6S}KSCtaE?goGwkrBBJ#PHaG9RipnhX`qF&=X~s|A<+TOvlcZD=qVFLuBq}tP zj7opTzZ;s8H01R)YNW3y(~dgNv{om9LeSv*V|jJtU*^9!H%^~58L8!93$zo)@NO5T z&u0Y=Y4yy0j5eXi#vdt2cgaV^d0rjfPWK_cFrrK+*1Nn^bR$M5nBd@bP$dc6r0sP6 z`vIqkV%18DKGU2kq@R8?Y`4GMdG$@i@YZ3eo?yiHxA%H~ZtqcN(nQoWs@_UBH}S0Y zsv0|*5DKm+@$ElJw)nmC_+4VM9Q*xbsYGX68>3J@ zsz2<8&jcjW-kFcP2B>DHIG14$zxWi#pDVaX*mQQ|pX64$+7vFIS_2M4N-w zt%=ygkif_CsXXV}Oxe5=8mIQNCRwolhCQGZ1zeEVMB(mYAoxO6y7

<|rDD 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 F{ue{hH)Q|- literal 0 HcmV?d00001 diff --git a/docs/02_components/decomposition_plan.md b/docs/02_components/decomposition_plan.md new file mode 100644 index 0000000..0ca4993 --- /dev/null +++ b/docs/02_components/decomposition_plan.md @@ -0,0 +1,364 @@ + +# ASTRAL-Next System Component Decomposition Plan + +## Design Principle: Interface-Based Architecture + +**CRITICAL REQUIREMENT**: Each component MUST implement a well-defined interface to ensure interchangeability with different implementations. + +**Benefits**: + +- Swap implementations (e.g., replace LiteSAM with TransFG, GTSAM with Ceres) +- Enable unit testing with mocks +- Support multiple backends (TensorRT vs ONNX, different databases) +- Facilitate future enhancements without breaking contracts + +**Interface Specification**: Each component spec must define: + +- Interface name (e.g., `ISatelliteDataManager`, `IMetricRefinement`) +- All public methods with strict contracts +- Input/output data structures +- Error conditions and exceptions +- Performance guarantees + +--- + +## System Architecture Overview + +**Two separate REST APIs in same repository:** + +### Route API (Separate Project) + +- Route/waypoint/geofence CRUD +- Shared by GPS-Denied and Mission Planner +- Does NOT call satellite provider + +### GPS-Denied API (Main System) + +- Tri-layer localization (SuperPoint+LightGlue, AnyLoc, LiteSAM) +- Calls satellite provider for tiles +- Rotation preprocessing (LiteSAM 45° limit) +- Per-frame Route API updates +- Progressive tile search (1→4→9→16→25) + +--- + +## ROUTE API COMPONENTS (4 components) + +### R01_route_rest_api + +**Interface**: `IRouteRestAPI` +**Endpoints**: `POST /routes`, `GET /routes/{routeId}`, `PUT /routes/{routeId}/waypoints`, `DELETE /routes/{routeId}` + +### R02_route_data_manager + +**Interface**: `IRouteDataManager` +**API**: `save_route()`, `load_route()`, `update_waypoint()`, `delete_waypoint()`, `get_route_metadata()` + +### R03_waypoint_validator + +**Interface**: `IWaypointValidator` +**API**: `validate_waypoint()`, `validate_geofence()`, `check_bounds()`, `validate_route_continuity()` + +### R04_route_database_layer + +**Interface**: `IRouteDatabase` +**API**: `insert_route()`, `update_route()`, `query_routes()`, `get_waypoints()` + +--- + +## GPS-DENIED API COMPONENTS (17 components) + +### Core REST API Layer + +**G01_gps_denied_rest_api** +**Interface**: `IGPSDeniedRestAPI` +**Endpoints**: `POST /gps-denied/flights`, `POST .../images/batch`, `POST .../user-fix`, `GET .../status`, `GET .../stream` + +**G02_flight_manager** +**Interface**: `IFlightManager` +**API**: `create_flight()`, `get_flight_state()`, `link_to_route()`, `update_flight_status()` + +**G03_route_api_client** +**Interface**: `IRouteAPIClient` +**API**: `update_route_waypoint()`, `get_route_info()`, `batch_update_waypoints()` + +### Data Management + +**G04_satellite_data_manager** +**Interface**: `ISatelliteDataManager` +**API**: `fetch_tile()`, `fetch_tile_grid()`, `prefetch_route_corridor()`, `progressive_fetch()`, `cache_tile()`, `get_cached_tile()`, `compute_tile_coords()`, `expand_search_grid()` +**Features**: Progressive retrieval, tile caching, grid calculations + +**G05_image_input_pipeline** +**Interface**: `IImageInputPipeline` +**API**: `queue_batch()`, `process_next_batch()`, `validate_batch()`, `store_images()`, `get_next_image()`, `get_image_by_sequence()` +**Features**: FIFO queuing, validation, storage + +**G06_image_rotation_manager** +**Interface**: `IImageRotationManager` +**API**: `rotate_image_360()`, `try_rotation_steps()`, `calculate_precise_angle()`, `get_current_heading()`, `update_heading()`, `detect_sharp_turn()`, `requires_rotation_sweep()` +**Features**: 30° rotation sweeps, heading tracking + +### Visual Processing + +**G07_sequential_visual_odometry** +**Interface**: `ISequentialVO` +**API**: `compute_relative_pose()`, `extract_features()`, `match_features()`, `estimate_motion()` + +**G08_global_place_recognition** +**Interface**: `IGlobalPlaceRecognition` +**API**: `retrieve_candidate_tiles()`, `compute_location_descriptor()`, `query_database()`, `rank_candidates()` + +**G09_metric_refinement** +**Interface**: `IMetricRefinement` +**API**: `align_to_satellite()`, `compute_homography()`, `extract_gps_from_alignment()`, `compute_match_confidence()` + +### State Estimation + +**G10_factor_graph_optimizer** +**Interface**: `IFactorGraphOptimizer` +**API**: `add_relative_factor()`, `add_absolute_factor()`, `add_altitude_prior()`, `optimize()`, `get_trajectory()` + +**G11_failure_recovery_coordinator** +**Interface**: `IFailureRecoveryCoordinator` +**API**: `check_confidence()`, `detect_tracking_loss()`, `start_search()`, `expand_search_radius()`, `try_current_grid()`, `create_user_input_request()`, `apply_user_anchor()` + +**G12_coordinate_transformer** +**Interface**: `ICoordinateTransformer` +**API**: `pixel_to_gps()`, `gps_to_pixel()`, `image_object_to_gps()`, `compute_gsd()`, `transform_points()` + +### Results & Communication + +**G13_result_manager** +**Interface**: `IResultManager` +**API**: `update_frame_result()`, `publish_to_route_api()`, `get_flight_results()`, `mark_refined()` + +**G14_sse_event_streamer** +**Interface**: `ISSEEventStreamer` +**API**: `create_stream()`, `send_frame_result()`, `send_search_progress()`, `send_user_input_request()`, `send_refinement()` + +### Infrastructure + +**G15_model_manager** +**Interface**: `IModelManager` +**API**: `load_model()`, `get_inference_engine()`, `optimize_to_tensorrt()`, `fallback_to_onnx()` + +**G16_configuration_manager** +**Interface**: `IConfigurationManager` +**API**: `load_config()`, `get_camera_params()`, `validate_config()`, `get_flight_config()` + +**G17_gps_denied_database_layer** +**Interface**: `IGPSDeniedDatabase` +**API**: `save_flight_state()`, `load_flight_state()`, `query_processing_history()` + +--- + +## HELPER COMPONENTS (8 components) + +**H01_camera_model** - `ICameraModel` +**H02_gsd_calculator** - `IGSDCalculator` +**H03_robust_kernels** - `IRobustKernels` +**H04_faiss_index_manager** - `IFaissIndexManager` +**H05_performance_monitor** - `IPerformanceMonitor` +**H06_web_mercator_utils** - `IWebMercatorUtils` +**H07_image_rotation_utils** - `IImageRotationUtils` +**H08_batch_validator** - `IBatchValidator` + +--- + +## Comprehensive Component Interaction Matrix + +### System Initialization + +| Source | Target | Method | Purpose | +|--------|--------|--------|---------| +| G02 | G15 | `load_model()` × 4 | Load SuperPoint, LightGlue, DINOv2, LiteSAM | +| G02 | G16 | `load_config()` | Load system configuration | +| G04 | G08 | Satellite tiles | G08 generates descriptors for Faiss | +| G08 | H04 | `build_index()` | Build satellite descriptor index | +| G08 | G15 | `get_inference_engine("DINOv2")` | Get model for descriptor generation | + +### Flight Creation + +| Source | Target | Method | Purpose | +|--------|--------|--------|---------| +| Client | G01 | `POST /gps-denied/flights` | Create flight | +| G01 | G02 | `create_flight()` | Initialize flight state | +| G02 | G16 | `get_flight_config()` | Get camera params, altitude | +| G02 | G03 | `get_route_info()` | Fetch route metadata | +| G03 | Route API | `GET /routes/{routeId}` | HTTP call | +| G02 | G04 | `prefetch_route_corridor()` | Prefetch tiles | +| G04 | Satellite Provider | `GET /api/satellite/tiles/batch` | HTTP batch download | +| G04 | H06 | `compute_tile_bounds()` | Tile coordinate calculations | +| G02 | G17 | `save_flight_state()` | Persist flight metadata | +| Client | G01 | `GET .../stream` | Open SSE connection | +| G01 | G14 | `create_stream()` | Establish SSE channel | + +### Image Upload + +| Source | Target | Method | Purpose | +|--------|--------|--------|---------| +| Client | G01 | `POST .../images/batch` | Upload 10-50 images | +| G01 | G05 | `queue_batch()` | Queue for processing | +| G05 | H08 | `validate_batch()` | Validate sequence, format | +| G05 | G17 | `store_images()` | Persist images | + +### Per-Frame Processing (First Frame / Sharp Turn) + +| Source | Target | Method | Purpose | +|--------|--------|--------|---------| +| G05 | G06 | `get_next_image()` | Get image for processing | +| G06 | G06 | `requires_rotation_sweep()` | Check if sweep needed | +| G06 | H07 | `rotate_image()` × 12 | Rotate in 30° steps | +| G06 | G09 | `align_to_satellite()` × 12 | Try LiteSAM each rotation | +| G09 | G04 | `get_cached_tile()` | Get expected tile | +| G09 | G15 | `get_inference_engine("LiteSAM")` | Get model | +| G06 | H07 | `calculate_rotation_from_points()` | Precise angle from homography | +| G06 | Internal | `update_heading()` | Store UAV heading | + +### Per-Frame Processing (Sequential VO) + +| Source | Target | Method | Purpose | +|--------|--------|--------|---------| +| G05 | G07 | `get_next_image()` | Provide image | +| G07 | G15 | `get_inference_engine("SuperPoint")` | Get feature extractor | +| G07 | G15 | `get_inference_engine("LightGlue")` | Get matcher | +| G07 | H05 | `start_timer()`, `end_timer()` | Monitor timing | +| G07 | G10 | `add_relative_factor()` | Add pose measurement | + +### Tracking Good (Drift Correction) + +| Source | Target | Method | Purpose | +|--------|--------|--------|---------| +| G07 | G11 | `check_confidence()` | Check tracking quality | +| G11 | G09 | `align_to_satellite()` | Align to 1 tile | +| G09 | G04 | `get_tile_grid(1)` | Get single tile | +| G09 | G10 | `add_absolute_factor()` | Add GPS measurement | + +### Tracking Lost (Progressive Search) + +| Source | Target | Method | Purpose | +|--------|--------|--------|---------| +| G07 | G11 | `check_confidence()` → FAIL | Low confidence | +| G11 | G06 | `requires_rotation_sweep()` | Trigger rotation sweep | +| G11 | G08 | `retrieve_candidate_tiles()` | Coarse localization | +| G08 | G15 | `get_inference_engine("DINOv2")` | Get model | +| G08 | H04 | `search()` | Query Faiss index | +| G08 | G04 | `get_tile_by_gps()` × 5 | Get candidate tiles | +| G11 | G04 | `expand_search_grid(4)` | Get 2×2 grid | +| G11 | G09 | `align_to_satellite()` | Try LiteSAM on 4 tiles | +| G11 (fail) | G04 | `expand_search_grid(9)` | Expand to 3×3 | +| G11 (fail) | G04 | `expand_search_grid(16)` | Expand to 4×4 | +| G11 (fail) | G04 | `expand_search_grid(25)` | Expand to 5×5 | +| G11 (fail) | G14 | `send_user_input_request()` | Request human help | +| G11 | G02 | `update_flight_status("BLOCKED")` | Block processing | + +### Optimization & Results + +| Source | Target | Method | Purpose | +|--------|--------|--------|---------| +| G10 | H03 | `huber_loss()`, `cauchy_loss()` | Apply robust kernels | +| G10 | Internal | `optimize()` | Run iSAM2 optimization | +| G10 | G12 | `get_trajectory()` | Get optimized poses | +| G12 | H01 | `project()`, `unproject()` | Camera operations | +| G12 | H02 | `compute_gsd()` | GSD calculations | +| G12 | H06 | `tile_to_latlon()` | Coordinate transforms | +| G12 | G13 | Frame GPS + object coords | Provide results | +| G13 | G03 | `update_route_waypoint()` | Per-frame Route API update | +| G03 | Route API | `PUT /routes/.../waypoints/...` | HTTP call | +| G13 | G14 | `send_frame_result()` | Publish to client | +| G14 | Client | SSE `frame_processed` | Real-time delivery | +| G13 | G17 | `save_flight_state()` | Persist state | + +### User Input Recovery + +| Source | Target | Method | Purpose | +|--------|--------|--------|---------| +| G14 | Client | SSE `user_input_needed` | Notify client | +| Client | G01 | `POST .../user-fix` | Provide anchor | +| G01 | G11 | `apply_user_anchor()` | Apply fix | +| G11 | G10 | `add_absolute_factor()` (high confidence) | Hard constraint | +| G10 | Internal | `optimize()` | Re-optimize | +| G11 | G02 | `update_flight_status("PROCESSING")` | Resume | + +### Asynchronous Refinement + +| Source | Target | Method | Purpose | +|--------|--------|--------|---------| +| G10 | Internal (background) | `optimize()` | Back-propagate anchors | +| G10 | G13 | `get_trajectory()` | Get refined poses | +| G13 | G03 | `batch_update_waypoints()` | Batch update Route API | +| G13 | G14 | `send_refinement()` × N | Send updates | +| G14 | Client | SSE `frame_refined` × N | Incremental updates | + +### Cross-Cutting Concerns + +| Source | Target | Method | Purpose | +|--------|--------|--------|---------| +| G16 | ALL | `get_*_config()` | Provide configuration | +| H05 | G07, G08, G09, G10, G11 | `start_timer()`, `end_timer()` | Performance monitoring | + +--- + +## Interaction Coverage Verification + +✅ **Initialization**: G02→G15, G16, G17; G04→G08→H04 +✅ **Flight creation**: Client→G01→G02→G03,G04,G16,G17,G14 +✅ **Image upload**: Client→G01→G05→H08,G17 +✅ **Rotation sweep**: G06→H07,G09 (12 iterations) +✅ **Sequential VO**: G07→G15,G10,H05 +✅ **Drift correction**: G11→G09→G04(1),G10 +✅ **Tracking loss**: G11→G06,G08,G04(progressive),G09,G14,G02 +✅ **Global PR**: G08→G15,H04,G04 +✅ **Optimization**: G10→H03,G12 +✅ **Coordinate transform**: G12→H01,H02,H06 +✅ **Results**: G12→G13→G03,G14,G17 +✅ **User input**: Client→G01→G11→G10,G02 +✅ **Refinement**: G10→G13→G03,G14 +✅ **Configuration**: G16→ALL +✅ **Performance**: H05→processing components + +**All major component interactions are covered.** + +--- + +## Deliverables + +**Component Count**: 29 total + +- Route API: 4 (R01-R04) +- GPS-Denied API: 17 (G01-G17) +- Helpers: 8 (H01-H08) + +**For each component**, create `docs/02_components/[project]_[##]_[component_name]/[component_name]_spec.md`: + +1. **Interface Definition** (interface name, methods, contracts) +2. **Component Description** (responsibilities, scope) +3. **API Methods** (inputs, outputs, errors, which components call it, test cases) +4. **Integration Tests** +5. **Non-Functional Requirements** (performance, accuracy targets) +6. **Dependencies** (which components it calls) +7. **Data Models** + +**Generate draw.io diagram** showing: + +- Two API projects (Route API, GPS-Denied API) +- All 29 components +- Route API ↔ GPS-Denied API communication +- GPS-Denied → Satellite Provider calls +- Rotation preprocessing flow +- Progressive search expansion (1→4→9→16→25) +- Per-frame Route API update flow +- Helper component usage + +### To-dos + +- [x] Create 4 Route API specs with interfaces (REST, data manager, validator, DB) +- [x] Create GPS-Denied core API specs with interfaces (REST, flight manager, Route client) +- [x] Create data management specs with interfaces (satellite, image pipeline, rotation) +- [x] Create visual processing specs with interfaces (VO, place recognition, LiteSAM) +- [x] Create coordination specs with interfaces (factor graph, failure recovery, transformer) +- [x] Create results/infrastructure specs with interfaces (result manager, SSE, models, config, DB) +- [x] Create 8 helper specs with interfaces +- [x] Generate draw.io with all components, interactions, flows + diff --git a/docs/02_components/gps_denied_01_gps_denied_rest_api/gps_denied_rest_api_spec.md b/docs/02_components/gps_denied_01_gps_denied_rest_api/gps_denied_rest_api_spec.md new file mode 100644 index 0000000..79aa9f1 --- /dev/null +++ b/docs/02_components/gps_denied_01_gps_denied_rest_api/gps_denied_rest_api_spec.md @@ -0,0 +1,387 @@ +# GPS-Denied REST API + +## Interface Definition + +**Interface Name**: `IGPSDeniedRestAPI` + +### Interface Methods + +```python +class IGPSDeniedRestAPI(ABC): + @abstractmethod + def create_flight(self, flight_data: FlightCreateRequest) -> FlightResponse: + pass + + @abstractmethod + def upload_image_batch(self, flight_id: str, batch: ImageBatch) -> BatchResponse: + pass + + @abstractmethod + def submit_user_fix(self, flight_id: str, fix_data: UserFixRequest) -> UserFixResponse: + pass + + @abstractmethod + def get_flight_status(self, flight_id: str) -> FlightStatusResponse: + pass + + @abstractmethod + def create_sse_stream(self, flight_id: str) -> SSEStream: + pass +``` + +## Component Description + +### Responsibilities +- Expose REST API endpoints for GPS-Denied image processing pipeline +- Handle flight creation with satellite data prefetching +- Accept batch image uploads (10-50 images per request) +- Accept user-provided GPS fixes for blocked flights +- Provide real-time status updates +- Stream results via Server-Sent Events (SSE) + +### Scope +- FastAPI-based REST endpoints +- Request/response validation +- Coordinate with Flight Manager for processing +- Multipart form data handling for image uploads +- SSE connection management +- Authentication and rate limiting + +## API Methods + +### `create_flight(flight_data: FlightCreateRequest) -> FlightResponse` + +**REST Endpoint**: `POST /gps-denied/flights` + +**Description**: Creates a new flight processing session, links to Route API, and prefetches satellite data. + +**Called By**: +- Client applications (GPS-Denied UI) + +**Input**: +```python +FlightCreateRequest: + route_id: str # UUID from Route API + start_gps: GPSPoint # Starting GPS coordinates (approximate) + camera_params: CameraParameters + rough_waypoints: List[GPSPoint] # Rough route for prefetching +``` + +**Output**: +```python +FlightResponse: + flight_id: str # UUID + status: str # "prefetching", "ready", "error" + message: Optional[str] +``` + +**Processing Flow**: +1. Validate request data +2. Call G02 Flight Manager → create_flight() +3. Flight Manager triggers satellite prefetch +4. Return flight_id immediately (prefetch is async) + +**Error Conditions**: +- `400 Bad Request`: Invalid input data +- `404 Not Found`: route_id doesn't exist in Route API +- `500 Internal Server Error`: System error + +**Test Cases**: +1. **Valid flight creation**: Returns 201 with flight_id +2. **Invalid route_id**: Returns 404 +3. **Missing camera_params**: Returns 400 +4. **Concurrent flight creation**: Multiple flights for same route → all succeed + +--- + +### `upload_image_batch(flight_id: str, batch: ImageBatch) -> BatchResponse` + +**REST Endpoint**: `POST /gps-denied/flights/{flightId}/images/batch` + +**Description**: Uploads a batch of 10-50 UAV images for processing. + +**Called By**: +- Client applications + +**Input**: +```python +flight_id: str # Path parameter +ImageBatch: multipart/form-data + images: List[UploadFile] # 10-50 images + metadata: BatchMetadata + start_sequence: int + end_sequence: int +``` + +**Output**: +```python +BatchResponse: + accepted: bool + sequences: List[int] # [start, end] + next_expected: int + message: Optional[str] +``` + +**Processing Flow**: +1. Validate flight_id exists +2. Validate batch size (10-50 images) +3. Validate sequence numbers (strict sequential) +4. Pass to G05 Image Input Pipeline +5. Return immediately (processing is async) + +**Error Conditions**: +- `400 Bad Request`: Invalid batch size, out-of-sequence images +- `404 Not Found`: flight_id doesn't exist +- `413 Payload Too Large`: Batch exceeds size limit +- `429 Too Many Requests`: Rate limit exceeded + +**Test Cases**: +1. **Valid batch upload**: 20 images → returns 202 Accepted +2. **Out-of-sequence batch**: Sequence gap detected → returns 400 +3. **Too many images**: 60 images → returns 400 +4. **Large images**: 50 × 8MB images → successfully uploads +5. **Client-side resized images**: 2048×1536 → optimal processing + +--- + +### `submit_user_fix(flight_id: str, fix_data: UserFixRequest) -> UserFixResponse` + +**REST Endpoint**: `POST /gps-denied/flights/{flightId}/user-fix` + +**Description**: Submits user-provided GPS anchor point to unblock failed localization. + +**Called By**: +- Client applications (when user responds to `user_input_needed` event) + +**Input**: +```python +UserFixRequest: + frame_id: int # Frame sequence number + uav_pixel: Tuple[float, float] # Pixel coordinates in UAV image + satellite_gps: GPSPoint # GPS corresponding to pixel location +``` + +**Output**: +```python +UserFixResponse: + accepted: bool + processing_resumed: bool + message: Optional[str] +``` + +**Processing Flow**: +1. Validate flight_id exists and is blocked +2. Pass to G11 Failure Recovery Coordinator +3. Coordinator applies anchor to Factor Graph +4. Resume processing pipeline + +**Error Conditions**: +- `400 Bad Request`: Invalid fix data +- `404 Not Found`: flight_id or frame_id not found +- `409 Conflict`: Flight not in blocked state + +**Test Cases**: +1. **Valid user fix**: Blocked flight → returns 200, processing resumes +2. **Fix for non-blocked flight**: Returns 409 +3. **Invalid GPS coordinates**: Returns 400 +4. **Multiple fixes**: Sequential fixes for different frames → all accepted + +--- + +### `get_flight_status(flight_id: str) -> FlightStatusResponse` + +**REST Endpoint**: `GET /gps-denied/flights/{flightId}/status` + +**Description**: Retrieves current processing status of a flight. + +**Called By**: +- Client applications (polling for status) + +**Input**: +```python +flight_id: str +``` + +**Output**: +```python +FlightStatusResponse: + status: str # "prefetching", "processing", "blocked", "completed", "failed" + frames_processed: int + frames_total: int + current_frame: Optional[int] + current_heading: Optional[float] # UAV heading in degrees + blocked: bool + search_grid_size: Optional[int] # 1, 4, 9, 16, or 25 + message: Optional[str] +``` + +**Error Conditions**: +- `404 Not Found`: flight_id doesn't exist + +**Test Cases**: +1. **Processing flight**: Returns current progress +2. **Blocked flight**: Returns blocked=true with search_grid_size +3. **Completed flight**: Returns status="completed" with final counts + +--- + +### `create_sse_stream(flight_id: str) -> SSEStream` + +**REST Endpoint**: `GET /gps-denied/flights/{flightId}/stream` + +**Description**: Opens Server-Sent Events connection for real-time result streaming. + +**Called By**: +- Client applications + +**Input**: +```python +flight_id: str +``` + +**Output**: +```python +SSE Stream with events: + - frame_processed + - frame_refined + - search_expanded + - user_input_needed + - processing_blocked + - route_api_updated + - route_completed +``` + +**Event Format**: +```json +{ + "event": "frame_processed", + "data": { + "frame_id": 237, + "gps": {"lat": 48.123, "lon": 37.456}, + "altitude": 800.0, + "confidence": 0.95, + "heading": 87.3, + "timestamp": "2025-11-24T10:30:00Z" + } +} +``` + +**Error Conditions**: +- `404 Not Found`: flight_id doesn't exist +- Connection closed on client disconnect + +**Test Cases**: +1. **Connect to stream**: Opens SSE connection successfully +2. **Receive frame events**: Process 100 frames → receive 100 events +3. **Receive user_input_needed**: Blocked frame → event sent +4. **Client reconnect**: Replay missed events from last_event_id + +## Integration Tests + +### Test 1: Complete Flight Processing Flow +1. POST /gps-denied/flights +2. GET /gps-denied/flights/{flightId}/stream (open SSE) +3. POST /gps-denied/flights/{flightId}/images/batch × 40 (2000 images total) +4. Receive frame_processed events via SSE +5. Receive route_completed event + +### Test 2: User Fix Flow +1. Create flight and process images +2. Receive user_input_needed event +3. POST /gps-denied/flights/{flightId}/user-fix +4. Receive processing_resumed event +5. Continue receiving frame_processed events + +### Test 3: Multiple Concurrent Flights +1. Create 10 flights concurrently +2. Upload batches to all flights in parallel +3. Stream results from all flights simultaneously +4. Verify no cross-contamination + +## Non-Functional Requirements + +### Performance +- **create_flight**: < 500ms response (prefetch is async) +- **upload_image_batch**: < 2 seconds for 50 × 2MB images +- **submit_user_fix**: < 200ms response +- **get_flight_status**: < 100ms +- **SSE latency**: < 500ms from event generation to client receipt + +### Scalability +- Support 100 concurrent flight processing sessions +- Handle 1000+ concurrent SSE connections +- Support 10,000 requests per minute + +### Reliability +- Request timeout: 30 seconds for batch uploads +- SSE keepalive: Ping every 30 seconds +- Automatic SSE reconnection with event replay +- Graceful handling of client disconnects + +### Security +- API key authentication +- Rate limiting: 100 requests/minute per client +- Max upload size: 500MB per batch +- CORS configuration for web clients + +## Dependencies + +### Internal Components +- **G02 Flight Manager**: For all flight operations +- **G05 Image Input Pipeline**: For batch processing +- **G11 Failure Recovery Coordinator**: For user fixes +- **G14 SSE Event Streamer**: For real-time streaming + +### External Dependencies +- **FastAPI**: Web framework +- **Uvicorn**: ASGI server +- **Pydantic**: Validation +- **python-multipart**: Multipart form handling + +## Data Models + +### FlightCreateRequest +```python +class GPSPoint(BaseModel): + lat: float + lon: float + +class CameraParameters(BaseModel): + focal_length: float # mm + sensor_width: float # mm + sensor_height: float # mm + resolution_width: int # pixels + resolution_height: int # pixels + distortion_coefficients: Optional[List[float]] = None + +class FlightCreateRequest(BaseModel): + route_id: str + start_gps: GPSPoint + camera_params: CameraParameters + rough_waypoints: List[GPSPoint] + altitude: float # Predefined altitude in meters +``` + +### BatchMetadata +```python +class BatchMetadata(BaseModel): + start_sequence: int # e.g., 101 + end_sequence: int # e.g., 150 + batch_number: int # e.g., 3 +``` + +### FlightStatusResponse +```python +class FlightStatusResponse(BaseModel): + status: str + frames_processed: int + frames_total: int + current_frame: Optional[int] + current_heading: Optional[float] + blocked: bool + search_grid_size: Optional[int] + message: Optional[str] + created_at: datetime + updated_at: datetime +``` + diff --git a/docs/02_components/gps_denied_02_flight_manager/flight_manager_spec.md b/docs/02_components/gps_denied_02_flight_manager/flight_manager_spec.md new file mode 100644 index 0000000..0d35455 --- /dev/null +++ b/docs/02_components/gps_denied_02_flight_manager/flight_manager_spec.md @@ -0,0 +1,358 @@ +# Flight Manager + +## Interface Definition + +**Interface Name**: `IFlightManager` + +### Interface Methods + +```python +class IFlightManager(ABC): + @abstractmethod + def create_flight(self, flight_data: FlightData) -> str: + pass + + @abstractmethod + def get_flight_state(self, flight_id: str) -> Optional[FlightState]: + pass + + @abstractmethod + def link_to_route(self, flight_id: str, route_id: str) -> bool: + pass + + @abstractmethod + def update_flight_status(self, flight_id: str, status: FlightStatus) -> bool: + pass + + @abstractmethod + def initialize_system(self) -> bool: + pass +``` + +## Component Description + +### Responsibilities +- Manage flight lifecycle (creation, state tracking, completion) +- Link flights to Route API routes +- Initialize system components (models, configurations, satellite database) +- Coordinate satellite data prefetching +- Track flight processing status and statistics +- Manage flight metadata persistence + +### Scope +- Central coordinator for flight processing sessions +- System initialization and resource management +- Flight state machine management +- Integration point between REST API and processing components + +## API Methods + +### `create_flight(flight_data: FlightData) -> str` + +**Description**: Creates a new flight processing session, initializes state, and triggers satellite prefetching. + +**Called By**: +- G01 GPS-Denied REST API + +**Input**: +```python +FlightData: + route_id: str + start_gps: GPSPoint + camera_params: CameraParameters + rough_waypoints: List[GPSPoint] + altitude: float +``` + +**Output**: +```python +flight_id: str # UUID +``` + +**Processing Flow**: +1. Generate flight_id (UUID) +2. Get flight configuration from G16 Configuration Manager +3. Get route info from G03 Route API Client +4. Initialize flight state +5. Trigger G04 Satellite Data Manager → prefetch_route_corridor() +6. Save flight state to G17 Database Layer +7. Return flight_id + +**Error Conditions**: +- `RouteNotFoundError`: route_id doesn't exist +- `ConfigurationError`: Invalid camera parameters +- `DatabaseError`: Failed to persist state + +**Test Cases**: +1. **Valid flight creation**: Returns flight_id, state persisted +2. **Invalid route_id**: Raises RouteNotFoundError +3. **Prefetch triggered**: Satellite manager receives prefetch request +4. **Concurrent creation**: 10 flights created simultaneously → all succeed + +--- + +### `get_flight_state(flight_id: str) -> Optional[FlightState]` + +**Description**: Retrieves current flight state including processing statistics. + +**Called By**: +- G01 GPS-Denied REST API (for status endpoint) +- G05 Image Input Pipeline (to check flight exists) +- G11 Failure Recovery Coordinator (for state updates) + +**Input**: +```python +flight_id: str +``` + +**Output**: +```python +FlightState: + flight_id: str + route_id: str + status: str # "prefetching", "ready", "processing", "blocked", "completed", "failed" + frames_processed: int + frames_total: int + current_frame: Optional[int] + current_heading: Optional[float] + blocked: bool + search_grid_size: Optional[int] + created_at: datetime + updated_at: datetime + cache_reference: str # Satellite data cache identifier +``` + +**Error Conditions**: +- Returns `None`: Flight not found (not an error condition) + +**Test Cases**: +1. **Get existing flight**: Returns complete FlightState +2. **Get non-existent flight**: Returns None +3. **Get during processing**: Returns accurate frame count + +--- + +### `link_to_route(flight_id: str, route_id: str) -> bool` + +**Description**: Links a flight to its Route API route for waypoint updates. + +**Called By**: +- Internal (during create_flight) + +**Input**: +```python +flight_id: str +route_id: str +``` + +**Output**: +```python +bool: True if linked, False if flight doesn't exist +``` + +**Processing Flow**: +1. Verify flight exists +2. Verify route exists via G03 Route API Client +3. Store linkage in flight state +4. Update database + +**Error Conditions**: +- `RouteNotFoundError`: route_id invalid + +**Test Cases**: +1. **Valid linkage**: Returns True +2. **Invalid flight_id**: Returns False +3. **Invalid route_id**: Raises RouteNotFoundError + +--- + +### `update_flight_status(flight_id: str, status: FlightStatus) -> bool` + +**Description**: Updates flight processing status (processing, blocked, completed, etc.). + +**Called By**: +- G05 Image Input Pipeline (status transitions) +- G11 Failure Recovery Coordinator (blocked/resumed) +- G13 Result Manager (completed) + +**Input**: +```python +flight_id: str +status: FlightStatus: + status_type: str # "processing", "blocked", "completed", "failed" + frames_processed: Optional[int] + current_frame: Optional[int] + current_heading: Optional[float] + blocked: Optional[bool] + search_grid_size: Optional[int] + message: Optional[str] +``` + +**Output**: +```python +bool: True if updated, False if flight not found +``` + +**Processing Flow**: +1. Load current flight state +2. Update relevant fields +3. Update updated_at timestamp +4. Persist to G17 Database Layer +5. Return success + +**Error Conditions**: +- Returns False if flight not found + +**Test Cases**: +1. **Update to processing**: status="processing" → updates successfully +2. **Update to blocked**: blocked=True, search_grid_size=9 → updates +3. **Resume from blocked**: blocked=False → processing continues +4. **Concurrent updates**: Multiple simultaneous updates → all persist correctly + +--- + +### `initialize_system() -> bool` + +**Description**: Initializes system components on startup (models, configurations, satellite database). + +**Called By**: +- System startup (main application) + +**Input**: +- None + +**Output**: +```python +bool: True if initialization successful +``` + +**Processing Flow**: +1. Load system configuration from G16 Configuration Manager +2. Initialize ML models via G15 Model Manager: + - Load SuperPoint model + - Load LightGlue model + - Load DINOv2 model + - Load LiteSAM model +3. Initialize G08 Global Place Recognition → build satellite descriptor database +4. Initialize G04 Satellite Data Manager cache +5. Verify all components ready + +**Error Conditions**: +- `InitializationError`: Component initialization failed +- `ModelLoadError`: ML model loading failed + +**Test Cases**: +1. **Clean startup**: All models load successfully +2. **Missing model file**: Raises ModelLoadError +3. **Configuration error**: Raises InitializationError +4. **Partial initialization failure**: Cleanup and raise error + +## Integration Tests + +### Test 1: Flight Lifecycle +1. initialize_system() +2. create_flight() with valid data +3. get_flight_state() → verify "prefetching" +4. Wait for prefetch completion +5. update_flight_status("processing") +6. get_flight_state() → verify "processing" +7. update_flight_status("completed") + +### Test 2: Multiple Concurrent Flights +1. create_flight() × 10 concurrently +2. update_flight_status() for all flights in parallel +3. get_flight_state() for all flights +4. Verify no state cross-contamination + +### Test 3: System Initialization +1. initialize_system() +2. Verify all 4 models loaded +3. Verify satellite database ready +4. Create flight immediately after init → succeeds + +## Non-Functional Requirements + +### Performance +- **create_flight**: < 300ms (excluding prefetch which is async) +- **get_flight_state**: < 50ms +- **update_flight_status**: < 30ms +- **initialize_system**: < 30 seconds (one-time startup cost) + +### Scalability +- Support 1000+ concurrent flight sessions +- Handle 100 status updates per second +- Maintain state for up to 10,000 flights (historical data) + +### Reliability +- Graceful handling of component initialization failures +- Flight state persistence survives process restarts +- Transaction safety for concurrent updates +- Automatic cleanup of completed flights after 7 days + +## Dependencies + +### Internal Components +- **G03 Route API Client**: For route validation and metadata +- **G04 Satellite Data Manager**: For prefetch operations +- **G08 Global Place Recognition**: For descriptor database initialization +- **G15 Model Manager**: For ML model loading +- **G16 Configuration Manager**: For system configuration +- **G17 GPS-Denied Database Layer**: For state persistence + +### External Dependencies +- None (coordinates with internal components) + +## Data Models + +### FlightData +```python +class FlightData(BaseModel): + route_id: str + start_gps: GPSPoint + camera_params: CameraParameters + rough_waypoints: List[GPSPoint] + altitude: float +``` + +### FlightState +```python +class FlightState(BaseModel): + flight_id: str + route_id: str + status: str + frames_processed: int = 0 + frames_total: int = 0 + current_frame: Optional[int] = None + current_heading: Optional[float] = None + blocked: bool = False + search_grid_size: Optional[int] = None + created_at: datetime + updated_at: datetime + cache_reference: str + camera_params: CameraParameters + altitude: float + start_gps: GPSPoint +``` + +### FlightStatus (Update DTO) +```python +class FlightStatus(BaseModel): + status_type: str + frames_processed: Optional[int] = None + current_frame: Optional[int] = None + current_heading: Optional[float] = None + blocked: Optional[bool] = None + search_grid_size: Optional[int] = None + message: Optional[str] = None +``` + +### SystemState +```python +class SystemState(BaseModel): + initialized: bool + models_loaded: Dict[str, bool] # {"SuperPoint": True, "LightGlue": True, ...} + satellite_db_ready: bool + active_flights_count: int + initialization_timestamp: datetime +``` + diff --git a/docs/02_components/gps_denied_03_route_api_client/route_api_client_spec.md b/docs/02_components/gps_denied_03_route_api_client/route_api_client_spec.md new file mode 100644 index 0000000..d213b05 --- /dev/null +++ b/docs/02_components/gps_denied_03_route_api_client/route_api_client_spec.md @@ -0,0 +1,331 @@ +# Route API Client + +## Interface Definition + +**Interface Name**: `IRouteAPIClient` + +### Interface Methods + +```python +class IRouteAPIClient(ABC): + @abstractmethod + def update_route_waypoint(self, route_id: str, waypoint_id: str, waypoint: Waypoint) -> bool: + pass + + @abstractmethod + def get_route_info(self, route_id: str) -> Optional[RouteInfo]: + pass + + @abstractmethod + def batch_update_waypoints(self, route_id: str, waypoints: List[Waypoint]) -> BatchUpdateResult: + pass +``` + +## Component Description + +### Responsibilities +- HTTP client for communicating with Route API +- Send per-frame GPS refinements to Route API +- Retrieve route metadata and waypoints +- Handle batch waypoint updates for trajectory refinements +- Manage connection pooling and retry logic +- Handle HTTP errors and timeouts + +### Scope +- Synchronous HTTP client (requests library) +- Waypoint update operations +- Route metadata retrieval +- Error handling and retries +- Rate limiting and backpressure management + +## API Methods + +### `update_route_waypoint(route_id: str, waypoint_id: str, waypoint: Waypoint) -> bool` + +**Description**: Updates a single waypoint in Route API. Called per-frame after GPS calculation. + +**Called By**: +- G13 Result Manager (per-frame update) + +**Input**: +```python +route_id: str +waypoint_id: str # Frame sequence number +waypoint: Waypoint: + lat: float + lon: float + altitude: Optional[float] + confidence: float + timestamp: datetime + refined: bool # Always True for GPS-Denied updates +``` + +**Output**: +```python +bool: True if updated successfully, False on failure +``` + +**HTTP Request**: +``` +PUT /routes/{route_id}/waypoints/{waypoint_id} +Content-Type: application/json + +{ + "lat": 48.123456, + "lon": 37.654321, + "altitude": 800.0, + "confidence": 0.95, + "timestamp": "2025-11-24T10:30:00Z", + "refined": true +} +``` + +**Error Handling**: +- **Retry**: 3 attempts with exponential backoff (1s, 2s, 4s) +- **Timeout**: 5 seconds per request +- **404 Not Found**: Log warning, return False +- **429 Too Many Requests**: Backoff and retry +- **500 Server Error**: Retry with backoff + +**Error Conditions**: +- Returns `False`: Update failed after retries +- Logs errors but doesn't raise exceptions (non-critical path) + +**Test Cases**: +1. **Successful update**: Returns True +2. **Route API unavailable**: Retries 3 times, returns False +3. **Waypoint not found**: Returns False +4. **Network timeout**: Retries, returns False if all fail +5. **High-frequency updates**: 100 updates/sec sustained + +--- + +### `get_route_info(route_id: str) -> Optional[RouteInfo]` + +**Description**: Retrieves route metadata including rough waypoints and geofences. + +**Called By**: +- G02 Flight Manager (during flight creation) + +**Input**: +```python +route_id: str +``` + +**Output**: +```python +RouteInfo: + route_id: str + name: str + description: str + points: List[GPSPoint] # Rough waypoints + geofences: Geofences + waypoint_count: int + created_at: datetime +``` + +**HTTP Request**: +``` +GET /routes/{route_id} +``` + +**Error Handling**: +- **Retry**: 3 attempts for transient errors +- **Timeout**: 10 seconds +- **404 Not Found**: Return None +- **500 Server Error**: Retry with backoff + +**Error Conditions**: +- Returns `None`: Route not found or error after retries +- `RouteAPIError`: Critical error retrieving route + +**Test Cases**: +1. **Existing route**: Returns complete RouteInfo +2. **Non-existent route**: Returns None +3. **Large route**: 2000+ waypoints → returns successfully +4. **Concurrent requests**: 10 simultaneous requests → all succeed + +--- + +### `batch_update_waypoints(route_id: str, waypoints: List[Waypoint]) -> BatchUpdateResult` + +**Description**: Updates multiple waypoints in a single request. Used for trajectory refinements. + +**Called By**: +- G13 Result Manager (asynchronous refinement updates) + +**Input**: +```python +route_id: str +waypoints: List[Waypoint] # Refined waypoints +``` + +**Output**: +```python +BatchUpdateResult: + success: bool + updated_count: int + failed_ids: List[str] +``` + +**HTTP Request**: +``` +PUT /routes/{route_id}/waypoints/batch +Content-Type: application/json + +{ + "waypoints": [ + { + "id": "AD000237", + "lat": 48.123, + "lon": 37.654, + "altitude": 800.0, + "confidence": 0.97, + "timestamp": "2025-11-24T10:30:00Z", + "refined": true + }, + ... + ] +} +``` + +**Error Handling**: +- **Partial success**: Some waypoints update, some fail +- **Retry**: 3 attempts for complete batch +- **Timeout**: 30 seconds (larger batches) +- Returns updated_count and failed_ids + +**Error Conditions**: +- Returns `success=False` with failed_ids list + +**Test Cases**: +1. **Batch update 100 waypoints**: All succeed +2. **Partial failure**: 5 waypoints fail → returns failed_ids +3. **Empty batch**: Returns success=True, updated_count=0 +4. **Large batch**: 500 waypoints → splits into sub-batches + +## Integration Tests + +### Test 1: Per-Frame Update Flow +1. Create flight and process 100 frames +2. update_route_waypoint() × 100 sequentially +3. Verify all updates successful via get_route_info() +4. Verify waypoints marked as refined=True + +### Test 2: Refinement Batch Update +1. Process route, track 200 frames needing refinement +2. batch_update_waypoints() with 200 waypoints +3. Verify all updates applied +4. Handle partial failures gracefully + +### Test 3: Error Recovery +1. Simulate Route API downtime +2. Attempt update_route_waypoint() → retries 3 times +3. Route API comes back online +4. Next update succeeds + +### Test 4: High-Frequency Updates +1. Send 200 waypoint updates sequentially +2. Measure throughput and success rate +3. Verify no rate limiting issues +4. Verify all updates persisted + +## Non-Functional Requirements + +### Performance +- **update_route_waypoint**: < 100ms average latency (critical path) +- **get_route_info**: < 200ms +- **batch_update_waypoints**: < 2 seconds for 100 waypoints +- **Throughput**: Support 100 waypoint updates per second + +### Scalability +- Connection pool: 20-50 connections +- Handle 1000+ waypoint updates per flight (2000+ frame flight) +- Support concurrent updates from multiple flights + +### Reliability +- **Retry strategy**: 3 attempts with exponential backoff +- **Circuit breaker**: Temporarily stop requests after 5 consecutive failures +- **Timeout management**: Progressive timeouts (5s, 10s, 30s) +- **Graceful degradation**: Continue processing even if Route API unavailable + +### Monitoring +- Track success/failure rates +- Monitor latency percentiles (p50, p95, p99) +- Alert on high failure rates +- Log all HTTP errors + +## Dependencies + +### Internal Components +- None (external HTTP client) + +### External Dependencies +- **Route API**: External REST API service +- **requests** or **httpx**: HTTP client library +- **tenacity**: Retry library +- **urllib3**: Connection pooling + +## Data Models + +### RouteInfo +```python +class GPSPoint(BaseModel): + lat: float + lon: float + +class Geofences(BaseModel): + polygons: List[Polygon] + +class RouteInfo(BaseModel): + route_id: str + name: str + description: str + points: List[GPSPoint] + geofences: Geofences + waypoint_count: int + created_at: datetime +``` + +### Waypoint +```python +class Waypoint(BaseModel): + id: str + lat: float + lon: float + altitude: Optional[float] + confidence: float + timestamp: datetime + refined: bool +``` + +### BatchUpdateResult +```python +class BatchUpdateResult(BaseModel): + success: bool + updated_count: int + failed_ids: List[str] + errors: Optional[Dict[str, str]] # waypoint_id -> error_message +``` + +### HTTPConfig +```python +class HTTPConfig(BaseModel): + route_api_base_url: str # e.g., "http://localhost:8000" + timeout: int = 5 # seconds + max_retries: int = 3 + retry_backoff: float = 1.0 # seconds + connection_pool_size: int = 50 + max_batch_size: int = 500 +``` + +### Retry Strategy +```python +retry_strategy = { + "stop": "stop_after_attempt(3)", + "wait": "wait_exponential(multiplier=1, min=1, max=10)", + "retry": "retry_if_exception_type((ConnectionError, Timeout, HTTPError))", + "reraise": True +} +``` + diff --git a/docs/02_components/gps_denied_04_satellite_data_manager/satellite_data_manager_spec.md b/docs/02_components/gps_denied_04_satellite_data_manager/satellite_data_manager_spec.md new file mode 100644 index 0000000..ba6fc8c --- /dev/null +++ b/docs/02_components/gps_denied_04_satellite_data_manager/satellite_data_manager_spec.md @@ -0,0 +1,551 @@ +# Satellite Data Manager + +## Interface Definition + +**Interface Name**: `ISatelliteDataManager` + +### Interface Methods + +```python +class ISatelliteDataManager(ABC): + @abstractmethod + def fetch_tile(self, lat: float, lon: float, zoom: int) -> Optional[np.ndarray]: + pass + + @abstractmethod + def fetch_tile_grid(self, center_lat: float, center_lon: float, grid_size: int, zoom: int) -> Dict[str, np.ndarray]: + pass + + @abstractmethod + def prefetch_route_corridor(self, waypoints: List[GPSPoint], corridor_width_m: float, zoom: int) -> bool: + pass + + @abstractmethod + def progressive_fetch(self, center_lat: float, center_lon: float, grid_sizes: List[int], zoom: int) -> Iterator[Dict[str, np.ndarray]]: + pass + + @abstractmethod + def cache_tile(self, tile_coords: TileCoords, tile_data: np.ndarray) -> bool: + pass + + @abstractmethod + def get_cached_tile(self, tile_coords: TileCoords) -> Optional[np.ndarray]: + pass + + @abstractmethod + def get_tile_grid(self, center: TileCoords, grid_size: int) -> List[TileCoords]: + pass + + @abstractmethod + def compute_tile_coords(self, lat: float, lon: float, zoom: int) -> TileCoords: + pass + + @abstractmethod + def expand_search_grid(self, center: TileCoords, current_size: int, new_size: int) -> List[TileCoords]: + pass + + @abstractmethod + def compute_tile_bounds(self, tile_coords: TileCoords) -> TileBounds: + pass + + @abstractmethod + def clear_flight_cache(self, flight_id: str) -> bool: + pass +``` + +## Component Description + +### Responsibilities +- Fetch satellite tiles from external provider API +- Manage local tile cache per flight +- Calculate tile coordinates and grid layouts +- Support progressive tile grid expansion (1→4→9→16→25) +- Handle Web Mercator projection calculations +- Coordinate corridor prefetching for flight routes + +### Scope +- **HTTP client** for Satellite Provider API +- **Local caching** with disk storage +- **Grid calculations** for search patterns +- **Tile coordinate transformations** (GPS↔Tile coordinates) +- **Progressive retrieval** for "kidnapped robot" recovery + +## API Methods + +### `fetch_tile(lat: float, lon: float, zoom: int) -> Optional[np.ndarray]` + +**Description**: Fetches a single satellite tile by GPS coordinates. + +**Called By**: +- G09 Metric Refinement (single tile for drift correction) +- Internal (during prefetching) + +**Input**: +```python +lat: float # Latitude +lon: float # Longitude +zoom: int # Zoom level (19 for 0.3m/pixel at Ukraine latitude) +``` + +**Output**: +```python +np.ndarray: Tile image (H×W×3 RGB) or None if failed +``` + +**HTTP Request**: +``` +GET /api/satellite/tiles/latlon?lat={lat}&lon={lon}&zoom={zoom} +``` + +**Processing Flow**: +1. Convert GPS to tile coordinates +2. Check cache +3. If not cached, fetch from satellite provider +4. Cache tile +5. Return tile image + +**Error Conditions**: +- Returns `None`: Tile unavailable, HTTP error +- Logs errors for monitoring + +**Test Cases**: +1. **Cache hit**: Tile in cache → returns immediately +2. **Cache miss**: Fetches from API → caches → returns +3. **API error**: Returns None +4. **Invalid coordinates**: Returns None + +--- + +### `fetch_tile_grid(center_lat: float, center_lon: float, grid_size: int, zoom: int) -> Dict[str, np.ndarray]` + +**Description**: Fetches NxN grid of tiles centered on GPS coordinates. + +**Called By**: +- G09 Metric Refinement (for progressive search) +- G11 Failure Recovery Coordinator + +**Input**: +```python +center_lat: float +center_lon: float +grid_size: int # 1, 4 (2×2), 9 (3×3), 16 (4×4), or 25 (5×5) +zoom: int +``` + +**Output**: +```python +Dict[str, np.ndarray] # tile_id -> tile_image +``` + +**Processing Flow**: +1. Compute tile grid centered on coordinates +2. For each tile in grid: + - Check cache + - If not cached, fetch from API +3. Return dict of tiles + +**HTTP Request** (if using batch endpoint): +``` +GET /api/satellite/tiles/batch?tiles=[...] +``` + +**Error Conditions**: +- Returns partial dict if some tiles fail +- Empty dict if all tiles fail + +**Test Cases**: +1. **2×2 grid**: Returns 4 tiles +2. **3×3 grid**: Returns 9 tiles +3. **5×5 grid**: Returns 25 tiles +4. **Partial failure**: Some tiles unavailable → returns available tiles +5. **All cached**: Fast retrieval without HTTP requests + +--- + +### `prefetch_route_corridor(waypoints: List[GPSPoint], corridor_width_m: float, zoom: int) -> bool` + +**Description**: Prefetches satellite tiles along route corridor for a flight. + +**Called By**: +- G02 Flight Manager (during flight creation) + +**Input**: +```python +waypoints: List[GPSPoint] # Rough route waypoints +corridor_width_m: float # Corridor width in meters (e.g., 1000m) +zoom: int +``` + +**Output**: +```python +bool: True if prefetch completed, False on error +``` + +**Processing Flow**: +1. For each waypoint pair: + - Calculate corridor polygon + - Determine tiles covering corridor +2. Fetch tiles (async, parallel) +3. Cache all tiles with flight_id reference + +**Algorithm**: +- Use H06 Web Mercator Utils for tile calculations +- Parallel fetching (10-20 concurrent requests) +- Progress tracking for monitoring + +**Error Conditions**: +- Returns `False`: Major error preventing prefetch +- Logs warnings for individual tile failures + +**Test Cases**: +1. **Simple route**: 10 waypoints → prefetches 50-100 tiles +2. **Long route**: 50 waypoints → prefetches 200-500 tiles +3. **Partial failure**: Some tiles fail → continues, returns True +4. **Complete failure**: All tiles fail → returns False + +--- + +### `progressive_fetch(center_lat: float, center_lon: float, grid_sizes: List[int], zoom: int) -> Iterator[Dict[str, np.ndarray]]` + +**Description**: Progressively fetches expanding tile grids for "kidnapped robot" recovery. + +**Called By**: +- G11 Failure Recovery Coordinator (progressive search) + +**Input**: +```python +center_lat: float +center_lon: float +grid_sizes: List[int] # e.g., [1, 4, 9, 16, 25] +zoom: int +``` + +**Output**: +```python +Iterator yielding Dict[str, np.ndarray] for each grid size +``` + +**Processing Flow**: +1. For each grid_size in sequence: + - Fetch tile grid + - Yield tiles + - If match found by caller, iterator can be stopped + +**Usage Pattern**: +```python +for tiles in progressive_fetch(lat, lon, [1, 4, 9, 16, 25], 19): + if litesam_match_found(tiles): + break # Stop expanding search +``` + +**Test Cases**: +1. **Progressive search**: Yields 1, then 4, then 9 tiles +2. **Early termination**: Match on 4 tiles → doesn't fetch 9, 16, 25 +3. **Full search**: No match → fetches all grid sizes + +--- + +### `cache_tile(tile_coords: TileCoords, tile_data: np.ndarray) -> bool` + +**Description**: Caches a satellite tile to disk. + +**Called By**: +- Internal (after fetching tiles) + +**Input**: +```python +tile_coords: TileCoords: + x: int + y: int + zoom: int +tile_data: np.ndarray +``` + +**Output**: +```python +bool: True if cached successfully +``` + +**Processing Flow**: +1. Generate cache key from tile_coords +2. Serialize tile_data (PNG format) +3. Write to disk cache directory +4. Update cache index + +**Error Conditions**: +- Returns `False`: Disk write error, space full + +**Test Cases**: +1. **Cache new tile**: Writes successfully +2. **Overwrite existing**: Updates tile +3. **Disk full**: Returns False + +--- + +### `get_cached_tile(tile_coords: TileCoords) -> Optional[np.ndarray]` + +**Description**: Retrieves a cached tile from disk. + +**Called By**: +- Internal (before fetching from API) +- G09 Metric Refinement (direct cache lookup) + +**Input**: +```python +tile_coords: TileCoords +``` + +**Output**: +```python +Optional[np.ndarray]: Tile image or None if not cached +``` + +**Processing Flow**: +1. Generate cache key +2. Check if file exists +3. Load and deserialize +4. Return tile_data + +**Error Conditions**: +- Returns `None`: Not cached, corrupted file + +**Test Cases**: +1. **Cache hit**: Returns tile quickly +2. **Cache miss**: Returns None +3. **Corrupted cache**: Returns None, logs warning + +--- + +### `get_tile_grid(center: TileCoords, grid_size: int) -> List[TileCoords]` + +**Description**: Calculates tile coordinates for NxN grid centered on a tile. + +**Called By**: +- Internal (for grid fetching) +- G11 Failure Recovery Coordinator + +**Input**: +```python +center: TileCoords +grid_size: int # 1, 4, 9, 16, 25 +``` + +**Output**: +```python +List[TileCoords] # List of tile coordinates in grid +``` + +**Algorithm**: +- For grid_size=9 (3×3): tiles from center-1 to center+1 in both x and y +- For grid_size=16 (4×4): asymmetric grid with center slightly off-center + +**Test Cases**: +1. **1-tile grid**: Returns [center] +2. **4-tile grid (2×2)**: Returns 4 tiles +3. **9-tile grid (3×3)**: Returns 9 tiles centered +4. **25-tile grid (5×5)**: Returns 25 tiles + +--- + +### `compute_tile_coords(lat: float, lon: float, zoom: int) -> TileCoords` + +**Description**: Converts GPS coordinates to tile coordinates. + +**Called By**: +- All methods that need tile coordinates from GPS + +**Input**: +```python +lat: float +lon: float +zoom: int +``` + +**Output**: +```python +TileCoords: + x: int + y: int + zoom: int +``` + +**Algorithm** (Web Mercator): +```python +n = 2 ** zoom +x = int((lon + 180) / 360 * n) +lat_rad = lat * π / 180 +y = int((1 - log(tan(lat_rad) + sec(lat_rad)) / π) / 2 * n) +``` + +**Test Cases**: +1. **Ukraine coordinates**: Produces valid tile coords +2. **Edge cases**: lat=0, lon=0, lat=90, lon=180 + +--- + +### `expand_search_grid(center: TileCoords, current_size: int, new_size: int) -> List[TileCoords]` + +**Description**: Returns only NEW tiles when expanding from current grid to larger grid. + +**Called By**: +- G11 Failure Recovery Coordinator (progressive search optimization) + +**Input**: +```python +center: TileCoords +current_size: int # e.g., 4 +new_size: int # e.g., 9 +``` + +**Output**: +```python +List[TileCoords] # Only tiles not in current_size grid +``` + +**Purpose**: Avoid re-fetching tiles already tried in smaller grid. + +**Test Cases**: +1. **4→9 expansion**: Returns 5 new tiles (9-4) +2. **9→16 expansion**: Returns 7 new tiles +3. **1→4 expansion**: Returns 3 new tiles + +--- + +### `compute_tile_bounds(tile_coords: TileCoords) -> TileBounds` + +**Description**: Computes GPS bounding box of a tile. + +**Called By**: +- G09 Metric Refinement (for homography calculations) +- H06 Web Mercator Utils (shared calculation) + +**Input**: +```python +tile_coords: TileCoords +``` + +**Output**: +```python +TileBounds: + nw: GPSPoint # North-West corner + ne: GPSPoint # North-East corner + sw: GPSPoint # South-West corner + se: GPSPoint # South-East corner + center: GPSPoint + gsd: float # Ground Sampling Distance (meters/pixel) +``` + +**Algorithm**: +- Inverse Web Mercator projection +- GSD calculation: `156543.03392 * cos(lat * π/180) / 2^zoom` + +**Test Cases**: +1. **Zoom 19 at Ukraine**: GSD ≈ 0.3 m/pixel +2. **Tile bounds**: Valid GPS coordinates + +--- + +### `clear_flight_cache(flight_id: str) -> bool` + +**Description**: Clears cached tiles for a completed flight. + +**Called By**: +- G02 Flight Manager (cleanup after flight completion) + +**Input**: +```python +flight_id: str +``` + +**Output**: +```python +bool: True if cleared successfully +``` + +**Processing Flow**: +1. Find all tiles associated with flight_id +2. Delete tile files +3. Update cache index + +**Test Cases**: +1. **Clear flight cache**: Removes all associated tiles +2. **Non-existent flight**: Returns True (no-op) + +## Integration Tests + +### Test 1: Prefetch and Retrieval +1. prefetch_route_corridor() with 20 waypoints +2. Verify tiles cached +3. get_cached_tile() for each tile → all hit cache +4. clear_flight_cache() → cache cleared + +### Test 2: Progressive Search Simulation +1. progressive_fetch() with [1, 4, 9, 16, 25] +2. Simulate match on 9 tiles +3. Verify only 1, 4, 9 fetched (not 16, 25) + +### Test 3: Grid Expansion +1. fetch_tile_grid(4) → 4 tiles +2. expand_search_grid(4, 9) → 5 new tiles +3. Verify no duplicate fetches + +## Non-Functional Requirements + +### Performance +- **fetch_tile**: < 200ms (cached: < 10ms) +- **fetch_tile_grid(9)**: < 1 second +- **prefetch_route_corridor**: < 30 seconds for 500 tiles +- **Cache lookup**: < 5ms + +### Scalability +- Cache 10,000+ tiles per flight +- Support 100 concurrent tile fetches +- Handle 10GB+ cache size + +### Reliability +- Retry failed HTTP requests (3 attempts) +- Graceful degradation on partial failures +- Cache corruption recovery + +## Dependencies + +### Internal Components +- **H06 Web Mercator Utils**: Tile coordinate calculations + +### External Dependencies +- **Satellite Provider API**: HTTP tile source +- **requests** or **httpx**: HTTP client +- **numpy**: Image handling +- **opencv-python**: Image I/O +- **diskcache**: Persistent cache + +## Data Models + +### TileCoords +```python +class TileCoords(BaseModel): + x: int + y: int + zoom: int +``` + +### TileBounds +```python +class TileBounds(BaseModel): + nw: GPSPoint + ne: GPSPoint + sw: GPSPoint + se: GPSPoint + center: GPSPoint + gsd: float # meters/pixel +``` + +### CacheConfig +```python +class CacheConfig(BaseModel): + cache_dir: str = "./satellite_cache" + max_size_gb: int = 50 + eviction_policy: str = "lru" + ttl_days: int = 30 +``` + diff --git a/docs/02_components/gps_denied_05_image_input_pipeline/image_input_pipeline_spec.md b/docs/02_components/gps_denied_05_image_input_pipeline/image_input_pipeline_spec.md new file mode 100644 index 0000000..49ba16f --- /dev/null +++ b/docs/02_components/gps_denied_05_image_input_pipeline/image_input_pipeline_spec.md @@ -0,0 +1,450 @@ +# Image Input Pipeline + +## Interface Definition + +**Interface Name**: `IImageInputPipeline` + +### Interface Methods + +```python +class IImageInputPipeline(ABC): + @abstractmethod + def queue_batch(self, flight_id: str, batch: ImageBatch) -> bool: + pass + + @abstractmethod + def process_next_batch(self, flight_id: str) -> Optional[ProcessedBatch]: + pass + + @abstractmethod + def validate_batch(self, batch: ImageBatch) -> ValidationResult: + pass + + @abstractmethod + def store_images(self, flight_id: str, images: List[ImageData]) -> bool: + pass + + @abstractmethod + def get_next_image(self, flight_id: str) -> Optional[ImageData]: + pass + + @abstractmethod + def get_image_by_sequence(self, flight_id: str, sequence: int) -> Optional[ImageData]: + pass + + @abstractmethod + def get_image_metadata(self, flight_id: str, sequence: int) -> Optional[ImageMetadata]: + pass + + @abstractmethod + def get_processing_status(self, flight_id: str) -> ProcessingStatus: + pass +``` + +## Component Description + +### Responsibilities +- Unified image ingestion, validation, storage, and retrieval +- FIFO batch queuing for processing +- Validate consecutive naming (AD000001, AD000002, etc.) +- Validate sequence integrity (strict sequential ordering) +- Image persistence with indexed retrieval +- Metadata extraction (EXIF, dimensions) + +### Scope +- Batch queue management +- Image validation +- Disk storage management +- Sequential processing coordination +- Metadata management + +## API Methods + +### `queue_batch(flight_id: str, batch: ImageBatch) -> bool` + +**Description**: Queues a batch of images for processing (FIFO). + +**Called By**: +- G01 GPS-Denied REST API (after upload) + +**Input**: +```python +flight_id: str +batch: ImageBatch: + images: List[bytes] # Raw image data + filenames: List[str] # e.g., ["AD000101.jpg", "AD000102.jpg", ...] + start_sequence: int # 101 + end_sequence: int # 150 +``` + +**Output**: +```python +bool: True if queued successfully +``` + +**Processing Flow**: +1. Validate batch using H08 Batch Validator +2. Check sequence continuity (no gaps) +3. Add to FIFO queue for flight_id +4. Return immediately (async processing) + +**Error Conditions**: +- `ValidationError`: Sequence gap, invalid naming +- `QueueFullError`: Queue capacity exceeded + +**Test Cases**: +1. **Valid batch**: Queued successfully +2. **Sequence gap**: Batch 101-150, expecting 51-100 → error +3. **Invalid naming**: Non-consecutive names → error +4. **Queue full**: Returns error with backpressure signal + +--- + +### `process_next_batch(flight_id: str) -> Optional[ProcessedBatch]` + +**Description**: Dequeues and processes the next batch from FIFO queue. + +**Called By**: +- Internal processing loop (background worker) + +**Input**: +```python +flight_id: str +``` + +**Output**: +```python +ProcessedBatch: + images: List[ImageData] + batch_id: str + start_sequence: int + end_sequence: int +``` + +**Processing Flow**: +1. Dequeue next batch +2. Decompress/decode images +3. Extract metadata (EXIF, dimensions) +4. Store images to disk +5. Return ProcessedBatch for pipeline + +**Error Conditions**: +- Returns `None`: Queue empty +- `ImageCorruptionError`: Invalid image data + +**Test Cases**: +1. **Process batch**: Dequeues, returns ImageData list +2. **Empty queue**: Returns None +3. **Corrupted image**: Logs error, skips image + +--- + +### `validate_batch(batch: ImageBatch) -> ValidationResult` + +**Description**: Validates batch integrity and sequence continuity. + +**Called By**: +- Internal (before queuing) +- H08 Batch Validator (delegated validation) + +**Input**: +```python +batch: ImageBatch +``` + +**Output**: +```python +ValidationResult: + valid: bool + errors: List[str] +``` + +**Validation Rules**: +1. **Batch size**: 10 <= len(images) <= 50 +2. **Naming convention**: ADxxxxxx.jpg (6 digits) +3. **Sequence continuity**: Consecutive numbers +4. **File format**: JPEG or PNG +5. **Image dimensions**: 640x480 to 6252x4168 +6. **File size**: < 10MB per image + +**Test Cases**: +1. **Valid batch**: Returns valid=True +2. **Too few images**: 5 images → invalid +3. **Too many images**: 60 images → invalid +4. **Non-consecutive**: AD000101, AD000103 → invalid +5. **Invalid naming**: IMG_0001.jpg → invalid + +--- + +### `store_images(flight_id: str, images: List[ImageData]) -> bool` + +**Description**: Persists images to disk with indexed storage. + +**Called By**: +- Internal (after processing batch) + +**Input**: +```python +flight_id: str +images: List[ImageData] +``` + +**Output**: +```python +bool: True if stored successfully +``` + +**Storage Structure**: +``` +/image_storage/ + {flight_id}/ + AD000001.jpg + AD000002.jpg + metadata.json +``` + +**Processing Flow**: +1. Create flight directory if not exists +2. Write each image to disk +3. Update metadata index +4. Persist to G17 Database Layer (metadata only) + +**Error Conditions**: +- `StorageError`: Disk full, permission error + +**Test Cases**: +1. **Store batch**: All images written successfully +2. **Disk full**: Returns False +3. **Verify storage**: Images retrievable after storage + +--- + +### `get_next_image(flight_id: str) -> Optional[ImageData]` + +**Description**: Gets the next image in sequence for processing. + +**Called By**: +- G06 Image Rotation Manager +- G07 Sequential VO +- Processing pipeline (main loop) + +**Input**: +```python +flight_id: str +``` + +**Output**: +```python +ImageData: + flight_id: str + sequence: int + filename: str + image: np.ndarray # Loaded image + metadata: ImageMetadata +``` + +**Processing Flow**: +1. Track current sequence number for flight +2. Load next image from disk +3. Increment sequence counter +4. Return ImageData + +**Error Conditions**: +- Returns `None`: No more images +- `ImageNotFoundError`: Expected image missing + +**Test Cases**: +1. **Get sequential images**: Returns images in order +2. **End of sequence**: Returns None +3. **Missing image**: Handles gracefully + +--- + +### `get_image_by_sequence(flight_id: str, sequence: int) -> Optional[ImageData]` + +**Description**: Retrieves a specific image by sequence number. + +**Called By**: +- G11 Failure Recovery Coordinator (for user fix) +- G13 Result Manager (for refinement) + +**Input**: +```python +flight_id: str +sequence: int +``` + +**Output**: +```python +Optional[ImageData] +``` + +**Processing Flow**: +1. Construct filename from sequence (ADxxxxxx.jpg) +2. Load from disk +3. Load metadata +4. Return ImageData + +**Error Conditions**: +- Returns `None`: Image not found + +**Test Cases**: +1. **Get specific image**: Returns correct image +2. **Invalid sequence**: Returns None + +--- + +### `get_image_metadata(flight_id: str, sequence: int) -> Optional[ImageMetadata]` + +**Description**: Retrieves metadata without loading full image (lightweight). + +**Called By**: +- G02 Flight Manager (status checks) +- G13 Result Manager (metadata-only queries) + +**Input**: +```python +flight_id: str +sequence: int +``` + +**Output**: +```python +ImageMetadata: + sequence: int + filename: str + dimensions: Tuple[int, int] # (width, height) + file_size: int # bytes + timestamp: datetime + exif_data: Optional[Dict] +``` + +**Test Cases**: +1. **Get metadata**: Returns quickly without loading image +2. **Missing image**: Returns None + +--- + +### `get_processing_status(flight_id: str) -> ProcessingStatus` + +**Description**: Gets current processing status for a flight. + +**Called By**: +- G01 GPS-Denied REST API (status endpoint) +- G02 Flight Manager + +**Input**: +```python +flight_id: str +``` + +**Output**: +```python +ProcessingStatus: + flight_id: str + total_images: int + processed_images: int + current_sequence: int + queued_batches: int + processing_rate: float # images/second +``` + +**Test Cases**: +1. **Get status**: Returns accurate counts +2. **During processing**: Updates in real-time + +## Integration Tests + +### Test 1: Batch Processing Flow +1. queue_batch() with 50 images +2. process_next_batch() → returns batch +3. store_images() → persists to disk +4. get_next_image() × 50 → retrieves all sequentially +5. Verify metadata + +### Test 2: Multiple Batches +1. queue_batch() × 5 (250 images total) +2. process_next_batch() × 5 +3. Verify FIFO order maintained +4. Verify sequence continuity + +### Test 3: Error Handling +1. Queue batch with sequence gap +2. Verify validation error +3. Queue valid batch → succeeds +4. Simulate disk full → storage fails gracefully + +## Non-Functional Requirements + +### Performance +- **queue_batch**: < 100ms +- **process_next_batch**: < 2 seconds for 50 images +- **get_next_image**: < 50ms +- **get_image_by_sequence**: < 50ms +- **Processing throughput**: 10-20 images/second + +### Scalability +- Support 3000 images per flight +- Handle 10 concurrent flights +- Manage 100GB+ image storage + +### Reliability +- Crash recovery (resume processing from last sequence) +- Atomic batch operations +- Data integrity validation + +## Dependencies + +### Internal Components +- **H08 Batch Validator**: For validation logic +- **G17 Database Layer**: For metadata persistence + +### External Dependencies +- **opencv-python**: Image I/O +- **Pillow**: Image processing +- **numpy**: Image arrays + +## Data Models + +### ImageBatch +```python +class ImageBatch(BaseModel): + images: List[bytes] + filenames: List[str] + start_sequence: int + end_sequence: int + batch_number: int +``` + +### ImageData +```python +class ImageData(BaseModel): + flight_id: str + sequence: int + filename: str + image: np.ndarray + metadata: ImageMetadata +``` + +### ImageMetadata +```python +class ImageMetadata(BaseModel): + sequence: int + filename: str + dimensions: Tuple[int, int] + file_size: int + timestamp: datetime + exif_data: Optional[Dict] +``` + +### ProcessingStatus +```python +class ProcessingStatus(BaseModel): + flight_id: str + total_images: int + processed_images: int + current_sequence: int + queued_batches: int + processing_rate: float +``` + diff --git a/docs/02_components/gps_denied_06_image_rotation_manager/image_rotation_manager_spec.md b/docs/02_components/gps_denied_06_image_rotation_manager/image_rotation_manager_spec.md new file mode 100644 index 0000000..30cabdb --- /dev/null +++ b/docs/02_components/gps_denied_06_image_rotation_manager/image_rotation_manager_spec.md @@ -0,0 +1,410 @@ +# Image Rotation Manager + +## Interface Definition + +**Interface Name**: `IImageRotationManager` + +### Interface Methods + +```python +class IImageRotationManager(ABC): + @abstractmethod + def rotate_image_360(self, image: np.ndarray, angle: float) -> np.ndarray: + pass + + @abstractmethod + def try_rotation_steps(self, flight_id: str, image: np.ndarray, satellite_tile: np.ndarray) -> Optional[RotationResult]: + pass + + @abstractmethod + def calculate_precise_angle(self, homography: np.ndarray, initial_angle: float) -> float: + pass + + @abstractmethod + def get_current_heading(self, flight_id: str) -> Optional[float]: + pass + + @abstractmethod + def update_heading(self, flight_id: str, heading: float) -> bool: + pass + + @abstractmethod + def detect_sharp_turn(self, flight_id: str, new_heading: float) -> bool: + pass + + @abstractmethod + def requires_rotation_sweep(self, flight_id: str) -> bool: + pass +``` + +## Component Description + +### Responsibilities +- Handle UAV image rotation preprocessing for LiteSAM +- **Critical**: LiteSAM fails if images rotated >45°, requires preprocessing +- Perform 30° step rotation sweeps (12 rotations: 0°, 30°, 60°, ..., 330°) +- Track UAV heading angle across flight +- Calculate precise rotation angle from homography point correspondences +- Detect sharp turns requiring rotation sweep +- Pre-rotate images to known heading for subsequent frames + +### Scope +- Image rotation operations +- UAV heading tracking and history +- Sharp turn detection +- Rotation sweep coordination with LiteSAM matching +- Precise angle calculation from homography + +## API Methods + +### `rotate_image_360(image: np.ndarray, angle: float) -> np.ndarray` + +**Description**: Rotates an image by specified angle around center. + +**Called By**: +- Internal (during rotation sweep) +- H07 Image Rotation Utils (may delegate to) + +**Input**: +```python +image: np.ndarray # Input image (H×W×3) +angle: float # Rotation angle in degrees (0-360) +``` + +**Output**: +```python +np.ndarray # Rotated image (same dimensions) +``` + +**Processing Details**: +- Rotation around image center +- Preserves image dimensions +- Fills borders with black or extrapolation + +**Error Conditions**: +- None (always returns rotated image) + +**Test Cases**: +1. **Rotate 90°**: Image rotated correctly +2. **Rotate 0°**: Image unchanged +3. **Rotate 180°**: Image inverted +4. **Rotate 45°**: Diagonal rotation + +--- + +### `try_rotation_steps(flight_id: str, image: np.ndarray, satellite_tile: np.ndarray) -> Optional[RotationResult]` + +**Description**: Performs 30° rotation sweep, trying LiteSAM match for each rotation. + +**Called By**: +- Internal (when requires_rotation_sweep() returns True) +- Main processing loop (first frame or sharp turn) + +**Input**: +```python +flight_id: str +image: np.ndarray # UAV image +satellite_tile: np.ndarray # Satellite reference tile +``` + +**Output**: +```python +RotationResult: + matched: bool + initial_angle: float # Best matching step angle (0, 30, 60, ...) + precise_angle: float # Refined angle from homography + confidence: float + homography: np.ndarray +``` + +**Algorithm**: +``` +For angle in [0°, 30°, 60°, 90°, 120°, 150°, 180°, 210°, 240°, 270°, 300°, 330°]: + rotated_image = rotate_image_360(image, angle) + result = LiteSAM.align_to_satellite(rotated_image, satellite_tile) + if result.matched and result.confidence > threshold: + precise_angle = calculate_precise_angle(result.homography, angle) + update_heading(flight_id, precise_angle) + return RotationResult(matched=True, initial_angle=angle, precise_angle=precise_angle, ...) +return None # No match found +``` + +**Processing Flow**: +1. For each 30° step: + - Rotate image + - Call G09 Metric Refinement (LiteSAM) + - Check if match found +2. If match found: + - Calculate precise angle from homography + - Update UAV heading + - Return result +3. If no match: + - Return None (triggers progressive search expansion) + +**Error Conditions**: +- Returns `None`: No match found in any rotation +- This is expected behavior (leads to progressive search) + +**Test Cases**: +1. **Match at 60°**: Finds match, returns result +2. **Match at 0°**: No rotation needed, finds match +3. **No match**: All 12 rotations tried, returns None +4. **Multiple matches**: Returns best confidence + +--- + +### `calculate_precise_angle(homography: np.ndarray, initial_angle: float) -> float` + +**Description**: Calculates precise rotation angle from homography matrix point shifts. + +**Called By**: +- Internal (after LiteSAM match in rotation sweep) + +**Input**: +```python +homography: np.ndarray # 3×3 homography matrix from LiteSAM +initial_angle: float # 30° step angle that matched +``` + +**Output**: +```python +float: Precise rotation angle (e.g., 62.3° refined from 60° step) +``` + +**Algorithm**: +1. Extract rotation component from homography +2. Calculate angle from rotation matrix +3. Refine initial_angle with delta from homography + +**Uses**: H07 Image Rotation Utils for angle calculation + +**Error Conditions**: +- Falls back to initial_angle if calculation fails + +**Test Cases**: +1. **Refine 60°**: Returns 62.5° (small delta) +2. **Refine 0°**: Returns 3.2° (small rotation) +3. **Invalid homography**: Returns initial_angle + +--- + +### `get_current_heading(flight_id: str) -> Optional[float]` + +**Description**: Gets current UAV heading angle for a flight. + +**Called By**: +- G06 Internal (to check if pre-rotation needed) +- Main processing loop (before LiteSAM) +- G11 Failure Recovery Coordinator (logging) + +**Input**: +```python +flight_id: str +``` + +**Output**: +```python +Optional[float]: Heading angle in degrees (0-360), or None if not initialized +``` + +**Error Conditions**: +- Returns `None`: First frame, heading not yet determined + +**Test Cases**: +1. **After first frame**: Returns heading angle +2. **Before first frame**: Returns None +3. **During flight**: Returns current heading + +--- + +### `update_heading(flight_id: str, heading: float) -> bool` + +**Description**: Updates UAV heading angle after successful match. + +**Called By**: +- Internal (after rotation sweep match) +- Internal (after normal LiteSAM match with small rotation delta) + +**Input**: +```python +flight_id: str +heading: float # New heading angle (0-360) +``` + +**Output**: +```python +bool: True if updated successfully +``` + +**Processing Flow**: +1. Normalize angle to 0-360 range +2. Add to heading history (last 10 headings) +3. Update current_heading for flight +4. Persist to database (optional) + +**Test Cases**: +1. **Update heading**: Sets new heading +2. **Angle normalization**: 370° → 10° +3. **History tracking**: Maintains last 10 headings + +--- + +### `detect_sharp_turn(flight_id: str, new_heading: float) -> bool` + +**Description**: Detects if UAV made a sharp turn (>45° heading change). + +**Called By**: +- Internal (before deciding if rotation sweep needed) +- Main processing loop + +**Input**: +```python +flight_id: str +new_heading: float # Proposed new heading +``` + +**Output**: +```python +bool: True if sharp turn detected (>45° change) +``` + +**Algorithm**: +```python +current = get_current_heading(flight_id) +if current is None: + return False +delta = abs(new_heading - current) +if delta > 180: # Handle wraparound + delta = 360 - delta +return delta > 45 +``` + +**Test Cases**: +1. **Small turn**: 60° → 75° → False (15° delta) +2. **Sharp turn**: 60° → 120° → True (60° delta) +3. **Wraparound**: 350° → 20° → False (30° delta) +4. **180° turn**: 0° → 180° → True + +--- + +### `requires_rotation_sweep(flight_id: str) -> bool` + +**Description**: Determines if rotation sweep is needed for current frame. + +**Called By**: +- Main processing loop (before each frame) +- G11 Failure Recovery Coordinator (after tracking loss) + +**Input**: +```python +flight_id: str +``` + +**Output**: +```python +bool: True if rotation sweep required +``` + +**Conditions for sweep**: +1. **First frame**: heading not initialized +2. **Sharp turn detected**: >45° heading change from VO +3. **Tracking loss**: LiteSAM failed to match in previous frame +4. **User flag**: Manual trigger (rare) + +**Test Cases**: +1. **First frame**: Returns True +2. **Second frame, no turn**: Returns False +3. **Sharp turn detected**: Returns True +4. **Tracking loss**: Returns True + +## Integration Tests + +### Test 1: First Frame Rotation Sweep +1. First frame arrives (no heading set) +2. requires_rotation_sweep() → True +3. try_rotation_steps() → rotates 12 times +4. Match found at 60° step +5. calculate_precise_angle() → 62.3° +6. update_heading(62.3°) +7. Subsequent frames use 62.3° heading + +### Test 2: Normal Frame Processing +1. Heading known (90°) +2. requires_rotation_sweep() → False +3. Pre-rotate image to 90° +4. LiteSAM match succeeds with small delta (+2.5°) +5. update_heading(92.5°) + +### Test 3: Sharp Turn Detection +1. UAV heading 45° +2. Next frame shows 120° heading (from VO estimate) +3. detect_sharp_turn() → True (75° delta) +4. requires_rotation_sweep() → True +5. Perform rotation sweep → find match at 120° step + +### Test 4: Tracking Loss Recovery +1. LiteSAM fails to match (no overlap after turn) +2. requires_rotation_sweep() → True +3. try_rotation_steps() with all 12 rotations +4. Match found → heading updated + +## Non-Functional Requirements + +### Performance +- **rotate_image_360**: < 20ms per rotation +- **try_rotation_steps**: < 1.2 seconds (12 rotations × 100ms LiteSAM) +- **calculate_precise_angle**: < 10ms +- **get_current_heading**: < 1ms +- **update_heading**: < 5ms + +### Accuracy +- **Angle precision**: ±0.5° for precise angle calculation +- **Sharp turn detection**: 100% accuracy for >45° turns + +### Reliability +- Rotation sweep always completes all 12 steps +- Graceful handling of no-match scenarios +- Heading history preserved across failures + +## Dependencies + +### Internal Components +- **G09 Metric Refinement**: For LiteSAM matching during rotation sweep +- **H07 Image Rotation Utils**: For image rotation and angle calculations + +### External Dependencies +- **opencv-python**: Image rotation (`cv2.warpAffine`) +- **numpy**: Matrix operations + +## Data Models + +### RotationResult +```python +class RotationResult(BaseModel): + matched: bool + initial_angle: float # 30° step angle (0, 30, 60, ...) + precise_angle: float # Refined angle from homography + confidence: float + homography: np.ndarray + inlier_count: int +``` + +### HeadingHistory +```python +class HeadingHistory(BaseModel): + flight_id: str + current_heading: float + heading_history: List[float] # Last 10 headings + last_update: datetime + sharp_turns: int # Count of sharp turns detected +``` + +### RotationConfig +```python +class RotationConfig(BaseModel): + step_angle: float = 30.0 # Degrees + sharp_turn_threshold: float = 45.0 # Degrees + confidence_threshold: float = 0.7 # For accepting match + history_size: int = 10 # Number of headings to track +``` + diff --git a/docs/02_components/gps_denied_07_sequential_visual_odometry/sequential_visual_odometry_spec.md b/docs/02_components/gps_denied_07_sequential_visual_odometry/sequential_visual_odometry_spec.md new file mode 100644 index 0000000..71088d0 --- /dev/null +++ b/docs/02_components/gps_denied_07_sequential_visual_odometry/sequential_visual_odometry_spec.md @@ -0,0 +1,316 @@ +# Sequential Visual Odometry + +## Interface Definition + +**Interface Name**: `ISequentialVO` + +### Interface Methods + +```python +class ISequentialVO(ABC): + @abstractmethod + def compute_relative_pose(self, prev_image: np.ndarray, curr_image: np.ndarray) -> Optional[RelativePose]: + pass + + @abstractmethod + def extract_features(self, image: np.ndarray) -> Features: + pass + + @abstractmethod + def match_features(self, features1: Features, features2: Features) -> Matches: + pass + + @abstractmethod + def estimate_motion(self, matches: Matches, camera_params: CameraParameters) -> Optional[Motion]: + pass +``` + +## Component Description + +### Responsibilities +- SuperPoint feature extraction from UAV images +- LightGlue feature matching between consecutive frames +- Handle <5% overlap scenarios +- Estimate relative pose (translation + rotation) between frames +- Return relative pose factors for Factor Graph Optimizer +- Detect tracking loss (low inlier count) + +### Scope +- Frame-to-frame visual odometry +- Feature-based motion estimation +- Handles low overlap and challenging agricultural environments +- Provides relative measurements for trajectory optimization + +## API Methods + +### `compute_relative_pose(prev_image: np.ndarray, curr_image: np.ndarray) -> Optional[RelativePose]` + +**Description**: Computes relative camera pose between consecutive frames. + +**Called By**: +- Main processing loop (per-frame) + +**Input**: +```python +prev_image: np.ndarray # Previous frame (t-1) +curr_image: np.ndarray # Current frame (t) +``` + +**Output**: +```python +RelativePose: + translation: np.ndarray # (x, y, z) in meters + rotation: np.ndarray # 3×3 rotation matrix or quaternion + confidence: float # 0.0 to 1.0 + inlier_count: int + total_matches: int + tracking_good: bool +``` + +**Processing Flow**: +1. extract_features(prev_image) → features1 +2. extract_features(curr_image) → features2 +3. match_features(features1, features2) → matches +4. estimate_motion(matches, camera_params) → motion +5. Return RelativePose + +**Tracking Quality Indicators**: +- **Good tracking**: inlier_count > 50, inlier_ratio > 0.5 +- **Degraded tracking**: inlier_count 20-50 +- **Tracking loss**: inlier_count < 20 + +**Error Conditions**: +- Returns `None`: Tracking lost (insufficient matches) + +**Test Cases**: +1. **Good overlap (>50%)**: Returns reliable pose +2. **Low overlap (5-10%)**: Still succeeds with LightGlue +3. **<5% overlap**: May return None (tracking loss) +4. **Agricultural texture**: Handles repetitive patterns + +--- + +### `extract_features(image: np.ndarray) -> Features` + +**Description**: Extracts SuperPoint keypoints and descriptors from image. + +**Called By**: +- Internal (during compute_relative_pose) +- G08 Global Place Recognition (for descriptor caching) + +**Input**: +```python +image: np.ndarray # Input image (H×W×3 or H×W) +``` + +**Output**: +```python +Features: + keypoints: np.ndarray # (N, 2) - (x, y) coordinates + descriptors: np.ndarray # (N, 256) - 256-dim descriptors + scores: np.ndarray # (N,) - detection confidence scores +``` + +**Processing Details**: +- Uses G15 Model Manager to get SuperPoint model +- Converts to grayscale if needed +- Non-maximum suppression for keypoint selection +- Typically extracts 500-2000 keypoints per image + +**Performance**: +- Inference time: ~15ms with TensorRT on RTX 2060 + +**Error Conditions**: +- Never fails (returns empty features if image invalid) + +**Test Cases**: +1. **FullHD image**: Extracts ~1000 keypoints +2. **High-res image (6252×4168)**: Extracts ~2000 keypoints +3. **Low-texture image**: Extracts fewer keypoints + +--- + +### `match_features(features1: Features, features2: Features) -> Matches` + +**Description**: Matches features using LightGlue attention-based matcher. + +**Called By**: +- Internal (during compute_relative_pose) + +**Input**: +```python +features1: Features # Previous frame features +features2: Features # Current frame features +``` + +**Output**: +```python +Matches: + matches: np.ndarray # (M, 2) - indices [idx1, idx2] + scores: np.ndarray # (M,) - match confidence scores + keypoints1: np.ndarray # (M, 2) - matched keypoints from frame 1 + keypoints2: np.ndarray # (M, 2) - matched keypoints from frame 2 +``` + +**Processing Details**: +- Uses G15 Model Manager to get LightGlue model +- Transformer-based attention mechanism +- "Dustbin" mechanism for unmatched features +- Adaptive depth (exits early for easy matches) +- **Critical**: Handles <5% overlap better than RANSAC + +**Performance**: +- Inference time: ~35-100ms (adaptive depth) +- Faster for high-overlap, slower for low-overlap + +**Test Cases**: +1. **High overlap**: Fast matching (~35ms), 500+ matches +2. **Low overlap (<5%)**: Slower (~100ms), 20-50 matches +3. **No overlap**: Few or no matches (< 10) + +--- + +### `estimate_motion(matches: Matches, camera_params: CameraParameters) -> Optional[Motion]` + +**Description**: Estimates camera motion from matched keypoints using Essential Matrix. + +**Called By**: +- Internal (during compute_relative_pose) + +**Input**: +```python +matches: Matches +camera_params: CameraParameters: + focal_length: float + principal_point: Tuple[float, float] + resolution: Tuple[int, int] +``` + +**Output**: +```python +Motion: + translation: np.ndarray # (x, y, z) - unit vector (scale ambiguous) + rotation: np.ndarray # 3×3 rotation matrix + inliers: np.ndarray # Boolean mask of inlier matches + inlier_count: int +``` + +**Algorithm**: +1. Normalize keypoint coordinates using camera intrinsics +2. Estimate Essential Matrix using RANSAC +3. Decompose Essential Matrix → [R, t] +4. Return motion with inlier mask + +**Scale Ambiguity**: +- Monocular VO has inherent scale ambiguity +- Translation is unit vector (direction only) +- Scale resolved by: + - Altitude prior (from G10 Factor Graph) + - Absolute GPS measurements (from G09 LiteSAM) + +**Error Conditions**: +- Returns `None`: Insufficient inliers (< 8 points for Essential Matrix) + +**Test Cases**: +1. **Good matches**: Returns motion with high inlier count +2. **Low inliers**: May return None +3. **Degenerate motion**: Handles pure rotation + +## Integration Tests + +### Test 1: Normal Flight Sequence +1. Load consecutive frames with 50% overlap +2. compute_relative_pose() → returns valid pose +3. Verify translation direction reasonable +4. Verify inlier_count > 100 + +### Test 2: Low Overlap Scenario +1. Load frames with 5% overlap +2. compute_relative_pose() → still succeeds +3. Verify inlier_count > 20 +4. Verify LightGlue finds matches despite low overlap + +### Test 3: Tracking Loss +1. Load frames with 0% overlap (sharp turn) +2. compute_relative_pose() → returns None +3. Verify tracking_good = False +4. Trigger global place recognition + +### Test 4: Agricultural Texture +1. Load images of wheat fields (repetitive texture) +2. compute_relative_pose() → SuperPoint handles better than SIFT +3. Verify match quality + +## Non-Functional Requirements + +### Performance +- **compute_relative_pose**: < 200ms total + - SuperPoint extraction: ~15ms × 2 = 30ms + - LightGlue matching: ~50ms + - Motion estimation: ~10ms +- **Frame rate**: 5-10 FPS processing (meets <5s requirement) + +### Accuracy +- **Relative rotation**: ±2° error +- **Relative translation direction**: ±5° error +- **Inlier ratio**: >50% for good tracking + +### Reliability +- Handle 100m spacing between frames +- Survive temporary tracking degradation +- Recover from brief occlusions + +## Dependencies + +### Internal Components +- **G15 Model Manager**: For SuperPoint and LightGlue models +- **G16 Configuration Manager**: For camera parameters +- **H01 Camera Model**: For coordinate normalization +- **H05 Performance Monitor**: For timing measurements + +### External Dependencies +- **SuperPoint**: Feature extraction model +- **LightGlue**: Feature matching model +- **opencv-python**: Essential Matrix estimation +- **numpy**: Matrix operations + +## Data Models + +### Features +```python +class Features(BaseModel): + keypoints: np.ndarray # (N, 2) + descriptors: np.ndarray # (N, 256) + scores: np.ndarray # (N,) +``` + +### Matches +```python +class Matches(BaseModel): + matches: np.ndarray # (M, 2) - pairs of indices + scores: np.ndarray # (M,) - match confidence + keypoints1: np.ndarray # (M, 2) + keypoints2: np.ndarray # (M, 2) +``` + +### RelativePose +```python +class RelativePose(BaseModel): + translation: np.ndarray # (3,) - unit vector + rotation: np.ndarray # (3, 3) or (4,) quaternion + confidence: float + inlier_count: int + total_matches: int + tracking_good: bool + scale_ambiguous: bool = True +``` + +### Motion +```python +class Motion(BaseModel): + translation: np.ndarray # (3,) + rotation: np.ndarray # (3, 3) + inliers: np.ndarray # Boolean mask + inlier_count: int +``` + diff --git a/docs/02_components/gps_denied_08_global_place_recognition/global_place_recognition_spec.md b/docs/02_components/gps_denied_08_global_place_recognition/global_place_recognition_spec.md new file mode 100644 index 0000000..68b82ad --- /dev/null +++ b/docs/02_components/gps_denied_08_global_place_recognition/global_place_recognition_spec.md @@ -0,0 +1,310 @@ +# Global Place Recognition + +## Interface Definition + +**Interface Name**: `IGlobalPlaceRecognition` + +### Interface Methods + +```python +class IGlobalPlaceRecognition(ABC): + @abstractmethod + def retrieve_candidate_tiles(self, image: np.ndarray, top_k: int) -> List[TileCandidate]: + pass + + @abstractmethod + def compute_location_descriptor(self, image: np.ndarray) -> np.ndarray: + pass + + @abstractmethod + def query_database(self, descriptor: np.ndarray, top_k: int) -> List[DatabaseMatch]: + pass + + @abstractmethod + def rank_candidates(self, candidates: List[TileCandidate]) -> List[TileCandidate]: + pass + + @abstractmethod + def initialize_database(self, satellite_tiles: List[SatelliteTile]) -> bool: + pass +``` + +## Component Description + +### Responsibilities +- AnyLoc (DINOv2 + VLAD) for coarse localization after tracking loss +- "Kidnapped robot" recovery after sharp turns +- Compute image descriptors robust to season/appearance changes +- Query Faiss index of satellite tile descriptors +- Return top-k candidate tile regions for progressive refinement +- Initialize satellite descriptor database during system startup + +### Scope +- Global localization (not frame-to-frame) +- Appearance-based place recognition +- Handles domain gap (UAV vs satellite imagery) +- Semantic feature extraction (DINOv2) +- Efficient similarity search (Faiss) + +## API Methods + +### `retrieve_candidate_tiles(image: np.ndarray, top_k: int) -> List[TileCandidate]` + +**Description**: Retrieves top-k candidate satellite tiles for a UAV image. + +**Called By**: +- G11 Failure Recovery Coordinator (after tracking loss) + +**Input**: +```python +image: np.ndarray # UAV image +top_k: int # Number of candidates (typically 5) +``` + +**Output**: +```python +List[TileCandidate]: + tile_id: str + gps_center: GPSPoint + similarity_score: float + rank: int +``` + +**Processing Flow**: +1. compute_location_descriptor(image) → descriptor +2. query_database(descriptor, top_k) → database_matches +3. Retrieve tile metadata for matches +4. rank_candidates() → sorted by similarity +5. Return top-k candidates + +**Error Conditions**: +- Returns empty list: Database not initialized, query failed + +**Test Cases**: +1. **UAV image over Ukraine**: Returns relevant tiles +2. **Different season**: DINOv2 handles appearance change +3. **Top-1 accuracy**: Correct tile in top-5 > 85% + +--- + +### `compute_location_descriptor(image: np.ndarray) -> np.ndarray` + +**Description**: Computes global descriptor using DINOv2 + VLAD aggregation. + +**Called By**: +- Internal (during retrieve_candidate_tiles) +- System initialization (for satellite database) + +**Input**: +```python +image: np.ndarray # UAV or satellite image +``` + +**Output**: +```python +np.ndarray: Descriptor vector (4096-dim or 8192-dim) +``` + +**Algorithm (AnyLoc)**: +1. Extract DINOv2 features (dense feature map) +2. Apply VLAD (Vector of Locally Aggregated Descriptors) aggregation +3. L2-normalize descriptor +4. Return compact global descriptor + +**Processing Details**: +- Uses G15 Model Manager to get DINOv2 model +- Dense features: extracts from multiple spatial locations +- VLAD codebook: pre-trained cluster centers +- Semantic features: invariant to texture/color changes + +**Performance**: +- Inference time: ~150ms for DINOv2 + VLAD + +**Test Cases**: +1. **Same location, different season**: Similar descriptors +2. **Different locations**: Dissimilar descriptors +3. **UAV vs satellite**: Domain-invariant features + +--- + +### `query_database(descriptor: np.ndarray, top_k: int) -> List[DatabaseMatch]` + +**Description**: Queries Faiss index for most similar satellite tiles. + +**Called By**: +- Internal (during retrieve_candidate_tiles) + +**Input**: +```python +descriptor: np.ndarray # Query descriptor +top_k: int +``` + +**Output**: +```python +List[DatabaseMatch]: + index: int # Tile index in database + distance: float # L2 distance + similarity_score: float # Normalized score +``` + +**Processing Details**: +- Uses H04 Faiss Index Manager +- Index type: IVF (Inverted File) or HNSW for fast search +- Distance metric: L2 (Euclidean) +- Query time: ~10-50ms for 10,000+ tiles + +**Error Conditions**: +- Returns empty list: Query failed + +**Test Cases**: +1. **Query satellite database**: Returns top-5 matches +2. **Large database (10,000 tiles)**: Fast retrieval (<50ms) + +--- + +### `rank_candidates(candidates: List[TileCandidate]) -> List[TileCandidate]` + +**Description**: Re-ranks candidates based on additional heuristics. + +**Called By**: +- Internal (during retrieve_candidate_tiles) + +**Input**: +```python +candidates: List[TileCandidate] # Initial ranking by similarity +``` + +**Output**: +```python +List[TileCandidate] # Re-ranked list +``` + +**Re-ranking Factors**: +1. **Similarity score**: Primary factor +2. **Spatial proximity**: Prefer tiles near dead-reckoning estimate +3. **Previous trajectory**: Favor continuation of route +4. **Geofence constraints**: Within operational area + +**Test Cases**: +1. **Spatial re-ranking**: Closer tile promoted +2. **Similar scores**: Spatial proximity breaks tie + +--- + +### `initialize_database(satellite_tiles: List[SatelliteTile]) -> bool` + +**Description**: Initializes satellite descriptor database during system startup. + +**Called By**: +- G02 Flight Manager (during system initialization) + +**Input**: +```python +List[SatelliteTile]: + tile_id: str + image: np.ndarray + gps_center: GPSPoint + bounds: TileBounds +``` + +**Output**: +```python +bool: True if database initialized successfully +``` + +**Processing Flow**: +1. For each satellite tile: + - compute_location_descriptor(tile.image) → descriptor + - Store descriptor with tile metadata +2. Build Faiss index using H04 Faiss Index Manager +3. Persist index to disk for fast startup + +**Performance**: +- Initialization time: ~10-30 minutes for 10,000 tiles (one-time cost) +- Can be done offline and loaded at startup + +**Test Cases**: +1. **Initialize with 1000 tiles**: Completes successfully +2. **Load pre-built index**: Fast startup (<10s) + +## Integration Tests + +### Test 1: Place Recognition Flow +1. Load UAV image from sharp turn +2. retrieve_candidate_tiles(top_k=5) +3. Verify correct tile in top-5 +4. Pass candidates to G11 Failure Recovery + +### Test 2: Season Invariance +1. Satellite tiles from summer +2. UAV images from autumn +3. retrieve_candidate_tiles() → correct match despite appearance change + +### Test 3: Database Initialization +1. Prepare 500 satellite tiles +2. initialize_database(tiles) +3. Verify Faiss index built +4. Query with test image → returns matches + +## Non-Functional Requirements + +### Performance +- **retrieve_candidate_tiles**: < 200ms total + - Descriptor computation: ~150ms + - Database query: ~50ms +- **compute_location_descriptor**: ~150ms +- **query_database**: ~10-50ms + +### Accuracy +- **Recall@5**: > 85% (correct tile in top-5) +- **Recall@1**: > 60% (correct tile is top-1) + +### Scalability +- Support 10,000+ satellite tiles in database +- Fast query even with large database + +## Dependencies + +### Internal Components +- **G15 Model Manager**: For DINOv2 model +- **H04 Faiss Index Manager**: For similarity search +- **G04 Satellite Data Manager**: For tile metadata + +### External Dependencies +- **DINOv2**: Foundation vision model +- **Faiss**: Similarity search library +- **numpy**: Array operations + +## Data Models + +### TileCandidate +```python +class TileCandidate(BaseModel): + tile_id: str + gps_center: GPSPoint + bounds: TileBounds + similarity_score: float + rank: int + spatial_score: Optional[float] +``` + +### DatabaseMatch +```python +class DatabaseMatch(BaseModel): + index: int + tile_id: str + distance: float + similarity_score: float +``` + +### SatelliteTile +```python +class SatelliteTile(BaseModel): + tile_id: str + image: np.ndarray + gps_center: GPSPoint + bounds: TileBounds + descriptor: Optional[np.ndarray] +``` + diff --git a/docs/02_components/gps_denied_09_metric_refinement/metric_refinement_spec.md b/docs/02_components/gps_denied_09_metric_refinement/metric_refinement_spec.md new file mode 100644 index 0000000..5c4fc9f --- /dev/null +++ b/docs/02_components/gps_denied_09_metric_refinement/metric_refinement_spec.md @@ -0,0 +1,308 @@ +# Metric Refinement + +## Interface Definition + +**Interface Name**: `IMetricRefinement` + +### Interface Methods + +```python +class IMetricRefinement(ABC): + @abstractmethod + def align_to_satellite(self, uav_image: np.ndarray, satellite_tile: np.ndarray) -> Optional[AlignmentResult]: + pass + + @abstractmethod + def compute_homography(self, uav_image: np.ndarray, satellite_tile: np.ndarray) -> Optional[np.ndarray]: + pass + + @abstractmethod + def extract_gps_from_alignment(self, homography: np.ndarray, tile_bounds: TileBounds, image_center: Tuple[int, int]) -> GPSPoint: + pass + + @abstractmethod + def compute_match_confidence(self, alignment: AlignmentResult) -> float: + pass +``` + +## Component Description + +### Responsibilities +- LiteSAM for precise UAV-to-satellite cross-view matching +- **Requires pre-rotated images** from Image Rotation Manager +- Compute homography mapping UAV image to satellite tile +- Extract absolute GPS coordinates from alignment +- Process against single tile (drift correction) or tile grid (progressive search) +- Achieve <20m accuracy requirement + +### Scope +- Cross-view geo-localization (UAV↔satellite) +- Handles altitude variations (<1km) +- Multi-scale processing for different GSDs +- Domain gap (UAV downward vs satellite nadir view) +- **Critical**: Fails if rotation >45° (handled by G06) + +## API Methods + +### `align_to_satellite(uav_image: np.ndarray, satellite_tile: np.ndarray) -> Optional[AlignmentResult]` + +**Description**: Aligns UAV image to satellite tile, returning GPS location. + +**Called By**: +- G06 Image Rotation Manager (during rotation sweep) +- G11 Failure Recovery Coordinator (progressive search) +- Main processing loop (drift correction with single tile) + +**Input**: +```python +uav_image: np.ndarray # Pre-rotated UAV image +satellite_tile: np.ndarray # Reference satellite tile +``` + +**Output**: +```python +AlignmentResult: + matched: bool + homography: np.ndarray # 3×3 transformation matrix + gps_center: GPSPoint # UAV image center GPS + confidence: float + inlier_count: int + total_correspondences: int +``` + +**Processing Flow**: +1. Extract features from both images using LiteSAM encoder +2. Compute dense correspondence field +3. Estimate homography from correspondences +4. Validate match quality (inlier count, reprojection error) +5. If valid match: + - Extract GPS from homography + - Return AlignmentResult +6. If no match: + - Return None + +**Match Criteria**: +- **Good match**: inlier_count > 30, confidence > 0.7 +- **Weak match**: inlier_count 15-30, confidence 0.5-0.7 +- **No match**: inlier_count < 15 + +**Error Conditions**: +- Returns `None`: No match found, rotation >45° (should be pre-rotated) + +**Test Cases**: +1. **Good alignment**: Returns GPS within 20m of ground truth +2. **Altitude variation**: Handles GSD mismatch +3. **Rotation >45°**: Fails (by design, requires pre-rotation) +4. **Multi-scale**: Processes at multiple scales + +--- + +### `compute_homography(uav_image: np.ndarray, satellite_tile: np.ndarray) -> Optional[np.ndarray]` + +**Description**: Computes homography transformation from UAV to satellite. + +**Called By**: +- Internal (during align_to_satellite) + +**Input**: +```python +uav_image: np.ndarray +satellite_tile: np.ndarray +``` + +**Output**: +```python +Optional[np.ndarray]: 3×3 homography matrix or None +``` + +**Algorithm (LiteSAM)**: +1. Extract multi-scale features using TAIFormer +2. Compute correlation via Convolutional Token Mixer (CTM) +3. Generate dense correspondences +4. Estimate homography using RANSAC +5. Refine with non-linear optimization + +**Homography Properties**: +- Maps pixels from UAV image to satellite image +- Accounts for: scale, rotation, perspective +- 8 DoF (degrees of freedom) + +**Error Conditions**: +- Returns `None`: Insufficient correspondences + +**Test Cases**: +1. **Valid correspondence**: Returns 3×3 matrix +2. **Insufficient features**: Returns None + +--- + +### `extract_gps_from_alignment(homography: np.ndarray, tile_bounds: TileBounds, image_center: Tuple[int, int]) -> GPSPoint` + +**Description**: Extracts GPS coordinates from homography and tile georeferencing. + +**Called By**: +- Internal (during align_to_satellite) +- G06 Image Rotation Manager (for precise angle calculation) + +**Input**: +```python +homography: np.ndarray # 3×3 matrix +tile_bounds: TileBounds # GPS bounds of satellite tile +image_center: Tuple[int, int] # Center pixel of UAV image +``` + +**Output**: +```python +GPSPoint: + lat: float + lon: float +``` + +**Algorithm**: +1. Apply homography to UAV image center point +2. Get pixel coordinates in satellite tile +3. Convert satellite pixel to GPS using tile_bounds and GSD +4. Return GPS coordinates + +**Uses**: G04 Satellite Data Manager for tile_bounds, H02 GSD Calculator + +**Test Cases**: +1. **Center alignment**: UAV center → correct GPS +2. **Corner alignment**: UAV corner → correct GPS +3. **Multiple points**: All points consistent + +--- + +### `compute_match_confidence(alignment: AlignmentResult) -> float` + +**Description**: Computes match confidence score from alignment quality. + +**Called By**: +- Internal (during align_to_satellite) +- G11 Failure Recovery Coordinator (to decide if match acceptable) + +**Input**: +```python +alignment: AlignmentResult +``` + +**Output**: +```python +float: Confidence score (0.0 to 1.0) +``` + +**Confidence Factors**: +1. **Inlier ratio**: inliers / total_correspondences +2. **Inlier count**: Absolute number of inliers +3. **Reprojection error**: Mean error of inliers (in pixels) +4. **Spatial distribution**: Inliers well-distributed vs clustered + +**Thresholds**: +- **High confidence (>0.8)**: inlier_ratio > 0.6, inlier_count > 50, MRE < 0.5px +- **Medium confidence (0.5-0.8)**: inlier_ratio > 0.4, inlier_count > 30 +- **Low confidence (<0.5)**: Reject match + +**Test Cases**: +1. **Good match**: confidence > 0.8 +2. **Weak match**: confidence 0.5-0.7 +3. **Poor match**: confidence < 0.5 + +## Integration Tests + +### Test 1: Single Tile Drift Correction +1. Load UAV image and expected satellite tile +2. Pre-rotate UAV image to known heading +3. align_to_satellite() → returns GPS +4. Verify GPS within 20m of ground truth + +### Test 2: Progressive Search (4 tiles) +1. Load UAV image from sharp turn +2. Get 2×2 tile grid from G04 +3. align_to_satellite() for each tile +4. First 3 tiles: No match +5. 4th tile: Match found → GPS extracted + +### Test 3: Rotation Sensitivity +1. Rotate UAV image by 60° (not pre-rotated) +2. align_to_satellite() → returns None (fails as expected) +3. Pre-rotate to 60° +4. align_to_satellite() → succeeds + +### Test 4: Multi-Scale Robustness +1. UAV at 500m altitude (GSD=0.1m/pixel) +2. Satellite at zoom 19 (GSD=0.3m/pixel) +3. LiteSAM handles scale difference → match succeeds + +## Non-Functional Requirements + +### Performance +- **align_to_satellite**: ~60ms per tile (TensorRT optimized) +- **Progressive search 25 tiles**: ~1.5 seconds total (25 × 60ms) +- Meets <5s per frame requirement + +### Accuracy +- **GPS accuracy**: 60% of frames < 20m error, 80% < 50m error +- **Mean Reprojection Error (MRE)**: < 1.0 pixels +- **Alignment success rate**: > 95% when rotation correct + +### Reliability +- Graceful failure when no match +- Robust to altitude variations (<1km) +- Handles seasonal appearance changes (to extent possible) + +## Dependencies + +### Internal Components +- **G15 Model Manager**: For LiteSAM model +- **G04 Satellite Data Manager**: For tile_bounds and GSD +- **H01 Camera Model**: For projection operations +- **H02 GSD Calculator**: For coordinate transformations +- **H05 Performance Monitor**: For timing + +### External Dependencies +- **LiteSAM**: Cross-view matching model +- **opencv-python**: Homography estimation +- **numpy**: Matrix operations + +## Data Models + +### AlignmentResult +```python +class AlignmentResult(BaseModel): + matched: bool + homography: np.ndarray # (3, 3) + gps_center: GPSPoint + confidence: float + inlier_count: int + total_correspondences: int + reprojection_error: float # Mean error in pixels +``` + +### GPSPoint +```python +class GPSPoint(BaseModel): + lat: float + lon: float +``` + +### TileBounds +```python +class TileBounds(BaseModel): + nw: GPSPoint + ne: GPSPoint + sw: GPSPoint + se: GPSPoint + center: GPSPoint + gsd: float # Ground Sampling Distance (m/pixel) +``` + +### LiteSAMConfig +```python +class LiteSAMConfig(BaseModel): + model_path: str + confidence_threshold: float = 0.7 + min_inliers: int = 15 + max_reprojection_error: float = 2.0 # pixels + multi_scale_levels: int = 3 +``` + diff --git a/docs/02_components/gps_denied_10_factor_graph_optimizer/factor_graph_optimizer_spec.md b/docs/02_components/gps_denied_10_factor_graph_optimizer/factor_graph_optimizer_spec.md new file mode 100644 index 0000000..cacf06a --- /dev/null +++ b/docs/02_components/gps_denied_10_factor_graph_optimizer/factor_graph_optimizer_spec.md @@ -0,0 +1,362 @@ +# Factor Graph Optimizer + +## Interface Definition + +**Interface Name**: `IFactorGraphOptimizer` + +### Interface Methods + +```python +class IFactorGraphOptimizer(ABC): + @abstractmethod + def add_relative_factor(self, frame_i: int, frame_j: int, relative_pose: RelativePose, covariance: np.ndarray) -> bool: + pass + + @abstractmethod + def add_absolute_factor(self, frame_id: int, gps: GPSPoint, covariance: np.ndarray, is_user_anchor: bool) -> bool: + pass + + @abstractmethod + def add_altitude_prior(self, frame_id: int, altitude: float, covariance: float) -> bool: + pass + + @abstractmethod + def optimize(self, iterations: int) -> OptimizationResult: + pass + + @abstractmethod + def get_trajectory(self) -> Dict[int, Pose]: + pass + + @abstractmethod + def get_marginal_covariance(self, frame_id: int) -> np.ndarray: + pass +``` + +## Component Description + +### Responsibilities +- GTSAM-based fusion of relative and absolute measurements +- Incremental optimization (iSAM2) for real-time performance +- Robust kernels (Huber/Cauchy) for 350m outlier handling +- Scale resolution through altitude priors and absolute GPS +- Trajectory smoothing and global consistency +- Back-propagation of refinements to previous frames + +### Scope +- Non-linear least squares optimization +- Factor graph representation of SLAM problem +- Handles monocular scale ambiguity +- Real-time incremental updates +- Asynchronous batch refinement + +## API Methods + +### `add_relative_factor(frame_i: int, frame_j: int, relative_pose: RelativePose, covariance: np.ndarray) -> bool` + +**Description**: Adds relative pose measurement between consecutive frames. + +**Called By**: +- G07 Sequential VO (frame-to-frame odometry) + +**Input**: +```python +frame_i: int # Previous frame ID +frame_j: int # Current frame ID (typically frame_i + 1) +relative_pose: RelativePose: + translation: np.ndarray # (3,) - in meters (scale from altitude prior) + rotation: np.ndarray # (3, 3) or quaternion +covariance: np.ndarray # (6, 6) - uncertainty +``` + +**Output**: +```python +bool: True if factor added successfully +``` + +**Processing Flow**: +1. Create BetweenFactor in GTSAM +2. Apply robust kernel (Huber) to handle outliers +3. Add to factor graph +4. Mark graph as needing optimization + +**Robust Kernel**: +- **Huber loss**: Downweights large errors (>threshold) +- **Critical** for 350m outlier handling from tilt + +**Test Cases**: +1. **Normal motion**: Factor added, contributes to optimization +2. **Large displacement** (350m outlier): Huber kernel reduces weight +3. **Consecutive factors**: Chain of relative factors builds trajectory + +--- + +### `add_absolute_factor(frame_id: int, gps: GPSPoint, covariance: np.ndarray, is_user_anchor: bool) -> bool` + +**Description**: Adds absolute GPS measurement for drift correction or user anchor. + +**Called By**: +- G09 Metric Refinement (after LiteSAM alignment) +- G11 Failure Recovery Coordinator (user-provided anchors) + +**Input**: +```python +frame_id: int +gps: GPSPoint: + lat: float + lon: float +covariance: np.ndarray # (2, 2) or (3, 3) - GPS uncertainty +is_user_anchor: bool # True for user-provided fixes (high confidence) +``` + +**Output**: +```python +bool: True if factor added +``` + +**Processing Flow**: +1. Convert GPS to local ENU coordinates (East-North-Up) +2. Create PriorFactor or UnaryFactor +3. Set covariance (low for user anchors, higher for LiteSAM) +4. Add to factor graph +5. Trigger optimization (immediate for user anchors) + +**Covariance Settings**: +- **User anchor**: σ = 5m (high confidence) +- **LiteSAM match**: σ = 20-50m (depends on confidence) + +**Test Cases**: +1. **LiteSAM GPS**: Adds absolute factor, corrects drift +2. **User anchor**: High confidence, immediately refines trajectory +3. **Multiple absolute factors**: Graph optimizes to balance all + +--- + +### `add_altitude_prior(frame_id: int, altitude: float, covariance: float) -> bool` + +**Description**: Adds altitude constraint to resolve monocular scale ambiguity. + +**Called By**: +- Main processing loop (for each frame) + +**Input**: +```python +frame_id: int +altitude: float # Predefined altitude in meters +covariance: float # Altitude uncertainty (e.g., 50m) +``` + +**Output**: +```python +bool: True if prior added +``` + +**Processing Flow**: +1. Create UnaryFactor for Z-coordinate +2. Set as soft constraint (not hard constraint) +3. Add to factor graph + +**Purpose**: +- Resolves scale ambiguity in monocular VO +- Prevents scale drift (trajectory collapsing or exploding) +- Soft constraint allows adjustment based on absolute GPS + +**Test Cases**: +1. **Without altitude prior**: Scale drifts over time +2. **With altitude prior**: Scale stabilizes +3. **Conflicting measurements**: Optimizer balances VO and altitude + +--- + +### `optimize(iterations: int) -> OptimizationResult` + +**Description**: Runs optimization to refine trajectory. + +**Called By**: +- Main processing loop (incremental after each frame) +- Asynchronous refinement thread (batch optimization) + +**Input**: +```python +iterations: int # Max iterations (typically 5-10 for incremental, 50-100 for batch) +``` + +**Output**: +```python +OptimizationResult: + converged: bool + final_error: float + iterations_used: int + optimized_frames: List[int] # Frames with updated poses +``` + +**Processing Details**: +- **Incremental** (iSAM2): Updates only affected nodes +- **Batch**: Re-optimizes entire trajectory when new absolute factors added +- **Robust M-estimation**: Automatically downweights outliers + +**Optimization Algorithm** (Levenberg-Marquardt): +1. Linearize factor graph around current estimate +2. Solve linear system +3. Update pose estimates +4. Check convergence (error reduction < threshold) + +**Test Cases**: +1. **Incremental optimization**: Fast (<100ms), local update +2. **Batch optimization**: Slower (~500ms), refines entire trajectory +3. **Convergence**: Error reduces, converges within iterations + +--- + +### `get_trajectory() -> Dict[int, Pose]` + +**Description**: Retrieves complete optimized trajectory. + +**Called By**: +- G13 Result Manager (for publishing results) +- G12 Coordinate Transformer (for GPS conversion) + +**Input**: None + +**Output**: +```python +Dict[int, Pose]: + frame_id -> Pose: + position: np.ndarray # (x, y, z) in ENU + orientation: np.ndarray # Quaternion or rotation matrix + timestamp: datetime +``` + +**Processing Flow**: +1. Extract all pose estimates from graph +2. Convert to appropriate coordinate system +3. Return dictionary + +**Test Cases**: +1. **After optimization**: Returns all frame poses +2. **Refined trajectory**: Poses updated after batch optimization + +--- + +### `get_marginal_covariance(frame_id: int) -> np.ndarray` + +**Description**: Gets uncertainty (covariance) of a pose estimate. + +**Called By**: +- G11 Failure Recovery Coordinator (to detect high uncertainty) + +**Input**: +```python +frame_id: int +``` + +**Output**: +```python +np.ndarray: (6, 6) covariance matrix [x, y, z, roll, pitch, yaw] +``` + +**Purpose**: +- Uncertainty quantification +- Trigger user input when uncertainty too high (> 50m radius) + +**Test Cases**: +1. **Well-constrained pose**: Small covariance +2. **Unconstrained pose**: Large covariance +3. **After absolute factor**: Covariance reduces + +## Integration Tests + +### Test 1: Incremental Trajectory Building +1. Initialize graph with first frame +2. Add relative factors from VO × 100 +3. Add altitude priors × 100 +4. Optimize incrementally after each frame +5. Verify smooth trajectory + +### Test 2: Drift Correction with Absolute GPS +1. Build trajectory with VO only (will drift) +2. Add absolute GPS factor at frame 50 +3. Optimize → trajectory corrects +4. Verify frames 1-49 also corrected (back-propagation) + +### Test 3: Outlier Handling +1. Add normal relative factors +2. Add 350m outlier factor (tilt error) +3. Optimize with robust kernel +4. Verify outlier downweighted, trajectory smooth + +### Test 4: User Anchor Integration +1. Processing blocked at frame 237 +2. User provides anchor (high confidence) +3. add_absolute_factor(is_user_anchor=True) +4. Optimize → trajectory snaps to anchor + +## Non-Functional Requirements + +### Performance +- **Incremental optimize**: < 100ms per frame (iSAM2) +- **Batch optimize**: < 500ms for 100 frames +- **get_trajectory**: < 10ms +- Real-time capable: 10 FPS processing + +### Accuracy +- **Mean Reprojection Error (MRE)**: < 1.0 pixels +- **GPS accuracy**: Meet 80% < 50m, 60% < 20m criteria +- **Trajectory smoothness**: No sudden jumps (except user anchors) + +### Reliability +- Numerical stability for 2000+ frame trajectories +- Graceful handling of degenerate configurations +- Robust to missing/corrupted measurements + +## Dependencies + +### Internal Components +- **H03 Robust Kernels**: For Huber/Cauchy loss functions +- **H02 GSD Calculator**: For coordinate conversions + +### External Dependencies +- **GTSAM**: Graph optimization library +- **numpy**: Matrix operations +- **scipy**: Sparse matrix operations (optional) + +## Data Models + +### Pose +```python +class Pose(BaseModel): + frame_id: int + position: np.ndarray # (3,) - [x, y, z] in ENU + orientation: np.ndarray # (4,) quaternion or (3,3) rotation matrix + timestamp: datetime + covariance: Optional[np.ndarray] # (6, 6) +``` + +### RelativePose +```python +class RelativePose(BaseModel): + translation: np.ndarray # (3,) + rotation: np.ndarray # (3, 3) or (4,) + covariance: np.ndarray # (6, 6) +``` + +### OptimizationResult +```python +class OptimizationResult(BaseModel): + converged: bool + final_error: float + iterations_used: int + optimized_frames: List[int] + mean_reprojection_error: float +``` + +### FactorGraphConfig +```python +class FactorGraphConfig(BaseModel): + robust_kernel_type: str = "Huber" # or "Cauchy" + huber_threshold: float = 1.0 # pixels + cauchy_k: float = 0.1 + isam2_relinearize_threshold: float = 0.1 + isam2_relinearize_skip: int = 1 +``` + diff --git a/docs/02_components/gps_denied_11_failure_recovery_coordinator/failure_recovery_coordinator_spec.md b/docs/02_components/gps_denied_11_failure_recovery_coordinator/failure_recovery_coordinator_spec.md new file mode 100644 index 0000000..112d313 --- /dev/null +++ b/docs/02_components/gps_denied_11_failure_recovery_coordinator/failure_recovery_coordinator_spec.md @@ -0,0 +1,404 @@ +# Failure Recovery Coordinator + +## Interface Definition + +**Interface Name**: `IFailureRecoveryCoordinator` + +### Interface Methods + +```python +class IFailureRecoveryCoordinator(ABC): + @abstractmethod + def check_confidence(self, vo_result: RelativePose, litesam_result: Optional[AlignmentResult]) -> ConfidenceAssessment: + pass + + @abstractmethod + def detect_tracking_loss(self, confidence: ConfidenceAssessment) -> bool: + pass + + @abstractmethod + def start_search(self, flight_id: str, frame_id: int, estimated_gps: GPSPoint) -> SearchSession: + pass + + @abstractmethod + def expand_search_radius(self, session: SearchSession) -> List[TileCoords]: + pass + + @abstractmethod + def try_current_grid(self, session: SearchSession, tiles: Dict[str, np.ndarray]) -> Optional[AlignmentResult]: + pass + + @abstractmethod + def mark_found(self, session: SearchSession, result: AlignmentResult) -> bool: + pass + + @abstractmethod + def get_search_status(self, session: SearchSession) -> SearchStatus: + pass + + @abstractmethod + def create_user_input_request(self, flight_id: str, frame_id: int, candidate_tiles: List[TileCandidate]) -> UserInputRequest: + pass + + @abstractmethod + def apply_user_anchor(self, flight_id: str, frame_id: int, anchor: UserAnchor) -> bool: + pass +``` + +## Component Description + +### Responsibilities +- Monitor confidence metrics (inlier count, MRE, covariance) +- Detect tracking loss and trigger recovery +- Coordinate progressive tile search (1→4→9→16→25) +- Handle human-in-the-loop when all strategies exhausted +- Block flight processing when awaiting user input +- Apply user-provided anchors to Factor Graph + +### Scope +- Confidence monitoring +- Progressive search coordination +- User input request/response handling +- Recovery strategy orchestration +- Integration point for G04, G06, G08, G09, G10 + +## API Methods + +### `check_confidence(vo_result: RelativePose, litesam_result: Optional[AlignmentResult]) -> ConfidenceAssessment` + +**Description**: Assesses tracking confidence from VO and LiteSAM results. + +**Called By**: Main processing loop (per frame) + +**Input**: +```python +vo_result: RelativePose +litesam_result: Optional[AlignmentResult] +``` + +**Output**: +```python +ConfidenceAssessment: + overall_confidence: float # 0-1 + vo_confidence: float + litesam_confidence: float + inlier_count: int + tracking_status: str # "good", "degraded", "lost" +``` + +**Confidence Metrics**: +- VO inlier count and ratio +- LiteSAM match confidence +- Factor graph marginal covariance +- Reprojection error + +**Thresholds**: +- **Good**: VO inliers > 50, LiteSAM confidence > 0.7 +- **Degraded**: VO inliers 20-50 +- **Lost**: VO inliers < 20 + +**Test Cases**: +1. Good tracking → "good" status +2. Low overlap → "degraded" +3. Sharp turn → "lost" + +--- + +### `detect_tracking_loss(confidence: ConfidenceAssessment) -> bool` + +**Description**: Determines if tracking is lost. + +**Called By**: Main processing loop + +**Input**: `ConfidenceAssessment` + +**Output**: `bool` - True if tracking lost + +**Test Cases**: +1. Confidence good → False +2. Confidence lost → True + +--- + +### `start_search(flight_id: str, frame_id: int, estimated_gps: GPSPoint) -> SearchSession` + +**Description**: Initiates progressive search session. + +**Called By**: Main processing loop (when tracking lost) + +**Input**: +```python +flight_id: str +frame_id: int +estimated_gps: GPSPoint # Dead-reckoning estimate +``` + +**Output**: +```python +SearchSession: + session_id: str + flight_id: str + frame_id: int + center_gps: GPSPoint + current_grid_size: int # Starts at 1 + max_grid_size: int # 25 + found: bool +``` + +**Processing Flow**: +1. Create search session +2. Set center from estimated_gps +3. Set current_grid_size = 1 +4. Return session + +**Test Cases**: +1. Start search → session created with grid_size=1 + +--- + +### `expand_search_radius(session: SearchSession) -> List[TileCoords]` + +**Description**: Expands search grid to next size (1→4→9→16→25). + +**Called By**: Internal (after try_current_grid fails) + +**Input**: `SearchSession` + +**Output**: `List[TileCoords]` - Tiles for next grid size + +**Processing Flow**: +1. Increment current_grid_size (1→4→9→16→25) +2. Call G04.expand_search_grid() to get new tiles only +3. Return new tile coordinates + +**Test Cases**: +1. Expand 1→4 → returns 3 new tiles +2. Expand 4→9 → returns 5 new tiles +3. At grid_size=25 → no more expansion + +--- + +### `try_current_grid(session: SearchSession, tiles: Dict[str, np.ndarray]) -> Optional[AlignmentResult]` + +**Description**: Tries LiteSAM matching on current tile grid. + +**Called By**: Internal (progressive search loop) + +**Input**: +```python +session: SearchSession +tiles: Dict[str, np.ndarray] # From G04 +``` + +**Output**: `Optional[AlignmentResult]` - Match result or None + +**Processing Flow**: +1. Get UAV image for frame_id +2. For each tile in grid: + - Call G09.align_to_satellite(uav_image, tile) + - If match found with confidence > threshold: + - mark_found(session, result) + - Return result +3. Return None if no match + +**Test Cases**: +1. Match on 3rd tile → returns result +2. No match in grid → returns None + +--- + +### `mark_found(session: SearchSession, result: AlignmentResult) -> bool` + +**Description**: Marks search session as successful. + +**Called By**: Internal + +**Input**: +```python +session: SearchSession +result: AlignmentResult +``` + +**Output**: `bool` - True + +**Processing Flow**: +1. Set session.found = True +2. Log success (grid_size where found) +3. Resume processing + +--- + +### `get_search_status(session: SearchSession) -> SearchStatus` + +**Description**: Gets current search status. + +**Called By**: G01 REST API (for status endpoint) + +**Output**: +```python +SearchStatus: + current_grid_size: int + found: bool + exhausted: bool # Reached grid_size=25 without match +``` + +--- + +### `create_user_input_request(flight_id: str, frame_id: int, candidate_tiles: List[TileCandidate]) -> UserInputRequest` + +**Description**: Creates user input request when all search strategies exhausted. + +**Called By**: Internal (when grid_size=25 and no match) + +**Input**: +```python +flight_id: str +frame_id: int +candidate_tiles: List[TileCandidate] # Top-5 from G08 +``` + +**Output**: +```python +UserInputRequest: + request_id: str + flight_id: str + frame_id: int + uav_image: np.ndarray + candidate_tiles: List[TileCandidate] + message: str +``` + +**Processing Flow**: +1. Get UAV image for frame_id +2. Get top-5 candidates from G08 +3. Create request +4. Send via G14 SSE → "user_input_needed" event +5. Update G02 flight_status("BLOCKED") + +**Test Cases**: +1. All search failed → creates request +2. Request sent to client via SSE + +--- + +### `apply_user_anchor(flight_id: str, frame_id: int, anchor: UserAnchor) -> bool` + +**Description**: Applies user-provided GPS anchor. + +**Called By**: G01 REST API (user-fix endpoint) + +**Input**: +```python +flight_id: str +frame_id: int +anchor: UserAnchor: + uav_pixel: Tuple[float, float] + satellite_gps: GPSPoint +``` + +**Output**: `bool` - True if applied + +**Processing Flow**: +1. Validate anchor data +2. Call G10.add_absolute_factor(frame_id, gps, is_user_anchor=True) +3. G10.optimize() → refines trajectory +4. Update G02 flight_status("PROCESSING") +5. Resume processing from next frame + +**Test Cases**: +1. Valid anchor → applied, processing resumes +2. Invalid anchor → rejected + +## Integration Tests + +### Test 1: Progressive Search Flow +1. Tracking lost detected +2. start_search() → grid_size=1 +3. try_current_grid(1 tile) → no match +4. expand_search_radius() → grid_size=4 +5. try_current_grid(4 tiles) → match found +6. mark_found() → success + +### Test 2: Full Search Exhaustion +1. start_search() +2. try grids: 1→4→9→16→25, all fail +3. create_user_input_request() +4. User provides anchor +5. apply_user_anchor() → processing resumes + +### Test 3: Confidence Monitoring +1. Normal frames → confidence good +2. Low overlap frame → confidence degraded +3. Sharp turn → tracking lost, trigger search + +## Non-Functional Requirements + +### Performance +- **check_confidence**: < 10ms +- **Progressive search (25 tiles)**: < 1.5s total +- **User input latency**: < 500ms from creation to SSE event + +### Reliability +- Always exhausts all search strategies before requesting user input +- Guarantees processing block when awaiting user input +- Graceful recovery from all failure modes + +## Dependencies + +### Internal Components +- G04 Satellite Data Manager (tile grids) +- G06 Image Rotation Manager (rotation sweep) +- G08 Global Place Recognition (candidates) +- G09 Metric Refinement (LiteSAM) +- G10 Factor Graph Optimizer (anchor application) +- G02 Flight Manager (status updates) +- G14 SSE Event Streamer (user input events) + +### External Dependencies +- None + +## Data Models + +### ConfidenceAssessment +```python +class ConfidenceAssessment(BaseModel): + overall_confidence: float + vo_confidence: float + litesam_confidence: float + inlier_count: int + tracking_status: str +``` + +### SearchSession +```python +class SearchSession(BaseModel): + session_id: str + flight_id: str + frame_id: int + center_gps: GPSPoint + current_grid_size: int + max_grid_size: int + found: bool + exhausted: bool +``` + +### UserInputRequest +```python +class UserInputRequest(BaseModel): + request_id: str + flight_id: str + frame_id: int + uav_image: np.ndarray + candidate_tiles: List[TileCandidate] + message: str + created_at: datetime +``` + +### UserAnchor +```python +class UserAnchor(BaseModel): + uav_pixel: Tuple[float, float] + satellite_gps: GPSPoint + confidence: float = 1.0 +``` + diff --git a/docs/02_components/gps_denied_12_coordinate_transformer/coordinate_transformer_spec.md b/docs/02_components/gps_denied_12_coordinate_transformer/coordinate_transformer_spec.md new file mode 100644 index 0000000..11a1243 --- /dev/null +++ b/docs/02_components/gps_denied_12_coordinate_transformer/coordinate_transformer_spec.md @@ -0,0 +1,333 @@ +# Coordinate Transformer + +## Interface Definition + +**Interface Name**: `ICoordinateTransformer` + +### Interface Methods + +```python +class ICoordinateTransformer(ABC): + @abstractmethod + def pixel_to_gps(self, pixel: Tuple[float, float], frame_pose: Pose, camera_params: CameraParameters, altitude: float) -> GPSPoint: + pass + + @abstractmethod + def gps_to_pixel(self, gps: GPSPoint, frame_pose: Pose, camera_params: CameraParameters, altitude: float) -> Tuple[float, float]: + pass + + @abstractmethod + def image_object_to_gps(self, object_pixel: Tuple[float, float], frame_id: int) -> GPSPoint: + pass + + @abstractmethod + def compute_gsd(self, altitude: float, focal_length: float, sensor_width: float, image_width: int) -> float: + pass + + @abstractmethod + def transform_points(self, points: List[Tuple[float, float]], transformation: np.ndarray) -> List[Tuple[float, float]]: + pass +``` + +## Component Description + +### Responsibilities +- Pixel-to-GPS coordinate conversions +- GPS-to-pixel inverse projections +- **Critical**: Convert object pixel coordinates (from external detection system) to GPS +- Ground Sampling Distance (GSD) calculations +- Handle multiple coordinate systems: WGS84, Web Mercator, ENU, image pixels, rotated coordinates +- Camera model integration for projection operations + +### Scope +- Coordinate system transformations +- Camera projection mathematics +- Integration with Factor Graph poses +- Object localization (pixel → GPS) +- Support for external object detection system + +## API Methods + +### `pixel_to_gps(pixel: Tuple[float, float], frame_pose: Pose, camera_params: CameraParameters, altitude: float) -> GPSPoint` + +**Description**: Converts pixel coordinates to GPS using camera pose and ground plane assumption. + +**Called By**: +- G13 Result Manager (for frame center GPS) +- Internal (for image_object_to_gps) + +**Input**: +```python +pixel: Tuple[float, float] # (x, y) in image coordinates +frame_pose: Pose # From Factor Graph (ENU coordinates) +camera_params: CameraParameters +altitude: float # Ground altitude +``` + +**Output**: +```python +GPSPoint: + lat: float + lon: float +``` + +**Algorithm**: +1. Unproject pixel to 3D ray using H01 Camera Model +2. Intersect ray with ground plane at altitude +3. Transform 3D point from camera frame to ENU using frame_pose +4. Convert ENU to WGS84 GPS using H06 Web Mercator Utils + +**Assumptions**: +- Ground plane assumption (terrain height negligible) +- Downward-pointing camera +- Known altitude + +**Error Conditions**: +- None (always returns GPS, may be inaccurate if assumptions violated) + +**Test Cases**: +1. **Image center**: Returns frame center GPS +2. **Image corner**: Returns GPS at corner +3. **Object pixel**: Returns object GPS +4. **Altitude variation**: Correct GPS at different altitudes + +--- + +### `gps_to_pixel(gps: GPSPoint, frame_pose: Pose, camera_params: CameraParameters, altitude: float) -> Tuple[float, float]` + +**Description**: Inverse projection from GPS to image pixel coordinates. + +**Called By**: +- Visualization tools (overlay GPS annotations) +- Testing/validation + +**Input**: +```python +gps: GPSPoint +frame_pose: Pose +camera_params: CameraParameters +altitude: float +``` + +**Output**: +```python +Tuple[float, float]: (x, y) pixel coordinates +``` + +**Algorithm**: +1. Convert GPS to ENU using H06 +2. Transform ENU point to camera frame using frame_pose +3. Project 3D point to image plane using H01 Camera Model +4. Return pixel coordinates + +**Test Cases**: +1. **Frame center GPS**: Returns image center pixel +2. **Out of view GPS**: Returns pixel outside image bounds + +--- + +### `image_object_to_gps(object_pixel: Tuple[float, float], frame_id: int) -> GPSPoint` + +**Description**: **Critical method** - Converts object pixel coordinates to GPS. Used by external object detection system. + +**Called By**: +- External object detection system (provides pixel coordinates) +- G13 Result Manager (converts objects to GPS for output) + +**Input**: +```python +object_pixel: Tuple[float, float] # Pixel coordinates from object detector +frame_id: int # Frame containing object +``` + +**Output**: +```python +GPSPoint: GPS coordinates of object center +``` + +**Processing Flow**: +1. Get frame_pose from G10 Factor Graph +2. Get camera_params from G16 Configuration Manager +3. Get altitude from configuration +4. Call pixel_to_gps(object_pixel, frame_pose, camera_params, altitude) +5. Return GPS + +**User Story**: +- External system detects object in UAV image at pixel (1024, 768) +- Calls image_object_to_gps(frame_id=237, object_pixel=(1024, 768)) +- Returns GPSPoint(lat=48.123, lon=37.456) +- Object GPS can be used for navigation, targeting, etc. + +**Test Cases**: +1. **Object at image center**: Returns frame center GPS +2. **Object at corner**: Returns GPS with offset +3. **Multiple objects**: Each gets correct GPS +4. **Refined trajectory**: Object GPS updates after refinement + +--- + +### `compute_gsd(altitude: float, focal_length: float, sensor_width: float, image_width: int) -> float` + +**Description**: Computes Ground Sampling Distance (meters per pixel). + +**Called By**: +- Internal (for pixel_to_gps) +- G09 Metric Refinement (for scale calculations) +- H02 GSD Calculator (may delegate to) + +**Input**: +```python +altitude: float # meters +focal_length: float # mm +sensor_width: float # mm +image_width: int # pixels +``` + +**Output**: +```python +float: GSD in meters/pixel +``` + +**Formula**: +``` +GSD = (altitude * sensor_width) / (focal_length * image_width) +``` + +**Example**: +- altitude = 800m +- focal_length = 24mm +- sensor_width = 36mm +- image_width = 6000px +- GSD = (800 * 36) / (24 * 6000) = 0.2 m/pixel + +**Test Cases**: +1. **Standard parameters**: Returns reasonable GSD (~0.1-0.3 m/pixel) +2. **Higher altitude**: GSD increases +3. **Longer focal length**: GSD decreases + +--- + +### `transform_points(points: List[Tuple[float, float]], transformation: np.ndarray) -> List[Tuple[float, float]]` + +**Description**: Applies homography or affine transformation to list of points. + +**Called By**: +- G06 Image Rotation Manager (for rotation transforms) +- G09 Metric Refinement (homography application) + +**Input**: +```python +points: List[Tuple[float, float]] +transformation: np.ndarray # 3×3 homography or 2×3 affine +``` + +**Output**: +```python +List[Tuple[float, float]]: Transformed points +``` + +**Processing Flow**: +1. Convert points to homogeneous coordinates +2. Apply transformation matrix +3. Normalize and return + +**Test Cases**: +1. **Identity transform**: Points unchanged +2. **Rotation**: Points rotated correctly +3. **Homography**: Points transformed with perspective + +## Integration Tests + +### Test 1: Frame Center GPS Calculation +1. Get frame_pose from Factor Graph +2. pixel_to_gps(image_center) → GPS +3. Verify GPS matches expected location +4. Verify accuracy < 50m + +### Test 2: Object Localization +1. External detector finds object at pixel (1500, 2000) +2. image_object_to_gps(frame_id, pixel) → GPS +3. Verify GPS correct +4. Multiple objects → all get correct GPS + +### Test 3: Round-Trip Conversion +1. Start with GPS point +2. gps_to_pixel() → pixel +3. pixel_to_gps() → GPS +4. Verify GPS matches original (within tolerance) + +### Test 4: GSD Calculation +1. compute_gsd() with known parameters +2. Verify matches expected value +3. Test at different altitudes + +## Non-Functional Requirements + +### Performance +- **pixel_to_gps**: < 5ms +- **gps_to_pixel**: < 5ms +- **image_object_to_gps**: < 10ms +- **compute_gsd**: < 1ms + +### Accuracy +- **GPS accuracy**: Inherits from Factor Graph accuracy (~20m) +- **GSD calculation**: ±1% precision +- **Projection accuracy**: < 1 pixel error + +### Reliability +- Handle edge cases (points outside image) +- Graceful handling of degenerate configurations +- Numerical stability + +## Dependencies + +### Internal Components +- **G10 Factor Graph Optimizer**: For frame poses +- **G16 Configuration Manager**: For camera parameters +- **H01 Camera Model**: For projection operations +- **H02 GSD Calculator**: For GSD calculations +- **H06 Web Mercator Utils**: For coordinate conversions + +### External Dependencies +- **numpy**: Matrix operations +- **opencv-python**: Homography operations (optional) + +## Data Models + +### Pose (from Factor Graph) +```python +class Pose(BaseModel): + position: np.ndarray # (3,) - [x, y, z] in ENU + orientation: np.ndarray # (4,) quaternion or (3,3) rotation + timestamp: datetime +``` + +### CameraParameters +```python +class CameraParameters(BaseModel): + focal_length: float # mm + sensor_width: float # mm + sensor_height: float # mm + resolution_width: int # pixels + resolution_height: int # pixels + principal_point: Tuple[float, float] # (cx, cy) + distortion_coefficients: Optional[List[float]] +``` + +### GPSPoint +```python +class GPSPoint(BaseModel): + lat: float + lon: float +``` + +### CoordinateFrame +```python +class CoordinateFrame(Enum): + WGS84 = "wgs84" # GPS coordinates + ENU = "enu" # East-North-Up local frame + ECEF = "ecef" # Earth-Centered Earth-Fixed + IMAGE = "image" # Image pixel coordinates + CAMERA = "camera" # Camera frame +``` + diff --git a/docs/02_components/gps_denied_13_result_manager/result_manager_spec.md b/docs/02_components/gps_denied_13_result_manager/result_manager_spec.md new file mode 100644 index 0000000..daf5ce5 --- /dev/null +++ b/docs/02_components/gps_denied_13_result_manager/result_manager_spec.md @@ -0,0 +1,267 @@ +# Result Manager + +## Interface Definition + +**Interface Name**: `IResultManager` + +### Interface Methods + +```python +class IResultManager(ABC): + @abstractmethod + def update_frame_result(self, flight_id: str, frame_id: int, result: FrameResult) -> bool: + pass + + @abstractmethod + def publish_to_route_api(self, flight_id: str, frame_id: int) -> bool: + pass + + @abstractmethod + def get_flight_results(self, flight_id: str) -> FlightResults: + pass + + @abstractmethod + def mark_refined(self, flight_id: str, frame_ids: List[int]) -> bool: + pass + + @abstractmethod + def get_changed_frames(self, flight_id: str, since: datetime) -> List[int]: + pass +``` + +## Component Description + +### Responsibilities +- Manage trajectory results per flight +- Track frame refinements and changes +- Trigger per-frame Route API updates via G03 +- Send incremental updates via G14 SSE +- Maintain result versioning for audit trail +- Convert optimized poses to GPS coordinates + +### Scope +- Result state management +- Route API integration +- SSE event triggering +- Incremental update detection +- Result persistence + +## API Methods + +### `update_frame_result(flight_id: str, frame_id: int, result: FrameResult) -> bool` + +**Description**: Updates result for a processed frame. + +**Called By**: +- Main processing loop (after each frame) +- G10 Factor Graph (after refinement) + +**Input**: +```python +flight_id: str +frame_id: int +result: FrameResult: + gps_center: GPSPoint + altitude: float + heading: float + confidence: float + timestamp: datetime + refined: bool + objects: List[ObjectLocation] # From external detector +``` + +**Output**: `bool` - True if updated + +**Processing Flow**: +1. Store result in memory/database +2. Call publish_to_route_api() +3. Call G14.send_frame_result() +4. Update flight statistics + +**Test Cases**: +1. New frame result → stored and published +2. Refined result → updates existing, marks refined=True + +--- + +### `publish_to_route_api(flight_id: str, frame_id: int) -> bool` + +**Description**: Sends frame GPS to Route API via G03 client. + +**Called By**: +- Internal (after update_frame_result) + +**Input**: +```python +flight_id: str +frame_id: int +``` + +**Output**: `bool` - True if published successfully + +**Processing Flow**: +1. Get result for frame_id +2. Convert to Waypoint format +3. Call G03.update_route_waypoint() +4. Handle errors (retry if transient) + +**Test Cases**: +1. Successful publish → Route API updated +2. Route API unavailable → logs error, continues + +--- + +### `get_flight_results(flight_id: str) -> FlightResults` + +**Description**: Retrieves all results for a flight. + +**Called By**: +- G01 REST API (results endpoint) +- Testing/validation + +**Input**: `flight_id: str` + +**Output**: +```python +FlightResults: + flight_id: str + frames: List[FrameResult] + statistics: FlightStatistics +``` + +**Test Cases**: +1. Get all results → returns complete trajectory + +--- + +### `mark_refined(flight_id: str, frame_ids: List[int]) -> bool` + +**Description**: Marks frames as refined after batch optimization. + +**Called By**: +- G10 Factor Graph (after asynchronous refinement) + +**Input**: +```python +flight_id: str +frame_ids: List[int] # Frames with updated poses +``` + +**Output**: `bool` + +**Processing Flow**: +1. For each frame_id: + - Get refined pose from G10 + - Convert to GPS via G12 + - Update result with refined=True + - publish_to_route_api() + - Call G14.send_refinement() + +**Test Cases**: +1. Batch refinement → all frames updated and published + +--- + +### `get_changed_frames(flight_id: str, since: datetime) -> List[int]` + +**Description**: Gets frames changed since timestamp (for incremental updates). + +**Called By**: +- G14 SSE Event Streamer (for reconnection replay) + +**Input**: +```python +flight_id: str +since: datetime +``` + +**Output**: `List[int]` - Frame IDs changed since timestamp + +**Test Cases**: +1. Get changes → returns only modified frames +2. No changes → returns empty list + +## Integration Tests + +### Test 1: Per-Frame Processing +1. Process frame 237 +2. update_frame_result() → stores result +3. Verify publish_to_route_api() called +4. Verify G14 SSE event sent + +### Test 2: Batch Refinement +1. Process 100 frames +2. Factor graph refines frames 10-50 +3. mark_refined([10-50]) → updates all +4. Verify Route API updated +5. Verify SSE refinement events sent + +### Test 3: Incremental Updates +1. Process frames 1-100 +2. Client disconnects at frame 50 +3. Client reconnects +4. get_changed_frames(since=frame_50_time) +5. Client receives frames 51-100 + +## Non-Functional Requirements + +### Performance +- **update_frame_result**: < 50ms +- **publish_to_route_api**: < 100ms (non-blocking) +- **get_flight_results**: < 200ms for 2000 frames + +### Reliability +- Result persistence survives crashes +- Guaranteed at-least-once delivery to Route API +- Idempotent updates + +## Dependencies + +### Internal Components +- G03 Route API Client +- G10 Factor Graph Optimizer +- G12 Coordinate Transformer +- G14 SSE Event Streamer +- G17 Database Layer + +### External Dependencies +- None + +## Data Models + +### FrameResult +```python +class ObjectLocation(BaseModel): + object_id: str + pixel: Tuple[float, float] + gps: GPSPoint + class_name: str + confidence: float + +class FrameResult(BaseModel): + frame_id: int + gps_center: GPSPoint + altitude: float + heading: float + confidence: float + timestamp: datetime + refined: bool + objects: List[ObjectLocation] + updated_at: datetime +``` + +### FlightResults +```python +class FlightStatistics(BaseModel): + total_frames: int + processed_frames: int + refined_frames: int + mean_confidence: float + processing_time: float + +class FlightResults(BaseModel): + flight_id: str + frames: List[FrameResult] + statistics: FlightStatistics +``` + diff --git a/docs/02_components/gps_denied_14_sse_event_streamer/sse_event_streamer_spec.md b/docs/02_components/gps_denied_14_sse_event_streamer/sse_event_streamer_spec.md new file mode 100644 index 0000000..e62e0af --- /dev/null +++ b/docs/02_components/gps_denied_14_sse_event_streamer/sse_event_streamer_spec.md @@ -0,0 +1,242 @@ +# SSE Event Streamer + +## Interface Definition + +**Interface Name**: `ISSEEventStreamer` + +### Interface Methods + +```python +class ISSEEventStreamer(ABC): + @abstractmethod + def create_stream(self, flight_id: str, client_id: str) -> StreamConnection: + pass + + @abstractmethod + def send_frame_result(self, flight_id: str, frame_result: FrameResult) -> bool: + pass + + @abstractmethod + def send_search_progress(self, flight_id: str, search_status: SearchStatus) -> bool: + pass + + @abstractmethod + def send_user_input_request(self, flight_id: str, request: UserInputRequest) -> bool: + pass + + @abstractmethod + def send_refinement(self, flight_id: str, frame_id: int, updated_result: FrameResult) -> bool: + pass + + @abstractmethod + def close_stream(self, flight_id: str, client_id: str) -> bool: + pass +``` + +## Component Description + +### Responsibilities +- Server-Sent Events broadcaster for real-time results +- Stream per-frame processing results to clients +- Send refinement updates asynchronously +- Request user input when processing blocked +- Handle client connections and reconnections +- Event replay from last received event + +### Scope +- SSE protocol implementation +- Event formatting and sending +- Connection management +- Client reconnection handling +- Multiple concurrent streams per flight + +## API Methods + +### `create_stream(flight_id: str, client_id: str) -> StreamConnection` + +**Description**: Creates SSE connection for a client. + +**Called By**: G01 REST API (GET /stream endpoint) + +**Output**: +```python +StreamConnection: + stream_id: str + flight_id: str + client_id: str + last_event_id: Optional[str] +``` + +**Event Types**: +- `frame_processed` +- `frame_refined` +- `search_expanded` +- `user_input_needed` +- `processing_blocked` +- `route_api_updated` +- `route_completed` + +**Test Cases**: +1. Create stream → client receives keepalive pings +2. Multiple clients → each gets own stream + +--- + +### `send_frame_result(flight_id: str, frame_result: FrameResult) -> bool` + +**Description**: Sends frame_processed event. + +**Called By**: G13 Result Manager + +**Event Format**: +```json +{ + "event": "frame_processed", + "id": "frame_237", + "data": { + "frame_id": 237, + "gps": {"lat": 48.123, "lon": 37.456}, + "altitude": 800.0, + "confidence": 0.95, + "heading": 87.3, + "timestamp": "2025-11-24T10:30:00Z" + } +} +``` + +**Test Cases**: +1. Send event → all clients receive +2. Client disconnected → event buffered for replay + +--- + +### `send_search_progress(flight_id: str, search_status: SearchStatus) -> bool` + +**Description**: Sends search_expanded event. + +**Called By**: G11 Failure Recovery Coordinator + +**Event Format**: +```json +{ + "event": "search_expanded", + "data": { + "frame_id": 237, + "grid_size": 9, + "status": "searching" + } +} +``` + +--- + +### `send_user_input_request(flight_id: str, request: UserInputRequest) -> bool` + +**Description**: Sends user_input_needed event. + +**Called By**: G11 Failure Recovery Coordinator + +**Event Format**: +```json +{ + "event": "user_input_needed", + "data": { + "request_id": "uuid", + "frame_id": 237, + "candidate_tiles": [...] + } +} +``` + +--- + +### `send_refinement(flight_id: str, frame_id: int, updated_result: FrameResult) -> bool` + +**Description**: Sends frame_refined event. + +**Called By**: G13 Result Manager + +**Event Format**: +```json +{ + "event": "frame_refined", + "data": { + "frame_id": 237, + "gps": {"lat": 48.1235, "lon": 37.4562}, + "refined": true + } +} +``` + +--- + +### `close_stream(flight_id: str, client_id: str) -> bool` + +**Description**: Closes SSE connection. + +**Called By**: G01 REST API (on client disconnect) + +## Integration Tests + +### Test 1: Real-Time Streaming +1. Client connects +2. Process 100 frames +3. Client receives 100 frame_processed events +4. Verify order and completeness + +### Test 2: Reconnection with Replay +1. Client connects +2. Process 50 frames +3. Client disconnects +4. Process 50 more frames +5. Client reconnects with last_event_id +6. Client receives frames 51-100 + +### Test 3: User Input Flow +1. Processing blocks +2. send_user_input_request() +3. Client receives event +4. Client responds with fix +5. Processing resumes + +## Non-Functional Requirements + +### Performance +- **Event latency**: < 500ms from generation to client +- **Throughput**: 100 events/second +- **Concurrent connections**: 1000+ clients + +### Reliability +- Event buffering for disconnected clients +- Automatic reconnection support +- Keepalive pings every 30 seconds + +## Dependencies + +### Internal Components +- None (receives calls from other components) + +### External Dependencies +- **FastAPI** or **Flask** SSE support +- **asyncio**: For async event streaming + +## Data Models + +### StreamConnection +```python +class StreamConnection(BaseModel): + stream_id: str + flight_id: str + client_id: str + created_at: datetime + last_event_id: Optional[str] +``` + +### SSEEvent +```python +class SSEEvent(BaseModel): + event: str + id: Optional[str] + data: Dict[str, Any] +``` + diff --git a/docs/02_components/gps_denied_15_model_manager/model_manager_spec.md b/docs/02_components/gps_denied_15_model_manager/model_manager_spec.md new file mode 100644 index 0000000..fdc0c69 --- /dev/null +++ b/docs/02_components/gps_denied_15_model_manager/model_manager_spec.md @@ -0,0 +1,224 @@ +# Model Manager + +## Interface Definition + +**Interface Name**: `IModelManager` + +### Interface Methods + +```python +class IModelManager(ABC): + @abstractmethod + def load_model(self, model_name: str, model_format: str) -> bool: + pass + + @abstractmethod + def get_inference_engine(self, model_name: str) -> InferenceEngine: + pass + + @abstractmethod + def optimize_to_tensorrt(self, model_name: str, onnx_path: str) -> str: + pass + + @abstractmethod + def fallback_to_onnx(self, model_name: str) -> bool: + pass + + @abstractmethod + def warmup_model(self, model_name: str) -> bool: + pass +``` + +## Component Description + +### Responsibilities +- Load ML models (TensorRT primary, ONNX fallback) +- Manage model lifecycle (loading, unloading, warmup) +- Provide inference engines for: + - SuperPoint (feature extraction) + - LightGlue (feature matching) + - DINOv2 (global descriptors) + - LiteSAM (cross-view matching) +- Handle TensorRT optimization and ONNX fallback +- Ensure <5s processing requirement through acceleration + +### Scope +- Model loading and caching +- TensorRT optimization +- ONNX fallback handling +- Inference engine abstraction +- GPU memory management + +## API Methods + +### `load_model(model_name: str, model_format: str) -> bool` + +**Description**: Loads model in specified format. + +**Called By**: G02 Flight Manager (during initialization) + +**Input**: +```python +model_name: str # "SuperPoint", "LightGlue", "DINOv2", "LiteSAM" +model_format: str # "tensorrt", "onnx", "pytorch" +``` + +**Output**: `bool` - True if loaded + +**Processing Flow**: +1. Check if model already loaded +2. Load model file +3. Initialize inference engine +4. Warm up model +5. Cache for reuse + +**Test Cases**: +1. Load TensorRT model → succeeds +2. TensorRT unavailable → fallback to ONNX +3. Load all 4 models → all succeed + +--- + +### `get_inference_engine(model_name: str) -> InferenceEngine` + +**Description**: Gets inference engine for a model. + +**Called By**: +- G07 Sequential VO (SuperPoint, LightGlue) +- G08 Global Place Recognition (DINOv2) +- G09 Metric Refinement (LiteSAM) + +**Output**: +```python +InferenceEngine: + model_name: str + format: str + infer(input: np.ndarray) -> np.ndarray +``` + +**Test Cases**: +1. Get SuperPoint engine → returns engine +2. Call infer() → returns features + +--- + +### `optimize_to_tensorrt(model_name: str, onnx_path: str) -> str` + +**Description**: Converts ONNX model to TensorRT for acceleration. + +**Called By**: System initialization (one-time) + +**Input**: +```python +model_name: str +onnx_path: str # Path to ONNX model +``` + +**Output**: `str` - Path to TensorRT engine + +**Processing Details**: +- FP16 precision (2-3x speedup) +- Graph fusion and kernel optimization +- One-time conversion, cached for reuse + +**Test Cases**: +1. Convert ONNX to TensorRT → engine created +2. Load TensorRT engine → inference faster than ONNX + +--- + +### `fallback_to_onnx(model_name: str) -> bool` + +**Description**: Falls back to ONNX if TensorRT fails. + +**Called By**: Internal (during load_model) + +**Processing Flow**: +1. Detect TensorRT failure +2. Load ONNX model +3. Log warning +4. Continue with ONNX + +**Test Cases**: +1. TensorRT fails → ONNX loaded automatically +2. System continues functioning + +--- + +### `warmup_model(model_name: str) -> bool` + +**Description**: Warms up model with dummy input. + +**Called By**: Internal (after load_model) + +**Purpose**: Initialize CUDA kernels, allocate GPU memory + +**Test Cases**: +1. Warmup → first real inference fast + +## Integration Tests + +### Test 1: Model Loading +1. load_model("SuperPoint", "tensorrt") +2. load_model("LightGlue", "tensorrt") +3. load_model("DINOv2", "tensorrt") +4. load_model("LiteSAM", "tensorrt") +5. Verify all loaded + +### Test 2: Inference Performance +1. Get inference engine +2. Run inference 100 times +3. Measure average latency +4. Verify meets performance targets + +### Test 3: Fallback Scenario +1. Simulate TensorRT failure +2. Verify fallback to ONNX +3. Verify inference still works + +## Non-Functional Requirements + +### Performance +- **SuperPoint**: ~15ms (TensorRT), ~50ms (ONNX) +- **LightGlue**: ~50ms (TensorRT), ~150ms (ONNX) +- **DINOv2**: ~150ms (TensorRT), ~500ms (ONNX) +- **LiteSAM**: ~60ms (TensorRT), ~200ms (ONNX) + +### Memory +- GPU memory: ~4GB for all 4 models + +### Reliability +- Graceful fallback to ONNX +- Automatic retry on transient errors + +## Dependencies + +### External Dependencies +- **TensorRT**: NVIDIA inference optimization +- **ONNX Runtime**: ONNX inference +- **PyTorch**: Model weights (optional) +- **CUDA**: GPU acceleration + +## Data Models + +### InferenceEngine +```python +class InferenceEngine(ABC): + model_name: str + format: str + + @abstractmethod + def infer(self, input: np.ndarray) -> np.ndarray: + pass +``` + +### ModelConfig +```python +class ModelConfig(BaseModel): + model_name: str + model_path: str + format: str + precision: str # "fp16", "fp32" + warmup_iterations: int = 3 +``` + diff --git a/docs/02_components/gps_denied_16_configuration_manager/configuration_manager_spec.md b/docs/02_components/gps_denied_16_configuration_manager/configuration_manager_spec.md new file mode 100644 index 0000000..6bed6fe --- /dev/null +++ b/docs/02_components/gps_denied_16_configuration_manager/configuration_manager_spec.md @@ -0,0 +1,172 @@ +# Configuration Manager + +## Interface Definition + +**Interface Name**: `IConfigurationManager` + +### Interface Methods + +```python +class IConfigurationManager(ABC): + @abstractmethod + def load_config(self, config_path: str) -> SystemConfig: + pass + + @abstractmethod + def get_camera_params(self, camera_id: Optional[str] = None) -> CameraParameters: + pass + + @abstractmethod + def validate_config(self, config: SystemConfig) -> ValidationResult: + pass + + @abstractmethod + def get_flight_config(self, flight_id: str) -> FlightConfig: + pass + + @abstractmethod + def update_config(self, section: str, key: str, value: Any) -> bool: + pass +``` + +## Component Description + +### Responsibilities +- Load system configuration from files/environment +- Provide camera parameters (focal length, sensor size, resolution) +- Manage flight-specific configurations +- Validate configuration integrity +- Support configuration updates at runtime + +### Scope +- System-wide configuration +- Camera parameter management +- Operational area bounds +- Model paths and settings +- Database connections +- API endpoints + +## API Methods + +### `load_config(config_path: str) -> SystemConfig` + +**Description**: Loads system configuration. + +**Called By**: System startup + +**Input**: `config_path: str` - Path to config file (YAML/JSON) + +**Output**: `SystemConfig` - Complete configuration + +**Test Cases**: +1. Load valid config → succeeds +2. Missing file → uses defaults +3. Invalid config → raises error + +--- + +### `get_camera_params(camera_id: Optional[str] = None) -> CameraParameters` + +**Description**: Gets camera parameters. + +**Called By**: All processing components + +**Output**: +```python +CameraParameters: + focal_length: float + sensor_width: float + sensor_height: float + resolution_width: int + resolution_height: int + principal_point: Tuple[float, float] + distortion_coefficients: List[float] +``` + +**Test Cases**: +1. Get default camera → returns params +2. Get specific camera → returns params + +--- + +### `validate_config(config: SystemConfig) -> ValidationResult` + +**Description**: Validates configuration. + +**Called By**: After load_config + +**Validation Rules**: +- Camera parameters sensible +- Paths exist +- Operational area valid +- Database connection string valid + +**Test Cases**: +1. Valid config → passes +2. Invalid focal length → fails + +--- + +### `get_flight_config(flight_id: str) -> FlightConfig` + +**Description**: Gets flight-specific configuration. + +**Called By**: G02 Flight Manager + +**Output**: +```python +FlightConfig: + camera_params: CameraParameters + altitude: float + operational_area: OperationalArea +``` + +**Test Cases**: +1. Get flight config → returns params + +--- + +### `update_config(section: str, key: str, value: Any) -> bool` + +**Description**: Updates config at runtime. + +**Called By**: Admin tools + +**Test Cases**: +1. Update value → succeeds +2. Invalid key → fails + +## Data Models + +### SystemConfig +```python +class SystemConfig(BaseModel): + camera: CameraParameters + operational_area: OperationalArea + models: ModelPaths + database: DatabaseConfig + api: APIConfig +``` + +### CameraParameters +```python +class CameraParameters(BaseModel): + focal_length: float # mm + sensor_width: float # mm + sensor_height: float # mm + resolution_width: int + resolution_height: int + principal_point: Tuple[float, float] + distortion_coefficients: List[float] +``` + +### OperationalArea +```python +class OperationalArea(BaseModel): + name: str = "Eastern Ukraine" + min_lat: float = 45.0 + max_lat: float = 52.0 + min_lon: float = 22.0 + max_lon: float = 40.0 +``` + diff --git a/docs/02_components/gps_denied_17_gps_denied_database_layer/gps_denied_database_layer_spec.md b/docs/02_components/gps_denied_17_gps_denied_database_layer/gps_denied_database_layer_spec.md new file mode 100644 index 0000000..25b3655 --- /dev/null +++ b/docs/02_components/gps_denied_17_gps_denied_database_layer/gps_denied_database_layer_spec.md @@ -0,0 +1,193 @@ +# GPS-Denied Database Layer + +## Interface Definition + +**Interface Name**: `IGPSDeniedDatabase` + +### Interface Methods + +```python +class IGPSDeniedDatabase(ABC): + @abstractmethod + def save_flight_state(self, flight_state: FlightState) -> bool: + pass + + @abstractmethod + def load_flight_state(self, flight_id: str) -> Optional[FlightState]: + pass + + @abstractmethod + def query_processing_history(self, filters: Dict[str, Any]) -> List[FlightState]: + pass + + @abstractmethod + def save_frame_result(self, flight_id: str, frame_result: FrameResult) -> bool: + pass + + @abstractmethod + def get_frame_results(self, flight_id: str) -> List[FrameResult]: + pass +``` + +## Component Description + +### Responsibilities +- Database access for GPS-Denied processing state +- Separate schema from Route API database +- Persist flight state, frame results +- Query processing history +- Support crash recovery + +### Scope +- Flight state persistence +- Frame result storage +- Processing history queries +- Connection management +- Transaction handling + +## API Methods + +### `save_flight_state(flight_state: FlightState) -> bool` + +**Description**: Saves flight processing state. + +**Called By**: G02 Flight Manager + +**Input**: +```python +FlightState: + flight_id: str + route_id: str + status: str + frames_processed: int + frames_total: int + current_heading: float + blocked: bool + ... +``` + +**Output**: `bool` - True if saved + +**Test Cases**: +1. Save state → persisted +2. Update state → overwrites + +--- + +### `load_flight_state(flight_id: str) -> Optional[FlightState]` + +**Description**: Loads flight state. + +**Called By**: G02 Flight Manager (crash recovery) + +**Output**: `Optional[FlightState]` + +**Test Cases**: +1. Load existing → returns state +2. Load non-existent → returns None + +--- + +### `query_processing_history(filters: Dict[str, Any]) -> List[FlightState]` + +**Description**: Queries historical processing data. + +**Called By**: Analytics, admin tools + +**Test Cases**: +1. Query by date range → returns flights +2. Query by status → returns filtered + +--- + +### `save_frame_result(flight_id: str, frame_result: FrameResult) -> bool` + +**Description**: Saves frame processing result. + +**Called By**: G13 Result Manager + +**Test Cases**: +1. Save result → persisted +2. Update result (refinement) → overwrites + +--- + +### `get_frame_results(flight_id: str) -> List[FrameResult]` + +**Description**: Gets all frame results for flight. + +**Called By**: G13 Result Manager + +**Test Cases**: +1. Get results → returns all frames +2. No results → returns empty list + +## Database Schema + +```sql +-- Flights table +CREATE TABLE gps_denied_flights ( + flight_id VARCHAR(36) PRIMARY KEY, + route_id VARCHAR(36) NOT NULL, + status VARCHAR(50), + frames_processed INT, + frames_total INT, + current_heading FLOAT, + blocked BOOLEAN, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +-- Frame results table +CREATE TABLE frame_results ( + id VARCHAR(36) PRIMARY KEY, + flight_id VARCHAR(36) NOT NULL, + frame_id INT NOT NULL, + gps_lat DECIMAL(10, 7), + gps_lon DECIMAL(11, 7), + altitude FLOAT, + heading FLOAT, + confidence FLOAT, + refined BOOLEAN, + timestamp TIMESTAMP, + updated_at TIMESTAMP, + FOREIGN KEY (flight_id) REFERENCES gps_denied_flights(flight_id) ON DELETE CASCADE, + UNIQUE KEY (flight_id, frame_id) +); +``` + +## Dependencies + +### External Dependencies +- **PostgreSQL** or **MySQL** +- **SQLAlchemy** or **psycopg2** + +## Data Models + +### FlightState +```python +class FlightState(BaseModel): + flight_id: str + route_id: str + status: str + frames_processed: int + frames_total: int + current_heading: Optional[float] + blocked: bool + created_at: datetime + updated_at: datetime +``` + +### FrameResult +```python +class FrameResult(BaseModel): + frame_id: int + gps_center: GPSPoint + altitude: float + heading: float + confidence: float + refined: bool + timestamp: datetime + updated_at: datetime +``` + diff --git a/docs/02_components/helpers/h01_camera_model_spec.md b/docs/02_components/helpers/h01_camera_model_spec.md new file mode 100644 index 0000000..d3aecce --- /dev/null +++ b/docs/02_components/helpers/h01_camera_model_spec.md @@ -0,0 +1,101 @@ +# Camera Model Helper + +## Interface Definition + +**Interface Name**: `ICameraModel` + +### Interface Methods + +```python +class ICameraModel(ABC): + @abstractmethod + def project(self, point_3d: np.ndarray, camera_params: CameraParameters) -> Tuple[float, float]: + pass + + @abstractmethod + def unproject(self, pixel: Tuple[float, float], depth: float, camera_params: CameraParameters) -> np.ndarray: + pass + + @abstractmethod + def get_focal_length(self, camera_params: CameraParameters) -> Tuple[float, float]: + pass + + @abstractmethod + def apply_distortion(self, pixel: Tuple[float, float], distortion_coeffs: List[float]) -> Tuple[float, float]: + pass + + @abstractmethod + def remove_distortion(self, pixel: Tuple[float, float], distortion_coeffs: List[float]) -> Tuple[float, float]: + pass +``` + +## Component Description + +Pinhole camera projection model with Brown-Conrady distortion handling. + +## API Methods + +### `project(point_3d: np.ndarray, camera_params: CameraParameters) -> Tuple[float, float]` + +**Description**: Projects 3D point to 2D image pixel. + +**Formula**: +``` +x = fx * X/Z + cx +y = fy * Y/Z + cy +``` + +--- + +### `unproject(pixel: Tuple[float, float], depth: float, camera_params: CameraParameters) -> np.ndarray` + +**Description**: Unprojects pixel to 3D ray at given depth. + +**Formula**: +``` +X = (x - cx) * depth / fx +Y = (y - cy) * depth / fy +Z = depth +``` + +--- + +### `get_focal_length(camera_params: CameraParameters) -> Tuple[float, float]` + +**Description**: Returns (fx, fy) in pixels. + +**Formula**: +``` +fx = focal_length_mm * image_width / sensor_width_mm +fy = focal_length_mm * image_height / sensor_height_mm +``` + +--- + +### `apply_distortion(pixel: Tuple[float, float], distortion_coeffs: List[float]) -> Tuple[float, float]` + +**Description**: Applies radial and tangential distortion (Brown-Conrady model). + +--- + +### `remove_distortion(pixel: Tuple[float, float], distortion_coeffs: List[float]) -> Tuple[float, float]` + +**Description**: Removes distortion from observed pixel. + +## Dependencies + +**External**: opencv-python, numpy + +## Data Models + +```python +class CameraParameters(BaseModel): + focal_length: float # mm + sensor_width: float # mm + sensor_height: float # mm + resolution_width: int + resolution_height: int + principal_point: Tuple[float, float] # (cx, cy) pixels + distortion_coefficients: List[float] # [k1, k2, p1, p2, k3] +``` + diff --git a/docs/02_components/helpers/h02_gsd_calculator_spec.md b/docs/02_components/helpers/h02_gsd_calculator_spec.md new file mode 100644 index 0000000..c5339bb --- /dev/null +++ b/docs/02_components/helpers/h02_gsd_calculator_spec.md @@ -0,0 +1,78 @@ +# GSD Calculator Helper + +## Interface Definition + +**Interface Name**: `IGSDCalculator` + +### Interface Methods + +```python +class IGSDCalculator(ABC): + @abstractmethod + def compute_gsd(self, altitude: float, camera_params: CameraParameters) -> float: + pass + + @abstractmethod + def altitude_to_scale(self, altitude: float, focal_length: float) -> float: + pass + + @abstractmethod + def meters_per_pixel(self, lat: float, zoom: int) -> float: + pass + + @abstractmethod + def gsd_from_camera(self, altitude: float, focal_length: float, sensor_width: float, image_width: int) -> float: + pass +``` + +## Component Description + +Ground Sampling Distance computations for altitude and coordinate systems. + +## API Methods + +### `compute_gsd(altitude: float, camera_params: CameraParameters) -> float` + +**Description**: Computes GSD from altitude and camera parameters. + +**Formula**: +``` +GSD = (altitude * sensor_width) / (focal_length * image_width) +``` + +**Example**: altitude=800m, focal=24mm, sensor=36mm, width=6000px → GSD=0.2 m/pixel + +--- + +### `altitude_to_scale(altitude: float, focal_length: float) -> float` + +**Description**: Converts altitude to scale factor for VO. + +--- + +### `meters_per_pixel(lat: float, zoom: int) -> float` + +**Description**: Computes GSD for Web Mercator tiles at zoom level. + +**Formula**: +``` +meters_per_pixel = 156543.03392 * cos(lat * π/180) / 2^zoom +``` + +**Example**: lat=48°N, zoom=19 → ~0.3 m/pixel + +--- + +### `gsd_from_camera(altitude: float, focal_length: float, sensor_width: float, image_width: int) -> float` + +**Description**: Direct GSD calculation from parameters. + +## Dependencies + +**External**: numpy + +## Test Cases + +1. Standard camera at 800m → GSD ~0.1-0.3 m/pixel +2. Web Mercator zoom 19 at Ukraine → ~0.3 m/pixel + diff --git a/docs/02_components/helpers/h03_robust_kernels_spec.md b/docs/02_components/helpers/h03_robust_kernels_spec.md new file mode 100644 index 0000000..d85bded --- /dev/null +++ b/docs/02_components/helpers/h03_robust_kernels_spec.md @@ -0,0 +1,74 @@ +# Robust Kernels Helper + +## Interface Definition + +**Interface Name**: `IRobustKernels` + +### Interface Methods + +```python +class IRobustKernels(ABC): + @abstractmethod + def huber_loss(self, error: float, threshold: float) -> float: + pass + + @abstractmethod + def cauchy_loss(self, error: float, k: float) -> float: + pass + + @abstractmethod + def compute_weight(self, error: float, kernel_type: str, params: Dict[str, float]) -> float: + pass +``` + +## Component Description + +Huber/Cauchy loss functions for outlier rejection in optimization. + +## API Methods + +### `huber_loss(error: float, threshold: float) -> float` + +**Description**: Huber robust loss function. + +**Formula**: +``` +if |error| <= threshold: + loss = 0.5 * error^2 +else: + loss = threshold * (|error| - 0.5 * threshold) +``` + +**Purpose**: Quadratic for small errors, linear for large errors (outliers). + +--- + +### `cauchy_loss(error: float, k: float) -> float` + +**Description**: Cauchy robust loss function. + +**Formula**: +``` +loss = (k^2 / 2) * log(1 + (error/k)^2) +``` + +**Purpose**: More aggressive outlier rejection than Huber. + +--- + +### `compute_weight(error: float, kernel_type: str, params: Dict[str, float]) -> float` + +**Description**: Computes robust weight for error. + +**Usage**: Factor Graph applies weights to downweight outliers. + +## Dependencies + +**External**: numpy + +## Test Cases + +1. Small error → weight ≈ 1.0 +2. Large error (350m outlier) → weight ≈ 0.1 (downweighted) +3. Huber vs Cauchy → Cauchy more aggressive + diff --git a/docs/02_components/helpers/h04_faiss_index_manager_spec.md b/docs/02_components/helpers/h04_faiss_index_manager_spec.md new file mode 100644 index 0000000..ad956d0 --- /dev/null +++ b/docs/02_components/helpers/h04_faiss_index_manager_spec.md @@ -0,0 +1,84 @@ +# Faiss Index Manager Helper + +## Interface Definition + +**Interface Name**: `IFaissIndexManager` + +### Interface Methods + +```python +class IFaissIndexManager(ABC): + @abstractmethod + def build_index(self, descriptors: np.ndarray, index_type: str) -> FaissIndex: + pass + + @abstractmethod + def add_descriptors(self, index: FaissIndex, descriptors: np.ndarray) -> bool: + pass + + @abstractmethod + def search(self, index: FaissIndex, query: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]: + pass + + @abstractmethod + def save_index(self, index: FaissIndex, path: str) -> bool: + pass + + @abstractmethod + def load_index(self, path: str) -> FaissIndex: + pass +``` + +## Component Description + +Manages Faiss indices for AnyLoc retrieval (IVF, HNSW options). + +## API Methods + +### `build_index(descriptors: np.ndarray, index_type: str) -> FaissIndex` + +**Description**: Builds Faiss index from descriptors. + +**Index Types**: +- **"IVF"**: Inverted File (fast for large databases) +- **"HNSW"**: Hierarchical Navigable Small World (best accuracy/speed trade-off) +- **"Flat"**: Brute force (exact, slow for large datasets) + +**Input**: (N, D) descriptors array + +--- + +### `add_descriptors(index: FaissIndex, descriptors: np.ndarray) -> bool` + +**Description**: Adds more descriptors to existing index. + +--- + +### `search(index: FaissIndex, query: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]` + +**Description**: Searches for k nearest neighbors. + +**Output**: (distances, indices) - shape (k,) + +--- + +### `save_index(index: FaissIndex, path: str) -> bool` + +**Description**: Saves index to disk for fast startup. + +--- + +### `load_index(path: str) -> FaissIndex` + +**Description**: Loads pre-built index from disk. + +## Dependencies + +**External**: faiss-gpu or faiss-cpu + +## Test Cases + +1. Build index with 10,000 descriptors → succeeds +2. Search query → returns top-k matches +3. Save/load index → index restored correctly + diff --git a/docs/02_components/helpers/h05_performance_monitor_spec.md b/docs/02_components/helpers/h05_performance_monitor_spec.md new file mode 100644 index 0000000..c39534c --- /dev/null +++ b/docs/02_components/helpers/h05_performance_monitor_spec.md @@ -0,0 +1,93 @@ +# Performance Monitor Helper + +## Interface Definition + +**Interface Name**: `IPerformanceMonitor` + +### Interface Methods + +```python +class IPerformanceMonitor(ABC): + @abstractmethod + def start_timer(self, operation: str) -> str: + pass + + @abstractmethod + def end_timer(self, timer_id: str) -> float: + pass + + @abstractmethod + def get_statistics(self, operation: str) -> PerformanceStats: + pass + + @abstractmethod + def check_sla(self, operation: str, threshold: float) -> bool: + pass + + @abstractmethod + def get_bottlenecks(self) -> List[Tuple[str, float]]: + pass +``` + +## Component Description + +Tracks processing times, ensures <5s constraint per frame. + +## API Methods + +### `start_timer(operation: str) -> str` + +**Description**: Starts timing an operation. + +**Returns**: timer_id (UUID) + +--- + +### `end_timer(timer_id: str) -> float` + +**Description**: Ends timer and records duration. + +**Returns**: Duration in seconds + +--- + +### `get_statistics(operation: str) -> PerformanceStats` + +**Description**: Gets statistics for an operation. + +**Output**: +```python +PerformanceStats: + operation: str + count: int + mean: float + p50: float + p95: float + p99: float + max: float +``` + +--- + +### `check_sla(operation: str, threshold: float) -> bool` + +**Description**: Checks if operation meets SLA threshold. + +**Example**: check_sla("frame_processing", 5.0) → True if < 5s + +--- + +### `get_bottlenecks() -> List[Tuple[str, float]]` + +**Description**: Returns slowest operations. + +## Dependencies + +**External**: time, statistics + +## Test Cases + +1. Start/end timer → records duration +2. Get statistics → returns percentiles +3. Check SLA → returns True if meeting targets + diff --git a/docs/02_components/helpers/h06_web_mercator_utils_spec.md b/docs/02_components/helpers/h06_web_mercator_utils_spec.md new file mode 100644 index 0000000..35f488d --- /dev/null +++ b/docs/02_components/helpers/h06_web_mercator_utils_spec.md @@ -0,0 +1,94 @@ +# Web Mercator Utils Helper + +## Interface Definition + +**Interface Name**: `IWebMercatorUtils` + +### Interface Methods + +```python +class IWebMercatorUtils(ABC): + @abstractmethod + def latlon_to_tile(self, lat: float, lon: float, zoom: int) -> Tuple[int, int]: + pass + + @abstractmethod + def tile_to_latlon(self, x: int, y: int, zoom: int) -> Tuple[float, float]: + pass + + @abstractmethod + def compute_tile_bounds(self, x: int, y: int, zoom: int) -> TileBounds: + pass + + @abstractmethod + def get_zoom_gsd(self, lat: float, zoom: int) -> float: + pass +``` + +## Component Description + +Web Mercator projection (EPSG:3857) for tile coordinates. Used for Google Maps tiles. + +## API Methods + +### `latlon_to_tile(lat: float, lon: float, zoom: int) -> Tuple[int, int]` + +**Description**: Converts GPS to tile coordinates. + +**Formula**: +``` +n = 2^zoom +x = floor((lon + 180) / 360 * n) +lat_rad = lat * π / 180 +y = floor((1 - log(tan(lat_rad) + sec(lat_rad)) / π) / 2 * n) +``` + +**Returns**: (x, y) tile coordinates + +--- + +### `tile_to_latlon(x: int, y: int, zoom: int) -> Tuple[float, float]` + +**Description**: Converts tile coordinates to GPS (NW corner). + +**Formula** (inverse of above) + +--- + +### `compute_tile_bounds(x: int, y: int, zoom: int) -> TileBounds` + +**Description**: Computes GPS bounding box of tile. + +**Returns**: +```python +TileBounds: + nw: GPSPoint # North-West + ne: GPSPoint # North-East + sw: GPSPoint # South-West + se: GPSPoint # South-East + center: GPSPoint + gsd: float +``` + +--- + +### `get_zoom_gsd(lat: float, zoom: int) -> float` + +**Description**: Gets GSD for zoom level at latitude. + +**Formula**: +``` +gsd = 156543.03392 * cos(lat * π/180) / 2^zoom +``` + +## Dependencies + +**External**: numpy + +## Test Cases + +1. GPS to tile at zoom 19 → valid tile coords +2. Tile to GPS → inverse correct +3. Compute bounds → 4 corners valid +4. GSD at zoom 19, Ukraine → ~0.3 m/pixel + diff --git a/docs/02_components/helpers/h07_image_rotation_utils_spec.md b/docs/02_components/helpers/h07_image_rotation_utils_spec.md new file mode 100644 index 0000000..3d62ee9 --- /dev/null +++ b/docs/02_components/helpers/h07_image_rotation_utils_spec.md @@ -0,0 +1,92 @@ +# Image Rotation Utils Helper + +## Interface Definition + +**Interface Name**: `IImageRotationUtils` + +### Interface Methods + +```python +class IImageRotationUtils(ABC): + @abstractmethod + def rotate_image(self, image: np.ndarray, angle: float, center: Optional[Tuple[int, int]] = None) -> np.ndarray: + pass + + @abstractmethod + def calculate_rotation_from_points(self, src_points: np.ndarray, dst_points: np.ndarray) -> float: + pass + + @abstractmethod + def normalize_angle(self, angle: float) -> float: + pass + + @abstractmethod + def compute_rotation_matrix(self, angle: float, center: Tuple[int, int]) -> np.ndarray: + pass +``` + +## Component Description + +Image rotation operations, angle calculations from point shifts. + +## API Methods + +### `rotate_image(image: np.ndarray, angle: float, center: Optional[Tuple[int, int]] = None) -> np.ndarray` + +**Description**: Rotates image around center. + +**Implementation**: Uses cv2.getRotationMatrix2D + cv2.warpAffine + +**Parameters**: +- **angle**: Degrees (0-360) +- **center**: Rotation center (default: image center) + +**Returns**: Rotated image (same dimensions) + +--- + +### `calculate_rotation_from_points(src_points: np.ndarray, dst_points: np.ndarray) -> float` + +**Description**: Calculates rotation angle from point correspondences. + +**Input**: (N, 2) arrays of matching points + +**Algorithm**: +1. Compute centroids +2. Calculate angle from centroid shifts +3. Return angle in degrees + +**Use Case**: Extract precise angle from LiteSAM homography + +--- + +### `normalize_angle(angle: float) -> float` + +**Description**: Normalizes angle to 0-360 range. + +**Formula**: +``` +angle = angle % 360 +if angle < 0: + angle += 360 +``` + +--- + +### `compute_rotation_matrix(angle: float, center: Tuple[int, int]) -> np.ndarray` + +**Description**: Computes 2D rotation matrix. + +**Returns**: 2×3 affine transformation matrix + +## Dependencies + +**External**: opencv-python, numpy + +## Test Cases + +1. Rotate 90° → image rotated correctly +2. Calculate angle from points → accurate angle +3. Normalize 370° → 10° +4. Rotation matrix → correct transformation + diff --git a/docs/02_components/helpers/h08_batch_validator_spec.md b/docs/02_components/helpers/h08_batch_validator_spec.md new file mode 100644 index 0000000..9ae9dae --- /dev/null +++ b/docs/02_components/helpers/h08_batch_validator_spec.md @@ -0,0 +1,329 @@ +# Batch Validator Helper + +## Interface Definition + +**Interface Name**: `IBatchValidator` + +### Interface Methods + +```python +class IBatchValidator(ABC): + @abstractmethod + def validate_batch_size(self, batch: ImageBatch) -> ValidationResult: + pass + + @abstractmethod + def check_sequence_continuity(self, batch: ImageBatch, expected_start: int) -> ValidationResult: + pass + + @abstractmethod + def validate_naming_convention(self, filenames: List[str]) -> ValidationResult: + pass + + @abstractmethod + def validate_format(self, image_data: bytes) -> ValidationResult: + pass +``` + +## Component Description + +### Responsibilities +- Validate image batch integrity +- Check sequence continuity and naming conventions +- Validate image format and dimensions +- Ensure batch size constraints (10-50 images) +- Support strict sequential ordering (ADxxxxxx.jpg) + +### Scope +- Batch validation for G05 Image Input Pipeline +- Image format validation +- Filename pattern matching +- Sequence gap detection + +## API Methods + +### `validate_batch_size(batch: ImageBatch) -> ValidationResult` + +**Description**: Validates batch contains 10-50 images. + +**Called By**: +- G05 Image Input Pipeline (before queuing) + +**Input**: +```python +batch: ImageBatch: + images: List[bytes] + filenames: List[str] + start_sequence: int + end_sequence: int +``` + +**Output**: +```python +ValidationResult: + valid: bool + errors: List[str] +``` + +**Validation Rules**: +- **Minimum batch size**: 10 images +- **Maximum batch size**: 50 images +- **Reason**: Balance between upload overhead and processing granularity + +**Error Conditions**: +- Returns `valid=False` with error message (not an exception) + +**Test Cases**: +1. **Valid batch (20 images)**: Returns `valid=True` +2. **Too few images (5)**: Returns `valid=False`, error="Batch size 5 below minimum 10" +3. **Too many images (60)**: Returns `valid=False`, error="Batch size 60 exceeds maximum 50" +4. **Empty batch**: Returns `valid=False` + +--- + +### `check_sequence_continuity(batch: ImageBatch, expected_start: int) -> ValidationResult` + +**Description**: Validates images form consecutive sequence with no gaps. + +**Called By**: +- G05 Image Input Pipeline (before queuing) + +**Input**: +```python +batch: ImageBatch +expected_start: int # Expected starting sequence number +``` + +**Output**: +```python +ValidationResult: + valid: bool + errors: List[str] +``` + +**Validation Rules**: +1. **Sequence starts at expected_start**: First image sequence == expected_start +2. **Consecutive numbers**: No gaps in sequence (AD000101, AD000102, AD000103, ...) +3. **Filename extraction**: Parse sequence from ADxxxxxx.jpg pattern +4. **Strict ordering**: Images must be in sequential order + +**Algorithm**: +```python +sequences = [extract_sequence(filename) for filename in batch.filenames] +if sequences[0] != expected_start: + return invalid("Expected start {expected_start}, got {sequences[0]}") +for i in range(len(sequences) - 1): + if sequences[i+1] != sequences[i] + 1: + return invalid(f"Gap detected: {sequences[i]} -> {sequences[i+1]}") +return valid() +``` + +**Error Conditions**: +- Returns `valid=False` with specific gap information + +**Test Cases**: +1. **Valid sequence (101-150)**: expected_start=101 → valid=True +2. **Wrong start**: expected_start=101, got 102 → valid=False +3. **Gap in sequence**: AD000101, AD000103 (missing 102) → valid=False +4. **Out of order**: AD000102, AD000101 → valid=False + +--- + +### `validate_naming_convention(filenames: List[str]) -> ValidationResult` + +**Description**: Validates filenames match ADxxxxxx.jpg pattern. + +**Called By**: +- Internal (during check_sequence_continuity) +- G05 Image Input Pipeline + +**Input**: +```python +filenames: List[str] +``` + +**Output**: +```python +ValidationResult: + valid: bool + errors: List[str] +``` + +**Validation Rules**: +1. **Pattern**: `AD\d{6}\.(jpg|JPG|png|PNG)` +2. **Examples**: AD000001.jpg, AD000237.JPG, AD002000.png +3. **Case insensitive**: Accepts .jpg, .JPG, .Jpg +4. **6 digits required**: Zero-padded to 6 digits + +**Regex Pattern**: `^AD\d{6}\.(jpg|JPG|png|PNG)$` + +**Error Conditions**: +- Returns `valid=False` listing invalid filenames + +**Test Cases**: +1. **Valid names**: ["AD000001.jpg", "AD000002.jpg"] → valid=True +2. **Invalid prefix**: "IMG_0001.jpg" → valid=False +3. **Wrong digit count**: "AD001.jpg" (3 digits) → valid=False +4. **Missing extension**: "AD000001" → valid=False +5. **Invalid extension**: "AD000001.bmp" → valid=False + +--- + +### `validate_format(image_data: bytes) -> ValidationResult` + +**Description**: Validates image file format and properties. + +**Called By**: +- G05 Image Input Pipeline (per-image validation) + +**Input**: +```python +image_data: bytes # Raw image file bytes +``` + +**Output**: +```python +ValidationResult: + valid: bool + errors: List[str] +``` + +**Validation Rules**: +1. **Format**: Valid JPEG or PNG +2. **Dimensions**: 640×480 to 6252×4168 pixels +3. **File size**: < 10MB per image +4. **Image readable**: Not corrupted +5. **Color channels**: RGB (3 channels) + +**Algorithm**: +```python +try: + image = PIL.Image.open(BytesIO(image_data)) + width, height = image.size + + if image.format not in ['JPEG', 'PNG']: + return invalid("Format must be JPEG or PNG") + + if width < 640 or height < 480: + return invalid("Dimensions too small") + + if width > 6252 or height > 4168: + return invalid("Dimensions too large") + + if len(image_data) > 10 * 1024 * 1024: + return invalid("File size exceeds 10MB") + + return valid() +except Exception as e: + return invalid(f"Corrupted image: {e}") +``` + +**Error Conditions**: +- Returns `valid=False` with specific error + +**Test Cases**: +1. **Valid JPEG (2048×1536)**: valid=True +2. **Valid PNG (6252×4168)**: valid=True +3. **Too small (320×240)**: valid=False +4. **Too large (8000×6000)**: valid=False +5. **File too big (15MB)**: valid=False +6. **Corrupted file**: valid=False +7. **BMP format**: valid=False + +## Integration Tests + +### Test 1: Complete Batch Validation +1. Create batch with 20 images, AD000101.jpg - AD000120.jpg +2. validate_batch_size() → valid +3. validate_naming_convention() → valid +4. check_sequence_continuity(expected_start=101) → valid +5. validate_format() for each image → all valid + +### Test 2: Invalid Batch Detection +1. Create batch with 60 images → validate_batch_size() fails +2. Create batch with gap (AD000101, AD000103) → check_sequence_continuity() fails +3. Create batch with IMG_0001.jpg → validate_naming_convention() fails +4. Create batch with corrupted image → validate_format() fails + +### Test 3: Edge Cases +1. Batch with exactly 10 images → valid +2. Batch with exactly 50 images → valid +3. Batch with 51 images → invalid +4. Batch starting at AD999995.jpg (near max) → valid + +## Non-Functional Requirements + +### Performance +- **validate_batch_size**: < 1ms +- **check_sequence_continuity**: < 10ms for 50 images +- **validate_naming_convention**: < 5ms for 50 filenames +- **validate_format**: < 20ms per image (with PIL) +- **Total batch validation**: < 100ms for 50 images + +### Reliability +- Never raises exceptions (returns ValidationResult with errors) +- Handles edge cases gracefully +- Clear, actionable error messages + +### Maintainability +- Configurable validation rules (min/max batch size, dimensions) +- Easy to add new validation rules +- Comprehensive error reporting + +## Dependencies + +### Internal Components +- None (pure utility, no internal dependencies) + +### External Dependencies +- **Pillow (PIL)**: Image format validation and dimension checking +- **re** (regex): Filename pattern matching + +## Data Models + +### ImageBatch +```python +class ImageBatch(BaseModel): + images: List[bytes] # Raw image data + filenames: List[str] # e.g., ["AD000101.jpg", ...] + start_sequence: int # 101 + end_sequence: int # 150 + batch_number: int # Sequential batch number +``` + +### ValidationResult +```python +class ValidationResult(BaseModel): + valid: bool + errors: List[str] = [] # Empty if valid + warnings: List[str] = [] # Optional warnings +``` + +### ValidationRules (Configuration) +```python +class ValidationRules(BaseModel): + min_batch_size: int = 10 + max_batch_size: int = 50 + min_width: int = 640 + min_height: int = 480 + max_width: int = 6252 + max_height: int = 4168 + max_file_size_mb: int = 10 + allowed_formats: List[str] = ["JPEG", "PNG"] + filename_pattern: str = r"^AD\d{6}\.(jpg|JPG|png|PNG)$" +``` + +### Sequence Extraction +```python +def extract_sequence(filename: str) -> int: + """ + Extracts sequence number from filename. + + Example: "AD000237.jpg" -> 237 + """ + match = re.match(r"AD(\d{6})\.", filename) + if match: + return int(match.group(1)) + raise ValueError(f"Invalid filename format: {filename}") +``` + diff --git a/docs/02_components/route_01_route_rest_api/route_rest_api_spec.md b/docs/02_components/route_01_route_rest_api/route_rest_api_spec.md new file mode 100644 index 0000000..b7c8133 --- /dev/null +++ b/docs/02_components/route_01_route_rest_api/route_rest_api_spec.md @@ -0,0 +1,289 @@ +# Route REST API + +## Interface Definition + +**Interface Name**: `IRouteRestAPI` + +### Interface Methods + +```python +class IRouteRestAPI(ABC): + @abstractmethod + def create_route(self, route_data: RouteCreateRequest) -> RouteResponse: + pass + + @abstractmethod + def get_route(self, route_id: str) -> RouteResponse: + pass + + @abstractmethod + def update_waypoints(self, route_id: str, waypoints: List[Waypoint]) -> UpdateResponse: + pass + + @abstractmethod + def delete_route(self, route_id: str) -> DeleteResponse: + pass +``` + +## Component Description + +### Responsibilities +- Expose REST API endpoints for route lifecycle management +- Handle HTTP request validation and routing +- Coordinate with Route Data Manager for persistence operations +- Validate incoming requests through Waypoint Validator +- Return appropriate HTTP responses with proper status codes + +### Scope +- CRUD operations for routes +- Waypoint management within routes +- Geofence management +- Route metadata retrieval +- Used by both GPS-Denied system and Mission Planner + +## API Methods + +### `create_route(route_data: RouteCreateRequest) -> RouteResponse` + +**Description**: Creates a new route with initial waypoints and geofences. + +**Called By**: +- Client applications (GPS-Denied UI, Mission Planner UI) + +**Input**: +```python +RouteCreateRequest: + id: Optional[str] # UUID, generated if not provided + name: str + description: str + points: List[GPSPoint] # Initial rough waypoints + geofences: Geofences +``` + +**Output**: +```python +RouteResponse: + route_id: str + created: bool + timestamp: datetime +``` + +**Error Conditions**: +- `400 Bad Request`: Invalid input data (missing required fields, invalid GPS coordinates) +- `409 Conflict`: Route with same ID already exists +- `500 Internal Server Error`: Database or internal error + +**Test Cases**: +1. **Valid route creation**: Provide valid route data → returns 201 with routeId +2. **Missing required field**: Omit name → returns 400 with error message +3. **Invalid GPS coordinates**: Provide lat > 90 → returns 400 +4. **Duplicate route ID**: Create route with existing ID → returns 409 + +--- + +### `get_route(route_id: str) -> RouteResponse` + +**Description**: Retrieves complete route information including all waypoints and geofences. + +**Called By**: +- Client applications +- G03 Route API Client (from GPS-Denied system) + +**Input**: +```python +route_id: str # UUID +``` + +**Output**: +```python +RouteResponse: + route_id: str + name: str + description: str + points: List[Waypoint] # All waypoints with metadata + geofences: Geofences + created_at: datetime + updated_at: datetime +``` + +**Error Conditions**: +- `404 Not Found`: Route ID does not exist +- `500 Internal Server Error`: Database error + +**Test Cases**: +1. **Existing route**: Valid routeId → returns 200 with complete route data +2. **Non-existent route**: Invalid routeId → returns 404 +3. **Route with many waypoints**: Route with 2000+ waypoints → returns 200 with all data + +--- + +### `update_waypoints(route_id: str, waypoint_id: str, waypoint_data: Waypoint) -> UpdateResponse` + +**Description**: Updates a specific waypoint within a route. Used for per-frame GPS refinement from GPS-Denied system. + +**Called By**: +- G03 Route API Client (per-frame updates) +- Client applications (manual corrections) + +**Input**: +```python +route_id: str +waypoint_id: str # Frame sequence number or waypoint ID +waypoint_data: Waypoint: + lat: float + lon: float + altitude: Optional[float] + confidence: float + timestamp: datetime + refined: bool # True if updated by GPS-Denied refinement +``` + +**Output**: +```python +UpdateResponse: + updated: bool + waypoint_id: str +``` + +**Error Conditions**: +- `404 Not Found`: Route or waypoint not found +- `400 Bad Request`: Invalid waypoint data +- `500 Internal Server Error`: Database error + +**Test Cases**: +1. **Update existing waypoint**: Valid data → returns 200 +2. **Refinement update**: GPS-Denied sends refined coordinates → updates successfully +3. **Invalid coordinates**: lat > 90 → returns 400 +4. **Non-existent waypoint**: Invalid waypoint_id → returns 404 + +--- + +### `delete_route(route_id: str) -> DeleteResponse` + +**Description**: Deletes a route and all associated waypoints. + +**Called By**: +- Client applications + +**Input**: +```python +route_id: str +``` + +**Output**: +```python +DeleteResponse: + deleted: bool + route_id: str +``` + +**Error Conditions**: +- `404 Not Found`: Route does not exist +- `500 Internal Server Error`: Database error + +**Test Cases**: +1. **Delete existing route**: Valid routeId → returns 200 +2. **Delete non-existent route**: Invalid routeId → returns 404 +3. **Delete route with active flight**: Route linked to processing flight → returns 200 (cascade handling in DB) + +## Integration Tests + +### Test 1: Route Creation and Retrieval Flow +1. POST `/routes` with valid data +2. Verify 201 response with routeId +3. GET `/routes/{routeId}` +4. Verify returned data matches created data + +### Test 2: GPS-Denied Integration Flow +1. Create route via POST +2. Simulate GPS-Denied per-frame updates via PUT `/routes/{routeId}/waypoints/{waypointId}` × 100 +3. GET route and verify all waypoints updated +4. Verify `refined: true` flag set + +### Test 3: Concurrent Waypoint Updates +1. Create route +2. Send 50 concurrent PUT requests to different waypoints +3. Verify all updates succeed +4. Verify data consistency + +## Non-Functional Requirements + +### Performance +- **Create route**: < 500ms response time +- **Get route**: < 200ms for routes with < 2000 waypoints +- **Update waypoint**: < 100ms (critical for GPS-Denied real-time updates) +- **Delete route**: < 300ms +- **Throughput**: Handle 100 concurrent waypoint updates per second + +### Scalability +- Support 1000+ concurrent route processing sessions +- Handle routes with up to 3000 waypoints + +### Availability +- 99.9% uptime SLA +- Graceful degradation under load + +### Security +- Input validation on all endpoints +- SQL injection prevention +- Rate limiting: 1000 requests/minute per client + +## Dependencies + +### Internal Components +- **R02 Route Data Manager**: For all data persistence operations +- **R03 Waypoint Validator**: For input validation +- **R04 Route Database Layer**: Indirectly through Data Manager + +### External Dependencies +- **FastAPI/Flask**: Web framework +- **Pydantic**: Request/response validation +- **Uvicorn**: ASGI server + +## Data Models + +### RouteCreateRequest +```python +class GPSPoint(BaseModel): + lat: float # Latitude -90 to 90 + lon: float # Longitude -180 to 180 + +class Polygon(BaseModel): + north_west: GPSPoint + south_east: GPSPoint + +class Geofences(BaseModel): + polygons: List[Polygon] + +class RouteCreateRequest(BaseModel): + id: Optional[str] = None # UUID + name: str + description: str + points: List[GPSPoint] # Initial rough waypoints + geofences: Geofences +``` + +### Waypoint +```python +class Waypoint(BaseModel): + id: str # Sequence number or UUID + lat: float + lon: float + altitude: Optional[float] = None + confidence: float # 0.0 to 1.0 + timestamp: datetime + refined: bool = False +``` + +### RouteResponse +```python +class RouteResponse(BaseModel): + route_id: str + name: str + description: str + points: List[Waypoint] + geofences: Geofences + created_at: datetime + updated_at: datetime +``` + diff --git a/docs/02_components/route_02_route_data_manager/route_data_manager_spec.md b/docs/02_components/route_02_route_data_manager/route_data_manager_spec.md new file mode 100644 index 0000000..2820ade --- /dev/null +++ b/docs/02_components/route_02_route_data_manager/route_data_manager_spec.md @@ -0,0 +1,338 @@ +# Route Data Manager + +## Interface Definition + +**Interface Name**: `IRouteDataManager` + +### Interface Methods + +```python +class IRouteDataManager(ABC): + @abstractmethod + def save_route(self, route: Route) -> str: + pass + + @abstractmethod + def load_route(self, route_id: str) -> Optional[Route]: + pass + + @abstractmethod + def update_waypoint(self, route_id: str, waypoint_id: str, waypoint: Waypoint) -> bool: + pass + + @abstractmethod + def delete_waypoint(self, route_id: str, waypoint_id: str) -> bool: + pass + + @abstractmethod + def get_route_metadata(self, route_id: str) -> Optional[RouteMetadata]: + pass + + @abstractmethod + def delete_route(self, route_id: str) -> bool: + pass +``` + +## Component Description + +### Responsibilities +- Manage route persistence and retrieval +- Coordinate with Route Database Layer for data operations +- Handle waypoint CRUD operations within routes +- Manage route metadata (timestamps, statistics) +- Ensure data consistency and transaction management + +### Scope +- Business logic layer between REST API and Database Layer +- Route lifecycle management +- Waypoint batch operations +- Query optimization for large route datasets +- Caching layer for frequently accessed routes (optional) + +## API Methods + +### `save_route(route: Route) -> str` + +**Description**: Persists a new route with initial waypoints and geofences. + +**Called By**: +- R01 Route REST API + +**Input**: +```python +Route: + id: Optional[str] # Generated if not provided + name: str + description: str + points: List[Waypoint] + geofences: Geofences +``` + +**Output**: +```python +route_id: str # UUID of saved route +``` + +**Error Conditions**: +- `DuplicateRouteError`: Route with same ID exists +- `ValidationError`: Invalid route data +- `DatabaseError`: Database connection or constraint violation + +**Test Cases**: +1. **New route**: Valid route → returns routeId, verifies in DB +2. **Route with 1000 waypoints**: Large route → saves successfully +3. **Duplicate ID**: Existing route ID → raises DuplicateRouteError +4. **Transaction rollback**: DB error mid-save → no partial data persisted + +--- + +### `load_route(route_id: str) -> Optional[Route]` + +**Description**: Retrieves complete route data including all waypoints. + +**Called By**: +- R01 Route REST API +- R03 Waypoint Validator (for context validation) + +**Input**: +```python +route_id: str +``` + +**Output**: +```python +Route or None if not found +``` + +**Error Conditions**: +- `DatabaseError`: Database connection error +- Returns `None`: Route not found (not an error condition) + +**Test Cases**: +1. **Existing route**: Valid ID → returns complete Route object +2. **Non-existent route**: Invalid ID → returns None +3. **Large route**: 3000 waypoints → returns all data efficiently +4. **Concurrent reads**: Multiple simultaneous loads → all succeed + +--- + +### `update_waypoint(route_id: str, waypoint_id: str, waypoint: Waypoint) -> bool` + +**Description**: Updates a single waypoint within a route. Optimized for high-frequency GPS-Denied updates. + +**Called By**: +- R01 Route REST API + +**Input**: +```python +route_id: str +waypoint_id: str +waypoint: Waypoint +``` + +**Output**: +```python +bool: True if updated, False if route/waypoint not found +``` + +**Error Conditions**: +- `ValidationError`: Invalid waypoint data +- `DatabaseError`: Database error + +**Test Cases**: +1. **Update existing waypoint**: Valid data → returns True +2. **Non-existent waypoint**: Invalid waypoint_id → returns False +3. **Concurrent updates**: 100 simultaneous updates to different waypoints → all succeed +4. **Update timestamp**: Automatically updates route.updated_at + +--- + +### `delete_waypoint(route_id: str, waypoint_id: str) -> bool` + +**Description**: Deletes a specific waypoint from a route. + +**Called By**: +- R01 Route REST API (rare, for manual corrections) + +**Input**: +```python +route_id: str +waypoint_id: str +``` + +**Output**: +```python +bool: True if deleted, False if not found +``` + +**Error Conditions**: +- `DatabaseError`: Database error + +**Test Cases**: +1. **Delete existing waypoint**: Valid IDs → returns True +2. **Delete non-existent waypoint**: Invalid ID → returns False +3. **Delete all waypoints**: Delete all waypoints one by one → succeeds + +--- + +### `get_route_metadata(route_id: str) -> Optional[RouteMetadata]` + +**Description**: Retrieves route metadata without loading all waypoints (lightweight operation). + +**Called By**: +- R01 Route REST API +- Client applications (route listing) + +**Input**: +```python +route_id: str +``` + +**Output**: +```python +RouteMetadata: + route_id: str + name: str + description: str + waypoint_count: int + created_at: datetime + updated_at: datetime +``` + +**Error Conditions**: +- Returns `None`: Route not found + +**Test Cases**: +1. **Get metadata**: Valid ID → returns metadata without waypoints +2. **Performance**: Metadata retrieval < 50ms even for 3000-waypoint route + +--- + +### `delete_route(route_id: str) -> bool` + +**Description**: Deletes a route and all associated waypoints. + +**Called By**: +- R01 Route REST API + +**Input**: +```python +route_id: str +``` + +**Output**: +```python +bool: True if deleted, False if not found +``` + +**Error Conditions**: +- `DatabaseError`: Database error + +**Test Cases**: +1. **Delete route**: Valid ID → deletes route and all waypoints +2. **Cascade delete**: Verify all waypoints deleted +3. **Non-existent route**: Invalid ID → returns False + +## Integration Tests + +### Test 1: Complete Route Lifecycle +1. save_route() with 100 waypoints +2. load_route() and verify all data +3. update_waypoint() for 50 waypoints +4. delete_waypoint() for 10 waypoints +5. get_route_metadata() and verify count +6. delete_route() and verify removal + +### Test 2: High-Frequency Update Simulation (GPS-Denied Pattern) +1. save_route() with 2000 waypoints +2. Simulate per-frame updates: update_waypoint() × 2000 in sequence +3. Verify all updates persisted correctly +4. Measure total time < 200 seconds (100ms per update) + +### Test 3: Concurrent Route Operations +1. Create 10 routes concurrently +2. Update different waypoints in parallel (100 concurrent updates) +3. Delete 5 routes concurrently while updating others +4. Verify data consistency + +## Non-Functional Requirements + +### Performance +- **save_route**: < 300ms for routes with 100 waypoints +- **load_route**: < 150ms for routes with 2000 waypoints +- **update_waypoint**: < 50ms (critical path for GPS-Denied) +- **get_route_metadata**: < 30ms +- **delete_route**: < 200ms + +### Scalability +- Support 1000+ concurrent route operations +- Handle routes with up to 3000 waypoints efficiently +- Optimize for read-heavy workload (90% reads, 10% writes) + +### Reliability +- ACID transaction guarantees +- Automatic retry on transient database errors (3 attempts) +- Data validation before persistence + +### Maintainability +- Clear separation from database implementation +- Support for future caching layer integration +- Comprehensive error handling and logging + +## Dependencies + +### Internal Components +- **R03 Waypoint Validator**: Validates waypoints before persistence +- **R04 Route Database Layer**: For all database operations + +### External Dependencies +- None (pure business logic layer) + +## Data Models + +### Route +```python +class Route(BaseModel): + id: str # UUID + name: str + description: str + points: List[Waypoint] + geofences: Geofences + created_at: datetime + updated_at: datetime +``` + +### RouteMetadata +```python +class RouteMetadata(BaseModel): + route_id: str + name: str + description: str + waypoint_count: int + geofence_count: int + created_at: datetime + updated_at: datetime +``` + +### Waypoint +```python +class Waypoint(BaseModel): + id: str + lat: float + lon: float + altitude: Optional[float] + confidence: float + timestamp: datetime + refined: bool +``` + +### Geofences +```python +class Polygon(BaseModel): + north_west: GPSPoint + south_east: GPSPoint + +class Geofences(BaseModel): + polygons: List[Polygon] +``` + diff --git a/docs/02_components/route_03_waypoint_validator/waypoint_validator_spec.md b/docs/02_components/route_03_waypoint_validator/waypoint_validator_spec.md new file mode 100644 index 0000000..18c6f97 --- /dev/null +++ b/docs/02_components/route_03_waypoint_validator/waypoint_validator_spec.md @@ -0,0 +1,294 @@ +# Waypoint Validator + +## Interface Definition + +**Interface Name**: `IWaypointValidator` + +### Interface Methods + +```python +class IWaypointValidator(ABC): + @abstractmethod + def validate_waypoint(self, waypoint: Waypoint) -> ValidationResult: + pass + + @abstractmethod + def validate_geofence(self, geofence: Geofences) -> ValidationResult: + pass + + @abstractmethod + def check_bounds(self, waypoint: Waypoint, geofences: Geofences) -> bool: + pass + + @abstractmethod + def validate_route_continuity(self, waypoints: List[Waypoint]) -> ValidationResult: + pass +``` + +## Component Description + +### Responsibilities +- Validate individual waypoint data (GPS coordinates, altitude, confidence) +- Validate geofence definitions (polygon bounds, topology) +- Check waypoints against geofence boundaries +- Validate route continuity (detect large gaps, validate sequencing) +- Provide detailed validation error messages + +### Scope +- Input validation for Route API +- Business rule enforcement (operational area restrictions for Ukraine) +- Geospatial boundary checking +- Data quality assurance + +## API Methods + +### `validate_waypoint(waypoint: Waypoint) -> ValidationResult` + +**Description**: Validates a single waypoint's data integrity and constraints. + +**Called By**: +- R01 Route REST API (before creating/updating) +- R02 Route Data Manager (pre-persistence validation) + +**Input**: +```python +Waypoint: + id: str + lat: float + lon: float + altitude: Optional[float] + confidence: float + timestamp: datetime + refined: bool +``` + +**Output**: +```python +ValidationResult: + valid: bool + errors: List[str] +``` + +**Validation Rules**: +1. **Latitude**: -90.0 <= lat <= 90.0 +2. **Longitude**: -180.0 <= lon <= 180.0 +3. **Altitude**: 0 <= altitude <= 1000 meters (if provided) +4. **Confidence**: 0.0 <= confidence <= 1.0 +5. **Timestamp**: Not in future, not older than 1 year +6. **Operational area** (Ukraine restriction): Latitude ~45-52N, Longitude ~22-40E +7. **ID**: Non-empty string + +**Error Conditions**: +- Returns `ValidationResult` with `valid=False` and error list (not an exception) + +**Test Cases**: +1. **Valid waypoint**: All fields correct → returns `valid=True` +2. **Invalid latitude**: lat=100 → returns `valid=False`, error="Latitude out of range" +3. **Invalid longitude**: lon=200 → returns `valid=False` +4. **Invalid confidence**: confidence=1.5 → returns `valid=False` +5. **Future timestamp**: timestamp=tomorrow → returns `valid=False` +6. **Outside operational area**: lat=10 (not Ukraine) → returns `valid=False` +7. **Valid altitude**: altitude=500 → returns `valid=True` +8. **Invalid altitude**: altitude=1500 → returns `valid=False` + +--- + +### `validate_geofence(geofence: Geofences) -> ValidationResult` + +**Description**: Validates geofence polygon definitions. + +**Called By**: +- R01 Route REST API (during route creation) + +**Input**: +```python +Geofences: + polygons: List[Polygon] + +Polygon: + north_west: GPSPoint + south_east: GPSPoint +``` + +**Output**: +```python +ValidationResult: + valid: bool + errors: List[str] +``` + +**Validation Rules**: +1. **North-West corner**: NW.lat > SE.lat +2. **North-West corner**: NW.lon < SE.lon (for Eastern Ukraine) +3. **Polygon size**: Max 500km × 500km +4. **Polygon count**: 1 <= len(polygons) <= 10 +5. **No self-intersection**: Polygons should not overlap +6. **Within operational area**: All corners within Ukraine bounds + +**Error Conditions**: +- Returns `ValidationResult` with validation errors + +**Test Cases**: +1. **Valid geofence**: Single polygon in Ukraine → valid=True +2. **Invalid corners**: NW.lat < SE.lat → valid=False +3. **Too large**: 600km × 600km → valid=False +4. **Too many polygons**: 15 polygons → valid=False +5. **Overlapping polygons**: Two overlapping → valid=False (warning) + +--- + +### `check_bounds(waypoint: Waypoint, geofences: Geofences) -> bool` + +**Description**: Checks if a waypoint falls within geofence boundaries. + +**Called By**: +- R01 Route REST API (optional check during waypoint updates) +- R02 Route Data Manager (business rule enforcement) + +**Input**: +```python +waypoint: Waypoint +geofences: Geofences +``` + +**Output**: +```python +bool: True if waypoint is within any geofence polygon +``` + +**Algorithm**: +- Point-in-polygon test for each geofence polygon +- Returns True if point is inside at least one polygon + +**Error Conditions**: +- None (returns False if outside all geofences) + +**Test Cases**: +1. **Inside geofence**: Waypoint in polygon center → returns True +2. **Outside geofence**: Waypoint 10km outside → returns False +3. **On boundary**: Waypoint on polygon edge → returns True +4. **Multiple geofences**: Waypoint in second polygon → returns True + +--- + +### `validate_route_continuity(waypoints: List[Waypoint]) -> ValidationResult` + +**Description**: Validates route continuity, detecting large gaps and sequence issues. + +**Called By**: +- R01 Route REST API (during route creation) +- R02 Route Data Manager (route quality check) + +**Input**: +```python +waypoints: List[Waypoint] # Should be ordered by sequence/timestamp +``` + +**Output**: +```python +ValidationResult: + valid: bool + errors: List[str] + warnings: List[str] +``` + +**Validation Rules**: +1. **Minimum waypoints**: len(waypoints) >= 2 +2. **Maximum waypoints**: len(waypoints) <= 3000 +3. **Timestamp ordering**: waypoints[i].timestamp < waypoints[i+1].timestamp +4. **Distance gaps**: Consecutive waypoints < 500 meters apart (warning if violated) +5. **Large gap detection**: Flag gaps > 1km (warning for potential data loss) +6. **No duplicate timestamps**: All timestamps unique + +**Error Conditions**: +- Returns `ValidationResult` with errors and warnings + +**Test Cases**: +1. **Valid route**: 100 waypoints, 100m spacing → valid=True +2. **Too few waypoints**: 1 waypoint → valid=False +3. **Too many waypoints**: 3500 waypoints → valid=False +4. **Unordered timestamps**: waypoints out of order → valid=False +5. **Large gap**: 2km gap between waypoints → valid=True with warning +6. **Duplicate timestamps**: Two waypoints same time → valid=False + +## Integration Tests + +### Test 1: Complete Validation Pipeline +1. Create waypoint with all valid data +2. validate_waypoint() → passes +3. Create geofence for Eastern Ukraine +4. validate_geofence() → passes +5. check_bounds() → waypoint inside geofence + +### Test 2: Route Validation Flow +1. Create 500 waypoints with 100m spacing +2. validate_route_continuity() → passes +3. Add waypoint 2km away +4. validate_route_continuity() → passes with warning +5. Add waypoint with past timestamp +6. validate_route_continuity() → fails + +### Test 3: Edge Cases +1. Waypoint on geofence boundary +2. Waypoint at North Pole (lat=90) +3. Waypoint at dateline (lon=180) +4. Route with exactly 3000 waypoints + +## Non-Functional Requirements + +### Performance +- **validate_waypoint**: < 1ms per waypoint +- **validate_geofence**: < 10ms per geofence +- **check_bounds**: < 2ms per check +- **validate_route_continuity**: < 100ms for 2000 waypoints + +### Accuracy +- GPS coordinate validation: 6 decimal places precision (0.1m) +- Geofence boundary check: 1-meter precision + +### Maintainability +- Validation rules configurable via configuration file +- Easy to add new validation rules +- Clear error messages for debugging + +## Dependencies + +### Internal Components +- **R04 Route Database Layer**: For loading existing route data (optional context) +- **H06 Web Mercator Utils**: For distance calculations (optional) + +### External Dependencies +- **Shapely** (optional): For advanced polygon operations +- **Geopy**: For geodesic distance calculations + +## Data Models + +### ValidationResult +```python +class ValidationResult(BaseModel): + valid: bool + errors: List[str] = [] + warnings: List[str] = [] +``` + +### OperationalArea (Configuration) +```python +class OperationalArea(BaseModel): + name: str = "Eastern Ukraine" + min_lat: float = 45.0 + max_lat: float = 52.0 + min_lon: float = 22.0 + max_lon: float = 40.0 +``` + +### ValidationRules (Configuration) +```python +class ValidationRules(BaseModel): + max_altitude: float = 1000.0 # meters + max_waypoint_gap: float = 500.0 # meters + max_route_waypoints: int = 3000 + min_route_waypoints: int = 2 + max_geofence_size: float = 500000.0 # meters (500km) + max_geofences: int = 10 +``` + diff --git a/docs/02_components/route_04_route_database_layer/route_database_layer_spec.md b/docs/02_components/route_04_route_database_layer/route_database_layer_spec.md new file mode 100644 index 0000000..a9bc844 --- /dev/null +++ b/docs/02_components/route_04_route_database_layer/route_database_layer_spec.md @@ -0,0 +1,475 @@ +# Route Database Layer + +## Interface Definition + +**Interface Name**: `IRouteDatabase` + +### Interface Methods + +```python +class IRouteDatabase(ABC): + @abstractmethod + def insert_route(self, route: Route) -> str: + pass + + @abstractmethod + def update_route(self, route: Route) -> bool: + pass + + @abstractmethod + def query_routes(self, filters: Dict[str, Any], limit: int, offset: int) -> List[Route]: + pass + + @abstractmethod + def get_route_by_id(self, route_id: str) -> Optional[Route]: + pass + + @abstractmethod + def get_waypoints(self, route_id: str, limit: Optional[int] = None) -> List[Waypoint]: + pass + + @abstractmethod + def insert_waypoint(self, route_id: str, waypoint: Waypoint) -> str: + pass + + @abstractmethod + def update_waypoint(self, route_id: str, waypoint_id: str, waypoint: Waypoint) -> bool: + pass + + @abstractmethod + def delete_route(self, route_id: str) -> bool: + pass +``` + +## Component Description + +### Responsibilities +- Direct database access layer for route data +- Execute SQL queries and commands +- Manage database connections and transactions +- Handle connection pooling and retry logic +- Provide database abstraction for potential migration (PostgreSQL, MySQL, etc.) + +### Scope +- CRUD operations on routes table +- CRUD operations on waypoints table +- CRUD operations on geofences table +- Query optimization for large datasets +- Database schema management +- Separate schema from GPS-Denied API database + +## API Methods + +### `insert_route(route: Route) -> str` + +**Description**: Inserts a new route with initial waypoints and geofences into the database. + +**Called By**: +- R02 Route Data Manager + +**Input**: +```python +Route: + id: str + name: str + description: str + points: List[Waypoint] + geofences: Geofences + created_at: datetime + updated_at: datetime +``` + +**Output**: +```python +route_id: str # Inserted route ID +``` + +**Database Operations**: +1. Begin transaction +2. INSERT INTO routes (id, name, description, created_at, updated_at) +3. INSERT INTO waypoints (route_id, ...) for each waypoint +4. INSERT INTO geofences (route_id, ...) for each polygon +5. Commit transaction + +**Error Conditions**: +- `IntegrityError`: Duplicate route_id (unique constraint violation) +- `DatabaseError`: Connection error, transaction failure +- Automatic rollback on any error + +**Test Cases**: +1. **Insert route with 100 waypoints**: Successful insertion, all waypoints persisted +2. **Duplicate route_id**: Raises IntegrityError +3. **Transaction rollback**: Error on waypoint insertion → route also rolled back +4. **Connection loss**: Mid-transaction error → graceful rollback + +--- + +### `update_route(route: Route) -> bool` + +**Description**: Updates route metadata (name, description, updated_at). + +**Called By**: +- R02 Route Data Manager + +**Input**: +```python +Route with updated fields +``` + +**Output**: +```python +bool: True if updated, False if route not found +``` + +**Database Operations**: +```sql +UPDATE routes +SET name = ?, description = ?, updated_at = ? +WHERE id = ? +``` + +**Error Conditions**: +- `DatabaseError`: Connection or query error + +**Test Cases**: +1. **Update existing route**: Returns True +2. **Update non-existent route**: Returns False +3. **Update with same data**: Succeeds, updates timestamp + +--- + +### `query_routes(filters: Dict[str, Any], limit: int, offset: int) -> List[Route]` + +**Description**: Queries routes with filtering, pagination for route listing. + +**Called By**: +- R02 Route Data Manager +- R01 Route REST API (list endpoints) + +**Input**: +```python +filters: Dict[str, Any] # e.g., {"name": "Mission%", "created_after": datetime} +limit: int # Max results +offset: int # For pagination +``` + +**Output**: +```python +List[Route] # Routes without full waypoint data (metadata only) +``` + +**Database Operations**: +```sql +SELECT * FROM routes +WHERE name LIKE ? AND created_at > ? +ORDER BY created_at DESC +LIMIT ? OFFSET ? +``` + +**Error Conditions**: +- `DatabaseError`: Query error + +**Test Cases**: +1. **Filter by name**: Returns matching routes +2. **Pagination**: offset=100, limit=50 → returns routes 100-149 +3. **Empty result**: No matches → returns [] +4. **No filters**: Returns all routes (with limit) + +--- + +### `get_route_by_id(route_id: str) -> Optional[Route]` + +**Description**: Retrieves complete route with all waypoints by ID. + +**Called By**: +- R02 Route Data Manager + +**Input**: +```python +route_id: str +``` + +**Output**: +```python +Optional[Route] # Complete route with all waypoints, or None +``` + +**Database Operations**: +1. SELECT FROM routes WHERE id = ? +2. SELECT FROM waypoints WHERE route_id = ? ORDER BY timestamp +3. SELECT FROM geofences WHERE route_id = ? +4. Assemble Route object + +**Error Conditions**: +- `DatabaseError`: Query error +- Returns None if route not found + +**Test Cases**: +1. **Existing route**: Returns complete Route object +2. **Non-existent route**: Returns None +3. **Large route (3000 waypoints)**: Returns all data within 150ms +4. **Route with no waypoints**: Returns route with empty points list + +--- + +### `get_waypoints(route_id: str, limit: Optional[int] = None) -> List[Waypoint]` + +**Description**: Retrieves waypoints for a route, optionally limited. + +**Called By**: +- R02 Route Data Manager + +**Input**: +```python +route_id: str +limit: Optional[int] # For pagination or preview +``` + +**Output**: +```python +List[Waypoint] +``` + +**Database Operations**: +```sql +SELECT * FROM waypoints +WHERE route_id = ? +ORDER BY timestamp ASC +LIMIT ? -- if limit provided +``` + +**Error Conditions**: +- `DatabaseError`: Query error + +**Test Cases**: +1. **All waypoints**: limit=None → returns all +2. **Limited waypoints**: limit=100 → returns first 100 +3. **No waypoints**: Empty list + +--- + +### `insert_waypoint(route_id: str, waypoint: Waypoint) -> str` + +**Description**: Inserts a new waypoint into a route. + +**Called By**: +- R02 Route Data Manager + +**Input**: +```python +route_id: str +waypoint: Waypoint +``` + +**Output**: +```python +waypoint_id: str +``` + +**Database Operations**: +```sql +INSERT INTO waypoints (id, route_id, lat, lon, altitude, confidence, timestamp, refined) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +``` + +**Error Conditions**: +- `ForeignKeyError`: route_id doesn't exist +- `IntegrityError`: Duplicate waypoint_id + +**Test Cases**: +1. **Valid insertion**: Returns waypoint_id +2. **Non-existent route**: Raises ForeignKeyError + +--- + +### `update_waypoint(route_id: str, waypoint_id: str, waypoint: Waypoint) -> bool` + +**Description**: Updates a waypoint. Critical path for GPS-Denied per-frame updates. + +**Called By**: +- R02 Route Data Manager + +**Input**: +```python +route_id: str +waypoint_id: str +waypoint: Waypoint +``` + +**Output**: +```python +bool: True if updated, False if not found +``` + +**Database Operations**: +```sql +UPDATE waypoints +SET lat = ?, lon = ?, altitude = ?, confidence = ?, refined = ? +WHERE id = ? AND route_id = ? +``` + +**Optimization**: +- Prepared statement caching +- Connection pooling for high throughput +- Indexed on (route_id, id) for fast lookups + +**Error Conditions**: +- `DatabaseError`: Query error + +**Test Cases**: +1. **Update existing waypoint**: Returns True, updates data +2. **Non-existent waypoint**: Returns False +3. **High-frequency updates**: 100 updates/sec sustained for 20 seconds + +--- + +### `delete_route(route_id: str) -> bool` + +**Description**: Deletes a route and cascades to waypoints and geofences. + +**Called By**: +- R02 Route Data Manager + +**Input**: +```python +route_id: str +``` + +**Output**: +```python +bool: True if deleted, False if not found +``` + +**Database Operations**: +```sql +DELETE FROM routes WHERE id = ? +-- Cascade deletes from waypoints and geofences via FK constraints +``` + +**Error Conditions**: +- `DatabaseError`: Query error + +**Test Cases**: +1. **Delete route with waypoints**: Deletes route and all waypoints +2. **Verify cascade**: Check waypoints table empty for route_id +3. **Non-existent route**: Returns False + +## Integration Tests + +### Test 1: Complete Database Lifecycle +1. insert_route() with 500 waypoints +2. get_route_by_id() and verify all data +3. update_waypoint() × 100 +4. query_routes() with filters +5. delete_route() and verify removal + +### Test 2: High-Frequency Update Pattern (GPS-Denied Simulation) +1. insert_route() with 2000 waypoints +2. update_waypoint() × 2000 sequentially +3. Measure total time and throughput +4. Verify all updates persisted correctly + +### Test 3: Concurrent Access +1. Insert 10 routes concurrently +2. Update waypoints in parallel (100 concurrent connections) +3. Query routes while updates occurring +4. Verify no deadlocks or data corruption + +### Test 4: Transaction Integrity +1. Begin insert_route() transaction +2. Simulate error mid-waypoint insertion +3. Verify complete rollback (no partial data) + +## Non-Functional Requirements + +### Performance +- **insert_route**: < 200ms for 100 waypoints +- **update_waypoint**: < 30ms (critical path) +- **get_route_by_id**: < 100ms for 2000 waypoints +- **query_routes**: < 150ms for pagination queries +- **Throughput**: 200+ waypoint updates per second + +### Scalability +- Connection pool: 50-100 connections +- Support 1000+ concurrent operations +- Handle tables with millions of waypoints + +### Reliability +- ACID transaction guarantees +- Automatic retry on transient errors (3 attempts with exponential backoff) +- Connection health checks +- Graceful degradation on connection pool exhaustion + +### Security +- SQL injection prevention (parameterized queries only) +- Principle of least privilege (database user permissions) +- Connection string encryption + +## Dependencies + +### Internal Components +- None (lowest layer) + +### External Dependencies +- **PostgreSQL** or **MySQL**: Relational database +- **SQLAlchemy** or **psycopg2**: Database driver +- **Alembic**: Schema migration tool + +## Data Models + +### Database Schema + +```sql +-- Routes table +CREATE TABLE routes ( + id VARCHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_created_at (created_at), + INDEX idx_name (name) +); + +-- Waypoints table +CREATE TABLE waypoints ( + id VARCHAR(36) PRIMARY KEY, + route_id VARCHAR(36) NOT NULL, + lat DECIMAL(10, 7) NOT NULL, + lon DECIMAL(11, 7) NOT NULL, + altitude DECIMAL(7, 2), + confidence DECIMAL(3, 2) NOT NULL, + timestamp TIMESTAMP NOT NULL, + refined BOOLEAN NOT NULL DEFAULT FALSE, + FOREIGN KEY (route_id) REFERENCES routes(id) ON DELETE CASCADE, + INDEX idx_route_timestamp (route_id, timestamp), + INDEX idx_route_id (route_id, id) +); + +-- Geofences table +CREATE TABLE geofences ( + id VARCHAR(36) PRIMARY KEY, + route_id VARCHAR(36) NOT NULL, + nw_lat DECIMAL(10, 7) NOT NULL, + nw_lon DECIMAL(11, 7) NOT NULL, + se_lat DECIMAL(10, 7) NOT NULL, + se_lon DECIMAL(11, 7) NOT NULL, + FOREIGN KEY (route_id) REFERENCES routes(id) ON DELETE CASCADE, + INDEX idx_route_id (route_id) +); +``` + +### Connection Configuration +```python +class DatabaseConfig(BaseModel): + host: str + port: int + database: str + username: str + password: str + pool_size: int = 50 + max_overflow: int = 50 + pool_timeout: int = 30 + pool_recycle: int = 3600 +``` + diff --git a/docs/_metodology/tutorial.md b/docs/_metodology/tutorial.md index 6c312a5..4447993 100644 --- a/docs/_metodology/tutorial.md +++ b/docs/_metodology/tutorial.md @@ -92,6 +92,13 @@ ### Revise - Revise the plan, answer questions, put detailed descriptions - Make sure stored components are coherent and make sense + - Ask AI + ``` + analyze carefully interaction between components. All covered? also, state that each component should implement interface, so that they could be interchangeable with different implementations + ``` + + ### Store plan + save plan to `docs/02_components/00_decomposition_plan.md` ## 2.2 **🤖📋AI plan**: Generate tests

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