Files
Sadmin d5a270f3af B22: прикладное логирование vector.* + администрирование в доках (по образцу РВС)
- 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
2026-09-25 09:38:39 +03:00

170 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Локальный 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,
}