"""Резолвер «точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3 (#2119). Собирает по участку: - power_points: центры питания (power_supply_centers) в радиусе от центроида участка (ST_DWithin geography) с классом напряжения / индексом загрузки / резервом свободной мощности. Для ПС ЕЭСК без раскрытого резерва — reserve_note «резерв по запросу в АО «ЕЭСК» (город)». - power_summary: ближайший ЦП с резервом + count по load_index. - water: все строки water_supply_reserves за ПОСЛЕДНИЙ period (city-level). - gas: городские ГРС «Газпром трансгаз Екатеринбург» со свободной мощностью (gas_grs_capacity), агрегация по grs_name + суммарный свободный объём. #2119 B1. + счётчики точек выхода (gas_grs_outlet_points, форма 6 ФАС №960 АО «Екатеринбурггаз») последнего period_month: всего / дефицитных / «нужен гидрорасчёт». #2119 B2 PR-4. - gas_outlet_points: гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от участка (ST_DWithin geom ~2000 м, latest period, LIMIT 40 по расстоянию) — для слоя на карте §3. Свободная мощность в млн.куб.м/МЕС (НЕ тыс.м³/ч как у ГРС!). #2119 B2. - heat: резервы тепловой мощности систем теплоснабжения (heat_system_reserves) за последний период ПО КАЖДОЙ организации + суммарный свободный объём. #2119 B2. - nearby_network_zones: ПОЗИТИВ-разрез — сетевые охранные зоны из cad_zouit в радиусе 300 м от участка (там физически ПРОХОДИТ сеть → «теплотрасса в 24 м, врезка дешёвая»). До 2 ближайших зон НА КАЖДЫЙ net_kind (thermal/electricity/gas/…), с гуманизированной подписью (кад.номер трубопровода / напряжение ВЛ + ТП). #2119 C2/C3. Источник координат участка — общий ``_get_parcel_wkt`` (cad_parcels_geom → квартал fallback). Радиус по умолчанию 3000 м (cap 10000). """ import logging from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from app.services.site_finder.network_zone_humanize import human_label, refine_net_kind logger = logging.getLogger(__name__) _DEFAULT_RADIUS_M = 3000 _MAX_RADIUS_M = 10000 # Радиус позитив-разреза сетевых охранных зон (cad_zouit): фиксированный 300 м — «сеть # рядом с участком». Не зависит от radius_m ЦП (тот про магистральные ЦП за км). # Degree-эквивалент для GIST-префильтра: 300 м / ~60.8 км/° долготы у ЕКБ (56.8° N) ≈ # 0.0050° — берём с запасом 0.006°, точный отсев делает geography ST_DWithin поверх. C2. _NETWORK_ZONE_RADIUS_M = 300 _NETWORK_ZONE_RADIUS_DEG = 0.006 # До скольких ближайших зон отдаём НА КАЖДЫЙ net_kind (thermal/electricity/gas/…). C2. _NETWORK_ZONE_PER_KIND = 2 # Предохранитель: запрос GIST-индексирован (~70 мс на плотном центре ЕКБ, EXPLAIN прод # 2026-07-03), но эндпоинт синхронный — таймаут-guard как у offer_trend (parcels.py). _NETWORK_ZONE_TIMEOUT_MS = 4000 # Радиус для гео-точек выхода газосети (gas_grs_outlet_points): фиксированный ~2000 м — # точки привязаны к конкретным объектам (котельные/промплощадки), не к магистральным # ЦП, поэтому свой узкий радиус независимо от radius_m ЦП. LIMIT — топ-N по расстоянию, # чтобы слой на карте §3 не разбухал (у ~19% строк есть geom после геокода). #2119 B2. _GAS_OUTLET_RADIUS_M = 2000 _GAS_OUTLET_LIMIT = 40 # Пометка для ПС АО «ЕЭСК» (городская сеть ЕКБ) без раскрытого резерва: свободная # мощность у ЕЭСК не публикуется в WFS/xlsx — уточняется по запросу. #2119 B1. _EESK_RESERVE_NOTE = "Резерв — по запросу в АО «ЕЭСК» (город)" def get_connection_capacity( db: Session, cad_num: str, radius_m: int = _DEFAULT_RADIUS_M, ) -> dict: """Точки подключения со свободной мощностью вблизи участка. Args: db: SQLAlchemy session. cad_num: кадастровый номер участка. radius_m: радиус поиска ЦП, м (caller клампит 1..10000). Returns: {"power_points": [...], "power_summary": {...}, "water": [...], "gas": {...}|None, "gas_outlet_points": [...], "heat": {...}|None, "nearby_network_zones": [...]} Raises: ValueError: участок не найден в БД (нет geom). """ # Lazy import — избегаем циклического импорта на module-load. from app.services.site_finder.quarter_dump_lookup import _get_parcel_wkt radius_m = max(1, min(int(radius_m), _MAX_RADIUS_M)) parcel_wkt = _get_parcel_wkt(db, cad_num) if parcel_wkt is None: raise ValueError(f"Участок {cad_num!r} не найден в БД") power_points = _query_power_points(db, parcel_wkt, radius_m) power_summary = _build_power_summary(power_points) water = _query_water_latest(db) gas = _query_gas_city_grs(db) gas_outlet_points = _query_gas_outlet_points(db, parcel_wkt) heat = _query_heat_latest(db) nearby_network_zones = _query_nearby_network_zones(db, parcel_wkt) return { "power_points": power_points, "power_summary": power_summary, "water": water, "gas": gas, "gas_outlet_points": gas_outlet_points, "heat": heat, "nearby_network_zones": nearby_network_zones, } def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dict]: """Центры питания в радиусе от центроида участка (ST_DWithin geography).""" rows = ( db.execute( text(""" SELECT sc_name, dzo_name, voltage_class, load_index, installed_capacity_mva, current_load_mva, reserve_mva, reserve_unit, district, reserve_asof, ST_Distance( geom::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography ) AS distance_m, ST_Y(geom) AS lat, ST_X(geom) AS lon FROM power_supply_centers WHERE geom IS NOT NULL AND ST_DWithin( geom::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, CAST(:radius_m AS float) ) ORDER BY distance_m ASC """), {"wkt": parcel_wkt, "radius_m": radius_m}, ) .mappings() .all() ) points: list[dict] = [] for r in rows: reserve_mva = _num(r["reserve_mva"]) points.append( { "name": r["sc_name"], "dzo_name": r["dzo_name"], "voltage_class": r["voltage_class"], "load_index": r["load_index"], "installed_capacity_mva": _num(r["installed_capacity_mva"]), "current_load_mva": _num(r["current_load_mva"]), "reserve_mva": reserve_mva, # Единица резерва: 'МВА' (Россети, default) или 'МВт' (ЕЭСК ПС 35-220). # district — адм. район ЕКБ из xlsx ЕЭСК (NULL у Россети-строк). #2119 B2. "reserve_unit": r["reserve_unit"], "district": r["district"], "reserve_asof": r["reserve_asof"].isoformat() if r["reserve_asof"] else None, "reserve_note": _reserve_note(r["dzo_name"], reserve_mva), "distance_m": round(float(r["distance_m"]), 1), "lat": round(float(r["lat"]), 6) if r["lat"] is not None else None, "lon": round(float(r["lon"]), 6) if r["lon"] is not None else None, } ) return points def _reserve_note(dzo_name: str | None, reserve_mva: float | None) -> str | None: """Пометка резерва для ПС ЕЭСК без опубликованного резерва (#2119 B1). Для ПС АО «ЕЭСК» (городская сеть ЕКБ) свободная мощность не раскрывается в WFS/xlsx-источниках → reserve_mva IS NULL. Показываем честную пометку «резерв по запросу». Для ПС других ДЗО (или ЕЭСК с известным резервом) → None. """ if reserve_mva is not None: return None if dzo_name and "еэск" in dzo_name.lower(): return _EESK_RESERVE_NOTE return None def _build_power_summary(points: list[dict]) -> dict: """Сводка: ближайший ЦП с положительным резервом + count по load_index.""" by_load_index: dict[str, int] = {} for p in points: li = p["load_index"] or "unknown" by_load_index[li] = by_load_index.get(li, 0) + 1 # points уже отсортированы по distance ASC → первый с reserve_mva>0 = ближайший. nearest_with_reserve = next( (p for p in points if p["reserve_mva"] is not None and p["reserve_mva"] > 0), None, ) return { "total_power_points": len(points), "by_load_index": by_load_index, "nearest_with_reserve": nearest_with_reserve, } def _query_water_latest(db: Session) -> list[dict]: """Все строки water_supply_reserves за ПОСЛЕДНИЙ period ПО КАЖДОМУ system_kind. «Последний» — max(period) лексикографически ('2026-Q1' < '2026-Q2' < …), что для формата 'YYYY-Qn' совпадает с хронологией. period IS NULL исключаем. MAX(period) берём per-system_kind (коррелированный подзапрос): Водоканал может опубликовать водоотведение на квартал позже водоснабжения — глобальный MAX молча выкинул бы отстающий вид целиком. """ rows = ( db.execute( text(""" SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day, w.note, w.period FROM water_supply_reserves w WHERE w.period IS NOT NULL AND w.period = ( SELECT MAX(w2.period) FROM water_supply_reserves w2 WHERE w2.period IS NOT NULL AND w2.system_kind = w.system_kind ) ORDER BY w.system_kind, w.system_name """) ) .mappings() .all() ) return [ { "system_kind": r["system_kind"], "system_name": r["system_name"], "reserve_thousand_m3_day": _num(r["reserve_thousand_m3_day"]), "note": r["note"], "period": r["period"], } for r in rows ] def _query_gas_city_grs(db: Session) -> dict: """Городские ГРС ЕКБ со свободной мощностью (gas_grs_capacity), агрегация по ГРС. Фильтр «городских» ГРС по имени (ILIKE '%свердловск%' / '%екатеринбург%'). Агрегация по grs_name (у ГРС несколько строк-выходов): SUM(design), SUM(free), AVG(pct), MAX(upgrade_due), COUNT(*) outputs_count. Пустая/несуществующая таблица → city_grs=[] (не падаем — блок аддитивный, gas_grs_capacity может ещё не существовать до применения миграции 181). Дополнительно (#2119 B2 PR-4): счётчики точек выхода газосети из gas_grs_outlet_points (форма 6 ФАС №960 АО «Екатеринбурггаз») за последний period_month — outlets_total / outlets_deficit (free<0) / outlets_needs_calc. См. _query_gas_outlet_counts для обоснования «по ВСЕМ строкам без разбивки по ГРС». Returns: {"city_grs": [...], "total_free_th_m3_h": float|None, "outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}. Размерности: свободная мощность ГРС — тыс. м³/ЧАС; свободная мощность точек выхода (в счётчиках/gas_outlet_points) — млн. м³/МЕС. НЕ путать при агрегации. """ outlet_counts = _query_gas_outlet_counts(db) try: # SAVEPOINT — db shared с caller (GET .../connection-capacity, Depends(get_db)). # Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) — # begin_nested откатывает только эту savepoint, outer tx остаётся usable. with db.begin_nested(): rows = ( db.execute( text(""" SELECT grs_name, SUM(design_capacity_th_m3_h) AS design_sum, SUM(free_capacity_th_m3_h) AS free_sum, AVG(free_capacity_pct) AS pct_avg, MAX(upgrade_due) AS upgrade_due, COUNT(*) AS outputs_count FROM gas_grs_capacity WHERE grs_name ILIKE '%свердловск%' OR grs_name ILIKE '%екатеринбург%' GROUP BY grs_name ORDER BY grs_name """) ) .mappings() .all() ) except Exception as e: # Таблица может не существовать до применения миграции 181 (schema-first). # Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. Счётчики # точек выхода (миграция 184, независимая) — оставляем, что удалось собрать. logger.warning("gas_grs_capacity недоступна (миграция 181 не применена?): %s", e) return {"city_grs": [], "total_free_th_m3_h": None, **outlet_counts} city_grs: list[dict] = [] total_free = 0.0 has_free = False for r in rows: free = _num(r["free_sum"]) if free is not None: total_free += free has_free = True city_grs.append( { "grs_name": r["grs_name"], "design_capacity_th_m3_h": _num(r["design_sum"]), "free_capacity_th_m3_h": free, "free_capacity_pct": ( round(float(r["pct_avg"]), 1) if r["pct_avg"] is not None else None ), "upgrade_due": r["upgrade_due"], "outputs_count": int(r["outputs_count"]), } ) return { "city_grs": city_grs, "total_free_th_m3_h": round(total_free, 3) if has_free else None, **outlet_counts, } def _query_gas_outlet_counts(db: Session) -> dict: """Счётчики точек выхода газосети (gas_grs_outlet_points) за последний period_month. По ВСЕМ строкам последнего среза БЕЗ разбивки по ГРС (обоснование): grs_name_norm в форме 6 АО «Екатеринбурггаз» — это block-marker «Точка входа» (5 блоков ГТЕ вроде «грс-1 свердловск»), НЕ строгий ключ городской связки gas_grs_capacity (ГТЕ, другой источник). Матч outlet→ГРС по grs_name_norm без FK ненадёжен на per-набор уровне (имена ГРС в двух раскрытиях расходятся) → честнее агрегировать счётчики ПО ГОРОДУ целиком, чем молча терять строки на непопавшем join. Это city-level индикатор §3 «сколько точек выхода в дефиците / требуют гидрорасчёта», не per-ГРС. Считаем за MAX(period_month) (свежайший помесячный срез); is_forecast не фильтруем — в проде факт/прогноз разведены, но для счётчика важен объём точек последнего среза. Returns: {"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}. - outlets_deficit — free_capacity_mln_m3 < 0 (дефицит со знаком, млн.куб.м/МЕС); - outlets_needs_calc — free_capacity_needs_calc = TRUE («0.000*», нужен гидрорасчёт). Таблицы может не быть (миграция 184 не применена) → все нули (graceful, аддитивно). """ try: # SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше). with db.begin_nested(): row = ( db.execute( text(""" SELECT COUNT(*) AS outlets_total, COUNT(*) FILTER ( WHERE free_capacity_mln_m3 < 0 ) AS outlets_deficit, COUNT(*) FILTER ( WHERE free_capacity_needs_calc ) AS outlets_needs_calc FROM gas_grs_outlet_points WHERE period_month = ( SELECT MAX(period_month) FROM gas_grs_outlet_points ) """) ) .mappings() .first() ) except Exception as e: # Таблица может не существовать до применения миграции 184 (schema-first). # Счётчики аддитивны — деградируем в нули, не роняя весь эндпоинт. logger.warning("gas_grs_outlet_points недоступна (миграция 184 не применена?): %s", e) return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0} if row is None: return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0} return { "outlets_total": int(row["outlets_total"] or 0), "outlets_deficit": int(row["outlets_deficit"] or 0), "outlets_needs_calc": int(row["outlets_needs_calc"] or 0), } def _query_gas_outlet_points(db: Session, parcel_wkt: str) -> list[dict]: """Гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от участка — слой §3. ST_DWithin(geom::geography, центроид::geography, _GAS_OUTLET_RADIUS_M) за последний period_month, ORDER BY distance ASC, LIMIT _GAS_OUTLET_LIMIT. geom есть лишь у ~19% строк (геокод DaData, PR-3) → в радиус попадает малое подмножество. Размерность free_capacity_mln_m3 — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Таблицы может не быть (миграция 184) → [] (graceful, блок аддитивный). План (EXPLAIN прод 2026-07-03): Seq Scan ~13 мс — geography-каст НЕ использует geometry-GIST; при 1037 строках это норм. Вырастет 10-50× → функциональный GIST по (CAST(geom AS geography)) или ST_DWithin по geometry с градусным радиусом. Returns: [{outlet_name, consumer_type, free_capacity_mln_m3, needs_calc, distance_m, lat, lon}] — отсортировано по distance ASC. """ try: # SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше). with db.begin_nested(): rows = ( db.execute( text(""" SELECT outlet_name, consumer_type, free_capacity_mln_m3, free_capacity_needs_calc AS needs_calc, ST_Distance( geom::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography ) AS distance_m, ST_Y(geom) AS lat, ST_X(geom) AS lon FROM gas_grs_outlet_points WHERE geom IS NOT NULL AND period_month = ( SELECT MAX(period_month) FROM gas_grs_outlet_points ) AND ST_DWithin( geom::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, CAST(:radius_m AS float) ) ORDER BY distance_m ASC LIMIT CAST(:limit AS int) """), { "wkt": parcel_wkt, "radius_m": _GAS_OUTLET_RADIUS_M, "limit": _GAS_OUTLET_LIMIT, }, ) .mappings() .all() ) except Exception as e: # Таблица может не существовать до применения миграции 184 (schema-first). # Слой аддитивный — деградируем в пустой, не роняя весь эндпоинт. logger.warning("gas_grs_outlet_points (радиус) недоступна (миграция 184?): %s", e) return [] return [ { "outlet_name": r["outlet_name"], "consumer_type": r["consumer_type"], # млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!); может быть <0 — дефицит со знаком. "free_capacity_mln_m3": _num(r["free_capacity_mln_m3"]), "needs_calc": bool(r["needs_calc"]), "distance_m": round(float(r["distance_m"]), 1), "lat": round(float(r["lat"]), 6) if r["lat"] is not None else None, "lon": round(float(r["lon"]), 6) if r["lon"] is not None else None, } for r in rows ] def _query_heat_latest(db: Session) -> dict: """Резервы тепловой мощности систем за ПОСЛЕДНИЙ период ПО КАЖДОЙ организации. Строки heat_system_reserves за MAX(period) PER org (коррелированный подзапрос — как в _query_water_latest: одна организация может опубликовать свежий квартал раньше другой, глобальный MAX молча выкинул бы отстающую). period IS NULL исключаем; MAX(period) лексикографически совпадает с хронологией для 'YYYY-Qn'. Сортировка org, system_name. Graceful при отсутствии таблицы (как gas-блок). Returns: {"systems": [{org, system_name, reserve_gcal_h, period}], "total_reserve_gcal_h": float|None}. """ try: # SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше). with db.begin_nested(): rows = ( db.execute( text(""" SELECT h.org, h.system_name, h.reserve_gcal_h, h.period FROM heat_system_reserves h WHERE h.period IS NOT NULL AND h.period = ( SELECT MAX(h2.period) FROM heat_system_reserves h2 WHERE h2.period IS NOT NULL AND h2.org = h.org ) ORDER BY h.org, h.system_name """) ) .mappings() .all() ) except Exception as e: # Таблица может не существовать до применения миграции 182 (schema-first). # Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. logger.warning("heat_system_reserves недоступна (миграция 182 не применена?): %s", e) return {"systems": [], "total_reserve_gcal_h": None} systems: list[dict] = [] total_reserve = 0.0 has_reserve = False for r in rows: reserve = _num(r["reserve_gcal_h"]) if reserve is not None: total_reserve += reserve has_reserve = True systems.append( { "org": r["org"], "system_name": r["system_name"], "reserve_gcal_h": reserve, "period": r["period"], } ) return { "systems": systems, "total_reserve_gcal_h": round(total_reserve, 3) if has_reserve else None, } def _query_nearby_network_zones(db: Session, parcel_wkt: str) -> list[dict]: """Сетевые охранные зоны cad_zouit в радиусе 300 м от участка — позитив-развёртка §3. Охранная зона сети = коридор, где физически ПРОХОДИТ сеть. «Теплотрасса в 24 м → врезка дешёвая» — решающий сигнал для финдиректора (особенно heat, у которого в §3 нет своей гео-привязки). Зоны, ПЕРЕСЕКАЮЩИЕ участок, включаем с distance_m=0. План (EXPLAIN прод 2026-07-03, cad_zouit_geom_gist существует): GIST-префильтр ``ST_DWithin(geom, parcel, 0.006°)`` + точный ``ST_DWithin(geom::geography, parcel::geography, 300)`` — Index Scan, ~70 мс на плотном центре ЕКБ. cad_zouit.geom = geometry(MultiPolygon,4326); geography-каст приклеен к ``)``, не к bind-имени (psycopg3-safe, как ``ARRAY[...]::int[]`` / power_points-запрос). #1957: одна физическая зона хранится 2× (одинаковый reg_numb_border, разный category_name) → DISTINCT ON (reg_numb_border) схлопывает, оставляя ближайшую строку. Гуманизация (C3, ``network_zone_humanize``): net_kind дочищается по name_by_doc (general «инженерные коммуникации» на проде часто РЕАЛЬНО теплосеть/газопровод), human_label вытаскивает кад.номер трубопровода / напряжение ВЛ + ТП. Итог — до _NETWORK_ZONE_PER_KIND ближайших зон НА КАЖДЫЙ net_kind (сорт по distance ASC). Таймаут-guard в savepoint: запрос лёгкий, но эндпоинт синхронный — при аномально медленном скане (QueryCanceled) деградируем в [], не роняя весь эндпоинт. Блок аддитивный (позитив-сигнал), отсутствие данных читается как «сеть рядом не найдена». """ try: with db.begin_nested(): # SET не принимает bind-параметры (extended protocol → syntax error # at or near "$1") — только литерал, как у offer_trend (parcels.py). db.execute(text(f"SET LOCAL statement_timeout = '{_NETWORK_ZONE_TIMEOUT_MS:d}'")) rows = ( db.execute( text(""" SELECT DISTINCT ON (z.reg_numb_border) z.type_zone, z.name_by_doc, z.reg_numb_border, round( ST_Distance( z.geom::geography, ST_GeomFromText(:wkt, 4326)::geography )::numeric ) AS distance_m FROM cad_zouit z WHERE z.geom IS NOT NULL AND z.type_zone IN ( 'Охранная зона тепловых сетей', 'Охранная зона инженерных коммуникаций', 'Охранная зона объектов электроэнергетики' ' (объектов электросетевого хозяйства и объектов по' ' производству электрической энергии)', 'Охранная зона трубопроводов (газопроводов,' ' нефтепроводов и нефтепродуктопроводов, аммиакопроводов)', 'Охранная зона линий и сооружений связи' ) AND ST_DWithin( z.geom, ST_GeomFromText(:wkt, 4326), CAST(:radius_deg AS float) ) AND ST_DWithin( z.geom::geography, ST_GeomFromText(:wkt, 4326)::geography, CAST(:radius_m AS float) ) ORDER BY z.reg_numb_border, distance_m ASC """), { "wkt": parcel_wkt, "radius_deg": _NETWORK_ZONE_RADIUS_DEG, "radius_m": _NETWORK_ZONE_RADIUS_M, }, ) .mappings() .all() ) # SET LOCAL переживает RELEASE SAVEPOINT (transaction-scoped) — # сбрасываем явно, как в offer_trend (parcels.py), чтобы таймаут # не утёк на последующие запросы этой транзакции. db.execute(text("SET LOCAL statement_timeout = 0")) except SQLAlchemyError as e: # QueryCanceled (таймаут) / отсутствие таблицы / прочие SQL-сбои — блок аддитивный # (позитив-сигнал), деградируем в пустой, не роняя весь эндпоинт connection-capacity. logger.warning("nearby_network_zones query failed: %s", e) return [] return _shape_network_zones(rows) def _shape_network_zones(rows: list) -> list[dict]: """DISTINCT-строки cad_zouit → гуманизированные зоны, до N ближайших per net_kind. Классификацию net_kind (refine) и подпись (human_label) считаем в Python (regex по name_by_doc), затем сортируем по distance ASC и берём _NETWORK_ZONE_PER_KIND на вид. Возвращаемый список отсортирован по (distance_m, net_kind) для стабильного порядка. """ per_kind: dict[str, list[dict]] = {} for r in rows: type_zone = r["type_zone"] or "" name_by_doc = r["name_by_doc"] net_kind = refine_net_kind(type_zone, name_by_doc) if net_kind is None: # type_zone из белого списка, но не классифицировался — пропускаем (не должно). continue distance_m = int(r["distance_m"]) if r["distance_m"] is not None else 0 per_kind.setdefault(net_kind, []).append( { "net_kind": net_kind, "distance_m": distance_m, "name_by_doc": name_by_doc, "human_label": human_label(net_kind, name_by_doc), } ) result: list[dict] = [] for kind_rows in per_kind.values(): kind_rows.sort(key=lambda z: z["distance_m"]) result.extend(kind_rows[:_NETWORK_ZONE_PER_KIND]) # Стабильный внешний порядок: ближайшее сверху, затем по виду сети. result.sort(key=lambda z: (z["distance_m"], z["net_kind"])) return result def _num(value: object) -> float | None: """NUMERIC (Decimal) из БД → float | None.""" return float(value) if value is not None else None # type: ignore[arg-type]