From b67fa09f1cd625f6a111f8fcfbc4dec0e408121a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 7 Jun 2026 14:11:07 +0300 Subject: [PATCH] =?UTF-8?q?fix(sf):=20=D0=BA=D0=B0=D0=BB=D0=B8=D0=B1=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0=20proj4=20=D0=9C=D0=A1=D0=9A-66=20x=5F?= =?UTF-8?q?0/y=5F0=20=D0=BF=D0=BE=20cad=5Fparcels=20ground-truth=20(#1088,?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20#1124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x_0: 1500000 → 1496944, y_0: -5911000 → -5911077 Калибровка по 738 permit'ам с кадастровым номером в cad_parcels: до: median≈3055м (систематический сдвиг ~3050м западнее по долготе) после: avg≈14м, median≈7м, p95≈58м Добавлены 4 теста cross-check с эталонными координатами из cad_parcels.geom. --- .../app/services/scrapers/ekburg_permits.py | 15 +-- backend/tests/services/test_ekburg_permits.py | 91 +++++++++++++++++-- 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/backend/app/services/scrapers/ekburg_permits.py b/backend/app/services/scrapers/ekburg_permits.py index 001e7814..4a678f95 100644 --- a/backend/app/services/scrapers/ekburg_permits.py +++ b/backend/app/services/scrapers/ekburg_permits.py @@ -32,8 +32,9 @@ Format: Form 3 (РНС) + Form 4 (РВЭ) — стандартные Росст 16 rve_date — Дата разрешения на ввод Координаты raw_coord_x / raw_coord_y — СКФ-66 (МСК-66, Свердловская обл., зона 1). -Параметры проекции: tmerc, lon_0=60°E, k=1, false_easting=1500000, false_northing=-5911000, +Параметры проекции: tmerc, lon_0=60°E, k=1, false_easting=1496944, false_northing=-5911077, эллипсоид Красовского, сдвиг datum towgs84=23.92,-141.27,-80.9,0,0.35,0.82,-0.12. +Параметры откалиброваны по cad_parcels ground-truth (738 точек): avg_err≈14м, median≈7м. Ячейки могут содержать несколько точек через пробел — берём первую. Геокодирование выполняется при парсинге (geom, geocoded_lat/lon). """ @@ -57,12 +58,14 @@ logger = logging.getLogger(__name__) # ── МСК-66 → WGS84 трансформер ─────────────────────────────────────────────── # СКФ-66 (местная СК Свердловской обл., зона 1): # tmerc, осевой меридиан 60°E, масштаб 1.0 -# ложный восток 1 500 000 м, ложный север -5 911 000 м (эмпирически подобран по -# нескольким объектам с известными адресами: Кольцово, пр. Космонавтов и др.) +# x_0=1496944 (false_easting), y_0=-5911077 (false_northing) +# Откалиброваны по cad_parcels ground-truth (738 точек permit'ов с кад.номером): +# avg_err≈14м, median≈7м, p95≈58м (без 8 записей с ошибочными Y в исходных данных). +# Предыдущее x_0=1500000 давало систематический сдвиг ~3050м западнее по долготе. # эллипсоид Красовского + сдвиг Пулково→WGS84 _MSK66_PROJ4 = ( "+proj=tmerc +lat_0=0 +lon_0=60 +k=1" - " +x_0=1500000 +y_0=-5911000" + " +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" ) @@ -91,8 +94,8 @@ def msk66_to_wgs84(raw_x: str | None, raw_y: str | None) -> tuple[float, float] или результат выходит за пределы ЕКБ-региона. В МСК-66 Свердловской обл.: - raw_coord_x ≈ 1 500 000 + смещение (false_easting = 1 500 000) - raw_coord_y ≈ northing - 5 911 000 (false_northing = -5 911 000) + raw_coord_x ≈ 1 500 000 + смещение (откалиброванный false_easting = 1 496 944) + raw_coord_y ≈ northing + смещение (откалиброванный false_northing = -5 911 077) """ if not raw_x or not raw_y: return None diff --git a/backend/tests/services/test_ekburg_permits.py b/backend/tests/services/test_ekburg_permits.py index f7ba29ae..51c47cd2 100644 --- a/backend/tests/services/test_ekburg_permits.py +++ b/backend/tests/services/test_ekburg_permits.py @@ -363,18 +363,95 @@ class TestSslConfiguration: # ── unit tests: msk66_to_wgs84 ─────────────────────────────────────────────── +# Параметры допуска для cad_parcels cross-check (≈50м по Хаверсину) +# На широте ЕКБ (~56.9°): 1° широты ≈ 111320м, 1° долготы ≈ 60730м +# 50м ÷ 111320 ≈ 0.00045° по широте; 50м ÷ 60730 ≈ 0.00082° по долготе +_TOL_LAT_DEG = 0.00045 # ~50м по широте +_TOL_LON_DEG = 0.00082 # ~50м по долготе + class TestMsk66ToWgs84: """Тесты трансформации МСК-66 → WGS84. - Реальные данные из ekburg_construction_permits: - X=1534814.7997, Y=394813.2001 — пр. Космонавтов 18, ЕКБ (~56.87N 60.57E) - X=1544026.7997, Y=381585.4401 — район аэропорта Кольцово (~56.74N 60.72E) - X=1526550.1397, Y=387129.3801 — ул. Смородиновая 187, западная часть ЕКБ + Реальные данные из ekburg_construction_permits (сверены с cad_parcels ground-truth). + Калиброванный proj4: x_0=1496944, y_0=-5911077 + — avg_err≈14м, median≈7м, p95≈58м на 738 точках с кадастровым номером. + + Точки для cross-check получены из cad_parcels.geom (ST_Centroid) и проверены SQL: + X=1534814.7997, Y=394813.2001 → lon≈60.619637, lat≈56.871948 + (cad 66:41:0110016:1046, погрешность <50м от центроида участка) + X=1544026.7997, Y=381585.4401 → lon≈60.768255, lat≈56.752330 + (cad 66:41:0503018:248, аэропорт Кольцово) + X=1526550.1397, Y=387129.3801 → lon≈60.483224, lat≈56.803547 + (cad 66:41:0306057:89, западная часть ЕКБ) + X=1531655.2797, Y=398430.2401 → lon≈60.568320, lat≈56.904674 + (cad 66:41:0106051:10, ул. Республиканская 1а; err_m=12) """ + def test_kosmonavtov_cad_crosscheck(self) -> None: + """X=1534814.7997/Y=394813.2001 — сверка с centroid cad_parcels 66:41:0110016:1046. + + Ожидаемые координаты получены из PostGIS: lon≈60.619637, lat≈56.871948. + Допуск ±0.00082° по долготе (~50м), ±0.00045° по широте (~50м). + """ + result = msk66_to_wgs84("1534814.7997", "394813.2001") + assert result is not None + lon, lat = result + assert ( + abs(lon - 60.619637) < _TOL_LON_DEG + ), f"lon={lon:.6f} далеко от эталонного 60.619637 (Δ={abs(lon - 60.619637):.6f}°)" + assert ( + abs(lat - 56.871948) < _TOL_LAT_DEG + ), f"lat={lat:.6f} далеко от эталонного 56.871948 (Δ={abs(lat - 56.871948):.6f}°)" + + def test_koltsovo_cad_crosscheck(self) -> None: + """X=1544026.7997/Y=381585.4401 — сверка с centroid cad_parcels 66:41:0503018:248. + + Ожидаемые координаты: lon≈60.768255, lat≈56.752330 (аэропорт Кольцово). + """ + result = msk66_to_wgs84("1544026.7997", "381585.4401") + assert result is not None + lon, lat = result + assert ( + abs(lon - 60.768255) < _TOL_LON_DEG + ), f"lon={lon:.6f} далеко от эталонного 60.768255 (Δ={abs(lon - 60.768255):.6f}°)" + assert ( + abs(lat - 56.752330) < _TOL_LAT_DEG + ), f"lat={lat:.6f} далеко от эталонного 56.752330 (Δ={abs(lat - 56.752330):.6f}°)" + + def test_smorodinovaya_cad_crosscheck(self) -> None: + """X=1526550.1397/Y=387129.3801 — сверка с centroid cad_parcels 66:41:0306057:89. + + Ожидаемые координаты: lon≈60.483224, lat≈56.803547 (западная часть ЕКБ). + """ + result = msk66_to_wgs84("1526550.1397", "387129.3801") + assert result is not None + lon, lat = result + assert ( + abs(lon - 60.483224) < _TOL_LON_DEG + ), f"lon={lon:.6f} далеко от эталонного 60.483224 (Δ={abs(lon - 60.483224):.6f}°)" + assert ( + abs(lat - 56.803547) < _TOL_LAT_DEG + ), f"lat={lat:.6f} далеко от эталонного 56.803547 (Δ={abs(lat - 56.803547):.6f}°)" + + def test_respublikanskaya_cad_crosscheck(self) -> None: + """X=1531655.2797/Y=398430.2401 — ул. Республиканская 1а, cad 66:41:0106051:10. + + Ожидаемые координаты: lon≈60.568320, lat≈56.904674 (err_m=12 по cad_parcels). + Этот тест ловит регрессию easting-сдвига: при x_0=1500000 computed был бы ~60.519°. + """ + result = msk66_to_wgs84("1531655.2797", "398430.2401") + assert result is not None + lon, lat = result + assert ( + abs(lon - 60.568320) < _TOL_LON_DEG + ), f"lon={lon:.6f} далеко от эталонного 60.568320 (Δ={abs(lon - 60.568320):.6f}°)" + assert ( + abs(lat - 56.904674) < _TOL_LAT_DEG + ), f"lat={lat:.6f} далеко от эталонного 56.904674 (Δ={abs(lat - 56.904674):.6f}°)" + def test_kosmonavtov_in_ekb(self) -> None: - """Пр. Космонавтов 18 — попадает в ЕКБ-регион.""" + """Пр. Космонавтов — попадает в ЕКБ-регион.""" result = msk66_to_wgs84("1534814.7997", "394813.2001") assert result is not None lon, lat = result @@ -426,7 +503,7 @@ class TestMsk66ToWgs84: def test_out_of_range_x_returns_none(self) -> None: """X вне диапазона МСК-66 (например, случайное число) → None.""" - # X=500000 — это не МСК-66 (false_easting=1500000, диапазон 1450000-1600000) + # X=500000 — это не МСК-66 (false_easting ~1500000, диапазон 1450000-1600000) result = msk66_to_wgs84("500000.0", "380000.0") assert result is None @@ -441,4 +518,4 @@ class TestMsk66ToWgs84: # Проверяем что первый элемент — долгота (60.x), второй — широта (56.x) assert 56.0 < lat < 58.0, f"ожидали широту, получили {lat}" assert 59.0 < lon < 62.0, f"ожидали долготу, получили {lon}" - assert lon > lat # lon ~60.5 > lat ~56.9 + assert lon > lat # lon ~60.6 > lat ~56.9