gendesign/backend/app/workers/tasks/pat_subzones_load.py
bot-backend 9736192359
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
feat(sf): подзоны ПАТ Кольцово → pat_subzones (#1150)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-07 14:43:07 +00:00

365 lines
15 KiB
Python
Raw 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.

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