From 4388408ef891e5d5e604047e9787f11a11c89b33 Mon Sep 17 00:00:00 2001 From: grigo Date: Mon, 6 Jul 2026 13:13:14 +0300 Subject: [PATCH] added GFSK --- .../loratester/api/TrackInfo.java | 1 + .../loratester/ui/MapFragment.java | 80 +++++++++++++++++- app/src/main/res/values/strings.xml | 1 + .../core/__pycache__/storage.cpython-313.pyc | Bin 59218 -> 59805 bytes server/core/storage.py | 15 +++- server/static/app.js | 49 +++++++++-- .../test_schema.cpython-313-pytest-9.0.3.pyc | Bin 26809 -> 29840 bytes server/tests/test_schema.py | 27 ++++++ 8 files changed, 164 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/grigowashere/loratester/api/TrackInfo.java b/app/src/main/java/com/grigowashere/loratester/api/TrackInfo.java index 46e9ba7..207aa32 100644 --- a/app/src/main/java/com/grigowashere/loratester/api/TrackInfo.java +++ b/app/src/main/java/com/grigowashere/loratester/api/TrackInfo.java @@ -7,4 +7,5 @@ public class TrackInfo { public Double ended_at; public String label; public int point_count; + public String modem_mode; } diff --git a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java index f4fa3c6..e1b53cf 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java @@ -46,6 +46,8 @@ import com.grigowashere.loratester.location.GeoUtils; import com.grigowashere.loratester.net.NetworkMonitor; import com.grigowashere.loratester.map.EsriWorldImagery; import com.grigowashere.loratester.model.RadioSnapshot; +import com.grigowashere.loratester.telnet.LoraStatsFormatter; +import com.grigowashere.loratester.telnet.ModemMode; import com.grigowashere.loratester.telnet.StatsExtractor; import com.grigowashere.loratester.track.TrackRecorder; @@ -776,7 +778,8 @@ public class MapFragment extends Fragment { String title = info.label != null && !info.label.isBlank() ? info.label : ("Репер #" + info.id); - labels[i] = "#" + info.id + " · " + title; + String mode = modemModeSuffix(modemModeFromReference(info)); + labels[i] = "#" + info.id + mode + " · " + title; checked[i] = selected.contains(info.id); } new AlertDialog.Builder(requireContext()) @@ -1146,7 +1149,8 @@ public class MapFragment extends Fragment { labels.add(getString(R.string.track_spinner_hint)); for (TrackInfo t : savedTracks) { String start = timeFormat.format(new Date((long) (t.started_at * 1000))); - labels.add("#" + t.id + " " + start + " (" + t.point_count + " pts)"); + String mode = modemModeSuffix(modemModeFromTrack(t)); + labels.add("#" + t.id + mode + " " + start + " (" + t.point_count + " pts)"); } if (savedTracks.isEmpty()) { labels.set(0, getString(R.string.track_none)); @@ -1214,6 +1218,78 @@ public class MapFragment extends Fragment { } fitBoundsOnce(boundsPoints, detail.points.size() == 1, true); + String mode = modemModeFromTrackDetail(detail); + if (mapStatus != null && mode != null) { + mapStatus.setText(getString( + R.string.track_loaded_mode, + detail.id, + LoraStatsFormatter.modemModeLabel(mode) + )); + } + } + + private static String modemModeSuffix(String mode) { + if (mode == null || mode.isBlank()) { + return ""; + } + return " · " + LoraStatsFormatter.modemModeLabel(mode); + } + + @SuppressWarnings("unchecked") + private static String modemModeFromTxParams(Map txParams) { + if (txParams == null) { + return null; + } + Object mode = txParams.get("modem_mode"); + if (mode != null) { + return ModemMode.normalize(String.valueOf(mode)); + } + if (txParams.get("bitrate_bps") != null) { + return ModemMode.GFSK; + } + if (txParams.get("spreading_factor") != null) { + return ModemMode.LORA; + } + return null; + } + + @SuppressWarnings("unchecked") + private static String modemModeFromReference(ReferencePointInfo info) { + if (info == null || info.reference_context == null) { + return null; + } + Object txParams = info.reference_context.get("tx_params"); + if (txParams instanceof Map) { + return modemModeFromTxParams((Map) txParams); + } + return null; + } + + private static String modemModeFromTrack(TrackInfo track) { + if (track != null && track.modem_mode != null && !track.modem_mode.isBlank()) { + return ModemMode.normalize(track.modem_mode); + } + return null; + } + + private static String modemModeFromTrackDetail(TrackDetail detail) { + if (detail == null || detail.points == null) { + return null; + } + for (int i = detail.points.size() - 1; i >= 0; i--) { + TrackDetail.TrackPoint p = detail.points.get(i); + RadioSnapshot snap = RadioSnapshot.fromMeta(p.meta, null, null); + if (snap.modemMode != null) { + return snap.modemMode; + } + if (snap.bitrateBps != null) { + return ModemMode.GFSK; + } + if (snap.sf != null) { + return ModemMode.LORA; + } + } + return null; } private static boolean isRxTrack(TrackDetail detail) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index efed0ff..e02217a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,6 +82,7 @@ Реперов на карте: %1$d RX: %1$.6f, %2$.6f · дальность %3$s м Выберите реперную точку + Трек #%1$d · %2$s Сохранённые треки — нет треков — Это устройство diff --git a/server/core/__pycache__/storage.cpython-313.pyc b/server/core/__pycache__/storage.cpython-313.pyc index 5c122e9a3931aa90ee468967e248264b4222eed8..260a6a8e0274f6c8affa10e7a2f82667efd5315b 100644 GIT binary patch delta 3077 zcmaJ@eNa@_6@Pah_pvM=e)18b_(;HI6%-*F#gB@z3W$rc0#&KY!Y*bhEO2)rNMiho z4fSItdQ5_2O-&P8SK}0? zRvv*EnalkgQj5~f=5mhXs9YFzbc(8#rbG4F#HbO{lB>edcAHgmD)cwgMB<8~E0x+f zr6Mw5nyv&)}NQ@{$sL6@^)sE^#ii8bU?iEfi=LUbn~RU+=2J zM4QC7s+E+M<&UUk>h=<+-B{_N-0hBn`#2la7B761Ui`iU^Bo2HNIm5F;+c%6>iJF` zHvKFC^f?jZu!qv&Urn@z6*VI&9BodB z>9pJ#3#y|faye5hqyA9^9ggJmACSE}{_Yo5Oo@3sFcw#MEni=hu3n6dUo={GREI*)&d=0v$*O`>T6 znWBy0X@rm(Xl=Bq!tnh7TB_3d$4F0+2dZ|NaK#MQJbnX-BI$EIK`FdiJ)QN!K(&?s z9cichcl8-o@CBOUHo-p$z9hJVV2c&E$Q~m2oFGi_t?qEAWYk4Lse+8Yk5Pb88< z5&{;Hwqhg$s-caGc;rJY5K^1HA{5!u>Td7_H;4})r8%8Xz`6HiVe>ANW=6B)46NvT z1kSgQfrVSf!_K}KcrICkDS@l38@>uGFzF1gY)yo+;3S@g{sYh!yv}s#Y&~R(){zjM zxMoKMTzM`D657{ksl-i!j`mOS9IV|iJC=IO79|YdcsR6S9k-#&kW@PyoK1$6ds1Zc zroD^}OLa>Y8`0cFZY^SJT*#9}jsu+W1U#uaKv99MFv1dJ{7#-Y6&!1l_@a za8SflnXxy9X~SsSV&T@xly&0g#8Y*}1Hy$gL3!I^MzS4Alsw%u#?HdsUl#EiB+6v*u`2eY7fn$|u%5s}@Dza$0ngUU^djd7&Ld#8jNS6U zCvT_ndUUA-^?29l2UXdhs zHuFdew@@M#u(7+8Z$SD*IoQ2B!fbeOQ3rKBv-mT}ACa&1NXDLp@(X$VIV9ef`!1-A zr9#)m$=OC8QNmAJn{iTXA=pY_9NukYJx{Qm;3*ioID`KP%|V&=>q^Eu(fku^zLdqb z%43(ZRQ4}<{rwy^)`~pcaTH{{WDV&3NgAy$F2jQU$*dE+{Zn#&Oav_#V(sE9%p$~U zLRyobdQ?=+JfN}i`Ab-2)<#rNRM4SBnNK#ip|L)-aMsia#2Rn9k1S)cm(a3l!b;1{BG^k{bn!RR{GQ``k3NP5 zIJ)cf2%Q3VlQ4l*x(1~>yNkv>^|3NoaRO>;9&ojz4UCZQhaXFiO9LBDTxo zKg2Qo?ySBx$xL##NG!(YYeoDgILMO&*HTp01-Cvfh^0*t^pe=KIxGlf^Pl2iJp@80 zSpYKr_*!ki!j`lzQ!XvW) delta 2704 zcmZuz3s98T75?x3d-ug7C?bjm77)lvPy~(m4k{o7L55c_47e_<*$NBZe<2jLLd2jA zc8EuOl)*zVi$uttfV-oQErACe1Kg z(G20rs!-UrYYKQSgh1PhXsGWE<)#ga!wOk>=iux72bKnVX0ct=*UGqp23BDfStuel z6T~3+E$DJrYmMvuHlJ|1HdVXoYc=5{zY9S#BiI8UVMp5^RO``v)q1QZBr?BZ81_f_ zwlHamF>1<~MVT7g)iS22an>=bd2;YB+x;+Dy=HSxx?+3e-g2fy$K4Ey`+3Ph*1584 z!q0LJm5c<%4IAVBzJg)Eo``S1#F*fCZ6Zn^%bd2{uD)%FSU$)2w%xXTmf6K1b8wQ3 zDr#2wZ!qF_@<2%gW2eDV7R}EhF(#ACQW-k~mF3e+gGl^?$!+E585@K}6|;jbAS0e6 zI1P;z3H%b8sytFrU|0ZMBh3?gIhx<6=56~=!w2dzXeNTN9+zP?ujg2Lo{RHCc5;2Rs zx{mEbtfOW(0i`EM#Lq>2qqjzowg3c2Sl|$h(1i#hSTwUXC^0?e0 zh)63zC_xQ%_Yf2k=x1%KVny?})DD3PZyFEB$Qs$@eOaA5nL0%xI8;Gf5lD>|*TKak z)V9i*T`N zl*P!y&mA=&y{x6xWJg*=!VGC$x6G7KZB*L9`51)lmHd(gw z6H~-Y>Y%UBLh>&n_zsFXXN2j>>!_{Ehr`a!CO#L#b5HH*F|h>bZHtCC;TMs~B%v=7 z*L+T&CJLz2OsVY)#3PNuqwUtKRE# zH~T~i@kx94FXkC!$;|yVs_8VvEr3@KJ=@QPo1G!zc>i_>p;h4K}X9Uyi&S^&dI$6msLm z{KlGU`QSm zoH`ixu!_8Y>W&)w5KFSS@%q{+u$CndR^*pS? zOOx7;PSHxxMxbBQ=gHbZ&`z)s7F?ObccOV&)?X=SyaUbm;rcr%M(P93j>&T3yOUIQ zUnaem#!?fJ#1gg+e*$x*)eannJ{nT5e)L)k%1jKw?(2!H1NyJeOxr~SiTYWy_$P+= z*?PaJ-b*J}l#)}=O}{nmq!0taLn*uqJ)?5z&C-83K^G5C zv))nPKuS`SLeX$C??#i$w&4N8_PxaFYXD^FgY84hVG>C++!F+Q3G}o5Uuiz>I*-%G zt{TU1JAL#wTy&B!K@>W)gXnBkMyqbEiDjewrPz`Livb@ccJs<&P%>E#gg2nedx zg37L-N=Fs2uEA657R!jz3ksDHK~+g$RVYvdRRKXaU(lTwP6E0Wg6@%^t0L%L1Wug3 zZTh_F;OLVNYzr-pI1G0`c$D?a;1AQ)$KzT2!HlCT4lNl|&^)8+KW~*>opDtgE-Am- Ud~Mc9R>e2u8_SoQK29_J8;)eV_5c6? diff --git a/server/core/storage.py b/server/core/storage.py index 293d251..62c9d8b 100644 --- a/server/core/storage.py +++ b/server/core/storage.py @@ -435,11 +435,24 @@ def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[s WHERE p.track_id = t.id AND p.role IS NOT NULL AND p.role != '' ORDER BY p.ts DESC LIMIT 1) """ + modem_mode_sub = """ + (SELECT COALESCE( + json_extract(p.meta, '$.modem_mode'), + CASE + WHEN json_extract(p.meta, '$.bitrate_bps') IS NOT NULL THEN 'GFSK' + WHEN json_extract(p.meta, '$.spreading_factor') IS NOT NULL THEN 'LORA' + ELSE NULL + END + ) FROM track_points p + WHERE p.track_id = t.id AND p.meta IS NOT NULL AND p.meta != '' + ORDER BY p.ts DESC LIMIT 1) + """ track_cols = f""" SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label, d.label AS device_label, (SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count, - {role_sub} AS role + {role_sub} AS role, + {modem_mode_sub} AS modem_mode FROM tracks t LEFT JOIN devices d ON d.device_id = t.device_id """ diff --git a/server/static/app.js b/server/static/app.js index 98b28ba..08e5f28 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -1028,6 +1028,37 @@ function referenceSessionById(id) { return referencePointSessions.find(s => String(s.id) === String(id)) || null; } +function modemModeFromTxParams(params) { + if (!params) return null; + if (params.modem_mode) return RadioUI.normalizeModemMode(params.modem_mode); + if (params.bitrate_bps != null) return RadioUI.MODEM_GFSK; + if (params.spreading_factor != null) return RadioUI.MODEM_LORA; + return null; +} + +function modemModeFromTrack(track) { + if (!track) return null; + if (track.modem_mode) return RadioUI.normalizeModemMode(track.modem_mode); + const points = track.points; + if (!points?.length) return null; + for (let i = points.length - 1; i >= 0; i--) { + let meta = points[i].meta; + if (!meta) continue; + if (typeof meta === 'string') { + try { meta = JSON.parse(meta); } catch (e) { continue; } + } + if (meta.modem_mode) return RadioUI.normalizeModemMode(meta.modem_mode); + if (meta.bitrate_bps != null) return RadioUI.MODEM_GFSK; + if (meta.spreading_factor != null) return RadioUI.MODEM_LORA; + } + return null; +} + +function modemModeSuffix(mode) { + const label = mode ? RadioUI.modemModeLabel(mode) : ''; + return label && label !== '—' ? ` · ${label}` : ''; +} + function renderReferencePointList() { const list = document.getElementById('referencePointList'); if (!list) return; @@ -1043,9 +1074,10 @@ function renderReferencePointList() { if (checked) row.classList.add('selected'); const ctx = session.reference_context || {}; const dist = ctx.distance_m != null ? ` · ${Number(ctx.distance_m).toFixed(0)} м` : ''; + const mode = modemModeSuffix(modemModeFromTxParams(ctx.tx_params)); row.innerHTML = ` ${escapeHtml(session.label || 'Репер #' + session.id)} - #${session.id} · RX${dist}`; + #${session.id} · RX${mode}${dist}`; const checkbox = row.querySelector('input'); checkbox.addEventListener('change', () => { const sid = String(session.id); @@ -1093,13 +1125,14 @@ function showReferencePointsOnMap() { if (!rxPos || rxPos.lat == null || rxPos.lon == null) return; const rxLatLng = [Number(rxPos.lat), Number(rxPos.lon)]; bounds.push(rxLatLng); + const refMode = modemModeSuffix(modemModeFromTxParams(ctx.tx_params)); const rxMarker = L.circleMarker(rxLatLng, { radius: 9, color: '#00ff88', weight: 2, fillColor: '#00ff88', fillOpacity: 0.25 - }).addTo(map).bindPopup(`RX репер #${session.id}`); + }).addTo(map).bindPopup(`RX репер #${session.id}${refMode}`); let txMarker = null; let line = null; if (txPos && txPos.lat != null && txPos.lon != null) { @@ -1259,8 +1292,9 @@ async function showReferencePoint(sessionId) { const data = await res.json(); const stats = data.stats || {}; const ctx = data.reference_context || {}; + const refMode = modemModeSuffix(modemModeFromTxParams(ctx.tx_params)); if (summary) { - summary.innerHTML = `
${escapeHtml(data.label || 'Реперная точка')} #${data.id} · RX
+ summary.innerHTML = `
${escapeHtml(data.label || 'Реперная точка')} #${data.id} · RX${refMode}
${escapeHtml(deviceDisplayName(data))} · samples: ${data.sample_count || 0} · ${escapeHtml(data.status || '')}
Контекст
${escapeHtml(formatReferenceTxParams(ctx.tx_params))}
@@ -4329,8 +4363,9 @@ async function refreshTimelineTelemetry() { function trackOptionLabel(t) { const start = new Date(t.started_at * 1000).toLocaleString(); const role = t.role ? ` · ${t.role}` : ''; + const mode = modemModeSuffix(modemModeFromTrack(t)); const dev = t.device_id ? ` · ${deviceDisplayName(t)}` : ''; - return `#${t.id}${role}${dev} · ${start} (${t.point_count})`; + return `#${t.id}${mode}${role}${dev} · ${start} (${t.point_count})`; } async function loadAllTracks() { @@ -4408,7 +4443,7 @@ async function showSingleTrack() { updateTimelineAtSingle(timelineCursor()); } document.getElementById('trackInfo').textContent = - `Трек #${loadedSingleTrack.id} (${loadedSingleTrack.points.length} точек)`; + `Трек #${loadedSingleTrack.id} (${loadedSingleTrack.points.length} точек)${modemModeSuffix(modemModeFromTrack(loadedSingleTrack))}`; updateTrackButtons(); } @@ -4470,8 +4505,10 @@ async function showDualTracks() { rebuildQualityViz(); const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : ''; + const txMode = modemModeSuffix(modemModeFromTrack(loadedTxTrack)); + const rxMode = modemModeSuffix(modemModeFromTrack(loadedRxTrack)); document.getElementById('trackInfo').textContent = - `TX #${loadedTxTrack.id} (${loadedTxTrack.points.length}) + RX #${loadedRxTrack.id} (${loadedRxTrack.points.length})${modeHint}`; + `TX #${loadedTxTrack.id} (${loadedTxTrack.points.length})${txMode} + RX #${loadedRxTrack.id} (${loadedRxTrack.points.length})${rxMode}${modeHint}`; updateTrackButtons(); } diff --git a/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc b/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc index 9e8fc021a7482c3111760e33a275a16f5d51f1df..9fc0b3a395dbda13518b4ade558232c7b9779aec 100644 GIT binary patch delta 3404 zcmb`KeN0=|6~Ny$&&D4Ze}KWkKpqAH566rUTnOVUNXxKkyJnl#q}DWLTf0o{CQa)!AX$@Htu}3uCjOb!cIwjpvFw~@ z2MC?EfA$1F&OP_MbKgDp+;cD27RlT)$^O)8HFNMB-MRPZkG%8QZn89A@jJ6*FW~6L zQ6f67-YqkbFA_zWU4+ypCXB&MQpVo3wUB0}v$wXE!@Jak*o^QaDiGBO5mANkA&dYq zljfq_gs5e+b{`3_AKG`4-HDIwG9e9#gU&x?k_J{^u*=YaMxE?*K_%&CKPY(J-VBOl zM*Mg!EyS)C7V>nGa(2bzWnN#dfTJW$qp~`n($Mf2mCXB~u*_A1$}Nam#8yNd zYb|u=>;f@qX^S5p2q}t8)c|d0M+^6nsl-nTgF5&$>qZMt9%dU#tMm8bYx@9-4j>3L z2NhkczjOzD{41q_jR!%|BWBQU_?t8G@Y+y{v0|S)UkhU#4NR#p+ObbzA9v+v(mIq?cCoCUoKD;>8-`%ArcE0R?-)3) z-^sT7JK6U(Idv0Gw%`2n*_1l=06N`=}e=H8Oj zbRazDl||ttOYBZCyKxW4#qP1|d45+`&;|i0I@Gx)0L2i1{X}4=4ivElQE&B;M z(=XPh|GZcQiMB^ojn%xiY<5YIN`m%uEF2gM;$ZU)`3>e~66Kpo{9mJ-=~4#VA}YlM#oL%wxbA>ib3?u=gGI&wJ90`{Y%K4XN4x}+`= z?xeWa#f+q`x+Y0fN};9TAc8Qf?fRstX;hBNQ*qjoELCMCU44{+VLCYzu5KES4Tr{+ zmg*I&W9Y#HxVy~Fatrpr`S!VEZ}68!ujE{co^{MS&bNQAV6l8UtB^2LA)#Z?5Xp32 zd1d@~ji?9_MTo{!0h?PzCCQJDC}X0IVtJ%qL@A<-;~446^$lYqjF1r{h$DyyKy<7r zHBt6?RT-Aj8LTu(%XnmEHc1|)sIIN1=mBey=2gr|6(pQnGGinXrT?+cVy^+c%3=Bf zzB`G)0ze-^sEA!0=jZ6-D7GV>Kxkoy<$J3ZN?9SWyo(0}Br!H7LxA^qW67TtX z-AXGZ{NVxJ;%W;z=G1k@&;*Oy7F)+$)}p0ofv>ojr(w}jaf?s?Eb$c!mZDVi<{J41 zTCZ5G2%u))+JBB<;9%zlgu9xv;5B7FvRCjiq~PkQ5U63VK#amz{iHPrv_%|-uv z!r0OYqn%4pbonBJ;R#i;j<#L;>+s48iRap0)k7Gzv|r1t!!S&nusZnH!~&G`+lcQV zz7&+=&bJ#bV2?I+Oe2c3Sk|NTUBp$yTZlQtJVHPWGEdi28_Q9=hWIH$d+%o`{T%T& z=1C#u3EXmWQG?T=?;w7GcnjyG*K&e&wYgZ)BYypFp<^l0^~mdZ4g0M7l#%?HP4$+N z6YS+)v5tNNx~GlGNWCBHZ~9x%*Mp}9y@9&lBTliqy$x0w#j^mBr_HSGu%Fyy(Zl^; zSyv*nS4(6vXEKg=!-0v=FpUkwsjSEfh>J@SuhfA&QFdGFpm z`W|}xHDFn*yILOM(uu~@8YMtZn&Po1WI7NYnxOBZ{jX4IggJyJhQ{TjO_8JF0W~rq z$0lKdik9JU(_93zFU;F}d>uQRcehJ>r)}GN*!$sqolEHC1B4b>Eg1xC4kFgYUK{Zy zc4#O2UqFJ7<55wLxQ%(?)bhfZ%8S*oVTNCp1BSnXp^+r+$o<6GfDVMDacD9!u12B? z-OPGMw;HtnK3dsxqhrB(bh{fdg*c5khroJ4uOl$8CT5Jm~Ulw**zH(dU`w)TF zLQGoYB`KaqA8bQXYt=@&wT2kHR#T0dm}nZmhuW7utci(RbkEA z9nYNfMQRPJCabNs)+JW)1fUpr6nGf$07ZZscm%KtxZ`;S?A<^aowgOT3c6z3%NoOX zZ2`u-;bUn(#xpPNu{T)S5VW73w->QaTC!iTRSSm^7n&~Td6?Qm!Q6J{qHDQ@74p>W z@NPWSNC*aWMd!YOVeZHD+J|~*OP-6BP?4Nb91rvfwG< zJm`+&otRA2bRX9Pe#NKrqgc{Oe>%$*pD-f~V+WuBCj{Jy+yq++p9*UX*erWKjx`BU z&25O}0CLVYX~LvBYX+^j9Be*(%QXP~>&tUYrVdOwJQ?jlVZ_ADb$-NW#s8TtvxOm{ zWI!+6Rd&*C;fI8iN3Tg(hKXyM$`8Z23qTfp4sZYpov%tS?uR)5$omMu!ZCbEz@54- zzfsC9aW(&MZui}3bPxH)LfCXdz=#`E$D&Ewku=u<+%BI48i1#1$eT~*(lqlKF+3Jt z^d5p7f9(7r^&!Y%n5KQ)&krE>Al+#ywDrL31-gL~l-2y4{a-PnGDv}>7PEA>~C8%3-zpB%kdRZZzdr)s11A8Nr+W1051af zY5Dt+mAaQO1I6Uy<48VY#_$#3HQ=0pkvKZ;4~#0HVcOStp!J?)KlMC&SXRpGh`k8N z^>4ts1iT46FiSBZif!#7y@klPfpdUt8+5a`-u%88z8Btl^a47ZOhlx5!`V`xI2+Yxq;7%K=!LW>o`wIBoCSFa+LmqaWb`SMs z{ijA!{<$xDXDDZrE4nq5EjQfI?kuV!N!I`8I8`=*k{d8(6+D6SWw#p~=c7LT@pWYq ztq>7F^Le>MYL59EF}N9CQcof4vXMw!!xiCCe_OtttKMB*UFBEBkY5vsl){ov4n4$e z