From 4891933879b9f96f6c01cd4a52bdb90c272df0d4 Mon Sep 17 00:00:00 2001 From: grigo Date: Wed, 17 Jun 2026 13:03:11 +0300 Subject: [PATCH] added opentopo --- server/README.md | 16 +- .../core/__pycache__/config.cpython-313.pyc | Bin 1630 -> 1994 bytes .../__pycache__/elevation.cpython-313.pyc | Bin 22916 -> 30894 bytes server/core/config.py | 15 +- server/core/elevation.py | 276 +++++++++++++++--- server/docker-compose.yml | 2 + ...est_elevation.cpython-313-pytest-9.0.3.pyc | Bin 23554 -> 26920 bytes server/tests/test_elevation.py | 118 +++++++- 8 files changed, 369 insertions(+), 58 deletions(-) diff --git a/server/README.md b/server/README.md index 316713c..945401e 100644 --- a/server/README.md +++ b/server/README.md @@ -40,14 +40,17 @@ docker compose up -d --build curl http://127.0.0.1:7634/api/health | jq ``` -Ожидается `"elevation_ok": true` если локальный Open-Meteo доступен с хоста/контейнера. +Ожидается `"elevation_ok": true` если OpenTopoData (основной) или Open-Meteo (fallback) доступны с хоста/контейнера. Переопределить URL высот (`.env` рядом с `docker-compose.yml`): ```env -LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation +LORATESTER_ELEVATION_OPENTOPO_URL=http://grigowashere.ru:5300/v1/srtm30 +LORATESTER_ELEVATION_FALLBACK_URL=http://192.168.1.109:8085/v1/elevation ``` +`LORATESTER_ELEVATION_URL` — устаревший alias для fallback (Open-Meteo-compatible). + БД хранится в volume `loratester-data` (`/data/loratester.db` внутри контейнера). ## Деплой (lora.grigowashere.ru) @@ -63,7 +66,8 @@ docker compose up -d --build cd /srv/storage/disk2/services/LoraTester/server pip install -r requirements.txt export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db -export LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation +export LORATESTER_ELEVATION_OPENTOPO_URL=http://grigowashere.ru:5300/v1/srtm30 +export LORATESTER_ELEVATION_FALLBACK_URL=http://192.168.1.109:8085/v1/elevation uvicorn fastapi_app:app --host 0.0.0.0 --port 7634 ``` @@ -99,7 +103,7 @@ curl http://127.0.0.1:7634/api/health - `POST /api/tracks/{id}/points` — `{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}` - `POST /api/tracks/{id}/finish` - `GET /api/tracks?device_id=` -- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через локальный Open-Meteo) +- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через OpenTopoData → Open-Meteo fallback) ### Команды (очередь на устройство) @@ -110,9 +114,9 @@ curl http://127.0.0.1:7634/api/health ### Профиль высот (веб, треки) -- `POST /api/elevation/profile` — `{points: [{lat, lon}], step_m?: 10}` → срез рельефа (локальный Open-Meteo) +- `POST /api/elevation/profile` — `{points: [{lat, lon}], step_m?: 10}` → срез рельефа (OpenTopoData → Open-Meteo) - `GET /api/tracks/{id}/elevation-profile?step_m=10` — то же по сохранённому треку -- `GET /api/elevation/nearest-hill?lat=&lon=&radius_m=5000` — ближайшая возвышенность (прокси Open-Meteo) +- `GET /api/elevation/nearest-hill?lat=&lon=&radius_m=5000` — ближайшая возвышенность - `GET /api/elevation/grid?lat=&lon=&radius_m=200&step_m=0` — сетка высот для хитмапы (100–500 m, step_m=0 авто) - `GET /api/commands/pending?device_id=` — Android, доставка + `delivered_at` - `GET /api/commands?to_device_id=&limit=` — история (веб) diff --git a/server/core/__pycache__/config.cpython-313.pyc b/server/core/__pycache__/config.cpython-313.pyc index 707a869a58fd20654f2aceddc37fba7d5737e738..e8ebb665a40c216ab03b5cb87cd68370c5274792 100644 GIT binary patch delta 409 zcmcb|bBdquGcPX}0}vGD7-iY9OyrYbQe)nzA;>hjkx7eDU~&VKHn(7~kSP;L4Fkhu z0VZ{Jp%|ee;TVz03mA39g@Z+4a;A(BHASKjIZ4rAF$_7ebVg0_$w!!$F)B{pz$|Lv zAK>a2;ve829~$ISrJ7MvQedU8pI(%io?o6=oRM0Ts#jEMWom3-pkHRFUtCm@Yiw|f zQU4ZLGoPEIkB^h1vo}cdEeVi$lN*?ICa-69WfY(Mm3fA~n5&O#m}7{izh6AEMYoW| zklOEGAt#c~s3|fzhK#N_iVqLb@bbXXYmZ^?l<2UzqOMJIn?nK5|>>q;(7 zpm880i^V2qvu)855u5Hc(QCTjM86v>ayM9XKC?25GJR)bVCHO8>kw|%{=m+_DD;to PL0VyQ7rPCMI#4G7t6eL$ diff --git a/server/core/__pycache__/elevation.cpython-313.pyc b/server/core/__pycache__/elevation.cpython-313.pyc index 5d547ac52036325430eabe6750f027c36287249b..9e88231aed40f46d486aaacdffeb2eabd76ab022 100644 GIT binary patch delta 13022 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 NlPLZCSz^G_^FIMqyIKGM diff --git a/server/core/config.py b/server/core/config.py index ed1142b..c2307cc 100644 --- a/server/core/config.py +++ b/server/core/config.py @@ -9,10 +9,19 @@ HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0") PORT = int(os.environ.get("LORATESTER_PORT", "7634")) TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000")) TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000")) -ELEVATION_API_URL = os.environ.get( - "LORATESTER_ELEVATION_URL", - "http://192.168.1.109:8085/v1/elevation", +ELEVATION_OPENTOPO_URL = os.environ.get( + "LORATESTER_ELEVATION_OPENTOPO_URL", + "http://grigowashere.ru:5300/v1/srtm30", ).rstrip("/") +ELEVATION_FALLBACK_URL = os.environ.get( + "LORATESTER_ELEVATION_FALLBACK_URL", + os.environ.get( + "LORATESTER_ELEVATION_URL", + "http://192.168.1.109:8085/v1/elevation", + ), +).rstrip("/") +# Backward-compatible alias for Open-Meteo-compatible fallback API. +ELEVATION_API_URL = ELEVATION_FALLBACK_URL ELEVATION_PROBE_TTL_SEC = float( os.environ.get("LORATESTER_ELEVATION_PROBE_TTL", "60") ) diff --git a/server/core/elevation.py b/server/core/elevation.py index 4b2bb91..13d5b23 100644 --- a/server/core/elevation.py +++ b/server/core/elevation.py @@ -1,4 +1,4 @@ -"""Terrain elevation via self-hosted Open-Meteo-compatible API.""" +"""Terrain elevation: OpenTopoData primary, Open-Meteo-compatible fallback.""" from __future__ import annotations @@ -10,8 +10,9 @@ from typing import Any, Optional import httpx from .config import ( - ELEVATION_API_URL, ELEVATION_CONNECT_TIMEOUT, + ELEVATION_FALLBACK_URL, + ELEVATION_OPENTOPO_URL, ELEVATION_PROBE_TTL_SEC, ) @@ -21,8 +22,11 @@ _BATCH_SIZE = 100 _MAX_PROFILE_POINTS = 500 _CACHE: dict[tuple[float, float], Optional[float]] = {} _probe_checked_at = 0.0 -_probe_ok = False -_probe_error: Optional[str] = None +_probe_opentopo_ok = False +_probe_opentopo_error: Optional[str] = None +_probe_fallback_ok = False +_probe_fallback_error: Optional[str] = None +_last_fetch_source: Optional[str] = None def _cache_key(lat: float, lon: float) -> tuple[float, float]: @@ -42,9 +46,8 @@ def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) -def probe_elevation_api(force: bool = False) -> dict[str, Any]: - """Ping elevation service before batch requests (cached for TTL).""" - global _probe_checked_at, _probe_ok, _probe_error +def _probe_opentopodata(force: bool = False) -> dict[str, Any]: + global _probe_checked_at, _probe_opentopo_ok, _probe_opentopo_error now = time.monotonic() if ( @@ -53,35 +56,129 @@ def probe_elevation_api(force: bool = False) -> dict[str, Any]: and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC ): return { - "ok": _probe_ok, - "url": ELEVATION_API_URL, - "error": _probe_error, + "ok": _probe_opentopo_ok, + "url": ELEVATION_OPENTOPO_URL, + "error": _probe_opentopo_error, } try: with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client: r = client.get( - ELEVATION_API_URL, + ELEVATION_OPENTOPO_URL, + params={"locations": "0.000000,0.000000"}, + ) + r.raise_for_status() + data = r.json() + if data.get("status") != "OK": + raise ValueError(f"status={data.get('status')}") + results = data.get("results") or [] + if not results or results[0].get("elevation") is None: + raise ValueError("response has no elevation values") + _probe_opentopo_ok = True + _probe_opentopo_error = None + logger.info("OpenTopoData ok: %s", ELEVATION_OPENTOPO_URL) + except Exception as e: + _probe_opentopo_ok = False + _probe_opentopo_error = str(e) + logger.warning( + "OpenTopoData unreachable %s: %s", ELEVATION_OPENTOPO_URL, e + ) + + return { + "ok": _probe_opentopo_ok, + "url": ELEVATION_OPENTOPO_URL, + "error": _probe_opentopo_error, + } + + +def _probe_fallback(force: bool = False) -> dict[str, Any]: + global _probe_checked_at, _probe_fallback_ok, _probe_fallback_error + + now = time.monotonic() + if ( + not force + and _probe_checked_at > 0 + and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC + ): + return { + "ok": _probe_fallback_ok, + "url": ELEVATION_FALLBACK_URL, + "error": _probe_fallback_error, + } + + try: + with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client: + r = client.get( + ELEVATION_FALLBACK_URL, params={"latitude": "0.000000", "longitude": "0.000000"}, ) r.raise_for_status() data = r.json() if "elevation" not in data: raise ValueError("response has no elevation field") - _probe_checked_at = now - _probe_ok = True - _probe_error = None - logger.info("elevation API ok: %s", ELEVATION_API_URL) + _probe_fallback_ok = True + _probe_fallback_error = None + logger.info("elevation fallback ok: %s", ELEVATION_FALLBACK_URL) except Exception as e: - _probe_checked_at = now - _probe_ok = False - _probe_error = str(e) - logger.warning("elevation API unreachable %s: %s", ELEVATION_API_URL, e) + _probe_fallback_ok = False + _probe_fallback_error = str(e) + logger.warning( + "elevation fallback unreachable %s: %s", ELEVATION_FALLBACK_URL, e + ) return { - "ok": _probe_ok, - "url": ELEVATION_API_URL, - "error": _probe_error, + "ok": _probe_fallback_ok, + "url": ELEVATION_FALLBACK_URL, + "error": _probe_fallback_error, + } + + +def probe_elevation_api(force: bool = False) -> dict[str, Any]: + """Ping elevation providers before batch requests (cached for TTL).""" + global _probe_checked_at + + now = time.monotonic() + if ( + not force + and _probe_checked_at > 0 + and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC + ): + op = { + "ok": _probe_opentopo_ok, + "url": ELEVATION_OPENTOPO_URL, + "error": _probe_opentopo_error, + } + fb = { + "ok": _probe_fallback_ok, + "url": ELEVATION_FALLBACK_URL, + "error": _probe_fallback_error, + } + else: + op = _probe_opentopodata(force=True) + fb = _probe_fallback(force=True) + _probe_checked_at = now + + ok = op["ok"] or fb["ok"] + if op["ok"]: + url = op["url"] + error = None + elif fb["ok"]: + url = fb["url"] + error = None + else: + url = ELEVATION_OPENTOPO_URL + error = f"opentopodata: {op['error']}; fallback: {fb['error']}" + + return { + "ok": ok, + "url": url, + "error": error, + "opentopodata_ok": op["ok"], + "opentopodata_url": op["url"], + "opentopodata_error": op["error"], + "fallback_ok": fb["ok"], + "fallback_url": fb["url"], + "fallback_error": fb["error"], } @@ -91,10 +188,40 @@ def elevation_status(force: bool = False) -> dict[str, Any]: "elevation_ok": probe["ok"], "elevation_url": probe["url"], "elevation_error": probe["error"], + "elevation_opentopodata_ok": probe.get("opentopodata_ok"), + "elevation_opentopodata_url": probe.get("opentopodata_url"), + "elevation_fallback_ok": probe.get("fallback_ok"), + "elevation_fallback_url": probe.get("fallback_url"), } -def _fetch_elevation_batch( +def _fetch_opentopodata_batch( + batch_lat: list[float], batch_lon: list[float] +) -> list[Optional[float]]: + if not batch_lat: + return [] + locations = "|".join( + f"{lat:.6f},{lon:.6f}" for lat, lon in zip(batch_lat, batch_lon) + ) + with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client: + r = client.get(ELEVATION_OPENTOPO_URL, params={"locations": locations}) + r.raise_for_status() + data = r.json() + if data.get("status") != "OK": + raise ValueError(f"OpenTopoData status={data.get('status')}") + results = data.get("results") or [] + out: list[Optional[float]] = [] + for j, item in enumerate(results): + if j >= len(batch_lat): + break + elev = item.get("elevation") + out.append(None if elev is None else float(elev)) + while len(out) < len(batch_lat): + out.append(None) + return out + + +def _fetch_fallback_batch( batch_lat: list[float], batch_lon: list[float] ) -> list[Optional[float]]: if not batch_lat: @@ -104,7 +231,7 @@ def _fetch_elevation_batch( "longitude": ",".join(f"{lon:.6f}" for lon in batch_lon), } with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client: - r = client.get(ELEVATION_API_URL, params=params) + r = client.get(ELEVATION_FALLBACK_URL, params=params) r.raise_for_status() data = r.json() elevations = data.get("elevation") or [] @@ -112,15 +239,81 @@ def _fetch_elevation_batch( for j, elev in enumerate(elevations): if j >= len(batch_lat): break - if elev is None: - out.append(None) - else: - out.append(float(elev)) + out.append(None if elev is None else float(elev)) while len(out) < len(batch_lat): out.append(None) return out +def _fetch_batch_with_fallback( + batch_lat: list[float], batch_lon: list[float] +) -> list[Optional[float]]: + global _last_fetch_source + + probe = probe_elevation_api() + op_ok = probe.get("opentopodata_ok", False) + fb_ok = probe.get("fallback_ok", False) + if not op_ok and not fb_ok: + return [None] * len(batch_lat) + + out: list[Optional[float]] = [None] * len(batch_lat) + used_opentopo = False + used_fallback = False + + if op_ok: + try: + out = _fetch_opentopodata_batch(batch_lat, batch_lon) + used_opentopo = any(v is not None for v in out) + except Exception as e: + logger.warning( + "OpenTopoData batch failed (%s points): %s", len(batch_lat), e + ) + out = [None] * len(batch_lat) + + missing_idx = [i for i, v in enumerate(out) if v is None] + if missing_idx and fb_ok: + miss_lat = [batch_lat[i] for i in missing_idx] + miss_lon = [batch_lon[i] for i in missing_idx] + try: + fb_vals = _fetch_fallback_batch(miss_lat, miss_lon) + for j, idx in enumerate(missing_idx): + out[idx] = fb_vals[j] if j < len(fb_vals) else None + if any(v is not None for v in fb_vals): + used_fallback = True + except Exception as e: + logger.warning( + "elevation fallback batch failed (%s points): %s", + len(miss_lat), + e, + ) + + if used_opentopo and used_fallback: + _last_fetch_source = "opentopodata+openmeteo" + elif used_opentopo: + _last_fetch_source = "opentopodata" + elif used_fallback: + _last_fetch_source = "openmeteo" + else: + _last_fetch_source = None + + return out + + +def _active_elevation_url() -> str: + probe = probe_elevation_api() + if probe.get("opentopodata_ok"): + return ELEVATION_OPENTOPO_URL + if probe.get("fallback_ok"): + return ELEVATION_FALLBACK_URL + return ELEVATION_OPENTOPO_URL + + +def _active_api_source() -> str: + return _last_fetch_source or ( + "opentopodata" if probe_elevation_api().get("opentopodata_ok") else "openmeteo" + ) + + def fetch_elevation_m(lat: float, lon: float) -> Optional[float]: vals = fetch_elevations_batch([lat], [lon]) return vals[0] if vals else None @@ -135,7 +328,7 @@ def fetch_elevations_batch( probe = probe_elevation_api() if not probe["ok"]: logger.warning( - "skip elevation fetch: API unreachable (%s)", + "skip elevation fetch: all providers unreachable (%s)", probe.get("error"), ) return [None] * len(lats) @@ -159,14 +352,15 @@ def fetch_elevations_batch( batch_lat = pending_lat[start : start + _BATCH_SIZE] batch_lon = pending_lon[start : start + _BATCH_SIZE] try: - batch_vals = _fetch_elevation_batch(batch_lat, batch_lon) + batch_vals = _fetch_batch_with_fallback(batch_lat, batch_lon) for j, val in enumerate(batch_vals): lat = batch_lat[j] lon = batch_lon[j] _CACHE[_cache_key(lat, lon)] = val out[batch_i[j]] = val logger.info( - "elevation ok: %s points, sample=%s", + "elevation ok (%s): %s points, sample=%s", + _last_fetch_source, len(batch_lat), batch_vals[0] if batch_vals else None, ) @@ -178,7 +372,7 @@ def fetch_elevations_batch( ) for j in range(len(batch_lat)): try: - single = _fetch_elevation_batch( + single = _fetch_batch_with_fallback( [batch_lat[j]], [batch_lon[j]] ) val = single[0] if single else None @@ -329,7 +523,7 @@ def build_elevation_profile( "total_m": 0.0, "api_source": "elevation", "api_error": f"elevation API unreachable: {probe['error']}", - "elevation_url": ELEVATION_API_URL, + "elevation_url": _active_elevation_url(), } lats = [s["lat"] for s in samples] @@ -356,8 +550,8 @@ def build_elevation_profile( "min_elevation_m": min(elev_vals) if elev_vals else None, "max_elevation_m": max(elev_vals) if elev_vals else None, "points": profile, - "api_source": "elevation", - "elevation_url": ELEVATION_API_URL, + "api_source": _active_api_source(), + "elevation_url": _active_elevation_url(), } if not elev_vals: result["api_error"] = "elevation API returned no values" @@ -429,7 +623,7 @@ def build_elevation_grid( return { "ok": False, "error": f"elevation API unreachable: {probe['error']}", - "elevation_url": ELEVATION_API_URL, + "elevation_url": _active_elevation_url(), } radius_m = max(50.0, min(float(radius_m), 500.0)) @@ -481,8 +675,8 @@ def build_elevation_grid( "points": points, "min_delta_m": round(min(deltas), 1), "max_delta_m": round(max(deltas), 1), - "api_source": "elevation", - "elevation_url": ELEVATION_API_URL, + "api_source": _active_api_source(), + "elevation_url": _active_elevation_url(), } @@ -499,7 +693,7 @@ def find_nearest_hill( return { "ok": False, "error": f"elevation API unreachable: {probe['error']}", - "elevation_url": ELEVATION_API_URL, + "elevation_url": _active_elevation_url(), } radius_m = max(500.0, min(float(radius_m), 15_000.0)) @@ -590,6 +784,6 @@ def find_nearest_hill( "candidates": len(candidates), "radius_m": radius_m, "step_m": step_m, - "api_source": "elevation", - "elevation_url": ELEVATION_API_URL, + "api_source": _active_api_source(), + "elevation_url": _active_elevation_url(), } diff --git a/server/docker-compose.yml b/server/docker-compose.yml index be5dafe..6447493 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -10,6 +10,8 @@ services: environment: LORATESTER_DB: /data/loratester.db LORATESTER_PORT: "7634" + LORATESTER_ELEVATION_OPENTOPO_URL: ${LORATESTER_ELEVATION_OPENTOPO_URL:-http://grigowashere.ru:5300/v1/srtm30} + LORATESTER_ELEVATION_FALLBACK_URL: ${LORATESTER_ELEVATION_FALLBACK_URL:-http://192.168.1.109:8085/v1/elevation} LORATESTER_ELEVATION_URL: ${LORATESTER_ELEVATION_URL:-http://192.168.1.109:8085/v1/elevation} LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60} LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8} diff --git a/server/tests/__pycache__/test_elevation.cpython-313-pytest-9.0.3.pyc b/server/tests/__pycache__/test_elevation.cpython-313-pytest-9.0.3.pyc index 1959b8a523ab6359ea95efb0e9a0f406c5b4240c..0cda348490dbf4947666a96b56472d061613ba70 100644 GIT binary patch delta 6012 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 delta 3889 zcmcIn3rt(r8NS!P*e+lo#y|o%F$5BB3z%RSfrLN;hOniE1X8s;3dVN8iFxE)NE@^T zw^G_^D`}F`*B;rjZcRYTW0@mu-6l1AR9mGgB9E=vEz;U<)tXhCR#R!Krb_$2YaS*c zNj0sO&UgRUJ@=mTfB%23C$EZ6{!~nwHJjrF`V}8>?EQDzv80vakz?zhmHI?E<-(;? zQm;flJ&oDb1kVc{B3-m!tJtb@h$UkAoFz|?^=|b(f6#xxGZGjY?2CqZYh{sP5?Rmi zAXPugf~sNI!#o3uf|>azhP5^;2_a!7Is2u3ldj2%X`Ay}a@n-0j3p!Dg0Vz&@b`?> zqR8#0T-`+-e>Z7;c%NxPC-n)knM;X}@n;f`npA@_;u#rLc#e5EVJF>cqoMp>;0}kK z|H6Dxe_hJuVjCo4S_neI$?}WVw>C6h z+0Zz>>*1@}kMQ5Fu*tGGX2~xYK3F0v_UdXoM5nB%(g9CUk#(w`JOJkyH(N3dTWKP; zjTc$64K9ccyxCHg-3W0zpou^>urEUH2DtfmEss91kHqyflnH{6a6?LdOUk$+Wz3io zZ%E%YHcyUH%!X~)30BU$^#zwQ3MA^jVmi$tNZA)~?h`43N zMs3BaahMJ52?YIY2Vx!sxB<<87CxI%T`)$XlZNsR!CiJD+`iHhZylnbzrH(WJ=gfe zmuhd8lE%7tD0h2G55&y?51@wM$X%C)dD#dcb}6ZAPo?$1xENjywU=LBTVT-0vx?jD zSM6XSm>1XRhq?!VI=W@aY8>+n_W9Y@;ChBYwh&E56!ruRZtlrjo!>&Dnov7@uzSxC z8}N)&#_S8WL=^+<>-<#Scj8WvRu^u{KVj(57OAbIo{URM1C^ReV;1RsUZCn1T6M)b zst>)pP1Z##qn3PZ1>kl*G#6H6;lZ;B1R-I%rER*~d$q|IrG}6HuwoVeRpC(CG29*^ zn9{2fk%Y=2I5eoR7s#ab3%h-uV~G47K`ZwcRiu0w>Q?~B96Q9%6lEF|h+X{6qKz3L z5~n2ALl+cPDbq4B!Ly3jjUOf>dm0dvJ*XNF6X$pc!{dMu;4>ymPK;LMLEO_1(75>` zBu;=N@k_-kQ#9VjQub_XCf7M~%M@-j%^TsZ0xYvzQwvr5QE5i?;wCL$ zwFc%_MuQ_URq=c0`dPda`2mj8vp>?F;6C))N@)Edx1*~|QB zeOmbE6>*}qgY>?ly6g|%`$A@o*3j51{QAa1!z2QyxS?{dIKcN;9_aMJfN(7;Fl`is zK%poIH%(-d^{R<^e1TD=dw~6j3~I_iV6dC|7+w5>UO$=9LEG4}8_w&hHkW9L`Uxg8 zMQQdr$?R3Y@_q1p)z*Z|_{Jk_7F5=!J2DZ)!XzPVm1qcIEWIa2B97 zE3F%u7rT*ay$;OK_swtM_i7D0g|O2E%N$6U>qY+5u1(_$Tw>#h z|0>`H;4VcCw??$39147(CkIH&{s;&GVt52qBS*|pHO~G5vkTAwxHmWE&#F8356p8X zMktBoMQuiZ{I%Aq_z%f+l!kJAxwEUX?Txt4g%*_>Ek@hdq-u<~ofiD{c1!W{?cpEv zy5GkuO9a#&>gT^u`n`7E=d(H3KgmH|Dujz}u}j_FX)x7kzQ4n^W`PTRWTG}VUKwhT?=FSaOQbzp4EP_xsK&yC^_*}*F^Hu5mlTJDQUt~K;L#bXaqrF|}Vv?Mu zntDeA!I1!cxB0oH-#L!AQIotw%wN3l50Mp;QCq3@2a(|)Cff;wQGKn&jj z>@whe0Eor#F44NyY8L!cO`8XXe4|1C7WOHrbk+(=tH9sr&#Flf%RdqbBH2c9ZNz$? rm>F4V7B@ufMsa<_nk?o-@{9yli&z@DKUu7d