fix(rosseti): считать координату ключа в SQL тем же double, что в питоне
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m24s
CI / backend-tests (pull_request) Successful in 17m41s

Ревью 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
This commit is contained in:
bot-backend 2026-09-02 14:51:47 +05:00
parent b35f444d50
commit 6a2d873c70
3 changed files with 33 additions and 10 deletions

View file

@ -107,7 +107,10 @@ def _coord_e5(value: float | None) -> str:
Целое, а не форматированный float: ключ обязан совпадать байт-в-байт с SQL- Целое, а не форматированный float: ключ обязан совпадать байт-в-байт с SQL-
бэкфиллом (99c), а текстовое представление double в питоне и в PG разное. бэкфиллом (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: if value is None:
return "" return ""

View file

@ -139,8 +139,16 @@ def test_stable_external_id_no_geometry() -> None:
assert rw._stable_external_id(f, f["properties"]) == "h:eb91917f35aff23f" assert rw._stable_external_id(f, f["properties"]) == "h:eb91917f35aff23f"
def test_coord_e5_half_away_from_zero() -> None: def test_coord_e5_rounds_on_binary_double_not_decimal() -> None:
"""Квантование как round(numeric) в PG: полукругление ОТ нуля, симметрично знаку.""" """Округление по ДВОИЧНОМУ 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.123455) == "-6012346" assert rw._coord_e5(-60.123455) == "-6012346"
assert rw._coord_e5(60.6) == "6060000" assert rw._coord_e5(60.6) == "6060000"

View file

@ -23,9 +23,17 @@
-- seed = sc_name_norm || '|' || voltage_class || '|' || lon_e5 || '|' || lat_e5 -- seed = sc_name_norm || '|' || voltage_class || '|' || lon_e5 || '|' || lat_e5
-- external_id = 'h:' || left(hex(sha256(utf8(seed))), 16) -- external_id = 'h:' || left(hex(sha256(utf8(seed))), 16)
-- где lon_e5/lat_e5 — координата в единицах 1e-5 градуса (~1 м), округление -- где lon_e5/lat_e5 — координата в единицах 1e-5 градуса (~1 м), округление
-- round() = half-away-from-zero (в питоне ровно то же: floor(|v|*1e5+0.5) со знаком); -- floor(|v|*1e5 + 0.5) со знаком — ДВОИЧНОЕ, ровно как в питоне; пустая строка,
-- пустая строка, если geom отсутствует. Целые, а не форматированный float — -- если geom отсутствует. Целые, а не форматированный float — текстовое
-- текстовое представление double в питоне и в PG различается. -- представление 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. -- sha256, а не sha1: sha256 встроен в PG16, sha1 потребовал бы pgcrypto.
-- --
-- Байт-в-байт совпадение с питоном держится на том, что SQL НИЧЕГО не нормализует -- Байт-в-байт совпадение с питоном держится на том, что SQL НИЧЕГО не нормализует
@ -68,9 +76,11 @@ BEGIN
sc_name_norm sc_name_norm
|| '|' || coalesce(voltage_class, '') || '|' || coalesce(voltage_class, '')
|| '|' || CASE WHEN geom IS NULL THEN '' || '|' || 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 '' || '|' || 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' 'UTF8'
)), )),
'hex' 'hex'
@ -116,9 +126,11 @@ BEGIN
rows_before, names_before, rows_after, names_after, deleted, rekeyed; rows_before, names_before, rows_after, names_after, deleted, rekeyed;
-- 700 — потолок здравого смысла: источник отдаёт ~488 ЦП по области. -- 700 — потолок здравого смысла: источник отдаёт ~488 ЦП по области.
-- Превышение = формула ключа не схлопнула дубли, смотреть глазами. -- Превышение = формула ключа не схлопнула дубли. EXCEPTION, а не WARNING:
-- иначе файл пометится applied навсегда, а дубли останутся. Откат всей
-- транзакции ничего не теряет и оставляет миграцию непринятой до разбора.
IF rows_after > 700 THEN IF rows_after > 700 THEN
RAISE WARNING '#3322: после дедупа осталось % строк (ожидалось ~481-488) — проверить формулу ключа', rows_after; RAISE EXCEPTION '#3322: после дедупа осталось % строк (ожидалось ~481-488) — формула ключа не схлопнула дубли, транзакция откачена', rows_after;
END IF; END IF;
END $$; END $$;