From 6a2d873c707772b269b31c08dca8c08d5dadd85f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:51:47 +0500 Subject: [PATCH] =?UTF-8?q?fix(rosseti):=20=D1=81=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BE=D1=80=D0=B4=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D1=82=D1=83=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=20=D0=B2=20SQL=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BC=20=D0=B6=D0=B5=20double,=20=D1=87=D1=82?= =?UTF-8?q?=D0=BE=20=D0=B2=20=D0=BF=D0=B8=D1=82=D0=BE=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью PR #3329. round(ST_X(geom)::numeric * 100000) округляет по кратчайшему десятичному представлению float8, а питон — по двоичному double: расхождение на 0.19% реальных координат (761 из 400000), напр. 64.423605 → питон 6442360 (6442360.499999999), numeric-путь 6442361. Каждое расхождение = вечный дубль ЦП, который сам не зарастёт — миграция применяется один раз (_schema_migrations). Теперь в SQL sign/floor/abs над float8 без каста в numeric: IEEE754 бит в бит как math.floor в питоне. test_coord_e5 брал 60.123455, где двоичное и десятичное округление совпадают — защита, которая не защищает. Добавлено расходящееся значение 64.423605. RAISE WARNING при rows_after > 700 заменён на RAISE EXCEPTION: warning не останавливает прогон, файл помечался бы applied навсегда вместе с дублями. Refs #3322 --- .../site_finder/rosseti_wfs_loader.py | 5 +++- .../tests/test_connection_capacity_loaders.py | 12 +++++++-- data/sql/99c_power_supply_centers_dedup.sql | 26 ++++++++++++++----- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/backend/app/services/site_finder/rosseti_wfs_loader.py b/backend/app/services/site_finder/rosseti_wfs_loader.py index 5f62d6a5..d586a4b3 100644 --- a/backend/app/services/site_finder/rosseti_wfs_loader.py +++ b/backend/app/services/site_finder/rosseti_wfs_loader.py @@ -107,7 +107,10 @@ def _coord_e5(value: float | None) -> str: Целое, а не форматированный float: ключ обязан совпадать байт-в-байт с SQL- бэкфиллом (99c), а текстовое представление double в питоне и в PG разное. - ``floor(|v|*1e5 + 0.5)`` со знаком = ``round(numeric)`` в PG (half-away-from-zero). + Округление ДВОИЧНОЕ (по значению double, не по десятичному представлению): + 64.423605*1e5 == 6442360.499999999 → 6442360, хотя «по десятичному» было бы + 6442361. В 99c та же семантика: floor/abs/sign над float8, БЕЗ каста в numeric + (каст округляет по кратчайшему десятичному repr и расходится в 0.19% координат). """ if value is None: return "" diff --git a/backend/tests/test_connection_capacity_loaders.py b/backend/tests/test_connection_capacity_loaders.py index 705b5c0d..ecc35091 100644 --- a/backend/tests/test_connection_capacity_loaders.py +++ b/backend/tests/test_connection_capacity_loaders.py @@ -139,8 +139,16 @@ def test_stable_external_id_no_geometry() -> None: assert rw._stable_external_id(f, f["properties"]) == "h:eb91917f35aff23f" -def test_coord_e5_half_away_from_zero() -> None: - """Квантование как round(numeric) в PG: полукругление ОТ нуля, симметрично знаку.""" +def test_coord_e5_rounds_on_binary_double_not_decimal() -> None: + """Округление по ДВОИЧНОМУ double, не по десятичному представлению. + + 64.423605*1e5 == 6442360.499999999 → 6442360; «по десятичному» вышло бы 6442361 + (так считал бы round(ST_X(geom)::numeric*100000) — расхождение на 0.19% реальных + координат). 99c обязана давать те же цифры, поэтому семантика закреплена тестом. + """ + assert rw._coord_e5(64.423605) == "6442360" + assert rw._coord_e5(-64.423605) == "-6442360" + # 60.123455*1e5 == ровно 6012345.5 → полукругление ОТ нуля, симметрично знаку. assert rw._coord_e5(60.123455) == "6012346" assert rw._coord_e5(-60.123455) == "-6012346" assert rw._coord_e5(60.6) == "6060000" diff --git a/data/sql/99c_power_supply_centers_dedup.sql b/data/sql/99c_power_supply_centers_dedup.sql index 581fc4ec..f8f4e963 100644 --- a/data/sql/99c_power_supply_centers_dedup.sql +++ b/data/sql/99c_power_supply_centers_dedup.sql @@ -23,9 +23,17 @@ -- seed = sc_name_norm || '|' || voltage_class || '|' || lon_e5 || '|' || lat_e5 -- external_id = 'h:' || left(hex(sha256(utf8(seed))), 16) -- где lon_e5/lat_e5 — координата в единицах 1e-5 градуса (~1 м), округление --- round() = half-away-from-zero (в питоне ровно то же: floor(|v|*1e5+0.5) со знаком); --- пустая строка, если geom отсутствует. Целые, а не форматированный float — --- текстовое представление double в питоне и в PG различается. +-- floor(|v|*1e5 + 0.5) со знаком — ДВОИЧНОЕ, ровно как в питоне; пустая строка, +-- если geom отсутствует. Целые, а не форматированный float — текстовое +-- представление double в питоне и в PG различается. +-- +-- ПОЧЕМУ НЕ round(...::numeric): каст float8→numeric берёт кратчайшее десятичное +-- представление, и округление идёт по нему, а не по двоичному double. На реальных +-- координатах расходится в 0.19% случаев (замер: 761 из 400000), напр. 64.423605 +-- → питон 6442360 (двоичное 6442360.499999999), numeric-путь 6442361. Каждое +-- расхождение = вечный дубль ЦП, который сам не зарастёт: миграция применяется +-- один раз (_schema_migrations). Поэтому в SQL считаем ТЕМ ЖЕ double: floor/abs/ +-- sign над float8 — это IEEE754, бит в бит как math.floor в питоне. -- sha256, а не sha1: sha256 встроен в PG16, sha1 потребовал бы pgcrypto. -- -- Байт-в-байт совпадение с питоном держится на том, что SQL НИЧЕГО не нормализует @@ -68,9 +76,11 @@ BEGIN sc_name_norm || '|' || coalesce(voltage_class, '') || '|' || CASE WHEN geom IS NULL THEN '' - ELSE round(ST_X(geom)::numeric * 100000)::text END + ELSE (sign(ST_X(geom)) + * floor(abs(ST_X(geom)) * 100000 + 0.5))::bigint::text END || '|' || CASE WHEN geom IS NULL THEN '' - ELSE round(ST_Y(geom)::numeric * 100000)::text END, + ELSE (sign(ST_Y(geom)) + * floor(abs(ST_Y(geom)) * 100000 + 0.5))::bigint::text END, 'UTF8' )), 'hex' @@ -116,9 +126,11 @@ BEGIN rows_before, names_before, rows_after, names_after, deleted, rekeyed; -- 700 — потолок здравого смысла: источник отдаёт ~488 ЦП по области. - -- Превышение = формула ключа не схлопнула дубли, смотреть глазами. + -- Превышение = формула ключа не схлопнула дубли. EXCEPTION, а не WARNING: + -- иначе файл пометится applied навсегда, а дубли останутся. Откат всей + -- транзакции ничего не теряет и оставляет миграцию непринятой до разбора. IF rows_after > 700 THEN - RAISE WARNING '#3322: после дедупа осталось % строк (ожидалось ~481-488) — проверить формулу ключа', rows_after; + RAISE EXCEPTION '#3322: после дедупа осталось % строк (ожидалось ~481-488) — формула ключа не схлопнула дубли, транзакция откачена', rows_after; END IF; END $$;