feat(sf): подзоны ПАТ Кольцово → pat_subzones (#1065) #1150
6 changed files with 9889 additions and 0 deletions
65
backend/app/services/site_finder/pat_lookup.py
Normal file
65
backend/app/services/site_finder/pat_lookup.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""ПАТ-lookup для site-finder analyze (#1065 / #1067).
|
||||
|
||||
Читает pat_subzones и возвращает подзоны ПАТ, пересекающие переданный участок.
|
||||
Graceful: если таблица ещё не задеплоена или БД недоступна — возвращает пустой список.
|
||||
Wiring в analyze_parcel — follow-up (не в этом PR).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ST_Intersects к pat_subzones, GIST idx_pat_subzones_geom.
|
||||
# Исключаем subzone_no=0 (aggregate_outline) — клиент получает конкретные подзоны 1..6.
|
||||
_PAT_INTERSECTS_SQL = text(
|
||||
"""
|
||||
SELECT subzone_no, name, restriction, aerodrome
|
||||
FROM pat_subzones
|
||||
WHERE subzone_no > 0
|
||||
AND ST_Intersects(geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326))
|
||||
ORDER BY subzone_no
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def parcel_pat_subzones(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]:
|
||||
"""Подзоны ПАТ, пересекающие участок.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy-сессия.
|
||||
parcel_wkt: геометрия участка WKT (EPSG:4326). None → пустой результат.
|
||||
|
||||
Returns:
|
||||
Список dict [{subzone_no, name, restriction, aerodrome}] отсортированный
|
||||
по subzone_no. Пустой список при отсутствии пересечений, ошибке БД
|
||||
или недеплоенной таблице.
|
||||
"""
|
||||
if not parcel_wkt:
|
||||
return []
|
||||
|
||||
try:
|
||||
rows = db.execute(_PAT_INTERSECTS_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
# Таблица ещё не задеплоена / структура изменилась — graceful degrade.
|
||||
logger.warning("parcel_pat_subzones: pat_subzones недоступна, skip: %s", exc)
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"subzone_no": r["subzone_no"],
|
||||
"name": r["name"],
|
||||
"restriction": r["restriction"],
|
||||
"aerodrome": r["aerodrome"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
__all__ = ["parcel_pat_subzones"]
|
||||
|
|
@ -75,6 +75,7 @@ celery_app = Celery(
|
|||
"app.workers.tasks.ekb_ppt_tep_sync",
|
||||
"app.workers.tasks.krt_geometry_sync",
|
||||
"app.workers.tasks.okn_objects_sync",
|
||||
"app.workers.tasks.pat_subzones_load",
|
||||
],
|
||||
)
|
||||
celery_app.conf.timezone = "Europe/Moscow"
|
||||
|
|
|
|||
365
backend/app/workers/tasks/pat_subzones_load.py
Normal file
365
backend/app/workers/tasks/pat_subzones_load.py
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
"""Celery task: загрузка подзон ПАТ Кольцово из JSON-файла координат (#1065 / #1067).
|
||||
|
||||
Фаза-2 пайплайна: Фаза-1 (извлечение координат из Приказа 367-П) уже выполнена —
|
||||
файл data/pat/koltsovo_367p_subzones.json содержит точки в МСК-66 зона 1.
|
||||
|
||||
Алгоритм:
|
||||
1. Читает JSON из data/pat/koltsovo_367p_subzones.json (путь относительно repo root).
|
||||
2. Для каждой подзоны собирает секторы: внешние → отдельные Polygon-ы,
|
||||
внутренние (label содержит «внутренн») → дырки в предшествующем внешнем Polygon-е.
|
||||
3. Репроецирует МСК-66 → WGS84 через _MSK66_TO_WGS84 (always_xy=True):
|
||||
point=[X, Y] где X=northing, Y=easting → transform(easting=Y, northing=X).
|
||||
4. shapely.make_valid, union → MultiPolygon, UPSERT в pat_subzones.
|
||||
5. Логирует ST_IsValid, bbox-центроид (проверка «в районе Кольцово»),
|
||||
сравнение площади union подзон vs aggregate_outline.
|
||||
|
||||
Beat не нужен — статика, задача запускается вручную: один раз после деплоя.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
from typing import Any
|
||||
|
||||
from pyproj import CRS, Transformer
|
||||
from shapely import make_valid
|
||||
from shapely.geometry import MultiPolygon, Polygon
|
||||
from shapely.ops import unary_union
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── МСК-66 → WGS84 (те же параметры что в ekburg_permits.py) ────────────────
|
||||
_MSK66_PROJ4 = (
|
||||
"+proj=tmerc +lat_0=0 +lon_0=60 +k=1"
|
||||
" +x_0=1496944 +y_0=-5911077"
|
||||
" +ellps=krass +towgs84=23.92,-141.27,-80.9,0,0.35,0.82,-0.12"
|
||||
" +units=m +no_defs"
|
||||
)
|
||||
_MSK66_TO_WGS84: Transformer = Transformer.from_crs(
|
||||
CRS.from_proj4(_MSK66_PROJ4),
|
||||
CRS.from_epsg(4326),
|
||||
always_xy=True,
|
||||
)
|
||||
|
||||
# bbox Кольцово (ЮВ от ЕКБ): lon 60.5..61.2, lat 56.5..57.0
|
||||
_KOLTSOVO_LON_MIN = 60.5
|
||||
_KOLTSOVO_LON_MAX = 61.2
|
||||
_KOLTSOVO_LAT_MIN = 56.5
|
||||
_KOLTSOVO_LAT_MAX = 57.0
|
||||
|
||||
# Путь к JSON относительно repo root (backend/../data/pat/…)
|
||||
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[4]
|
||||
_JSON_PATH = _REPO_ROOT / "data" / "pat" / "koltsovo_367p_subzones.json"
|
||||
|
||||
_SOURCE = "Приказ Росавиации № 367-П от 02.06.2023 (ред. Приказа Росавиации от 27.10.2025 № 804-П)"
|
||||
|
||||
# Ключевые слова для определения внутренних (hole) секторов
|
||||
_HOLE_KEYWORDS = ("внутренн", "internal", "исключ")
|
||||
|
||||
|
||||
def _is_hole_sector(label: str | None) -> bool:
|
||||
"""Возвращает True если сектор является внутренней границей (дыркой)."""
|
||||
if not label:
|
||||
return False
|
||||
lbl = label.lower()
|
||||
return any(kw in lbl for kw in _HOLE_KEYWORDS)
|
||||
|
||||
|
||||
def _reproject_ring(points: list[list[float]]) -> list[tuple[float, float]]:
|
||||
"""МСК-66 [X=northing, Y=easting] → (lon, lat) WGS84 для каждой точки.
|
||||
|
||||
always_xy=True → transform принимает (x=easting, y=northing).
|
||||
"""
|
||||
coords: list[tuple[float, float]] = []
|
||||
for pt in points:
|
||||
northing = pt[0]
|
||||
easting = pt[1]
|
||||
lon, lat = _MSK66_TO_WGS84.transform(easting, northing)
|
||||
coords.append((lon, lat))
|
||||
return coords
|
||||
|
||||
|
||||
def _close_ring(coords: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
"""Замкнуть кольцо если первая и последняя точки не совпадают."""
|
||||
if coords and coords[0] != coords[-1]:
|
||||
coords = [*coords, coords[0]]
|
||||
return coords
|
||||
|
||||
|
||||
def _build_subzone_geometry(sectors: list[dict[str, Any]]) -> MultiPolygon:
|
||||
"""Строит MultiPolygon подзоны из списка секторов.
|
||||
|
||||
Алгоритм обхода:
|
||||
- Встретили внешний сектор → начинаем новый Polygon (exterior).
|
||||
- Встретили внутренний сектор → добавляем hole к последнему внешнему Polygon-у.
|
||||
- Если нет ни одного внешнего — все секторы трактуются как exterior.
|
||||
|
||||
Returns MultiPolygon (валидный через make_valid + unary_union).
|
||||
"""
|
||||
polys: list[Polygon] = []
|
||||
current_exterior: list[tuple[float, float]] | None = None
|
||||
current_holes: list[list[tuple[float, float]]] = []
|
||||
|
||||
def flush_current() -> None:
|
||||
"""Сохранить накопленный exterior+holes как Polygon."""
|
||||
nonlocal current_exterior, current_holes
|
||||
if current_exterior is None:
|
||||
return
|
||||
ext = _close_ring(current_exterior)
|
||||
holes = [_close_ring(h) for h in current_holes]
|
||||
poly = Polygon(ext, holes)
|
||||
poly = make_valid(poly)
|
||||
if not poly.is_empty:
|
||||
polys.append(poly)
|
||||
current_exterior = None
|
||||
current_holes = []
|
||||
|
||||
for sector in sectors:
|
||||
raw_pts: list[list[float]] = sector.get("points", [])
|
||||
if not raw_pts:
|
||||
logger.warning("pat_subzones_load: сектор '%s' без точек, пропуск", sector.get("label"))
|
||||
continue
|
||||
|
||||
coords = _reproject_ring(raw_pts)
|
||||
label = sector.get("label")
|
||||
|
||||
if _is_hole_sector(label):
|
||||
# Внутренняя граница — дырка в текущем внешнем контуре
|
||||
if current_exterior is None:
|
||||
logger.warning(
|
||||
"pat_subzones_load: внутренний сектор '%s' без предшествующего внешнего"
|
||||
" — трактуем как exterior",
|
||||
label,
|
||||
)
|
||||
current_exterior = coords
|
||||
else:
|
||||
current_holes.append(coords)
|
||||
else:
|
||||
# Новый внешний контур → flush предыдущий
|
||||
flush_current()
|
||||
current_exterior = coords
|
||||
|
||||
flush_current()
|
||||
|
||||
if not polys:
|
||||
logger.error("pat_subzones_load: нет валидных полигонов в секторах")
|
||||
return MultiPolygon()
|
||||
|
||||
merged = unary_union(polys)
|
||||
merged = make_valid(merged)
|
||||
|
||||
if merged.geom_type == "Polygon":
|
||||
merged = MultiPolygon([merged])
|
||||
elif merged.geom_type == "MultiPolygon":
|
||||
pass
|
||||
else:
|
||||
# GeometryCollection или иное — пробуем извлечь полигоны
|
||||
sub_polys = [g for g in merged.geoms if g.geom_type in ("Polygon", "MultiPolygon")]
|
||||
if sub_polys:
|
||||
merged = make_valid(unary_union(sub_polys))
|
||||
if merged.geom_type == "Polygon":
|
||||
merged = MultiPolygon([merged])
|
||||
|
||||
return merged # type: ignore[return-value]
|
||||
|
||||
|
||||
def _centroid_in_koltsovo(geom: MultiPolygon) -> bool:
|
||||
"""Проверяет что центроид геометрии находится в bbox Кольцово."""
|
||||
if geom.is_empty:
|
||||
return False
|
||||
c = geom.centroid
|
||||
return (
|
||||
_KOLTSOVO_LON_MIN <= c.x <= _KOLTSOVO_LON_MAX
|
||||
and _KOLTSOVO_LAT_MIN <= c.y <= _KOLTSOVO_LAT_MAX
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(name="tasks.pat_subzones_load.load_koltsovo_pat")
|
||||
def load_koltsovo_pat() -> dict[str, Any]:
|
||||
"""Загрузить подзоны ПАТ Кольцово из JSON в таблицу pat_subzones.
|
||||
|
||||
Читает data/pat/koltsovo_367p_subzones.json, строит MultiPolygon для каждой
|
||||
подзоны, UPSERT через SAVEPOINT. Возвращает dict со статистикой.
|
||||
"""
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
logger.info("pat_subzones_load: start, json=%s", _JSON_PATH)
|
||||
|
||||
if not _JSON_PATH.exists():
|
||||
raise FileNotFoundError(f"JSON не найден: {_JSON_PATH}")
|
||||
|
||||
with open(_JSON_PATH, encoding="utf-8") as fh:
|
||||
data: dict[str, Any] = json.load(fh)
|
||||
|
||||
aerodrome: str = data.get("aerodrome", "Екатеринбург (Кольцово)")
|
||||
subzones_raw: dict[str, Any] = data.get("subzones", {})
|
||||
aggregate_pts: list[list[float]] = data.get("aggregate_outline", {}).get("points", [])
|
||||
|
||||
# ── Строим aggregate_outline geometry (subzone_no=0) ─────────────────────
|
||||
aggregate_geom: MultiPolygon | None = None
|
||||
if aggregate_pts:
|
||||
coords = _reproject_ring(aggregate_pts)
|
||||
coords = _close_ring(coords)
|
||||
agg_poly = make_valid(Polygon(coords))
|
||||
aggregate_geom = MultiPolygon([agg_poly]) if agg_poly.geom_type == "Polygon" else None
|
||||
if aggregate_geom:
|
||||
logger.info(
|
||||
"pat_subzones_load: aggregate_outline area=%.4f deg² centroid_ok=%s",
|
||||
aggregate_geom.area,
|
||||
_centroid_in_koltsovo(aggregate_geom),
|
||||
)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
total_area = 0.0
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for sz_no_str, sz_data in subzones_raw.items():
|
||||
sz_no = int(sz_no_str)
|
||||
name: str = sz_data.get("name", f"Подзона {sz_no}")
|
||||
restriction: str = sz_data.get("restriction", "")
|
||||
sectors: list[dict[str, Any]] = sz_data.get("sectors", [])
|
||||
|
||||
geom = _build_subzone_geometry(sectors)
|
||||
is_valid = not geom.is_empty and geom.is_valid
|
||||
centroid_ok = _centroid_in_koltsovo(geom)
|
||||
|
||||
logger.info(
|
||||
"pat_subzones_load: subzone=%d name='%s' sectors=%d "
|
||||
"is_valid=%s centroid_ok=%s area=%.6f",
|
||||
sz_no,
|
||||
name,
|
||||
len(sectors),
|
||||
is_valid,
|
||||
centroid_ok,
|
||||
geom.area,
|
||||
)
|
||||
|
||||
if not centroid_ok:
|
||||
logger.warning(
|
||||
"pat_subzones_load: subzone=%d centroid ВНЕ Кольцово — "
|
||||
"возможна ошибка осей/проекции. centroid=(%.5f, %.5f)",
|
||||
sz_no,
|
||||
geom.centroid.x if not geom.is_empty else 0,
|
||||
geom.centroid.y if not geom.is_empty else 0,
|
||||
)
|
||||
|
||||
total_area += geom.area
|
||||
geom_wkt = geom.wkt
|
||||
|
||||
try:
|
||||
db.execute(text("SAVEPOINT sp_subzone"))
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO pat_subzones
|
||||
(aerodrome, subzone_no, name, restriction, geom, source)
|
||||
VALUES (
|
||||
CAST(:aerodrome AS text),
|
||||
CAST(:subzone_no AS integer),
|
||||
CAST(:name AS text),
|
||||
CAST(:restriction AS text),
|
||||
ST_Multi(ST_GeomFromText(CAST(:geom_wkt AS text), 4326)),
|
||||
CAST(:source AS text)
|
||||
)
|
||||
ON CONFLICT (aerodrome, subzone_no) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
restriction = EXCLUDED.restriction,
|
||||
geom = EXCLUDED.geom,
|
||||
source = EXCLUDED.source,
|
||||
fetched_at = now()
|
||||
"""
|
||||
),
|
||||
{
|
||||
"aerodrome": aerodrome,
|
||||
"subzone_no": sz_no,
|
||||
"name": name,
|
||||
"restriction": restriction,
|
||||
"geom_wkt": geom_wkt,
|
||||
"source": _SOURCE,
|
||||
},
|
||||
)
|
||||
db.execute(text("RELEASE SAVEPOINT sp_subzone"))
|
||||
results.append(
|
||||
{"subzone_no": sz_no, "is_valid": is_valid, "centroid_ok": centroid_ok}
|
||||
)
|
||||
except Exception as exc:
|
||||
db.execute(text("ROLLBACK TO SAVEPOINT sp_subzone"))
|
||||
logger.error("pat_subzones_load: subzone=%d UPSERT failed: %s", sz_no, exc)
|
||||
raise
|
||||
|
||||
# ── UPSERT aggregate_outline (subzone_no=0) ──────────────────────────
|
||||
if aggregate_geom is not None:
|
||||
try:
|
||||
db.execute(text("SAVEPOINT sp_aggregate"))
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO pat_subzones
|
||||
(aerodrome, subzone_no, name, restriction, geom, source)
|
||||
VALUES (
|
||||
CAST(:aerodrome AS text),
|
||||
0,
|
||||
'Общий контур ПАТ',
|
||||
'',
|
||||
ST_Multi(ST_GeomFromText(CAST(:geom_wkt AS text), 4326)),
|
||||
CAST(:source AS text)
|
||||
)
|
||||
ON CONFLICT (aerodrome, subzone_no) DO UPDATE SET
|
||||
geom = EXCLUDED.geom,
|
||||
source = EXCLUDED.source,
|
||||
fetched_at = now()
|
||||
"""
|
||||
),
|
||||
{
|
||||
"aerodrome": aerodrome,
|
||||
"geom_wkt": aggregate_geom.wkt,
|
||||
"source": _SOURCE,
|
||||
},
|
||||
)
|
||||
db.execute(text("RELEASE SAVEPOINT sp_aggregate"))
|
||||
logger.info("pat_subzones_load: aggregate_outline upserted (subzone_no=0)")
|
||||
except Exception as exc:
|
||||
db.execute(text("ROLLBACK TO SAVEPOINT sp_aggregate"))
|
||||
logger.error("pat_subzones_load: aggregate_outline UPSERT failed: %s", exc)
|
||||
raise
|
||||
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ── Валидация: суммарная площадь подзон vs aggregate_outline ─────────────
|
||||
if aggregate_geom is not None and aggregate_geom.area > 0:
|
||||
ratio = total_area / aggregate_geom.area
|
||||
if ratio < 0.5 or ratio > 5.0:
|
||||
logger.warning(
|
||||
"pat_subzones_load: суммарная площадь подзон (%.4f) расходится "
|
||||
"с aggregate_outline (%.4f) в %.1fx — возможная ошибка осей/проекции",
|
||||
total_area,
|
||||
aggregate_geom.area,
|
||||
ratio,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"pat_subzones_load: total_area=%.4f aggregate_area=%.4f ratio=%.2fx OK",
|
||||
total_area,
|
||||
aggregate_geom.area,
|
||||
ratio,
|
||||
)
|
||||
|
||||
summary = {
|
||||
"aerodrome": aerodrome,
|
||||
"subzones_loaded": len(results),
|
||||
"results": results,
|
||||
"total_area_deg2": round(total_area, 6),
|
||||
}
|
||||
logger.info("pat_subzones_load: done %s", summary)
|
||||
return summary
|
||||
268
backend/tests/services/site_finder/test_pat_subzones.py
Normal file
268
backend/tests/services/site_finder/test_pat_subzones.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"""Тесты PAT-подзон (#1065 / #1067):
|
||||
- репроекция координат МСК-66 → WGS84 (проверка оси, bbox Кольцово)
|
||||
- построение MultiPolygon из секторов (внешние + дырки)
|
||||
- lookup graceful при отсутствии таблицы / WKT
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pyproj import CRS, Transformer
|
||||
from shapely.geometry import MultiPolygon
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.services.site_finder.pat_lookup import parcel_pat_subzones
|
||||
from app.workers.tasks.pat_subzones_load import (
|
||||
_build_subzone_geometry,
|
||||
_close_ring,
|
||||
_is_hole_sector,
|
||||
)
|
||||
|
||||
# ── Репроекция ──────────────────────────────────────────────────────────────
|
||||
_MSK66_PROJ4 = (
|
||||
"+proj=tmerc +lat_0=0 +lon_0=60 +k=1"
|
||||
" +x_0=1496944 +y_0=-5911077"
|
||||
" +ellps=krass +towgs84=23.92,-141.27,-80.9,0,0.35,0.82,-0.12"
|
||||
" +units=m +no_defs"
|
||||
)
|
||||
_TR = Transformer.from_crs(
|
||||
CRS.from_proj4(_MSK66_PROJ4),
|
||||
CRS.from_epsg(4326),
|
||||
always_xy=True,
|
||||
)
|
||||
|
||||
# Известные точки из subzone 1 sector 1 (koltsovo_367p_subzones.json)
|
||||
# point=[X=northing, Y=easting]; верный порядок: transform(easting=Y, northing=X)
|
||||
_KOLTSOVO_POINTS = [
|
||||
([381550.81, 1547094.51], (60.818, 56.751)), # первая точка сектора 1
|
||||
([381553.39, 1547585.62], (60.826, 56.752)), # третья
|
||||
([381562.21, 1547603.09], (60.827, 56.752)), # четвёртая
|
||||
]
|
||||
|
||||
_KOLTSOVO_LON_MIN = 60.5
|
||||
_KOLTSOVO_LON_MAX = 61.2
|
||||
_KOLTSOVO_LAT_MIN = 56.5
|
||||
_KOLTSOVO_LAT_MAX = 57.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("point,expected", _KOLTSOVO_POINTS)
|
||||
def test_reproject_msk66_to_wgs84_in_koltsovo(
|
||||
point: list[float], expected: tuple[float, float]
|
||||
) -> None:
|
||||
"""Репроекция МСК-66 [northing, easting] → WGS84 даёт координаты в bbox Кольцово."""
|
||||
northing, easting = point[0], point[1]
|
||||
lon, lat = _TR.transform(easting, northing) # always_xy: x=easting, y=northing
|
||||
|
||||
# Грубый bbox Кольцово
|
||||
msg_lon = f"lon={lon:.4f} вне bbox Кольцово [{_KOLTSOVO_LON_MIN}, {_KOLTSOVO_LON_MAX}]"
|
||||
assert _KOLTSOVO_LON_MIN <= lon <= _KOLTSOVO_LON_MAX, msg_lon
|
||||
msg_lat = f"lat={lat:.4f} вне bbox Кольцово [{_KOLTSOVO_LAT_MIN}, {_KOLTSOVO_LAT_MAX}]"
|
||||
assert _KOLTSOVO_LAT_MIN <= lat <= _KOLTSOVO_LAT_MAX, msg_lat
|
||||
|
||||
# Проверяем приблизительное совпадение с ожидаемым (±0.005°)
|
||||
exp_lon, exp_lat = expected
|
||||
assert abs(lon - exp_lon) < 0.005, f"lon расхождение: got {lon:.4f} exp {exp_lon}"
|
||||
assert abs(lat - exp_lat) < 0.005, f"lat расхождение: got {lat:.4f} exp {exp_lat}"
|
||||
|
||||
|
||||
def test_axis_swap_gives_wrong_result() -> None:
|
||||
"""Неверный порядок осей (x=northing, y=easting) выдаёт координаты вне Кольцово."""
|
||||
northing, easting = 381550.81, 1547094.51
|
||||
# Неверный порядок: x=northing, y=easting
|
||||
lon_wrong, lat_wrong = _TR.transform(northing, easting)
|
||||
# Должно быть ВНЕ bbox Кольцово
|
||||
in_koltsovo = (
|
||||
_KOLTSOVO_LON_MIN <= lon_wrong <= _KOLTSOVO_LON_MAX
|
||||
and _KOLTSOVO_LAT_MIN <= lat_wrong <= _KOLTSOVO_LAT_MAX
|
||||
)
|
||||
msg = (
|
||||
f"Неверный порядок осей дал координаты в Кольцово: lon={lon_wrong:.4f} lat={lat_wrong:.4f}"
|
||||
)
|
||||
assert not in_koltsovo, msg
|
||||
|
||||
|
||||
# ── Построение геометрии ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_sector(label: str | None, pts: list[tuple[float, float]]) -> dict[str, Any]:
|
||||
"""Вспомогательная фикстура: сектор с точками уже в WGS84-диапазоне.
|
||||
|
||||
Используем точки в МСК-66 которые проецируются в WGS84 Кольцово.
|
||||
"""
|
||||
# Конвертируем WGS84-like точки в «псевдо МСК-66» для реального прогона через _build.
|
||||
# Проще передать реальные МСК-66 точки из JSON.
|
||||
return {
|
||||
"label": label,
|
||||
"points": [list(p) for p in pts],
|
||||
}
|
||||
|
||||
|
||||
# Набор точек в МСК-66 (northing, easting) образующих маленький треугольник около Кольцово
|
||||
_TRI_OUTER = [
|
||||
[381500.0, 1547000.0],
|
||||
[381600.0, 1547000.0],
|
||||
[381600.0, 1547100.0],
|
||||
[381500.0, 1547000.0], # замкнуто
|
||||
]
|
||||
|
||||
_TRI_HOLE = [
|
||||
[381520.0, 1547020.0],
|
||||
[381560.0, 1547020.0],
|
||||
[381560.0, 1547060.0],
|
||||
[381520.0, 1547020.0],
|
||||
]
|
||||
|
||||
|
||||
def test_build_single_exterior_sector() -> None:
|
||||
"""Один внешний сектор → валидный MultiPolygon."""
|
||||
sectors = [{"label": "Сектор 1", "points": _TRI_OUTER}]
|
||||
geom = _build_subzone_geometry(sectors)
|
||||
assert isinstance(geom, MultiPolygon)
|
||||
assert not geom.is_empty
|
||||
assert geom.is_valid
|
||||
|
||||
|
||||
def test_build_exterior_with_hole() -> None:
|
||||
"""Внешний сектор + внутренняя граница → Polygon с дыркой."""
|
||||
sectors = [
|
||||
{"label": "Сектор 1. Внешняя граница", "points": _TRI_OUTER},
|
||||
{"label": "Сектор 1. Внутренняя граница 1", "points": _TRI_HOLE},
|
||||
]
|
||||
geom = _build_subzone_geometry(sectors)
|
||||
assert isinstance(geom, MultiPolygon)
|
||||
assert not geom.is_empty
|
||||
assert geom.is_valid
|
||||
# Площадь с дыркой должна быть меньше только внешней
|
||||
outer_only = _build_subzone_geometry([sectors[0]])
|
||||
assert geom.area < outer_only.area
|
||||
|
||||
|
||||
def test_build_two_exterior_sectors() -> None:
|
||||
"""Два внешних сектора → MultiPolygon из двух Polygon-ов."""
|
||||
tri2 = [
|
||||
[382000.0, 1548000.0],
|
||||
[382100.0, 1548000.0],
|
||||
[382100.0, 1548100.0],
|
||||
[382000.0, 1548000.0],
|
||||
]
|
||||
sectors = [
|
||||
{"label": "Сектор 1", "points": _TRI_OUTER},
|
||||
{"label": "Сектор 2", "points": tri2},
|
||||
]
|
||||
geom = _build_subzone_geometry(sectors)
|
||||
assert isinstance(geom, MultiPolygon)
|
||||
assert not geom.is_empty
|
||||
assert geom.is_valid
|
||||
|
||||
|
||||
def test_build_empty_sector_skipped() -> None:
|
||||
"""Сектор без точек не ломает построение."""
|
||||
sectors = [
|
||||
{"label": "Пустой", "points": []},
|
||||
{"label": "Сектор 1", "points": _TRI_OUTER},
|
||||
]
|
||||
geom = _build_subzone_geometry(sectors)
|
||||
assert isinstance(geom, MultiPolygon)
|
||||
assert not geom.is_empty
|
||||
|
||||
|
||||
def test_is_hole_sector_detection() -> None:
|
||||
"""Детекция внутренних секторов по label."""
|
||||
assert _is_hole_sector("Сектор 1. Внутренняя граница 1") is True
|
||||
assert _is_hole_sector("Внутренняя граница") is True
|
||||
assert _is_hole_sector("internal boundary") is True
|
||||
assert _is_hole_sector("Исключённая территория") is True
|
||||
assert _is_hole_sector("Сектор 1. Внешняя граница") is False
|
||||
assert _is_hole_sector("Сектор 1") is False
|
||||
assert _is_hole_sector(None) is False
|
||||
|
||||
|
||||
def test_close_ring_adds_closing_point() -> None:
|
||||
"""Незамкнутое кольцо дополняется копией первой точки."""
|
||||
coords = [(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)]
|
||||
closed = _close_ring(coords)
|
||||
assert closed[-1] == closed[0]
|
||||
assert len(closed) == 4
|
||||
|
||||
|
||||
def test_close_ring_already_closed() -> None:
|
||||
"""Уже замкнутое кольцо не изменяется."""
|
||||
coords = [(1.0, 2.0), (3.0, 4.0), (1.0, 2.0)]
|
||||
closed = _close_ring(coords)
|
||||
assert len(closed) == 3
|
||||
|
||||
|
||||
# ── Lookup graceful ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _Result:
|
||||
def __init__(self, rows: list[dict[str, Any]]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def mappings(self) -> _Result:
|
||||
return self
|
||||
|
||||
def all(self) -> list[dict[str, Any]]:
|
||||
return self._rows
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(
|
||||
self,
|
||||
rows: list[dict[str, Any]] | None = None,
|
||||
raise_exc: Exception | None = None,
|
||||
) -> None:
|
||||
self._rows = rows or []
|
||||
self._raise = raise_exc
|
||||
|
||||
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result:
|
||||
if self._raise is not None:
|
||||
raise self._raise
|
||||
return _Result(self._rows)
|
||||
|
||||
|
||||
_WKT = "POLYGON((60.8 56.7, 60.9 56.7, 60.9 56.8, 60.8 56.8, 60.8 56.7))"
|
||||
|
||||
|
||||
def test_pat_lookup_returns_subzones() -> None:
|
||||
rows = [
|
||||
{
|
||||
"subzone_no": 2,
|
||||
"name": "Вторая подзона",
|
||||
"restriction": "Ограничения второй подзоны",
|
||||
"aerodrome": "Екатеринбург (Кольцово)",
|
||||
}
|
||||
]
|
||||
result = parcel_pat_subzones(_FakeDB(rows), _WKT)
|
||||
assert len(result) == 1
|
||||
assert result[0]["subzone_no"] == 2
|
||||
assert result[0]["name"] == "Вторая подзона"
|
||||
assert result[0]["aerodrome"] == "Екатеринбург (Кольцово)"
|
||||
|
||||
|
||||
def test_pat_lookup_empty_when_no_wkt() -> None:
|
||||
result = parcel_pat_subzones(_FakeDB([{"subzone_no": 1}]), None)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_pat_lookup_empty_when_no_intersections() -> None:
|
||||
result = parcel_pat_subzones(_FakeDB([]), _WKT)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_pat_lookup_graceful_table_missing() -> None:
|
||||
"""pat_subzones не задеплоена → пусто, без падения."""
|
||||
db = _FakeDB(raise_exc=OperationalError("stmt", {}, Exception("relation does not exist")))
|
||||
result = parcel_pat_subzones(db, _WKT)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_pat_lookup_graceful_programming_error() -> None:
|
||||
"""ProgrammingError (колонка не существует) → graceful."""
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
|
||||
db = _FakeDB(raise_exc=ProgrammingError("stmt", {}, Exception("column not found")))
|
||||
result = parcel_pat_subzones(db, _WKT)
|
||||
assert result == []
|
||||
9130
data/pat/koltsovo_367p_subzones.json
Normal file
9130
data/pat/koltsovo_367p_subzones.json
Normal file
File diff suppressed because it is too large
Load diff
60
data/sql/142_pat_subzones.sql
Normal file
60
data/sql/142_pat_subzones.sql
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
-- 142_pat_subzones.sql
|
||||
-- #1065 / #1067 (Site Finder v2 / GG-форсайт) — подзоны приаэродромных территорий (ПАТ).
|
||||
-- Первый аэродром: Кольцово (Приказ Росавиации № 367-П от 02.06.2023, ред. 804-П от 27.10.2025).
|
||||
--
|
||||
-- Геометрия строится Celery-воркером pat_subzones_load из извлечённых координат МСК-66
|
||||
-- (файл data/pat/koltsovo_367p_subzones.json) с репроекцией → WGS84 (EPSG:4326).
|
||||
--
|
||||
-- Поддерживает несколько аэродромов (поле aerodrome), subzone_no=0 — общий контур.
|
||||
--
|
||||
-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз по NN=142).
|
||||
-- Idempotent (CREATE TABLE/INDEX IF NOT EXISTS + guarded DO-блоки).
|
||||
-- Данные загружаются отдельно Celery-воркером (tasks.pat_subzones_load.load_koltsovo_pat).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Таблица pat_subzones ───────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS pat_subzones (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
aerodrome TEXT NOT NULL, -- 'Екатеринбург (Кольцово)' и т.д.
|
||||
subzone_no INTEGER NOT NULL, -- 1..6; 0 = aggregate_outline
|
||||
name TEXT, -- 'Первая подзона' и т.д.
|
||||
restriction TEXT, -- текст ограничения из приказа
|
||||
geom geometry(MultiPolygon, 4326) NOT NULL,
|
||||
source TEXT, -- URL/реквизит НПА
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE pat_subzones IS
|
||||
'Подзоны приаэродромных территорий (ПАТ), EPSG:4326 (#1065/#1067). '
|
||||
'Источник: Приказы Росавиации. Первый аэродром: Кольцово (367-П/804-П). '
|
||||
'Геометрия загружается воркером tasks.pat_subzones_load.load_koltsovo_pat.';
|
||||
|
||||
COMMENT ON COLUMN pat_subzones.aerodrome IS
|
||||
'Наименование аэродрома из НПА, напр. «Екатеринбург (Кольцово)».';
|
||||
COMMENT ON COLUMN pat_subzones.subzone_no IS
|
||||
'Номер подзоны 1..6 по НБНРД. 0 — aggregate_outline (общий контур ПАТ).';
|
||||
COMMENT ON COLUMN pat_subzones.geom IS
|
||||
'MultiPolygon WGS84. Внутренние границы секторов стали дырками Polygon.';
|
||||
|
||||
-- ── 2. UNIQUE (aerodrome, subzone_no) — ключ для UPSERT ──────────────────────
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'uq_pat_subzones_aerodrome_no'
|
||||
) THEN
|
||||
ALTER TABLE pat_subzones
|
||||
ADD CONSTRAINT uq_pat_subzones_aerodrome_no UNIQUE (aerodrome, subzone_no);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ── 3. GIST-индекс по геометрии (ST_Intersects / parcel-lookup) ──────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_pat_subzones_geom
|
||||
ON pat_subzones USING GIST (geom);
|
||||
|
||||
-- ── 4. Индекс по аэродрому (фильтрация per-aerodrome) ────────────────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_pat_subzones_aerodrome
|
||||
ON pat_subzones (aerodrome);
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Reference in a new issue