feat(sf): подзоны ПАТ Кольцово → pat_subzones (#1150)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m25s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m26s
Deploy / deploy (push) Successful in 1m11s

Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
bot-backend 2026-06-07 14:43:07 +00:00 committed by bot-reviewer
parent e2faab801c
commit 9736192359
6 changed files with 9889 additions and 0 deletions

View 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"]

View file

@ -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"

View 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

View 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 == []

File diff suppressed because it is too large Load diff

View 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;