diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 2dbedb41..447307cc 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -3874,6 +3874,7 @@ def get_parcel_connection_capacity( power_points=data["power_points"], power_summary=data["power_summary"], water=data["water"], + gas=data.get("gas"), ) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 01e1d4f9..0891406e 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -126,6 +126,9 @@ class PowerConnectionPoint(BaseModel): distance_m: float lat: float | None lon: float | None + # Пометка для ПС ЕЭСК без опубликованного резерва: свободная мощность у АО «ЕЭСК» + # (городская сеть ЕКБ) не раскрывается WFS/xlsx — берётся по запросу. #2119 B1. + reserve_note: str | None = None class PowerCapacitySummary(BaseModel): @@ -148,10 +151,33 @@ class WaterCapacityRow(BaseModel): period: str | None +class GasGrsRow(BaseModel): + """Свободная мощность одной городской ГРС (агрегация по выходам). #2119 B1.""" + + grs_name: str + design_capacity_th_m3_h: float | None # проектная (сумма по выходам), тыс. м³/ч + free_capacity_th_m3_h: float | None # свободная (сумма по выходам), тыс. м³/ч + free_capacity_pct: float | None # средняя свободная, % + upgrade_due: str | None # срок расшивки (макс. по выходам, свободный текст) + outputs_count: int # число строк-выходов ГРС, вошедших в агрегацию + + +class GasCapacityBlock(BaseModel): + """Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1.""" + + # Городские ГРС (Свердловск/Екатеринбург) — агрегация по grs_name. Пусто, если + # таблица gas_grs_capacity ещё не наполнена (weekly-sync, RUN-ON-PROD). + city_grs: list[GasGrsRow] + # Суммарная свободная мощность городских ГРС, тыс. м³/ч (None если данных нет). + total_free_th_m3_h: float | None + + class ConnectionCapacityResponse(BaseModel): power_points: list[PowerConnectionPoint] power_summary: PowerCapacitySummary water: list[WaterCapacityRow] + # Газовый блок аддитивен (Phase B1): None если ключ 'gas' не собран. + gas: GasCapacityBlock | None = None # ── NSPD Risk Zones schemas (issue #94 TIER 3) ─────────────────────────────── diff --git a/backend/app/services/site_finder/connection_capacity_lookup.py b/backend/app/services/site_finder/connection_capacity_lookup.py index 17d21a3d..2f11c71b 100644 --- a/backend/app/services/site_finder/connection_capacity_lookup.py +++ b/backend/app/services/site_finder/connection_capacity_lookup.py @@ -3,9 +3,12 @@ Собирает по участку: - 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. Источник координат участка — общий ``_get_parcel_wkt`` (cad_parcels_geom → квартал fallback). Радиус по умолчанию 3000 м (cap 10000). @@ -21,6 +24,10 @@ logger = logging.getLogger(__name__) _DEFAULT_RADIUS_M = 3000 _MAX_RADIUS_M = 10000 +# Пометка для ПС АО «ЕЭСК» (городская сеть ЕКБ) без раскрытого резерва: свободная +# мощность у ЕЭСК не публикуется в WFS/xlsx — уточняется по запросу. #2119 B1. +_EESK_RESERVE_NOTE = "Резерв — по запросу в АО «ЕЭСК» (город)" + def get_connection_capacity( db: Session, @@ -52,11 +59,13 @@ def get_connection_capacity( 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) return { "power_points": power_points, "power_summary": power_summary, "water": water, + "gas": gas, } @@ -91,6 +100,7 @@ def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dic points: list[dict] = [] for r in rows: + reserve_mva = _num(r["reserve_mva"]) points.append( { "name": r["sc_name"], @@ -99,8 +109,9 @@ def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dic "load_index": r["load_index"], "installed_capacity_mva": _num(r["installed_capacity_mva"]), "current_load_mva": _num(r["current_load_mva"]), - "reserve_mva": _num(r["reserve_mva"]), + "reserve_mva": reserve_mva, "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, @@ -109,6 +120,20 @@ def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dic 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] = {} @@ -168,6 +193,69 @@ def _query_water_latest(db: Session) -> list[dict]: ] +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). + + Returns: {"city_grs": [...], "total_free_th_m3_h": float|None}. + """ + try: + 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). + # Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. + logger.warning("gas_grs_capacity недоступна (миграция 181 не применена?): %s", e) + return {"city_grs": [], "total_free_th_m3_h": None} + + 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, + } + + def _num(value: object) -> float | None: """NUMERIC (Decimal) из БД → float | None.""" return float(value) if value is not None else None # type: ignore[arg-type] diff --git a/backend/app/services/site_finder/gazprom_grs_loader.py b/backend/app/services/site_finder/gazprom_grs_loader.py new file mode 100644 index 00000000..329a2a16 --- /dev/null +++ b/backend/app/services/site_finder/gazprom_grs_loader.py @@ -0,0 +1,355 @@ +"""Загрузчик свободной мощности ГРС «Газпром трансгаз Екатеринбург» (#2119 B1). + +No-B2B верифицированный источник: страница раскрытия ГТЕ содержит ИНЛАЙН +HTML-таблицу «Информация о технической возможности…» (~316 строк) со свободной +мощностью газораспределительных станций (ГРС) — проектная / фактическая загрузка / +свободная мощность (тыс. м³/ч) + срок расшивки. UPSERT-ит в ``gas_grs_capacity``. + +Источник ГЕО-БЛОКИРУЕТ non-RU IP → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery weekly / +manual docker exec) — как rosseti-/vodokanal-лоадеры. Код не предполагает +локальный сетевой доступ. + +Парсинг DEFENSIVE: колонки мапятся ПО ЗАГОЛОВКАМ (не по фикс-позиции — ГТЕ может +переставить/добавить столбцы), имя ГРС с rowspan («выход 1/2/3») forward-fill'ится +на строки-выходы (идея из vodokanal_reserve_loader.parse_docx_table_rows). Числа — +через ОБЩИЙ ``parse_reserve_number`` из rosseti_reserve_loader (запятая, «н/д», +знак). Неизвестные колонки не теряются — падают в ``raw`` jsonb. +""" + +import json +import logging +import re + +import httpx +from bs4 import BeautifulSoup +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.db import SessionLocal +from app.services.site_finder.rosseti_reserve_loader import parse_reserve_number + +logger = logging.getLogger(__name__) + +# Страница расклытия «Газпром трансгаз Екатеринбург» с инлайн HTML-таблицей. +# Punycode не требуется — латинский хост. +DISCLOSURE_URL = "https://ekaterinburg-tr.gazprom.ru/about/informatsiya-o-tekhnicheskoj-vozmo/" + +_HTTP_TIMEOUT = 60 + +_MULTISPACE_RE = re.compile(r"\s+") + +# «н/д» / «-» / прочерк / пусто в текстовой ячейке (напр. срок расшивки) → None. +_NODATA_TEXT_TOKENS = frozenset( + {"", "н/д", "нд", "n/a", "na", "-", "—", "–", "нет данных", "х", "x"} +) + +# Заголовок-матчинг: подстрока в тексте /первой строки → логическая колонка. +# Порядок важен — «свободн» проверяем раньше «загрузк», а «%» отдельным ключом. +# Каждая запись: (набор подстрок-триггеров, ключ). Первый матч на колонку выигрывает. +_HEADER_PATTERNS: list[tuple[tuple[str, ...], str]] = [ + (("выход",), "output_name"), + (("наименование", "грс", "станц"), "grs_name"), + (("проектн",), "design"), + (("свободн", "резерв"), "free"), + (("загрузк", "фактич", "текущ"), "load"), + (("срок", "расшивк", "мероприят"), "upgrade_due"), +] + +# Region-колонка (если ГТЕ добавит регион в таблицу) → фильтр Свердловской области. +_REGION_HEADER_TOKENS = ("регион", "область", "субъект") +_SVERDLOVSK_TOKEN = "свердлов" + + +def normalize_grs_name(name: str | None) -> str: + """Нормализует имя ГРС для матча/дедупа. + + Правила: lower, ё→е, схлопывание пробелов и дефисов (все виды тире → одиночный + дефис без пробелов вокруг), удаление кавычек. Префиксы «ГРС»/«АРП» СОХРАНЯЮТСЯ — + они различают станции. Пусто → ''. + + Examples: + «ГРС Кольцово» → «грс кольцово» + «ГРС Берёзовский» → «грс березовский» + «ГРС Северная — 2» → «грс северная-2» + """ + if not name: + return "" + s = name.strip().lower().replace("ё", "е") + s = s.replace("«", "").replace("»", "").replace('"', "").replace("'", "") + # Все виды тире (—, –, -) в одиночный дефис, пробелы вокруг убираем. + s = re.sub(r"\s*[—–-]\s*", "-", s) + s = _MULTISPACE_RE.sub(" ", s).strip() + return s + + +def _clean_text(value: object | None) -> str | None: + """Текст ячейки → strip + collapse пробелов. Пусто / «н/д»-токен → None.""" + if value is None: + return None + s = _MULTISPACE_RE.sub(" ", str(value).replace("\xa0", " ")).strip() + if not s or s.lower() in _NODATA_TEXT_TOKENS: + return None + return s + + +def _classify_header(header_text: str) -> str | None: + """Заголовок колонки → логический ключ (grs_name/output_name/design/…) или None.""" + low = header_text.lower() + for tokens, key in _HEADER_PATTERNS: + if any(tok in low for tok in tokens): + return key + return None + + +def _is_pct_header(header_text: str) -> bool: + """Колонка про проценты свободной мощности?""" + low = header_text.lower() + return "%" in low or "процент" in low + + +def _is_region_header(header_text: str) -> bool: + """Колонка про регион/область (для Свердловск-фильтра)?""" + low = header_text.lower() + return any(tok in low for tok in _REGION_HEADER_TOKENS) + + +def _extract_table(html: str) -> object | None: + """Находит наиболее «содержательную» (с макс. числом строк). None — нет.""" + soup = BeautifulSoup(html, "html.parser") + tables = soup.find_all("table") + if not tables: + return None + # Основная таблица — с наибольшим числом строк (шапки/навигация мелкие). + return max(tables, key=lambda t: len(t.find_all("tr"))) + + +def _row_cells(tr: object) -> list[object]: + """Ячейки строки (
и в порядке появления).""" + return tr.find_all(["th", "td"]) # type: ignore[union-attr] + + +def _build_column_map(header_cells: list[object]) -> tuple[dict[int, str], dict[int, str]]: + """Из ячеек строки-заголовка строит col_idx→logical_key + col_idx→raw_header. + + Возвращает (mapping, headers): mapping только для распознанных колонок + (grs_name/output_name/design/load/free/pct/upgrade_due/region); headers — сырые + тексты ВСЕХ колонок (для raw + region-детекта). «%»-колонка → ключ 'pct'. + """ + mapping: dict[int, str] = {} + headers: dict[int, str] = {} + for idx, cell in enumerate(header_cells): + htext = _clean_text(cell.get_text(" ")) or "" # type: ignore[union-attr] + headers[idx] = htext + if _is_region_header(htext): + mapping[idx] = "region" + continue + if _is_pct_header(htext): + mapping[idx] = "pct" + continue + key = _classify_header(htext) + if key is not None: + mapping[idx] = key + return mapping, headers + + +def parse_grs_table(html: str) -> list[dict]: + """Парсит инлайн HTML-таблицу ГТЕ → список записей ГРС (по выходам). + + DEFENSIVE: + - основная = с макс. числом строк; + - первая строка = заголовок, колонки мапятся ПО ЗАГОЛОВКАМ (contains); + - rowspan имени ГРС → forward-fill на строки-выходы (несколько выходов у ГРС). + ДОПУЩЕНИЕ: offset-коррекция работает только когда колонка имени — ПЕРВАЯ + (в текущей таблице ГТЕ так); rowspan не-первой колонки даст пропуск строк + (не порчу данных — безымянные строки скипаются, raw сохраняется); + - числа через parse_reserve_number (запятая, «н/д», знак); + - неизвестные колонки → raw (не теряются). + + Каждая запись: + {grs_name, output_name, region, design_capacity_th_m3_h, current_load_th_m3_h, + free_capacity_th_m3_h, free_capacity_pct, upgrade_due, raw}. + grs_name пустой (нет forward-fill и нет своего имени) → строка пропускается. + """ + table = _extract_table(html) + if table is None: + logger.warning("gazprom_grs:
не найдена в HTML") + return [] + + rows = table.find_all("tr") # type: ignore[union-attr] + if not rows: + return [] + + mapping, headers = _build_column_map(_row_cells(rows[0])) + if "grs_name" not in mapping.values(): + logger.warning("gazprom_grs: колонка имени ГРС не распознана по заголовкам (%s)", headers) + return [] + + records: list[dict] = [] + # forward-fill имени ГРС по rowspan: если ячейка имени отсутствует в строке + # (schrunk из-за rowspan предыдущей), наследуем последнее известное имя. + last_grs_name: str | None = None + + for tr in rows[1:]: + cells = _row_cells(tr) + if not cells: + continue + + # Матчим ячейки к логическим ключам по позиции колонки-заголовка. При + # rowspan имени в строке-выходе ячейки имени НЕТ → индексы сдвигаются; мы + # определяем это по числу ячеек < числу заголовков и forward-fill'им имя. + values: dict[str, str | None] = {} + raw_cells: list[str] = [] + + name_col = next(i for i, k in mapping.items() if k == "grs_name") + shifted = len(cells) < len(headers) and name_col == 0 + # Если строка короче заголовков и имя-колонка первая — считаем что + # rowspan «съел» первую ячейку: логические индексы сдвигаем на +1. + offset = 1 if shifted else 0 + + for idx, cell in enumerate(cells): + # raw_cells — сырой текст ячейки (в т.ч. «н/д»), чтобы raw jsonb не терял + # исходное значение; values — очищенный (_clean_text: «н/д» → None). + raw_text = _MULTISPACE_RE.sub( + " ", + cell.get_text(" ").replace("\xa0", " "), # type: ignore[union-attr] + ).strip() + raw_cells.append(raw_text) + logical_idx = idx + offset + key = mapping.get(logical_idx) + if key is not None: + values[key] = _clean_text(raw_text) + + # values уже очищены (_clean_text применён при заполнении: str | None). + grs_name = values.get("grs_name") + if grs_name: + last_grs_name = grs_name # type: ignore[assignment] + else: + grs_name = last_grs_name # forward-fill (rowspan имени ГРС) + if not grs_name: + continue # шапка/итоговая строка без имени и без forward-fill + + rec = { + "grs_name": grs_name, + "output_name": values.get("output_name"), + "region": values.get("region"), + "design_capacity_th_m3_h": parse_reserve_number(values.get("design")), + "current_load_th_m3_h": parse_reserve_number(values.get("load")), + "free_capacity_th_m3_h": parse_reserve_number(values.get("free")), + "free_capacity_pct": parse_reserve_number(values.get("pct")), + "upgrade_due": values.get("upgrade_due"), + "raw": {"headers": list(headers.values()), "cells": raw_cells}, + } + records.append(rec) + + logger.info("gazprom_grs: распознано записей ГРС=%d", len(records)) + return records + + +def _passes_region_filter(records: list[dict]) -> list[dict]: + """Если хоть у одной записи есть region — фильтруем по «свердлов»; иначе всё. + + В таблице ГТЕ обычно НЕТ колонки региона (весь ГТЕ = Свердловская + сопред. + области) → грузим всё с region=NULL. Если ГТЕ добавит регион — оставляем только + свердловские строки. + """ + if any(r.get("region") for r in records): + return [r for r in records if (r.get("region") or "").lower().find(_SVERDLOVSK_TOKEN) >= 0] + return records + + +def fetch_grs_html() -> str: + """Тянет HTML страницы раскрытия ГТЕ. RUN-ON-PROD (гео-блок). httpx с таймаутом.""" + resp = httpx.get(DISCLOSURE_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True) + resp.raise_for_status() + return resp.text + + +def _upsert_grs_rows(db: Session, records: list[dict]) -> dict[str, int]: + """UPSERT записей ГРС в gas_grs_capacity. Per-row SAVEPOINT (битая строка не валит батч). + + ON CONFLICT (grs_name_norm, output_name) — NULLS NOT DISTINCT в схеме (output_name + nullable). Returns: счётчики rows/inserted/updated/skipped. + """ + inserted = 0 + updated = 0 + skipped = 0 + for rec in records: + grs_name = rec["grs_name"] + params = { + "grs_name": grs_name, + "grs_name_norm": normalize_grs_name(grs_name), + "output_name": rec["output_name"], + "region": rec["region"], + "design": rec["design_capacity_th_m3_h"], + "load": rec["current_load_th_m3_h"], + "free": rec["free_capacity_th_m3_h"], + "pct": rec["free_capacity_pct"], + "upgrade_due": rec["upgrade_due"], + "raw": json.dumps(rec["raw"], ensure_ascii=False), + } + try: + with db.begin_nested(): # SAVEPOINT — откат только этой строки + result = db.execute( + text(""" + INSERT INTO gas_grs_capacity + (grs_name, grs_name_norm, output_name, region, + design_capacity_th_m3_h, current_load_th_m3_h, + free_capacity_th_m3_h, free_capacity_pct, + upgrade_due, raw, fetched_at) + VALUES ( + :grs_name, :grs_name_norm, :output_name, :region, + :design, :load, :free, :pct, + :upgrade_due, CAST(:raw AS jsonb), NOW() + ) + ON CONFLICT (grs_name_norm, output_name) DO UPDATE + SET grs_name = EXCLUDED.grs_name, + region = EXCLUDED.region, + design_capacity_th_m3_h = EXCLUDED.design_capacity_th_m3_h, + current_load_th_m3_h = EXCLUDED.current_load_th_m3_h, + free_capacity_th_m3_h = EXCLUDED.free_capacity_th_m3_h, + free_capacity_pct = EXCLUDED.free_capacity_pct, + upgrade_due = EXCLUDED.upgrade_due, + raw = EXCLUDED.raw, + fetched_at = NOW() + RETURNING (xmax = 0) AS is_insert + """), + params, + ).scalar() + if result: + inserted += 1 + else: + updated += 1 + except Exception as e: + logger.warning("gas_grs upsert failed for %r/%r: %s", grs_name, rec["output_name"], e) + skipped += 1 + return {"rows": len(records), "inserted": inserted, "updated": updated, "skipped": skipped} + + +def load_grs_capacity(db: Session | None = None) -> dict[str, int]: + """Тянет страницу ГТЕ + парсит HTML-таблицу + UPSERT свободной мощности ГРС. + + RUN-ON-PROD (гео-блок источника). ``db`` для совместимости сигнатуры; None → + своя SessionLocal (fetch + запись sync, как rosseti_wfs_loader). + + Returns: счётчики {rows, inserted, updated, skipped}. + """ + html = fetch_grs_html() + records = _passes_region_filter(parse_grs_table(html)) + + owns_session = db is None + if db is None: + db = SessionLocal() + + try: + counts = _upsert_grs_rows(db, records) + db.commit() + except Exception as e: + db.rollback() + logger.exception("load_grs_capacity: outer tx rolled back: %s", e) + raise + finally: + if owns_session: + db.close() + + logger.info("load_grs_capacity done: %s", counts) + return counts diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index 7aff2c84..23ad5a2f 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -301,6 +301,15 @@ def build_beat_schedule() -> dict: "schedule": _parse_cron("30 4 * * tue"), # 04:30 МСК, вторник "options": {"queue": "celery"}, } + # Газовая вертикаль (Forgejo #2119 Фаза B1): свободная мощность ГРС «Газпром + # трансгаз Екатеринбург» из инлайн HTML-таблицы раскрытия. Источник ГЕО-БЛОКИРУЕТ + # non-RU IP → RUN-ON-PROD. 05:00 МСК вторник — через 30 мин после воды (разные + # хосты, не бьём одновременно), в том же вторник-кластере вне monday-нагрузки. + schedule["gas-grs-capacity-sync-weekly"] = { + "task": "tasks.connection_capacity_sync.sync_gas_grs_capacity", + "schedule": _parse_cron("0 5 * * tue"), # 05:00 МСК, вторник + "options": {"queue": "celery"}, + } # #105 Phase 4: ЕКБ РНС/РВЭ — ежемесячно 1-го числа в 05:00 МСК. # Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233). diff --git a/backend/app/workers/tasks/connection_capacity_sync.py b/backend/app/workers/tasks/connection_capacity_sync.py index 87fd1095..33718d56 100644 --- a/backend/app/workers/tasks/connection_capacity_sync.py +++ b/backend/app/workers/tasks/connection_capacity_sync.py @@ -93,3 +93,27 @@ def sync_water_reserves() -> dict: logger.exception("sync_water_reserves failed: %s", e) _log_breadcrumb("water_capacity_sync", "error", f"{type(e).__name__}: {e}") raise + + +@celery_app.task( + name="tasks.connection_capacity_sync.sync_gas_grs_capacity", + queue="celery", +) +def sync_gas_grs_capacity() -> dict: + """Свободная мощность ГРС «Газпром трансгаз Екатеринбург» (HTML) → gas_grs_capacity. + + RUN-ON-PROD (гео-блок источника). Инлайн HTML-таблица раскрытия ГТЕ → UPSERT + свободной мощности ГРС (по выходам). #2119 Фаза B1. + """ + _log_breadcrumb("gas_grs_sync", "info", "task started") + try: + from app.services.site_finder.gazprom_grs_loader import load_grs_capacity + + result = load_grs_capacity() + logger.info("sync_gas_grs_capacity done: %s", result) + _log_breadcrumb("gas_grs_sync", "info", f"done: {result}") + return result + except Exception as e: + logger.exception("sync_gas_grs_capacity failed: %s", e) + _log_breadcrumb("gas_grs_sync", "error", f"{type(e).__name__}: {e}") + raise diff --git a/backend/tests/test_gas_grs_loader.py b/backend/tests/test_gas_grs_loader.py new file mode 100644 index 00000000..195dedfb --- /dev/null +++ b/backend/tests/test_gas_grs_loader.py @@ -0,0 +1,274 @@ +"""Unit-тесты газовой вертикали ГРС «Газпром трансгаз Екатеринбург» (#2119 Фаза B1). + +Pure / mock-based — без реальной сети и БД. Покрывает: +- normalize_grs_name: ё→е, схлопывание пробелов/дефисов, сохранение «ГРС»-префикса. +- parse_grs_table: заголовок-матчинг колонок, rowspan forward-fill имени ГРС на + строки-выходы, запятые/«н/д»-числа, multi-выход. +- _passes_region_filter: колонка региона → фильтр «свердлов»; нет → всё. +- _upsert_grs_rows: SAVEPOINT + счётчики на fake-DB. +- _query_gas_city_grs: агрегация по grs_name (SUM/AVG/MAX/COUNT), пустая таблица → []. +- _reserve_note: ЕЭСК + reserve_mva IS NULL → пометка; иначе None. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import Any + +from app.services.site_finder import connection_capacity_lookup as ccl +from app.services.site_finder import gazprom_grs_loader as gg + +# ── normalize_grs_name ──────────────────────────────────────────────────────── + + +def test_normalize_grs_yo_to_e() -> None: + assert gg.normalize_grs_name("ГРС Берёзовский") == "грс березовский" + + +def test_normalize_grs_keeps_prefix() -> None: + """Префикс «ГРС»/«АРП» СОХРАНЯЕТСЯ (различает станции).""" + assert gg.normalize_grs_name("ГРС Кольцово") == "грс кольцово" + assert gg.normalize_grs_name("АРП Северная") == "арп северная" + + +def test_normalize_grs_dash_and_spaces_collapse() -> None: + assert gg.normalize_grs_name("ГРС Северная — 2") == "грс северная-2" + assert gg.normalize_grs_name("ГРС Западная") == "грс западная" + + +def test_normalize_grs_empty() -> None: + assert gg.normalize_grs_name(None) == "" + assert gg.normalize_grs_name("") == "" + + +# ── parse_grs_table: заголовок-матчинг + rowspan forward-fill ───────────────── + + +_SINGLE_GRS_HTML = """ + +
+ + + + + + + + + +
Наименование ГРСВыходПроектная, тыс. м³/чФактическая загрузкаСвободная мощностьСвободная, %Срок расшивки
ГРС Свердловскаявыход 1100,560,040,540,32027
+ +""" + + +def test_parse_single_grs_header_matching_and_numbers() -> None: + """Колонки мапятся по заголовкам; запятая-десятичный → float.""" + recs = gg.parse_grs_table(_SINGLE_GRS_HTML) + assert len(recs) == 1 + r = recs[0] + assert r["grs_name"] == "ГРС Свердловская" + assert r["output_name"] == "выход 1" + assert r["design_capacity_th_m3_h"] == 100.5 + assert r["current_load_th_m3_h"] == 60.0 + assert r["free_capacity_th_m3_h"] == 40.5 + assert r["free_capacity_pct"] == 40.3 + assert r["upgrade_due"] == "2027" + # raw сохраняет заголовки + ячейки (неизвестные колонки не теряются). + assert "headers" in r["raw"] and "cells" in r["raw"] + + +# rowspan имени ГРС: строка-выход 2/3 НЕ содержит ячейки имени (rowspan «съел» её). +_MULTI_OUTPUT_HTML = """ + + + + + + + + + + + + + + + + +
НаименованиеВыходПроектнаяЗагрузкаСвободная%Срок
ГРС Екатеринбургвыход 150,030,020,040,02028
выход 260,040,020,033,3н/д
выход 370,070,00,00,0после 2030
+ +""" + + +def test_parse_multi_output_rowspan_forward_fill() -> None: + """rowspan имени ГРС → выходы 2/3 наследуют имя; «н/д» → None.""" + recs = gg.parse_grs_table(_MULTI_OUTPUT_HTML) + assert len(recs) == 3 + assert [r["grs_name"] for r in recs] == ["ГРС Екатеринбург"] * 3 + assert [r["output_name"] for r in recs] == ["выход 1", "выход 2", "выход 3"] + # Числа корректно смещены (offset при rowspan-строках). + assert recs[0]["free_capacity_th_m3_h"] == 20.0 + assert recs[1]["design_capacity_th_m3_h"] == 60.0 + assert recs[1]["upgrade_due"] is None # «н/д» → None + assert recs[2]["free_capacity_th_m3_h"] == 0.0 # 0 при полной загрузке сохраняется + assert recs[2]["upgrade_due"] == "после 2030" + + +def test_parse_no_table_returns_empty() -> None: + assert gg.parse_grs_table("

нет таблиц

") == [] + + +def test_parse_unrecognized_name_column_returns_empty() -> None: + """Заголовок без имени ГРС → пустой результат (defensive).""" + html = ( + "" + "
Колонка АКолонка Б
12
" + ) + assert gg.parse_grs_table(html) == [] + + +# ── _passes_region_filter ───────────────────────────────────────────────────── + + +def test_region_filter_no_region_keeps_all() -> None: + recs = [{"region": None, "grs_name": "ГРС А"}, {"region": None, "grs_name": "ГРС Б"}] + assert gg._passes_region_filter(recs) == recs + + +def test_region_filter_present_keeps_sverdlovsk() -> None: + recs = [ + {"region": "Свердловская область", "grs_name": "ГРС А"}, + {"region": "Челябинская область", "grs_name": "ГРС Б"}, + ] + out = gg._passes_region_filter(recs) + assert len(out) == 1 + assert out[0]["grs_name"] == "ГРС А" + + +# ── Fake DB session для upsert- и query-тестов ──────────────────────────────── + + +class _FakeResult: + def __init__(self, scalar_value: Any = True, mappings_rows: list[dict] | None = None) -> None: + self._scalar = scalar_value + self._mappings_rows = mappings_rows or [] + + def scalar(self) -> Any: + return self._scalar + + def mappings(self) -> _FakeResult: + return self + + def all(self) -> list[dict]: + return self._mappings_rows + + +class _FakeSession: + """Мини-заглушка Session: execute + begin_nested (как в loaders-тестах).""" + + def __init__( + self, + scalar_value: Any = True, + mappings_rows: list[dict] | None = None, + execute_raises: Exception | None = None, + ) -> None: + self.calls: list[tuple[str, Any]] = [] + self._scalar = scalar_value + self._mappings_rows = mappings_rows + self._execute_raises = execute_raises + self.committed = False + + @contextmanager + def begin_nested(self): # type: ignore[no-untyped-def] + yield + + def execute(self, stmt: Any, params: Any = None) -> _FakeResult: + self.calls.append((str(stmt), params)) + if self._execute_raises is not None: + raise self._execute_raises + return _FakeResult(self._scalar, self._mappings_rows) + + def commit(self) -> None: + self.committed = True + + def rollback(self) -> None: + pass + + +# ── _upsert_grs_rows ────────────────────────────────────────────────────────── + + +def test_upsert_grs_rows_counts_and_savepoint() -> None: + db = _FakeSession(scalar_value=True) + recs = gg.parse_grs_table(_MULTI_OUTPUT_HTML) + counts = gg._upsert_grs_rows(db, recs) # type: ignore[arg-type] + assert counts == {"rows": 3, "inserted": 3, "updated": 0, "skipped": 0} + # grs_name_norm нормализован в params. + assert db.calls[0][1]["grs_name_norm"] == "грс екатеринбург" + assert db.calls[1][1]["output_name"] == "выход 2" + + +# ── _query_gas_city_grs: агрегация ──────────────────────────────────────────── + + +def test_query_gas_city_grs_aggregation() -> None: + """Агрегированные строки (per grs_name) маппятся в GasGrsRow-словари + total.""" + rows = [ + { + "grs_name": "ГРС Свердловская", + "design_sum": 210.0, + "free_sum": 40.5, + "pct_avg": 20.25, + "upgrade_due": "2028", + "outputs_count": 3, + }, + { + "grs_name": "ГРС Екатеринбург Северная", + "design_sum": 100.0, + "free_sum": 10.0, + "pct_avg": 10.0, + "upgrade_due": None, + "outputs_count": 1, + }, + ] + db = _FakeSession(mappings_rows=rows) + out = ccl._query_gas_city_grs(db) # type: ignore[arg-type] + assert len(out["city_grs"]) == 2 + assert out["city_grs"][0]["grs_name"] == "ГРС Свердловская" + assert out["city_grs"][0]["free_capacity_th_m3_h"] == 40.5 + assert out["city_grs"][0]["free_capacity_pct"] == 20.2 # round(20.25, 1) + assert out["city_grs"][0]["outputs_count"] == 3 + assert out["total_free_th_m3_h"] == 50.5 # 40.5 + 10.0 + + +def test_query_gas_city_grs_empty_table() -> None: + db = _FakeSession(mappings_rows=[]) + out = ccl._query_gas_city_grs(db) # type: ignore[arg-type] + assert out == {"city_grs": [], "total_free_th_m3_h": None} + + +def test_query_gas_city_grs_missing_table_degrades() -> None: + """Таблица не существует (миграция 181 не применена) → пустой блок, не падаем.""" + db = _FakeSession(execute_raises=RuntimeError('relation "gas_grs_capacity" does not exist')) + out = ccl._query_gas_city_grs(db) # type: ignore[arg-type] + assert out == {"city_grs": [], "total_free_th_m3_h": None} + + +# ── _reserve_note: пометка ЕЭСК ─────────────────────────────────────────────── + + +def test_reserve_note_eesk_null_reserve() -> None: + note = ccl._reserve_note("АО «ЕЭСК»", None) + assert note == ccl._EESK_RESERVE_NOTE + assert "ЕЭСК" in note + + +def test_reserve_note_eesk_with_reserve_none() -> None: + """ЕЭСК с известным резервом → без пометки.""" + assert ccl._reserve_note("АО «ЕЭСК»", 12.0) is None + + +def test_reserve_note_other_dzo_none() -> None: + """ПС не-ЕЭСК без резерва → без пометки.""" + assert ccl._reserve_note("Россети Урал", None) is None + assert ccl._reserve_note(None, None) is None diff --git a/data/sql/181_gas_grs_capacity.sql b/data/sql/181_gas_grs_capacity.sql new file mode 100644 index 00000000..ac40ac09 --- /dev/null +++ b/data/sql/181_gas_grs_capacity.sql @@ -0,0 +1,75 @@ +-- 181_gas_grs_capacity.sql +-- Forgejo #2119 Phase B1 — газовая вертикаль «свободная мощность ГРС» для ПТИЦА §3. +-- +-- Одна таблица gas_grs_capacity: свободная мощность ГРС (газораспределительная +-- станция) из раскрытия «Газпром трансгаз Екатеринбург» (ГТЕ): +-- https://ekaterinburg-tr.gazprom.ru/about/informatsiya-o-tekhnicheskoj-vozmo/ +-- — инлайн HTML-таблица «Информация о технической возможности…». +-- +-- Источник ГЕО-БЛОКИРУЕТ non-RU IP → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery / manual +-- docker exec) — как rosseti-/vodokanal-лоадеры (180). Код не должен предполагать +-- локальный сетевой доступ. +-- +-- У ГРС бывает несколько выходов («выход 1/2/3») — тогда одна ГРС = N строк с +-- разным output_name; output_name IS NULL = вся ГРС целиком (одна строка). +-- grs_name_norm — нормализованное имя для матча (lower, ё→е, пробелы/дефисы +-- схлопнуты; префиксы «ГРС»/«АРП» СОХРАНЯЮТСЯ, т.к. различают станции). +-- +-- Refresh: weekly via tasks.connection_capacity_sync.sync_gas_grs_capacity. +-- Порядок применения: миграция ПЕРЕД деплоем кода (schema-first, sql.md). + +BEGIN; + +-- ── gas_grs_capacity ───────────────────────────────────────────────────────── +-- geom NULL до Фазы C (геокод ГРС). raw jsonb — сырые ячейки строки целиком +-- (неизвестные/будущие колонки таблицы ГТЕ не теряются, а падают в raw). +CREATE TABLE IF NOT EXISTS gas_grs_capacity ( + id SERIAL PRIMARY KEY, + grs_name TEXT NOT NULL, -- исходное имя ГРС + grs_name_norm TEXT NOT NULL, -- нормализованное для матча + output_name TEXT, -- «выход 1/2/3»; NULL = вся ГРС + region TEXT, -- область (Свердловская и др.) + design_capacity_th_m3_h NUMERIC, -- проектная, тыс. м³/ч + current_load_th_m3_h NUMERIC, -- текущая загрузка, тыс. м³/ч + free_capacity_th_m3_h NUMERIC, -- свободная мощность, тыс. м³/ч + free_capacity_pct NUMERIC, -- свободная, % + upgrade_due TEXT, -- срок расшивки (как в источнике) + asof DATE, -- дата актуальности данных + geom GEOMETRY(POINT, 4326), -- NULL до Фазы C геокода + raw JSONB, -- сырая строка таблицы целиком + fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- NULLS NOT DISTINCT: output_name nullable (у ГРС без разбивки по выходам — + -- одна строка с output_name IS NULL). Без этого ON CONFLICT не матчит строку + -- с NULL-выходом → weekly-прогон плодит дубли (те же грабли, что #140 / + -- 180_connection_capacity.sql power_tp_rp_reserves). + UNIQUE NULLS NOT DISTINCT (grs_name_norm, output_name) +); + +CREATE INDEX IF NOT EXISTS idx_gas_grs_capacity_name_norm + ON gas_grs_capacity (grs_name_norm); + +CREATE INDEX IF NOT EXISTS gist_gas_grs_capacity_geom + ON gas_grs_capacity USING GIST (geom); + +COMMENT ON TABLE gas_grs_capacity IS + 'Свободная мощность ГРС (газораспределительных станций) из раскрытия «Газпром ' + 'трансгаз Екатеринбург». Один output_name на строку («выход 1/2/3»; NULL = вся ' + 'ГРС). geom NULL до Фазы C геокода. Refresh: weekly via ' + 'tasks.connection_capacity_sync.sync_gas_grs_capacity. Forgejo #2119 Phase B1.'; + +COMMENT ON COLUMN gas_grs_capacity.grs_name_norm IS + 'Нормализованное имя ГРС для матча/дедупа (lower, ё→е, пробелы/дефисы схлопнуты; ' + 'префиксы «ГРС»/«АРП» СОХРАНЯЮТСЯ — различают станции).'; + +COMMENT ON COLUMN gas_grs_capacity.output_name IS + 'Выход ГРС («выход 1/2/3»). NULL = вся ГРС целиком (нет разбивки по выходам).'; + +COMMENT ON COLUMN gas_grs_capacity.free_capacity_th_m3_h IS + 'Свободная (резервная) мощность ГРС, тыс. м³/ч. Может быть 0 при полной загрузке.'; + +COMMENT ON COLUMN gas_grs_capacity.upgrade_due IS + 'Срок расшивки (планового увеличения пропускной способности), как в источнике ' + '(свободный текст: год / «после 2027» / пусто).'; + +COMMIT; diff --git a/frontend/src/components/site-finder/PowerConnectionPointsLayer.tsx b/frontend/src/components/site-finder/PowerConnectionPointsLayer.tsx index 4373b95a..31d44b9f 100644 --- a/frontend/src/components/site-finder/PowerConnectionPointsLayer.tsx +++ b/frontend/src/components/site-finder/PowerConnectionPointsLayer.tsx @@ -124,6 +124,11 @@ function PowerPopup({ point }: { point: PowerConnectionPoint }) { {badge.label} )} + {point.reserve_note && ( +
+ {point.reserve_note} +
+ )} {distance && (
До участка: {distance} diff --git a/frontend/src/components/site-finder/analysis/ResourceReservesCard.tsx b/frontend/src/components/site-finder/analysis/ResourceReservesCard.tsx index f32c08d0..c46fceb4 100644 --- a/frontend/src/components/site-finder/analysis/ResourceReservesCard.tsx +++ b/frontend/src/components/site-finder/analysis/ResourceReservesCard.tsx @@ -15,10 +15,12 @@ * бэка не отработали, показываем нейтральный «загружаются», а не «нет данных». */ -import { Info, Zap, Droplets } from "lucide-react"; +import { Info, Zap, Droplets, Flame } from "lucide-react"; import type { ConnectionCapacityResponse, + GasCapacityBlock, + GasGrsRow, WaterCapacityRow, } from "@/hooks/useConnectionCapacity"; import { CONNECTION_CAPACITY_RADIUS_M } from "@/hooks/useConnectionCapacity"; @@ -28,6 +30,7 @@ import { formatMeters, formatMva, formatThousandM3Day, + formatThousandM3Hour, humanizeLoadIndex, pickCitySystem, pluralizeSystems, @@ -48,6 +51,11 @@ const RESERVE_BADGE = { bg: "var(--danger-soft)", fg: "var(--danger)", }, + depleted: { + label: "исчерпана", + bg: "var(--danger-soft)", + fg: "var(--danger)", + }, } as const; function StatusBadge({ @@ -266,6 +274,88 @@ function WaterBlock({ rows }: { rows: WaterCapacityRow[] }) { ); } +// ── Газ ──────────────────────────────────────────────────────────────────────── + +function GasGrsLine({ row }: { row: GasGrsRow }) { + const freeNum = toFiniteNumber(row.free_capacity_th_m3_h); + const freeStr = formatThousandM3Hour(row.free_capacity_th_m3_h); + const designStr = formatThousandM3Hour(row.design_capacity_th_m3_h); + const isDepleted = freeNum !== null && freeNum <= 0; + const badge = isDepleted ? RESERVE_BADGE.depleted : RESERVE_BADGE.reserve; + const upgrade = row.upgrade_due?.trim() || null; + + return ( +
+ + {row.grs_name}: свободно{" "} + {freeStr ? ( + + {freeStr} + + ) : ( + + )} + {designStr ? ` из ${designStr}` : ""} + {row.outputs_count > 1 && ( + + {" "} + ({row.outputs_count} выхода) + + )} + {upgrade && ( + + {" "} + · расшивка: {upgrade} + + )} + + +
+ ); +} + +function GasBlock({ gas }: { gas: GasCapacityBlock | null | undefined }) { + if (!gas || gas.city_grs.length === 0) { + return ( +
+ Данные о загрузке ГРС загружаются. +
+ ); + } + + const totalNum = toFiniteNumber(gas.total_free_th_m3_h); + const totalStr = formatThousandM3Hour(gas.total_free_th_m3_h); + + return ( +
+ {gas.city_grs.map((row, i) => ( + + ))} + + {totalNum !== null && totalStr && ( + + Суммарно по городским ГРС: {totalStr} + + )} + + + Газпром трансгаз Екатеринбург · техвозможность ГРС + +
+ ); +} + // ── Card ─────────────────────────────────────────────────────────────────────── const SUB_HEADER: React.CSSProperties = { @@ -318,6 +408,15 @@ export function ResourceReservesCard({ data }: Props) {
+ {/* Газ */} +
+
+
+ +
+ {/* Вода / канализация */}
diff --git a/frontend/src/components/site-finder/connection-capacity.ts b/frontend/src/components/site-finder/connection-capacity.ts index b55a454c..696119cf 100644 --- a/frontend/src/components/site-finder/connection-capacity.ts +++ b/frontend/src/components/site-finder/connection-capacity.ts @@ -126,6 +126,18 @@ export function formatMeters( return `${Math.round(n).toLocaleString("ru-RU")} м`; } +// тыс. м³/ч (свободная/проектная мощность ГРС). Приводит строку→число (Decimal→str). +export function formatThousandM3Hour( + v: number | string | null | undefined, +): string | null { + const n = toFiniteNumber(v); + if (n === null) return null; + return `${n.toLocaleString("ru-RU", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + })} тыс. м³/ч`; +} + // ── Вода: выбор «городской» системы + группировка ────────────────────────────── // // Городская система ЦСВ/ЦСК выделяется в карточке отдельной строкой: сначала по diff --git a/frontend/src/hooks/useConnectionCapacity.ts b/frontend/src/hooks/useConnectionCapacity.ts index 9979a28b..4ed02a8d 100644 --- a/frontend/src/hooks/useConnectionCapacity.ts +++ b/frontend/src/hooks/useConnectionCapacity.ts @@ -13,6 +13,8 @@ export type PowerConnectionPoint = export type PowerCapacitySummary = components["schemas"]["PowerCapacitySummary"]; export type WaterCapacityRow = components["schemas"]["WaterCapacityRow"]; +export type GasCapacityBlock = components["schemas"]["GasCapacityBlock"]; +export type GasGrsRow = components["schemas"]["GasGrsRow"]; /** * Радиус поиска центров питания (ЦП, ПС 35/110 кВ) вокруг участка. Дефолт 3 км — diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 9e70bba1..5d802932 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -3456,6 +3456,7 @@ export interface components { power_summary: components["schemas"]["PowerCapacitySummary"]; /** Water */ water: components["schemas"]["WaterCapacityRow"][]; + gas?: components["schemas"]["GasCapacityBlock"] | null; }; /** ConnectionPointsResponse */ ConnectionPointsResponse: { @@ -3850,6 +3851,34 @@ export interface components { */ price_source: string; }; + /** + * GasCapacityBlock + * @description Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1. + */ + GasCapacityBlock: { + /** City Grs */ + city_grs: components["schemas"]["GasGrsRow"][]; + /** Total Free Th M3 H */ + total_free_th_m3_h: number | null; + }; + /** + * GasGrsRow + * @description Свободная мощность одной городской ГРС (агрегация по выходам). #2119 B1. + */ + GasGrsRow: { + /** Grs Name */ + grs_name: string; + /** Design Capacity Th M3 H */ + design_capacity_th_m3_h: number | null; + /** Free Capacity Th M3 H */ + free_capacity_th_m3_h: number | null; + /** Free Capacity Pct */ + free_capacity_pct: number | null; + /** Upgrade Due */ + upgrade_due: string | null; + /** Outputs Count */ + outputs_count: number; + }; /** * GroundedIn * @description Привязка ответа к источнику — какой ран/схема/секции легли в основу ответа. @@ -4712,6 +4741,8 @@ export interface components { lat: number | null; /** Lon */ lon: number | null; + /** Reserve Note */ + reserve_note?: string | null; }; /** RecommendBucket */ RecommendBucket: {