d5a270f3af
- backend/logging_setup.py: setup_logging() (stdout INFO + файл /tmp/vector/vector.log,
ротация 5×2 МБ, VectorLOGLEVEL/VectorLOGFILE/VectorLOGDIR), get_logger('vector.*')
- события: vector.analyze (матрасчёт с длительностью), vector.auth (login_ok/failed/
lockout), vector.audit (каждая запись), vector.contour (ok/failed/rejected),
vector.operations (operation_complete), vector.geocode (успех/timeout/ошибка)
- существующие логгеры переведены в namespace vector.* (geo, geo.osm, rules, water, admin)
- README §13а: таблица наблюдаемых событий + правила (без ПДн/секретов)
- SETUP_GUIDE: §15 RPO/RTO + backup≠restore, §16 grep по vector.*, §17 диагностика
по цепочкам (БД, 500, fallback-зоны, вход), §17а чек-лист передачи проекта
- DEPLOY.md: вместо устаревшего дубля — ссылка на актуальные доки + шпаргалка стенда
- тесты: 239 passed
170 lines
7.5 KiB
Python
170 lines
7.5 KiB
Python
"""Локальный OSM-слой (B16): пространственные запросы к PostGIS вместо Overpass.
|
||
|
||
Таблицы planet_osm_* заливаются osm2pgsql из дампа Беларуси
|
||
(scripts/b16-import.sh; SRID 3857 — дефолт osm2pgsql, проекция Web Mercator).
|
||
Обновление — разово/по мере надобности. Никакого интернета.
|
||
|
||
Доступ к БД — свой sync-движок из DATABASE_URL (asyncpg → psycopg2).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import math
|
||
import os
|
||
from typing import Any, Optional
|
||
|
||
from sqlalchemy import create_engine, text
|
||
from sqlalchemy.engine import Engine
|
||
|
||
logger = logging.getLogger('vector.geo.osm')
|
||
|
||
_PG_URL = os.getenv('DATABASE_URL', 'postgresql://postgres:postgres@postgres:5432/vector_mchs')
|
||
if _PG_URL.startswith('postgresql+asyncpg://'):
|
||
_PG_URL = _PG_URL.replace('postgresql+asyncpg://', 'postgresql://')
|
||
|
||
_engine: Optional[Engine] = None
|
||
_available: Optional[bool] = None
|
||
|
||
|
||
def _get_engine() -> Engine:
|
||
global _engine
|
||
if _engine is None:
|
||
_engine = create_engine(_PG_URL, pool_pre_ping=True)
|
||
return _engine
|
||
|
||
|
||
def osm_available() -> bool:
|
||
"""True, если локальные OSM-таблицы залиты (иначе geo_service падает на Overpass)."""
|
||
global _available
|
||
if _available is not None:
|
||
return _available
|
||
try:
|
||
with _get_engine().connect() as conn:
|
||
row = conn.execute(text(
|
||
"SELECT count(*) FROM information_schema.tables "
|
||
"WHERE table_name = 'planet_osm_line'"
|
||
)).scalar()
|
||
_available = bool(row)
|
||
except Exception as e:
|
||
logger.warning('OSM PostGIS недоступен: %s', e)
|
||
_available = False
|
||
return _available
|
||
|
||
|
||
def _sector_wkt_3857(lat: float, lon: float, radius_m: int, direction: str) -> str:
|
||
"""Сектор-«пирог» 45° в Web Mercator (проекция данных planet_osm_*).
|
||
|
||
Строим в WGS84 (локальные метрики), потом трансформируем — mercator-метры
|
||
на широте Беларуси раздуты, строить дугу в них нельзя.
|
||
"""
|
||
angles = {'N': 0, 'NE': 45, 'E': 90, 'SE': 135, 'S': 180, 'SW': 225, 'W': 270, 'NW': 315}
|
||
az = angles.get(direction, 0)
|
||
half = 22.5
|
||
seg = 24
|
||
pts: list[tuple[float, float]] = []
|
||
for i in range(seg + 1):
|
||
bearing = math.radians(az - half + (2 * half) * i / seg)
|
||
dlat = (radius_m / 111320.0) * math.cos(bearing)
|
||
dlon = (radius_m / (111320.0 * max(0.2, math.cos(math.radians(lat))))) * math.sin(bearing)
|
||
pts.append((lon + dlon, lat + dlat))
|
||
# Замыкаем пирог через центр
|
||
pts_wkt = f'{lon:.7f} {lat:.7f}, ' + ','.join(f'{p[0]:.7f} {p[1]:.7f}' for p in pts)
|
||
return f'POLYGON(({pts_wkt}, {lon:.7f} {lat:.7f}))'
|
||
|
||
|
||
def get_zone_features_postgis(lat: float, lon: float, direction: str, radius_m: int) -> dict[str, Any]:
|
||
"""Характеристики сектора — 1 запрос к локальному PostGIS.
|
||
|
||
Контракт тот же, что get_zone_features (Overpass): roads_km, road_density,
|
||
water_distance_km, settlement_distance_km, forest_pct.
|
||
"""
|
||
sector_wkt = _sector_wkt_3857(lat, lon, radius_m, direction)
|
||
center_pt = f'POINT({lon} {lat})'
|
||
|
||
q = text("""
|
||
WITH s AS (
|
||
SELECT ST_Transform(ST_SetSRID(ST_GeomFromText(:sector_wkt), 4326), 3857) AS sector,
|
||
ST_Transform(ST_SetSRID(ST_GeomFromText(:center_pt), 4326), 3857) AS center_m
|
||
),
|
||
road_len AS (
|
||
SELECT COALESCE(SUM(ST_Length(l.way)), 0) AS meters
|
||
FROM planet_osm_line l, s
|
||
WHERE l.highway IS NOT NULL
|
||
AND l.way && s.sector
|
||
AND ST_Intersects(l.way, s.sector)
|
||
),
|
||
forest_area AS (
|
||
SELECT COALESCE(SUM(ST_Area(ST_Intersection(p.way, s.sector))), 0) AS m2
|
||
FROM planet_osm_polygon p, s
|
||
WHERE (p.landuse = 'forest' OR p."natural" = 'wood')
|
||
AND p.way && s.sector
|
||
),
|
||
water_near AS (
|
||
SELECT MIN(ST_Distance(p.way, s.center_m)) AS meters
|
||
FROM planet_osm_polygon p, s
|
||
WHERE (p."natural" = 'water' OR p.landuse = 'reservoir')
|
||
AND p.way && s.sector
|
||
AND ST_Intersects(p.way, s.sector)
|
||
),
|
||
waterway_near AS (
|
||
SELECT MIN(ST_Distance(l.way, s.center_m)) AS meters
|
||
FROM planet_osm_line l, s
|
||
WHERE l.waterway IN ('river','stream','canal','ditch','drain')
|
||
AND l.way && s.sector
|
||
AND ST_Intersects(l.way, s.sector)
|
||
),
|
||
settle_near AS (
|
||
SELECT MIN(ST_Distance(p.way, s.center_m)) AS meters
|
||
FROM planet_osm_point p, s
|
||
WHERE p.place IN ('village','town','city')
|
||
AND p.way && s.sector
|
||
AND ST_Intersects(p.way, s.sector)
|
||
)
|
||
SELECT
|
||
(SELECT meters FROM road_len),
|
||
(SELECT m2 FROM forest_area),
|
||
(SELECT meters FROM water_near),
|
||
(SELECT meters FROM waterway_near),
|
||
(SELECT meters FROM settle_near)
|
||
""")
|
||
|
||
# Масштаб 3857 на широте Минска (~53.9°): cos(lat)
|
||
scale = max(0.2, math.cos(math.radians(lat)))
|
||
|
||
with _get_engine().connect() as conn:
|
||
row = conn.execute(q, {
|
||
'sector_wkt': sector_wkt, 'center_pt': center_pt, 'scale': scale,
|
||
}).one()
|
||
|
||
# 3857-метры на широте lat раздуты в 1/cos(lat) раз:
|
||
# real = mercator * cos(lat) (проверено: merc/geo = 1/cos(lat) точно).
|
||
roads_km = float(row[0] or 0) / 1000.0 * scale
|
||
forest_m2 = float(row[1] or 0) * (scale * scale) # mercator-м² → реальные м²
|
||
# Дистанции: mercator-метры → реальные метры (та же поправка, что у дорог:
|
||
# раньше деление на cos здесь ДВОИЛО раздувание — 850 «км» до озера в Браславе).
|
||
water_poly_m = float(row[2]) * scale if row[2] is not None else None
|
||
waterway_m = float(row[3]) * scale if row[3] is not None else None
|
||
settle_m = float(row[4]) * scale if row[4] is not None else None
|
||
|
||
search_area_km2 = math.pi * (radius_m / 1000.0) ** 2
|
||
road_density = roads_km / search_area_km2 if search_area_km2 > 0 else 0.0
|
||
|
||
# Контракт Zone.forest_pct — ДОЛЯ леса 0..1 (так его читает
|
||
# scoring_service.score_zone: weight × forest_pct; тесты скоринга
|
||
# используют 0.3–0.8 как доли). Раньше отдались проценты 0..100 —
|
||
# лес в 100 раз перевешивал остальные факторы (зоны «вверх-вправо»).
|
||
forest_pct = 0.0
|
||
if forest_m2 > 0 and search_area_km2 > 0:
|
||
forest_pct = round(min(1.0, (forest_m2 / 1e6) / search_area_km2), 3)
|
||
|
||
water_candidates = [float(m) / 1000.0 for m in (water_poly_m, waterway_m) if m is not None]
|
||
water_distance_km = round(min(water_candidates), 3) if water_candidates else None
|
||
settlement_distance_km = round(float(settle_m) / 1000.0, 3) if settle_m is not None else None
|
||
|
||
return {
|
||
'roads_km': round(roads_km, 3),
|
||
'road_density': round(road_density, 2),
|
||
'water_distance_km': water_distance_km,
|
||
'settlement_distance_km': settlement_distance_km,
|
||
'forest_pct': forest_pct,
|
||
} |