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 $$;