fix(sf): калибровка proj4 МСК-66 x_0/y_0 по cad_parcels ground-truth (#1088, ревью #1124)
All checks were successful
CI / changes (push) Successful in 12s
CI / changes (pull_request) Successful in 10s
CI / backend-tests (push) Successful in 6m29s
CI / backend-tests (pull_request) Successful in 6m31s

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.
This commit is contained in:
bot-backend 2026-06-07 14:11:07 +03:00
parent 5546056666
commit b67fa09f1c
2 changed files with 93 additions and 13 deletions

View file

@ -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_err14м, median7м.
Ячейки могут содержать несколько точек через пробел берём первую.
Геокодирование выполняется при парсинге (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

View file

@ -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_err14м, median7м, p9558м на 738 точках с кадастровым номером.
Точки для cross-check получены из cad_parcels.geom (ST_Centroid) и проверены SQL:
X=1534814.7997, Y=394813.2001 lon60.619637, lat56.871948
(cad 66:41:0110016:1046, погрешность <50м от центроида участка)
X=1544026.7997, Y=381585.4401 lon60.768255, lat56.752330
(cad 66:41:0503018:248, аэропорт Кольцово)
X=1526550.1397, Y=387129.3801 lon60.483224, lat56.803547
(cad 66:41:0306057:89, западная часть ЕКБ)
X=1531655.2797, Y=398430.2401 lon60.568320, lat56.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: lon60.619637, lat56.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.
Ожидаемые координаты: lon60.768255, lat56.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.
Ожидаемые координаты: lon60.483224, lat56.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.
Ожидаемые координаты: lon60.568320, lat56.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