{
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "view-in-github",
        "colab_type": "text"
      },
      "source": [
        "<a href=\"https://colab.research.google.com/gist/ArtemMareev/fd829e63a58384ffc1f764cd41b773e6/-_-_ml_-_-_lsc_-_lsc_.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "4A04NKEFjUkT"
      },
      "outputs": [],
      "source": [
        "%%capture\n",
        "!pip install pyharm\n",
        "!wget https://icgem.gfz-potsdam.de/getmodel/gfc/eeb03971cf6e533e6eeb6b010336463286dcda0846684248d5530acf8e800055/XGM2019e_2159.gfc"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "Vea03g1fjcHZ"
      },
      "source": [
        "# Относительная ML-поправка к LSC при высоте ETOPO1 над опорной сферой\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "l5-5rUDTnG3m"
      },
      "source": [
        "## Согласованная сферическая конструкция\n",
        "\n",
        "Высота ETOPO1 трактуется как высота физической точки $P_i$ над опорной сферой:\n",
        "\n",
        "$H_i^{\\mathrm{sph}}=H_i^{\\mathrm{ETOPO1}},$\n",
        "\n",
        "$r_{P_i}=R+H_i^{\\mathrm{sph}}.$\n",
        "\n",
        "Используется стандартная аномалия высоты Брунса $\\zeta=T/\\gamma$. Истинная нормальная высота определяется энергетически:\n",
        "\n",
        "$H_{i,\\mathrm{true}}^N=H_i^{\\mathrm{sph}}-\\zeta_{i,\\mathrm{true}},$\n",
        "\n",
        "$r_{Q_i}^{\\mathrm{true}}=R+H_{i,\\mathrm{true}}^N,$\n",
        "\n",
        "$W^{200}(Q_i^{\\mathrm{true}})=W^N(P_i).$\n",
        "\n",
        "Исходная оценка $\\zeta_{i,\\mathrm{true}}$ уточняется одномерным решением уравнения по высоте. Главный контроль геометрии:\n",
        "\n",
        "$e_i^H=W^{200}(Q_i^{\\mathrm{true}})-W^N(P_i)\\approx0.$\n",
        "\n",
        "Нивелирное наблюдение моделируется как:\n",
        "\n",
        "$H_{i,\\mathrm{lev}}^{N,\\mathrm{obs}}=H_{i,\\mathrm{true}}^N+b_H+\\varepsilon_{H,i},$\n",
        "\n",
        "$r_{Q_i}^{\\mathrm{obs}}=R+H_{i,\\mathrm{lev}}^{N,\\mathrm{obs}}.$\n",
        "\n",
        "Относительная разность геопотенциальных чисел вычисляется непосредственно:\n",
        "\n",
        "$\\Delta C_{i0}^{\\mathrm{lev}}=W^{200}(Q_0^{\\mathrm{obs}})-W^{200}(Q_i^{\\mathrm{obs}}),$\n",
        "\n",
        "$\\Delta C_{i0}^{200}=W^{200}(P_0)-W^{200}(P_i),$\n",
        "\n",
        "$\\Delta T_{i0}^{\\mathrm{lev}}=\\Delta C_{i0}^{200}-\\Delta C_{i0}^{\\mathrm{lev}}.$\n",
        "\n",
        "Относительный LSC-прогноз и ML-метка:\n",
        "\n",
        "$\\Delta T_{i0}^{\\mathrm{LSC}}=T_{\\mathrm{LSC}}(P_i)-T_{\\mathrm{LSC}}(P_0),$\n",
        "\n",
        "$y_i=\\Delta T_{i0}^{\\mathrm{lev}}-\\Delta T_{i0}^{\\mathrm{LSC}}.$\n",
        "\n",
        "ML получает только разностную палетку:\n",
        "\n",
        "$\\Delta\\boldsymbol{\\Phi}_{i0}=\\boldsymbol{\\Phi}(P_i)-\\boldsymbol{\\Phi}(P_0).$\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "NOcRV5G9jZba"
      },
      "outputs": [],
      "source": [
        "# =========================================================\n",
        "# ДАТАСЕТ 2-ГО УРОВНЯ: ETOPO1 + ЗАШУМЛЁННЫЕ НАБЛЮДЕНИЯ\n",
        "# ML-цель формируется после LSC как относительная поправка к разности потенциала относительно P0.\n",
        "# =========================================================\n",
        "import sys, zipfile, urllib.request, subprocess\n",
        "from pathlib import Path\n",
        "import numpy as np\n",
        "import pandas as pd\n",
        "import pyharm as ph\n",
        "\n",
        "try:\n",
        "    import rasterio\n",
        "    from rasterio.windows import Window\n",
        "except ImportError:\n",
        "    subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"rasterio\"])\n",
        "    import rasterio\n",
        "    from rasterio.windows import Window\n",
        "\n",
        "ARCSEC = 4.84813681109536e-6\n",
        "MGAL = 1e5\n",
        "\n",
        "ETOPO1_URL = (\n",
        "    \"https://www.ngdc.noaa.gov/mgg/global/relief/ETOPO1/data/\"\n",
        "    \"ice_surface/grid_registered/georeferenced_tiff/\"\n",
        "    \"ETOPO1_Ice_g_geotiff.zip\"\n",
        ")\n",
        "ETOPO1_DIR = \"/content/data/etopo1\"\n",
        "\n",
        "def prepare_etopo1(data_dir=ETOPO1_DIR, url=ETOPO1_URL, force_download=False):\n",
        "    data_dir = Path(data_dir)\n",
        "    data_dir.mkdir(parents=True, exist_ok=True)\n",
        "    tifs = sorted(data_dir.rglob(\"*.tif\"))\n",
        "\n",
        "    if tifs and not force_download:\n",
        "        print(f\"ETOPO1 найден: {tifs[0]}\")\n",
        "        return str(tifs[0])\n",
        "\n",
        "    zip_path = data_dir / \"ETOPO1_Ice_g_geotiff.zip\"\n",
        "\n",
        "    if force_download or not zip_path.exists():\n",
        "        print(\"Загрузка ETOPO1...\")\n",
        "        urllib.request.urlretrieve(url, zip_path)\n",
        "\n",
        "    print(\"Распаковка ETOPO1...\")\n",
        "    with zipfile.ZipFile(zip_path) as zf:\n",
        "        zf.extractall(data_dir)\n",
        "\n",
        "    tifs = sorted(data_dir.rglob(\"*.tif\"))\n",
        "    if not tifs:\n",
        "        raise FileNotFoundError(\"GeoTIFF ETOPO1 после распаковки не найден.\")\n",
        "\n",
        "    print(f\"ETOPO1 подготовлен: {tifs[0]}\")\n",
        "    return str(tifs[0])\n",
        "\n",
        "def _lon_to_180(lon):\n",
        "    lon = np.asarray(lon, float)\n",
        "    return (lon + 180.0) % 360.0 - 180.0\n",
        "\n",
        "def _bilinear_sample_window(src, lat, lon):\n",
        "    lat = np.asarray(lat, float)\n",
        "    lon = np.asarray(lon, float)\n",
        "    inv = ~src.transform\n",
        "\n",
        "    col_edge = inv.a * lon + inv.b * lat + inv.c\n",
        "    row_edge = inv.d * lon + inv.e * lat + inv.f\n",
        "    col = col_edge - 0.5\n",
        "    row = row_edge - 0.5\n",
        "\n",
        "    c0 = np.floor(col).astype(int)\n",
        "    r0 = np.floor(row).astype(int)\n",
        "    c0 = np.clip(c0, 0, src.width - 2)\n",
        "    r0 = np.clip(r0, 0, src.height - 2)\n",
        "\n",
        "    dc = np.clip(col - c0, 0.0, 1.0)\n",
        "    dr = np.clip(row - r0, 0.0, 1.0)\n",
        "\n",
        "    cmin = int(c0.min())\n",
        "    cmax = int(c0.max()) + 1\n",
        "    rmin = int(r0.min())\n",
        "    rmax = int(r0.max()) + 1\n",
        "\n",
        "    window = Window(cmin, rmin, cmax - cmin + 1, rmax - rmin + 1)\n",
        "    arr = src.read(1, window=window, masked=True).astype(float)\n",
        "    arr = np.ma.filled(arr, np.nan)\n",
        "\n",
        "    rr = r0 - rmin\n",
        "    cc = c0 - cmin\n",
        "\n",
        "    z = (\n",
        "        (1.0 - dr) * (1.0 - dc) * arr[rr, cc]\n",
        "        + (1.0 - dr) * dc * arr[rr, cc + 1]\n",
        "        + dr * (1.0 - dc) * arr[rr + 1, cc]\n",
        "        + dr * dc * arr[rr + 1, cc + 1]\n",
        "    )\n",
        "\n",
        "    if np.any(~np.isfinite(z)):\n",
        "        raise ValueError(\"Интерполяция ETOPO1 вернула NaN или inf.\")\n",
        "\n",
        "    return z\n",
        "\n",
        "def interpolate_etopo1(path, lat, lon, method=\"bilinear\"):\n",
        "    lat = np.asarray(lat, float)\n",
        "    lon = _lon_to_180(lon)\n",
        "\n",
        "    if lat.shape != lon.shape:\n",
        "        raise ValueError(\"Широты и долготы должны иметь одинаковую форму.\")\n",
        "\n",
        "    with rasterio.open(path) as src:\n",
        "        if method == \"nearest\":\n",
        "            return np.asarray(\n",
        "                list(src.sample(zip(lon, lat), indexes=1)),\n",
        "                float\n",
        "            ).ravel()\n",
        "\n",
        "        if method != \"bilinear\":\n",
        "            raise ValueError(\"interpolation: только 'bilinear' или 'nearest'.\")\n",
        "\n",
        "        if np.ptp(lon) <= 180.0:\n",
        "            return _bilinear_sample_window(src, lat, lon)\n",
        "\n",
        "        result = np.empty_like(lat)\n",
        "        west = lon < 0.0\n",
        "        east = ~west\n",
        "\n",
        "        if west.any():\n",
        "            result[west] = _bilinear_sample_window(src, lat[west], lon[west])\n",
        "        if east.any():\n",
        "            result[east] = _bilinear_sample_window(src, lat[east], lon[east])\n",
        "\n",
        "        return result\n",
        "\n",
        "def field_with_gradient(shcs, nmax, lat, lon, r):\n",
        "    point = ph.crd.PointSctr.from_arrays(lat, lon, r)\n",
        "    V = ph.shs.point(point, shcs, nmax)\n",
        "    gN, gE, gR = ph.shs.point_grad1(point, shcs, nmax)\n",
        "    return V, np.stack([gN, gE, gR])\n",
        "\n",
        "def deflections(grad_T, gamma):\n",
        "    xi = grad_T[0] / gamma\n",
        "    eta = -grad_T[1] / gamma\n",
        "    return xi, eta\n",
        "\n",
        "def spherical_grid(lat_range, lon_range, num_of_points):\n",
        "    size = int(np.sqrt(num_of_points))\n",
        "\n",
        "    if size * size != int(num_of_points):\n",
        "        raise ValueError(\"num_of_points должно быть полным квадратом.\")\n",
        "\n",
        "    print(f\"Сетка: {size}x{size} = {size**2} точек\")\n",
        "\n",
        "    lat = np.linspace(\n",
        "        np.radians(lat_range[0]),\n",
        "        np.radians(lat_range[1]),\n",
        "        size\n",
        "    )\n",
        "    lon = np.linspace(\n",
        "        np.radians(lon_range[0]),\n",
        "        np.radians(lon_range[1]),\n",
        "        size\n",
        "    )\n",
        "\n",
        "    lat, lon = np.meshgrid(lat, lon, indexing=\"ij\")\n",
        "    return lat.ravel(), lon.ravel(), size\n",
        "\n",
        "def _noise(rng, sigma, n, enabled):\n",
        "    sigma = float(sigma)\n",
        "\n",
        "    if sigma < 0:\n",
        "        raise ValueError(\"СКО случайной ошибки не может быть отрицательной.\")\n",
        "\n",
        "    if not enabled or sigma == 0.0:\n",
        "        return np.zeros(n)\n",
        "\n",
        "    return rng.normal(0.0, sigma, n)\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# ЭНЕРГЕТИЧЕСКИ СОГЛАСОВАННАЯ НОРМАЛЬНАЯ ВЫСОТА\n",
        "# =========================================================\n",
        "def _potential_at_radius(shcs, nmax, lat_rad, lon_rad, radius_m):\n",
        "    lat_rad, lon_rad, radius_m = np.broadcast_arrays(\n",
        "        np.asarray(lat_rad, dtype=float),\n",
        "        np.asarray(lon_rad, dtype=float),\n",
        "        np.asarray(radius_m, dtype=float)\n",
        "    )\n",
        "    if np.any(~np.isfinite(lat_rad)) or np.any(~np.isfinite(lon_rad)):\n",
        "        raise ValueError(\"Координаты содержат NaN или inf.\")\n",
        "    if np.any(~np.isfinite(radius_m)) or np.any(radius_m <= 0.0):\n",
        "        raise ValueError(\"Радиусы должны быть конечными и положительными.\")\n",
        "\n",
        "    shape = lat_rad.shape\n",
        "    point = ph.crd.PointSctr.from_arrays(\n",
        "        lat_rad.ravel(), lon_rad.ravel(), radius_m.ravel()\n",
        "    )\n",
        "    values = ph.shs.point(point, shcs, int(nmax))\n",
        "    return np.asarray(values, dtype=float).reshape(shape)\n",
        "\n",
        "\n",
        "def solve_energy_consistent_zeta(\n",
        "    *,\n",
        "    latitude_rad,\n",
        "    longitude_rad,\n",
        "    H_sph_m,\n",
        "    R_sphere_m,\n",
        "    shcs,\n",
        "    degree_full,\n",
        "    degree_reference,\n",
        "    W_full_P,\n",
        "    W_200_P,\n",
        "    g_200_P,\n",
        "    residual_atol_m2_s2=1e-4,\n",
        "    max_iterations=8,\n",
        "    derivative_step_m=0.5,\n",
        "    max_newton_step_m=100.0\n",
        "):\n",
        "    \"\"\"\n",
        "    Решает по стандартной аномалии высоты Брунса уравнение\n",
        "        W^200(R + H_sph - zeta_true) = W^N(R + H_sph).\n",
        "\n",
        "    Исходное приближение — линейная оценка по разности потенциалов\n",
        "    в физической точке. Производная определяется конечной разностью\n",
        "    того же поля W^200, поэтому знак радиальной компоненты не задаётся\n",
        "    вручную.\n",
        "    \"\"\"\n",
        "    lat = np.asarray(latitude_rad, dtype=float)\n",
        "    lon = np.asarray(longitude_rad, dtype=float)\n",
        "    H_sph = np.asarray(H_sph_m, dtype=float)\n",
        "    W_full_P = np.asarray(W_full_P, dtype=float)\n",
        "    W_200_P = np.asarray(W_200_P, dtype=float)\n",
        "    g_200_P = np.asarray(g_200_P, dtype=float)\n",
        "\n",
        "    lat, lon, H_sph, W_full_P, W_200_P, g_200_P = np.broadcast_arrays(\n",
        "        lat, lon, H_sph, W_full_P, W_200_P, g_200_P\n",
        "    )\n",
        "\n",
        "    r_P = float(R_sphere_m) + H_sph\n",
        "    if np.any(~np.isfinite(r_P)) or np.any(r_P <= 0.0):\n",
        "        raise ValueError(\"Радиусы физических точек P должны быть положительными.\")\n",
        "    if np.any(g_200_P <= 0.0):\n",
        "        raise ValueError(\"g_200_P должна быть положительной.\")\n",
        "    if residual_atol_m2_s2 <= 0.0:\n",
        "        raise ValueError(\"residual_atol_m2_s2 должен быть положительным.\")\n",
        "    if max_iterations < 1:\n",
        "        raise ValueError(\"max_iterations должно быть не меньше 1.\")\n",
        "    if derivative_step_m <= 0.0:\n",
        "        raise ValueError(\"derivative_step_m должен быть положительным.\")\n",
        "\n",
        "    # Стандартная теорема Брунса: zeta = T/gamma.\n",
        "    # T(P)=WN(P)-W200(P), поэтому начальная линейная оценка имеет знак плюс:\n",
        "    # zeta ≈ (WN(P)-W200(P))/g200(P).\n",
        "    zeta_initial = (W_full_P - W_200_P) / g_200_P\n",
        "    zeta = zeta_initial.copy()\n",
        "\n",
        "    def residual(z):\n",
        "        # При стандартном знаке zeta=T/gamma нормальная высота:\n",
        "        # H_normal = H_sph - zeta, поэтому Q лежит на радиусе R+H_sph-zeta.\n",
        "        r_Q = float(R_sphere_m) + H_sph - z\n",
        "        if np.any(~np.isfinite(r_Q)) or np.any(r_Q <= 0.0):\n",
        "            raise ValueError(\"Радиусы точек Q должны быть положительными.\")\n",
        "        return _potential_at_radius(\n",
        "            shcs, degree_reference, lat, lon, r_Q\n",
        "        ) - W_full_P\n",
        "\n",
        "    initial_residual = residual(zeta)\n",
        "    iterations_used = 0\n",
        "\n",
        "    for iteration in range(1, int(max_iterations) + 1):\n",
        "        F = residual(zeta)\n",
        "        iterations_used = iteration\n",
        "        if float(np.max(np.abs(F))) <= float(residual_atol_m2_s2):\n",
        "            break\n",
        "\n",
        "        F_plus = residual(zeta + float(derivative_step_m))\n",
        "        derivative = (F_plus - F) / float(derivative_step_m)\n",
        "\n",
        "        if np.any(~np.isfinite(derivative)) or np.any(np.abs(derivative) < 1e-6):\n",
        "            raise RuntimeError(\n",
        "                \"Неустойчивая производная при решении уравнения для zeta_true.\"\n",
        "            )\n",
        "\n",
        "        step = F / derivative\n",
        "        step = np.clip(\n",
        "            step,\n",
        "            -float(max_newton_step_m),\n",
        "            float(max_newton_step_m)\n",
        "        )\n",
        "        zeta = zeta - step\n",
        "\n",
        "    final_residual = residual(zeta)\n",
        "    max_final = float(np.max(np.abs(final_residual)))\n",
        "    if max_final > float(residual_atol_m2_s2):\n",
        "        raise RuntimeError(\n",
        "            \"Не достигнута энергетическая согласованность zeta_true: \"\n",
        "            f\"max|W200(Q)-WN(P)|={max_final:.3e} м²/с², \"\n",
        "            f\"допуск={float(residual_atol_m2_s2):.3e}.\"\n",
        "        )\n",
        "\n",
        "    H_normal_true = H_sph - zeta\n",
        "    r_Q_true = float(R_sphere_m) + H_normal_true\n",
        "    if np.any(~np.isfinite(r_Q_true)) or np.any(r_Q_true <= 0.0):\n",
        "        raise ValueError(\"Радиусы истинных точек Q должны быть положительными.\")\n",
        "\n",
        "    diagnostics = {\n",
        "        \"zeta_definition\": \"standard_Bruns_zeta=T/gamma\",\n",
        "        \"H_normal_definition\": \"H_normal=H_sph-zeta\",\n",
        "        \"initial_estimate\": \"zeta_initial=(WN(P)-W200(P))/g200(P)\",\n",
        "        \"iterations_used\": int(iterations_used),\n",
        "        \"max_initial_residual_m2_s2\": float(np.max(np.abs(initial_residual))),\n",
        "        \"max_final_residual_m2_s2\": max_final,\n",
        "        \"rms_final_residual_m2_s2\": float(\n",
        "            np.sqrt(np.mean(final_residual**2))\n",
        "        ),\n",
        "        \"max_zeta_correction_m\": float(\n",
        "            np.max(np.abs(zeta - zeta_initial))\n",
        "        ),\n",
        "        \"residual_atol_m2_s2\": float(residual_atol_m2_s2),\n",
        "        \"derivative_step_m\": float(derivative_step_m),\n",
        "        \"max_iterations\": int(max_iterations)\n",
        "    }\n",
        "    return zeta, zeta_initial, final_residual, diagnostics\n",
        "\n",
        "\n",
        "def sphere_dataset_maker(\n",
        "    lat_range,\n",
        "    lon_range,\n",
        "    num_of_points,\n",
        "    heights,\n",
        "    R_sphere,\n",
        "    ggfm_param,\n",
        "    nmax_norm=200,\n",
        "    etopo_tif_path=None,\n",
        "    interpolation=\"bilinear\",\n",
        "    ocean_to_zero=True,\n",
        "    force_etopo_download=False,\n",
        "    sigma_dg_mgal=0.01,\n",
        "    sigma_zeta_m=0.20,\n",
        "    sigma_H_leveling_m=0.0,\n",
        "    leveling_common_bias_m=0.0,\n",
        "    sigma_defl_arcsec=0.2,\n",
        "    noise_seed=42,\n",
        "    add_noise=True,\n",
        "    energy_match_atol_m2_s2=1e-4,\n",
        "    energy_match_max_iterations=8,\n",
        "    energy_derivative_step_m=0.5\n",
        "):\n",
        "    \"\"\"\n",
        "    Формирует синтетический датасет на физической поверхности P_i.\n",
        "\n",
        "    Принятая конвенция:\n",
        "        H_sph = H_ETOPO1,\n",
        "        r_P = R + H_sph,\n",
        "        H_normal_true = H_sph - zeta_true,\n",
        "        r_Q_true = R + H_normal_true,\n",
        "        W200(Q_true) = WN(P).\n",
        "\n",
        "    Нивелирное наблюдение:\n",
        "        H_leveling_obs = H_normal_true + b_H + eps_H.\n",
        "\n",
        "    ML-цель формируется позже, после LSC, относительно одной точки P0.\n",
        "    \"\"\"\n",
        "    if heights is not None:\n",
        "        print(\"Параметр heights игнорируется: используется одна поверхность ETOPO1.\")\n",
        "\n",
        "    lat, lon, grid_size = spherical_grid(\n",
        "        lat_range, lon_range, num_of_points\n",
        "    )\n",
        "    lat_deg = np.degrees(lat)\n",
        "    lon_deg = np.degrees(lon)\n",
        "    n = len(lat)\n",
        "\n",
        "    if etopo_tif_path is None:\n",
        "        etopo_tif_path = prepare_etopo1(\n",
        "            force_download=force_etopo_download\n",
        "        )\n",
        "\n",
        "    H_etopo = interpolate_etopo1(\n",
        "        etopo_tif_path, lat_deg, lon_deg, interpolation\n",
        "    )\n",
        "    H_sph = (\n",
        "        np.maximum(H_etopo, 0.0)\n",
        "        if ocean_to_zero\n",
        "        else H_etopo.copy()\n",
        "    )\n",
        "    print(\n",
        "        f\"Высоты ETOPO1 над сферой: min={H_sph.min():.1f} м, \"\n",
        "        f\"mean={H_sph.mean():.1f} м, max={H_sph.max():.1f} м\"\n",
        "    )\n",
        "\n",
        "    degree = int(ggfm_param[\"degree\"])\n",
        "    nmax_norm = int(nmax_norm)\n",
        "    if nmax_norm > degree:\n",
        "        raise ValueError(\"nmax_norm не может превышать степень ГГМ.\")\n",
        "    if nmax_norm != 200:\n",
        "        raise ValueError(\"Опорное поле текущего эксперимента должно иметь n<=200.\")\n",
        "\n",
        "    shcs = ph.shc.Shc.from_file(\n",
        "        \"gfc\", ggfm_param[\"file_path\"], degree\n",
        "    )\n",
        "\n",
        "    # Физическая поверхность P задаётся только ETOPO1.\n",
        "    r_P = float(R_sphere) + H_sph\n",
        "    if np.any(~np.isfinite(r_P)) or np.any(r_P <= 0.0):\n",
        "        raise ValueError(\"Радиусы физических точек P должны быть положительными.\")\n",
        "    W_full_P, grad_full_P = field_with_gradient(\n",
        "        shcs, degree, lat, lon, r_P\n",
        "    )\n",
        "    W_200_P, grad_200_P = field_with_gradient(\n",
        "        shcs, nmax_norm, lat, lon, r_P\n",
        "    )\n",
        "    T_P_true = W_full_P - W_200_P\n",
        "    g_full_P_clean = np.linalg.norm(grad_full_P, axis=0)\n",
        "    g_200_P = np.linalg.norm(grad_200_P, axis=0)\n",
        "    dg_P_clean = g_full_P_clean - g_200_P\n",
        "\n",
        "    # Стандартная аномалия высоты Брунса zeta_true=T/gamma.\n",
        "    # Истинная нормальная высота определяется как H_sph-zeta_true.\n",
        "    (\n",
        "        zeta_true,\n",
        "        zeta_initial,\n",
        "        energy_error_true,\n",
        "        zeta_solver_diagnostics\n",
        "    ) = solve_energy_consistent_zeta(\n",
        "        latitude_rad=lat,\n",
        "        longitude_rad=lon,\n",
        "        H_sph_m=H_sph,\n",
        "        R_sphere_m=R_sphere,\n",
        "        shcs=shcs,\n",
        "        degree_full=degree,\n",
        "        degree_reference=nmax_norm,\n",
        "        W_full_P=W_full_P,\n",
        "        W_200_P=W_200_P,\n",
        "        g_200_P=g_200_P,\n",
        "        residual_atol_m2_s2=energy_match_atol_m2_s2,\n",
        "        max_iterations=energy_match_max_iterations,\n",
        "        derivative_step_m=energy_derivative_step_m\n",
        "    )\n",
        "\n",
        "    H_normal_true = H_sph - zeta_true\n",
        "    r_Q_true = float(R_sphere) + H_normal_true\n",
        "    W_200_Q_true = _potential_at_radius(\n",
        "        shcs, nmax_norm, lat, lon, r_Q_true\n",
        "    )\n",
        "    energy_error_true = W_200_Q_true - W_full_P\n",
        "\n",
        "    max_energy_error = float(np.max(np.abs(energy_error_true)))\n",
        "    if max_energy_error > float(energy_match_atol_m2_s2):\n",
        "        raise AssertionError(\n",
        "            \"Нарушено W200(Q_true)=WN(P): \"\n",
        "            f\"max residual={max_energy_error:.3e} м²/с².\"\n",
        "        )\n",
        "\n",
        "    rng = np.random.default_rng(noise_seed)\n",
        "\n",
        "    # Наблюдаемая нормальная высота геометрического нивелирования.\n",
        "    H_leveling_obs = (\n",
        "        H_normal_true\n",
        "        + float(leveling_common_bias_m)\n",
        "        + _noise(rng, sigma_H_leveling_m, n, add_noise)\n",
        "    )\n",
        "    # Нормальная высота может быть нулевой или отрицательной; требуется только\n",
        "    # физически допустимый положительный радиус соответствующей точки Q_obs.\n",
        "    r_Q_obs = float(R_sphere) + H_leveling_obs\n",
        "    if np.any(~np.isfinite(r_Q_obs)) or np.any(r_Q_obs <= 0.0):\n",
        "        raise ValueError(\n",
        "            \"После внесения ошибок получены неположительные радиусы Q_obs.\"\n",
        "        )\n",
        "    W_200_Q_obs = _potential_at_radius(\n",
        "        shcs, nmax_norm, lat, lon, r_Q_obs\n",
        "    )\n",
        "\n",
        "    # Наблюдения трансформант относятся к фиксированной поверхности P.\n",
        "    zeta_obs = zeta_true + _noise(\n",
        "        rng, sigma_zeta_m, n, add_noise\n",
        "    )\n",
        "    eps_dg_si = _noise(\n",
        "        rng, sigma_dg_mgal, n, add_noise\n",
        "    ) / MGAL\n",
        "    dg_obs = dg_P_clean + eps_dg_si\n",
        "    g_full_obs = g_full_P_clean + eps_dg_si\n",
        "\n",
        "    xi_clean, eta_clean = deflections(\n",
        "        grad_full_P - grad_200_P, g_200_P\n",
        "    )\n",
        "    xi_obs = xi_clean + _noise(\n",
        "        rng, sigma_defl_arcsec, n, add_noise\n",
        "    ) * ARCSEC\n",
        "    eta_obs = eta_clean + _noise(\n",
        "        rng, sigma_defl_arcsec, n, add_noise\n",
        "    ) * ARCSEC\n",
        "\n",
        "    result = pd.DataFrame({\n",
        "        \"Latitude (deg)\": lat_deg,\n",
        "        \"Longitude (deg)\": lon_deg,\n",
        "\n",
        "        # Сферическая физическая геометрия.\n",
        "        \"H_sph (m)\": H_sph,\n",
        "        \"r_P (m)\": r_P,\n",
        "        \"zeta_initial (m)\": zeta_initial,\n",
        "        \"zeta_true (m)\": zeta_true,\n",
        "        \"H_normal_true (m)\": H_normal_true,\n",
        "        \"r_Q_true (m)\": r_Q_true,\n",
        "\n",
        "        # Нивелирное наблюдение и соответствующая точка Q_obs.\n",
        "        \"H_leveling_obs (m)\": H_leveling_obs,\n",
        "        \"r_Q_obs (m)\": r_Q_obs,\n",
        "\n",
        "        # Потенциалы согласованной конструкции.\n",
        "        \"W_full_P (m2/s2)\": W_full_P,\n",
        "        \"W_200_P (m2/s2)\": W_200_P,\n",
        "        \"W_200_Q_true (m2/s2)\": W_200_Q_true,\n",
        "        \"W_200_Q_obs (m2/s2)\": W_200_Q_obs,\n",
        "        \"energy_consistency_error_true (m2/s2)\": energy_error_true,\n",
        "\n",
        "        # Наблюдения на физической поверхности P.\n",
        "        \"g (m/s2)\": g_full_obs,\n",
        "        \"g_full_clean_P (m/s2)\": g_full_P_clean,\n",
        "        \"g_200_P (m/s2)\": g_200_P,\n",
        "        \"gamma (m/s2)\": g_200_P,\n",
        "        \"dg (m/s2)\": dg_obs,\n",
        "        \"dg_clean_P (m/s2)\": dg_P_clean,\n",
        "        \"zeta_obs (m)\": zeta_obs,\n",
        "        \"xi (rad)\": xi_obs,\n",
        "        \"eta (rad)\": eta_obs,\n",
        "        \"xi (arcsec)\": xi_obs / ARCSEC,\n",
        "        \"eta (arcsec)\": eta_obs / ARCSEC,\n",
        "\n",
        "        # Чистый остаточный потенциал в P.\n",
        "        \"T_P_true_residual (m2/s2)\": T_P_true\n",
        "    })\n",
        "\n",
        "    result.attrs.update({\n",
        "        \"single_terrain_surface\": True,\n",
        "        \"physical_surface_only\": True,\n",
        "        \"etopo_height_convention\": \"H_sph=H_ETOPO1 above reference sphere\",\n",
        "        \"r_P_definition\": \"r_P=R+H_sph\",\n",
        "        \"zeta_sign_convention\": \"standard_Bruns_zeta=T/gamma; H_normal_true=H_sph-zeta_true\",\n",
        "        \"energy_equation\": \"W200(Q_true)=WN(P)\",\n",
        "        \"H_normal_true_definition\": \"H_normal_true=H_sph-zeta_true\",\n",
        "        \"H_leveling_definition\": (\n",
        "            \"H_leveling_obs=H_normal_true+common_bias+random_error\"\n",
        "        ),\n",
        "        \"dC_leveling_definition\": (\n",
        "            \"dClev_i0=W200(Q0_obs)-W200(Qi_obs)\"\n",
        "        ),\n",
        "        \"dC_200_definition\": \"dC200_i0=W200(P0)-W200(Pi)\",\n",
        "        \"energy_solver\": zeta_solver_diagnostics,\n",
        "        \"etopo_tif_path\": etopo_tif_path,\n",
        "        \"grid_size\": grid_size,\n",
        "        \"noise_seed\": int(noise_seed),\n",
        "        \"add_noise\": bool(add_noise),\n",
        "        \"sigma_dg_mgal\": float(sigma_dg_mgal),\n",
        "        \"sigma_zeta_m\": float(sigma_zeta_m),\n",
        "        \"sigma_H_leveling_m\": float(sigma_H_leveling_m),\n",
        "        \"leveling_common_bias_m\": float(leveling_common_bias_m),\n",
        "        \"sigma_defl_arcsec\": float(sigma_defl_arcsec),\n",
        "        \"leveling_spatial_accumulation_model\": None,\n",
        "        \"reference_model_degree\": int(nmax_norm),\n",
        "        \"ml_target_generated_after_lsc\": True,\n",
        "        \"ml_target_quantity\": \"relative_LSC_correction\"\n",
        "    })\n",
        "\n",
        "    print(\n",
        "        f\"Готово: {len(result)} физических точек P; \"\n",
        "        f\"наблюдения зашумлены={bool(add_noise)}.\"\n",
        "    )\n",
        "    print(\n",
        "        \"Энергетическая согласованность: \"\n",
        "        f\"max|W200(Q_true)-WN(P)|={max_energy_error:.3e} м²/с²; \"\n",
        "        f\"итераций={zeta_solver_diagnostics['iterations_used']}.\"\n",
        "    )\n",
        "\n",
        "    # Объект pyharm.Shc не сохраняется в DataFrame.attrs.\n",
        "    return result, shcs\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "5XxtiuWijrQA"
      },
      "source": [
        "# Ковариационная функция Форсберга"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "ihLRHitxjpz-"
      },
      "outputs": [],
      "source": [
        "# =========================================================\n",
        "# Логарифмическая ковариационная функция Форсберга (1987).\n",
        "# Версия для одной поверхности рельефа ETOPO1.\n",
        "#\n",
        "#   C(s, zPQ) = -f * SUM_i a_i * ln(Z_i + sqrt(s^2 + Z_i^2))\n",
        "#   a = [1, -3, 3, -1]\n",
        "#   Z_i = zPQ + D + i*T\n",
        "#\n",
        "# ВАЖНО ДЛЯ ТЕКУЩЕГО ЭТАПА:\n",
        "#   1. Датасет содержит одну поверхность рельефа.\n",
        "#   2. Каждая точка имеет собственную высоту H (m).\n",
        "#   3. Фильтрация по одному значению H больше не выполняется.\n",
        "#   4. При подгонке D и T пока сохраняется приближение zPQ = 0.\n",
        "#      То есть параметры модели оцениваются по горизонтальным\n",
        "#      расстояниям без учёта различий высот отдельных пар.\n",
        "#   5. Индивидуальные высоты далее учитываются в ковариационных\n",
        "#      матрицах LSC через H_P и H_Q.\n",
        "#\n",
        "# ЗАЩИТА ОТ ВЫРОЖДЕНИЯ:\n",
        "#   T ищется как tau*D, tau >= 0.15.\n",
        "# =========================================================\n",
        "\n",
        "import numpy as np\n",
        "from scipy.optimize import least_squares\n",
        "\n",
        "\n",
        "A_COEF = np.array(\n",
        "    [1.0, -3.0, 3.0, -1.0],\n",
        "    dtype=float,\n",
        ")\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# Базовая логарифмическая ковариационная функция\n",
        "# =========================================================\n",
        "\n",
        "def forsberg_cov(\n",
        "    s,\n",
        "    C0,\n",
        "    D,\n",
        "    T,\n",
        "    zPQ=0.0,\n",
        "):\n",
        "    \"\"\"\n",
        "    Логарифмическая ковариационная функция Форсберга.\n",
        "\n",
        "    Parameters\n",
        "    ----------\n",
        "    s : array-like\n",
        "        Горизонтальное расстояние между точками, м.\n",
        "\n",
        "    C0 : float\n",
        "        Нормирующая дисперсия.\n",
        "\n",
        "    D : float\n",
        "        Глубина верхнего эквивалентного слоя, м.\n",
        "\n",
        "    T : float\n",
        "        Расстояние между эквивалентными слоями, м.\n",
        "\n",
        "    zPQ : float or array-like\n",
        "        Сумма вертикальных координат точек P и Q\n",
        "        в принятой системе координат.\n",
        "    \"\"\"\n",
        "\n",
        "    s = np.asarray(\n",
        "        s,\n",
        "        dtype=float,\n",
        "    )\n",
        "\n",
        "    zPQ = np.asarray(\n",
        "        zPQ,\n",
        "        dtype=float,\n",
        "    )\n",
        "\n",
        "    if D <= 0.0:\n",
        "        raise ValueError(\n",
        "            \"Параметр D должен быть положительным.\"\n",
        "        )\n",
        "\n",
        "    if T <= 0.0:\n",
        "        raise ValueError(\n",
        "            \"Параметр T должен быть положительным.\"\n",
        "        )\n",
        "\n",
        "    denominator = np.log(\n",
        "        (\n",
        "            (D + T) ** 3\n",
        "            * (D + 3.0 * T)\n",
        "        )\n",
        "        /\n",
        "        (\n",
        "            D\n",
        "            * (D + 2.0 * T) ** 3\n",
        "        )\n",
        "    )\n",
        "\n",
        "    if not np.isfinite(denominator):\n",
        "        raise ValueError(\n",
        "            \"Получена некорректная нормировка \"\n",
        "            \"ковариационной функции.\"\n",
        "        )\n",
        "\n",
        "    if abs(denominator) < 1e-20:\n",
        "        raise ValueError(\n",
        "            \"Вырожденная нормировка модели Форсберга.\"\n",
        "        )\n",
        "\n",
        "    f = C0 / denominator\n",
        "\n",
        "    acc = np.zeros(\n",
        "        np.broadcast_shapes(\n",
        "            s.shape,\n",
        "            zPQ.shape,\n",
        "        ),\n",
        "        dtype=float,\n",
        "    )\n",
        "\n",
        "    s_b, z_b = np.broadcast_arrays(\n",
        "        s,\n",
        "        zPQ,\n",
        "    )\n",
        "\n",
        "    for i in range(4):\n",
        "\n",
        "        Zi = (\n",
        "            z_b\n",
        "            + D\n",
        "            + i * T\n",
        "        )\n",
        "\n",
        "        Ri = np.sqrt(\n",
        "            s_b**2\n",
        "            + Zi**2\n",
        "        )\n",
        "\n",
        "        log_argument = (\n",
        "            Zi\n",
        "            + Ri\n",
        "        )\n",
        "\n",
        "        if np.any(\n",
        "            log_argument <= 0.0\n",
        "        ):\n",
        "            raise ValueError(\n",
        "                \"Некорректный аргумент логарифма \"\n",
        "                \"в модели Форсберга.\"\n",
        "            )\n",
        "\n",
        "        acc += (\n",
        "            A_COEF[i]\n",
        "            * np.log(\n",
        "                log_argument\n",
        "            )\n",
        "        )\n",
        "\n",
        "    return -f * acc\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# Географические координаты -> локальные плоские координаты\n",
        "# =========================================================\n",
        "\n",
        "def latlon_to_xy(\n",
        "    lat_deg,\n",
        "    lon_deg,\n",
        "    R=6371008.8,\n",
        "    lat0=None,\n",
        "    lon0=None,\n",
        "):\n",
        "    \"\"\"\n",
        "    Локальная равнопромежуточная аппроксимация.\n",
        "\n",
        "    X — восточная координата, м.\n",
        "    Y — северная координата, м.\n",
        "    \"\"\"\n",
        "\n",
        "    lat = np.radians(\n",
        "        np.asarray(\n",
        "            lat_deg,\n",
        "            dtype=float,\n",
        "        )\n",
        "    )\n",
        "\n",
        "    lon = np.radians(\n",
        "        np.asarray(\n",
        "            lon_deg,\n",
        "            dtype=float,\n",
        "        )\n",
        "    )\n",
        "\n",
        "    if lat.shape != lon.shape:\n",
        "        raise ValueError(\n",
        "            \"Массивы широт и долгот должны \"\n",
        "            \"иметь одинаковую форму.\"\n",
        "        )\n",
        "\n",
        "    if lat0 is None:\n",
        "        lat0_rad = float(\n",
        "            np.mean(lat)\n",
        "        )\n",
        "    else:\n",
        "        lat0_rad = float(\n",
        "            np.radians(lat0)\n",
        "        )\n",
        "\n",
        "    if lon0 is None:\n",
        "        lon0_rad = float(\n",
        "            np.mean(lon)\n",
        "        )\n",
        "    else:\n",
        "        lon0_rad = float(\n",
        "            np.radians(lon0)\n",
        "        )\n",
        "\n",
        "    X = (\n",
        "        R\n",
        "        * (lon - lon0_rad)\n",
        "        * np.cos(lat0_rad)\n",
        "    )\n",
        "\n",
        "    Y = (\n",
        "        R\n",
        "        * (lat - lat0_rad)\n",
        "    )\n",
        "\n",
        "    return X, Y\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# Эмпирическая ковариационная функция\n",
        "# =========================================================\n",
        "\n",
        "def empirical_covariance(\n",
        "    X,\n",
        "    Y,\n",
        "    G,\n",
        "    max_dist=None,\n",
        "    bin_width=None,\n",
        "    center=True,\n",
        "):\n",
        "    \"\"\"\n",
        "    Эмпирическая ковариация по бинам горизонтального расстояния.\n",
        "\n",
        "    На текущем этапе высоты рельефа при формировании эмпирической\n",
        "    ковариации непосредственно не учитываются. Ковариация считается\n",
        "    стационарной функцией горизонтального расстояния.\n",
        "    \"\"\"\n",
        "\n",
        "    X = np.asarray(\n",
        "        X,\n",
        "        dtype=float,\n",
        "    ).ravel()\n",
        "\n",
        "    Y = np.asarray(\n",
        "        Y,\n",
        "        dtype=float,\n",
        "    ).ravel()\n",
        "\n",
        "    G = np.asarray(\n",
        "        G,\n",
        "        dtype=float,\n",
        "    ).ravel()\n",
        "\n",
        "    if not (\n",
        "        len(X)\n",
        "        == len(Y)\n",
        "        == len(G)\n",
        "    ):\n",
        "        raise ValueError(\n",
        "            \"X, Y и G должны иметь одинаковую длину.\"\n",
        "        )\n",
        "\n",
        "    finite = (\n",
        "        np.isfinite(X)\n",
        "        & np.isfinite(Y)\n",
        "        & np.isfinite(G)\n",
        "    )\n",
        "\n",
        "    X = X[finite]\n",
        "    Y = Y[finite]\n",
        "    G = G[finite]\n",
        "\n",
        "    n = len(G)\n",
        "\n",
        "    if n < 3:\n",
        "        raise ValueError(\n",
        "            \"Для оценки эмпирической ковариации \"\n",
        "            \"нужно не менее трёх точек.\"\n",
        "        )\n",
        "\n",
        "    if center:\n",
        "        G = (\n",
        "            G\n",
        "            - np.mean(G)\n",
        "        )\n",
        "\n",
        "    C0_emp = float(\n",
        "        np.mean(\n",
        "            G**2\n",
        "        )\n",
        "    )\n",
        "\n",
        "    if not np.isfinite(C0_emp):\n",
        "        raise ValueError(\n",
        "            \"Не удалось вычислить эмпирическую дисперсию.\"\n",
        "        )\n",
        "\n",
        "    if C0_emp <= 0.0:\n",
        "        raise ValueError(\n",
        "            \"Эмпирическая дисперсия должна быть положительной.\"\n",
        "        )\n",
        "\n",
        "    smax = np.sqrt(\n",
        "        (\n",
        "            X.max()\n",
        "            - X.min()\n",
        "        ) ** 2\n",
        "        +\n",
        "        (\n",
        "            Y.max()\n",
        "            - Y.min()\n",
        "        ) ** 2\n",
        "    )\n",
        "\n",
        "    if smax <= 0.0:\n",
        "        raise ValueError(\n",
        "            \"Все точки имеют одинаковые координаты.\"\n",
        "        )\n",
        "\n",
        "    if bin_width is None:\n",
        "\n",
        "        bin_width = np.sqrt(\n",
        "            (\n",
        "                2.0\n",
        "                * np.pi\n",
        "                * (smax / 2.0) ** 2\n",
        "            )\n",
        "            / n\n",
        "        )\n",
        "\n",
        "    if max_dist is None:\n",
        "        max_dist = (\n",
        "            smax\n",
        "            / 2.0\n",
        "        )\n",
        "\n",
        "    if bin_width <= 0.0:\n",
        "        raise ValueError(\n",
        "            \"Ширина бина должна быть положительной.\"\n",
        "        )\n",
        "\n",
        "    if max_dist <= 0.0:\n",
        "        raise ValueError(\n",
        "            \"Максимальное расстояние должно быть положительным.\"\n",
        "        )\n",
        "\n",
        "    iu, ju = np.triu_indices(\n",
        "        n,\n",
        "        k=1,\n",
        "    )\n",
        "\n",
        "    s = np.sqrt(\n",
        "        (\n",
        "            X[iu]\n",
        "            - X[ju]\n",
        "        ) ** 2\n",
        "        +\n",
        "        (\n",
        "            Y[iu]\n",
        "            - Y[ju]\n",
        "        ) ** 2\n",
        "    )\n",
        "\n",
        "    products = (\n",
        "        G[iu]\n",
        "        * G[ju]\n",
        "    )\n",
        "\n",
        "    mask = (\n",
        "        np.isfinite(s)\n",
        "        & np.isfinite(products)\n",
        "        & (s < max_dist)\n",
        "    )\n",
        "\n",
        "    s = s[mask]\n",
        "    products = products[mask]\n",
        "\n",
        "    if len(s) == 0:\n",
        "        raise ValueError(\n",
        "            \"После ограничения расстояния \"\n",
        "            \"не осталось пар точек.\"\n",
        "        )\n",
        "\n",
        "    nbins = int(\n",
        "        np.ceil(\n",
        "            max_dist\n",
        "            / bin_width\n",
        "        )\n",
        "    )\n",
        "\n",
        "    nbins = max(\n",
        "        nbins,\n",
        "        1,\n",
        "    )\n",
        "\n",
        "    indices = np.floor(\n",
        "        s\n",
        "        / bin_width\n",
        "    ).astype(int)\n",
        "\n",
        "    indices = np.clip(\n",
        "        indices,\n",
        "        0,\n",
        "        nbins - 1,\n",
        "    )\n",
        "\n",
        "    covariance_sum = np.bincount(\n",
        "        indices,\n",
        "        weights=products,\n",
        "        minlength=nbins,\n",
        "    )[:nbins]\n",
        "\n",
        "    pair_count = np.bincount(\n",
        "        indices,\n",
        "        minlength=nbins,\n",
        "    )[:nbins]\n",
        "\n",
        "    good = (\n",
        "        pair_count > 0\n",
        "    )\n",
        "\n",
        "    centers = (\n",
        "        np.arange(nbins)\n",
        "        + 0.5\n",
        "    ) * bin_width\n",
        "\n",
        "    covariance = np.zeros(\n",
        "        nbins,\n",
        "        dtype=float,\n",
        "    )\n",
        "\n",
        "    covariance[good] = (\n",
        "        covariance_sum[good]\n",
        "        / pair_count[good]\n",
        "    )\n",
        "\n",
        "    return (\n",
        "        centers[good],\n",
        "        covariance[good],\n",
        "        pair_count[good],\n",
        "        C0_emp,\n",
        "    )\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# Подгонка параметров модели Форсберга\n",
        "# =========================================================\n",
        "\n",
        "def fit_forsberg(\n",
        "    dist,\n",
        "    cov_emp,\n",
        "    counts,\n",
        "    C0_emp,\n",
        "    D_range=(200.0, 100000.0),\n",
        "    tau_range=(0.15, 5.0),\n",
        "    n_grid=30,\n",
        "    use_weights=True,\n",
        "    anchor_C0=True,\n",
        "    zPQ_fit=0.0,\n",
        "):\n",
        "    \"\"\"\n",
        "    Подгонка параметров D и tau = T/D.\n",
        "\n",
        "    C0 фиксируется равным эмпирической дисперсии.\n",
        "\n",
        "    zPQ_fit пока равно нулю. Это промежуточное приближение:\n",
        "    рельеф учитывается при последующем формировании ковариационных\n",
        "    матриц LSC, но пока не учитывается при оценке D и T.\n",
        "    \"\"\"\n",
        "\n",
        "    dist = np.asarray(\n",
        "        dist,\n",
        "        dtype=float,\n",
        "    )\n",
        "\n",
        "    cov_emp = np.asarray(\n",
        "        cov_emp,\n",
        "        dtype=float,\n",
        "    )\n",
        "\n",
        "    counts = np.asarray(\n",
        "        counts,\n",
        "        dtype=float,\n",
        "    )\n",
        "\n",
        "    if not (\n",
        "        len(dist)\n",
        "        == len(cov_emp)\n",
        "        == len(counts)\n",
        "    ):\n",
        "        raise ValueError(\n",
        "            \"dist, cov_emp и counts должны \"\n",
        "            \"иметь одинаковую длину.\"\n",
        "        )\n",
        "\n",
        "    finite = (\n",
        "        np.isfinite(dist)\n",
        "        & np.isfinite(cov_emp)\n",
        "        & np.isfinite(counts)\n",
        "        & (counts > 0)\n",
        "    )\n",
        "\n",
        "    dist_fit = dist[finite]\n",
        "    cov_fit = cov_emp[finite]\n",
        "    counts_fit = counts[finite]\n",
        "\n",
        "    if len(dist_fit) < 2:\n",
        "        raise ValueError(\n",
        "            \"Недостаточно бинов для подгонки \"\n",
        "            \"ковариационной модели.\"\n",
        "        )\n",
        "\n",
        "    if use_weights:\n",
        "\n",
        "        weights = np.sqrt(\n",
        "            counts_fit\n",
        "        )\n",
        "\n",
        "    else:\n",
        "\n",
        "        weights = np.ones_like(\n",
        "            dist_fit\n",
        "        )\n",
        "\n",
        "    if anchor_C0:\n",
        "\n",
        "        dist_fit = np.concatenate(\n",
        "            [\n",
        "                [0.0],\n",
        "                dist_fit,\n",
        "            ]\n",
        "        )\n",
        "\n",
        "        cov_fit = np.concatenate(\n",
        "            [\n",
        "                [C0_emp],\n",
        "                cov_fit,\n",
        "            ]\n",
        "        )\n",
        "\n",
        "        weights = np.concatenate(\n",
        "            [\n",
        "                [weights.max()],\n",
        "                weights,\n",
        "            ]\n",
        "        )\n",
        "\n",
        "    weights = (\n",
        "        weights\n",
        "        / weights.max()\n",
        "    )\n",
        "\n",
        "    D_min = float(\n",
        "        D_range[0]\n",
        "    )\n",
        "\n",
        "    D_max = float(\n",
        "        D_range[1]\n",
        "    )\n",
        "\n",
        "    tau_min = float(\n",
        "        tau_range[0]\n",
        "    )\n",
        "\n",
        "    tau_max = float(\n",
        "        tau_range[1]\n",
        "    )\n",
        "\n",
        "    if D_min <= 0.0:\n",
        "        raise ValueError(\n",
        "            \"Нижняя граница D должна быть положительной.\"\n",
        "        )\n",
        "\n",
        "    if D_max <= D_min:\n",
        "        raise ValueError(\n",
        "            \"Верхняя граница D должна быть больше нижней.\"\n",
        "        )\n",
        "\n",
        "    if tau_min <= 0.0:\n",
        "        raise ValueError(\n",
        "            \"Нижняя граница tau должна быть положительной.\"\n",
        "        )\n",
        "\n",
        "    if tau_max <= tau_min:\n",
        "        raise ValueError(\n",
        "            \"Верхняя граница tau должна быть больше нижней.\"\n",
        "        )\n",
        "\n",
        "    def residual(parameters):\n",
        "\n",
        "        D, tau = parameters\n",
        "        T = (\n",
        "            tau\n",
        "            * D\n",
        "        )\n",
        "\n",
        "        if (\n",
        "            D <= 0.0\n",
        "            or T <= 0.0\n",
        "        ):\n",
        "            return np.full_like(\n",
        "                cov_fit,\n",
        "                1e9,\n",
        "            )\n",
        "\n",
        "        try:\n",
        "\n",
        "            with np.errstate(\n",
        "                invalid=\"ignore\",\n",
        "                divide=\"ignore\",\n",
        "                over=\"ignore\",\n",
        "            ):\n",
        "\n",
        "                model = forsberg_cov(\n",
        "                    dist_fit,\n",
        "                    C0_emp,\n",
        "                    D,\n",
        "                    T,\n",
        "                    zPQ=zPQ_fit,\n",
        "                )\n",
        "\n",
        "        except (\n",
        "            ValueError,\n",
        "            FloatingPointError,\n",
        "        ):\n",
        "\n",
        "            return np.full_like(\n",
        "                cov_fit,\n",
        "                1e9,\n",
        "            )\n",
        "\n",
        "        if not np.all(\n",
        "            np.isfinite(model)\n",
        "        ):\n",
        "\n",
        "            return np.full_like(\n",
        "                cov_fit,\n",
        "                1e9,\n",
        "            )\n",
        "\n",
        "        return (\n",
        "            weights\n",
        "            * (\n",
        "                model\n",
        "                - cov_fit\n",
        "            )\n",
        "        )\n",
        "\n",
        "    D_grid = np.logspace(\n",
        "        np.log10(D_min),\n",
        "        np.log10(D_max),\n",
        "        int(n_grid),\n",
        "    )\n",
        "\n",
        "    tau_grid = np.linspace(\n",
        "        tau_min,\n",
        "        tau_max,\n",
        "        int(n_grid),\n",
        "    )\n",
        "\n",
        "    best_parameters = None\n",
        "    best_error = np.inf\n",
        "\n",
        "    for D in D_grid:\n",
        "\n",
        "        for tau in tau_grid:\n",
        "\n",
        "            current_residual = residual(\n",
        "                [D, tau]\n",
        "            )\n",
        "\n",
        "            current_error = float(\n",
        "                np.sum(\n",
        "                    current_residual**2\n",
        "                )\n",
        "            )\n",
        "\n",
        "            if (\n",
        "                np.isfinite(current_error)\n",
        "                and current_error < best_error\n",
        "            ):\n",
        "\n",
        "                best_error = current_error\n",
        "                best_parameters = (\n",
        "                    D,\n",
        "                    tau,\n",
        "                )\n",
        "\n",
        "    if best_parameters is None:\n",
        "        raise RuntimeError(\n",
        "            \"Не удалось найти начальное приближение \"\n",
        "            \"для параметров Форсберга.\"\n",
        "        )\n",
        "\n",
        "    solution = least_squares(\n",
        "        residual,\n",
        "        x0=np.asarray(\n",
        "            best_parameters,\n",
        "            dtype=float,\n",
        "        ),\n",
        "        bounds=(\n",
        "            [\n",
        "                D_min,\n",
        "                tau_min,\n",
        "            ],\n",
        "            [\n",
        "                D_max,\n",
        "                tau_max,\n",
        "            ],\n",
        "        ),\n",
        "        method=\"trf\",\n",
        "    )\n",
        "\n",
        "    D_fit = float(\n",
        "        solution.x[0]\n",
        "    )\n",
        "\n",
        "    tau_fit = float(\n",
        "        solution.x[1]\n",
        "    )\n",
        "\n",
        "    T_fit = (\n",
        "        tau_fit\n",
        "        * D_fit\n",
        "    )\n",
        "\n",
        "    Z0 = (\n",
        "        D_fit\n",
        "        + np.arange(4)\n",
        "        * T_fit\n",
        "    )\n",
        "\n",
        "    kernel0 = float(\n",
        "        np.sum(\n",
        "            A_COEF\n",
        "            / Z0**2\n",
        "        )\n",
        "    )\n",
        "\n",
        "    model_bins = forsberg_cov(\n",
        "        dist,\n",
        "        C0_emp,\n",
        "        D_fit,\n",
        "        T_fit,\n",
        "        zPQ=zPQ_fit,\n",
        "    )\n",
        "\n",
        "    residual_sum = float(\n",
        "        np.sum(\n",
        "            (\n",
        "                model_bins\n",
        "                - cov_emp\n",
        "            ) ** 2\n",
        "        )\n",
        "    )\n",
        "\n",
        "    total_sum = float(\n",
        "        np.sum(\n",
        "            (\n",
        "                cov_emp\n",
        "                - np.mean(cov_emp)\n",
        "            ) ** 2\n",
        "        )\n",
        "    )\n",
        "\n",
        "    if total_sum > 0.0:\n",
        "\n",
        "        r2 = (\n",
        "            1.0\n",
        "            - residual_sum\n",
        "            / total_sum\n",
        "        )\n",
        "\n",
        "    else:\n",
        "\n",
        "        r2 = np.nan\n",
        "\n",
        "    return {\n",
        "        \"C0\": float(C0_emp),\n",
        "        \"D\": D_fit,\n",
        "        \"T\": T_fit,\n",
        "        \"tau\": tau_fit,\n",
        "        \"kernel0\": kernel0,\n",
        "        \"r2\": float(r2),\n",
        "        \"converged\": bool(\n",
        "            solution.success\n",
        "        ),\n",
        "        \"message\": str(\n",
        "            solution.message\n",
        "        ),\n",
        "        \"dist\": np.asarray(\n",
        "            dist,\n",
        "            dtype=float,\n",
        "        ),\n",
        "        \"cov_emp\": np.asarray(\n",
        "            cov_emp,\n",
        "            dtype=float,\n",
        "        ),\n",
        "        \"cov_model\": np.asarray(\n",
        "            model_bins,\n",
        "            dtype=float,\n",
        "        ),\n",
        "        \"counts\": np.asarray(\n",
        "            counts,\n",
        "            dtype=int,\n",
        "        ),\n",
        "        \"zPQ_fit\": float(\n",
        "            np.asarray(zPQ_fit)\n",
        "        ),\n",
        "    }\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# Определение типа датасета\n",
        "# =========================================================\n",
        "\n",
        "def _is_single_terrain_surface(\n",
        "    df,\n",
        "):\n",
        "    \"\"\"\n",
        "    Проверяет, является ли датасет одной поверхностью рельефа.\n",
        "\n",
        "    Основной признак — атрибут single_terrain_surface.\n",
        "    Дополнительный признак — большое число различных высот H.\n",
        "    \"\"\"\n",
        "\n",
        "    if bool(\n",
        "        df.attrs.get(\n",
        "            \"single_terrain_surface\",\n",
        "            False,\n",
        "        )\n",
        "    ):\n",
        "        return True\n",
        "\n",
        "    if \"H (m)\" not in df.columns:\n",
        "        return False\n",
        "\n",
        "    H = np.asarray(\n",
        "        df[\"H (m)\"],\n",
        "        dtype=float,\n",
        "    )\n",
        "\n",
        "    H = H[\n",
        "        np.isfinite(H)\n",
        "    ]\n",
        "\n",
        "    if len(H) == 0:\n",
        "        return False\n",
        "\n",
        "    unique_count = len(\n",
        "        np.unique(\n",
        "            np.round(\n",
        "                H,\n",
        "                decimals=6,\n",
        "            )\n",
        "        )\n",
        "    )\n",
        "\n",
        "    # Старый датасет обычно имел несколько фиксированных слоёв.\n",
        "    # Реальный рельеф содержит большое число различных высот.\n",
        "    return (\n",
        "        unique_count > 8\n",
        "    )\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# Подгонка модели по датасету\n",
        "# =========================================================\n",
        "\n",
        "def fit_forsberg_from_dataset(\n",
        "    df,\n",
        "    value_col=\"dg (m/s2)\",\n",
        "    height_layer=None,\n",
        "    to_mgal=True,\n",
        "    tau_range=(0.15, 5.0),\n",
        "    D_range=(200.0, 100000.0),\n",
        "    max_dist=None,\n",
        "    bin_width=None,\n",
        "):\n",
        "    \"\"\"\n",
        "    Подгонка модели Форсберга по данным DataFrame.\n",
        "\n",
        "    Новый датасет ETOPO1\n",
        "    --------------------\n",
        "    Используются все строки. Высота каждой точки индивидуальна.\n",
        "    Аргумент height_layer принимается, но игнорируется.\n",
        "\n",
        "    Старый многослойный датасет\n",
        "    ---------------------------\n",
        "    Сохраняется возможность выбрать один постоянный слой.\n",
        "\n",
        "    Важно\n",
        "    -----\n",
        "    При оценке D и T пока используется zPQ=0. Индивидуальные\n",
        "    высоты учитываются в последующих ковариационных матрицах LSC.\n",
        "    \"\"\"\n",
        "\n",
        "    if value_col not in df.columns:\n",
        "        raise KeyError(\n",
        "            f\"В датасете отсутствует столбец {value_col!r}.\"\n",
        "        )\n",
        "\n",
        "    terrain_surface = _is_single_terrain_surface(\n",
        "        df\n",
        "    )\n",
        "\n",
        "    if terrain_surface:\n",
        "\n",
        "        # Новый датасет: одна реальная поверхность.\n",
        "        d = df.copy()\n",
        "\n",
        "        H_used = np.nan\n",
        "\n",
        "    elif \"H (m)\" in df.columns:\n",
        "\n",
        "        # Совместимость со старым многослойным датасетом.\n",
        "        if height_layer is None:\n",
        "\n",
        "            H_used = float(\n",
        "                df[\"H (m)\"].min()\n",
        "            )\n",
        "\n",
        "        else:\n",
        "\n",
        "            H_used = float(\n",
        "                height_layer\n",
        "            )\n",
        "\n",
        "        mask = np.isclose(\n",
        "            df[\"H (m)\"].to_numpy(\n",
        "                dtype=float\n",
        "            ),\n",
        "            H_used,\n",
        "            rtol=0.0,\n",
        "            atol=1e-8,\n",
        "        )\n",
        "\n",
        "        d = df.loc[\n",
        "            mask\n",
        "        ].copy()\n",
        "\n",
        "    else:\n",
        "\n",
        "        H_used = 0.0\n",
        "        d = df.copy()\n",
        "\n",
        "    required_columns = [\n",
        "        \"Latitude (deg)\",\n",
        "        \"Longitude (deg)\",\n",
        "        value_col,\n",
        "    ]\n",
        "\n",
        "    missing_columns = [\n",
        "        column\n",
        "        for column in required_columns\n",
        "        if column not in d.columns\n",
        "    ]\n",
        "\n",
        "    if missing_columns:\n",
        "        raise KeyError(\n",
        "            \"В датасете отсутствуют столбцы: \"\n",
        "            + \", \".join(\n",
        "                missing_columns\n",
        "            )\n",
        "        )\n",
        "\n",
        "    if len(d) < 3:\n",
        "\n",
        "        raise ValueError(\n",
        "            \"После выбора данных осталось менее трёх точек. \"\n",
        "            \"Для датасета рельефа нельзя выбирать строки \"\n",
        "            \"по одному постоянному значению H.\"\n",
        "        )\n",
        "\n",
        "    values = d[\n",
        "        value_col\n",
        "    ].to_numpy(\n",
        "        dtype=float\n",
        "    )\n",
        "\n",
        "    latitude = d[\n",
        "        \"Latitude (deg)\"\n",
        "    ].to_numpy(\n",
        "        dtype=float\n",
        "    )\n",
        "\n",
        "    longitude = d[\n",
        "        \"Longitude (deg)\"\n",
        "    ].to_numpy(\n",
        "        dtype=float\n",
        "    )\n",
        "\n",
        "    finite = (\n",
        "        np.isfinite(values)\n",
        "        & np.isfinite(latitude)\n",
        "        & np.isfinite(longitude)\n",
        "    )\n",
        "\n",
        "    if \"H (m)\" in d.columns:\n",
        "\n",
        "        height = d[\n",
        "            \"H (m)\"\n",
        "        ].to_numpy(\n",
        "            dtype=float\n",
        "        )\n",
        "\n",
        "        finite &= np.isfinite(\n",
        "            height\n",
        "        )\n",
        "\n",
        "    else:\n",
        "\n",
        "        height = np.zeros_like(\n",
        "            values\n",
        "        )\n",
        "\n",
        "    values = values[finite]\n",
        "    latitude = latitude[finite]\n",
        "    longitude = longitude[finite]\n",
        "    height = height[finite]\n",
        "\n",
        "    if len(values) < 3:\n",
        "\n",
        "        raise ValueError(\n",
        "            \"После удаления некорректных значений \"\n",
        "            \"осталось менее трёх точек.\"\n",
        "        )\n",
        "\n",
        "    if to_mgal:\n",
        "\n",
        "        values = (\n",
        "            values\n",
        "            * 1e5\n",
        "        )\n",
        "\n",
        "    X, Y = latlon_to_xy(\n",
        "        latitude,\n",
        "        longitude,\n",
        "    )\n",
        "\n",
        "    # -----------------------------------------------------\n",
        "    # Диагностика доли линейного тренда.\n",
        "    # Тренд здесь не вычитается: только оценивается его доля.\n",
        "    # -----------------------------------------------------\n",
        "\n",
        "    design = np.column_stack(\n",
        "        [\n",
        "            np.ones_like(X),\n",
        "            X,\n",
        "            Y,\n",
        "        ]\n",
        "    )\n",
        "\n",
        "    coefficients, *_ = np.linalg.lstsq(\n",
        "        design,\n",
        "        values,\n",
        "        rcond=None,\n",
        "    )\n",
        "\n",
        "    variance = float(\n",
        "        np.var(values)\n",
        "    )\n",
        "\n",
        "    if variance > 0.0:\n",
        "\n",
        "        trend_share = (\n",
        "            1.0\n",
        "            - np.var(\n",
        "                values\n",
        "                - design\n",
        "                @ coefficients\n",
        "            )\n",
        "            / variance\n",
        "        )\n",
        "\n",
        "    else:\n",
        "\n",
        "        trend_share = 0.0\n",
        "\n",
        "    (\n",
        "        dist,\n",
        "        cov_emp,\n",
        "        counts,\n",
        "        C0_emp,\n",
        "    ) = empirical_covariance(\n",
        "        X,\n",
        "        Y,\n",
        "        values,\n",
        "        max_dist=max_dist,\n",
        "        bin_width=bin_width,\n",
        "        center=True,\n",
        "    )\n",
        "\n",
        "    result = fit_forsberg(\n",
        "        dist,\n",
        "        cov_emp,\n",
        "        counts,\n",
        "        C0_emp,\n",
        "        D_range=D_range,\n",
        "        tau_range=tau_range,\n",
        "        zPQ_fit=0.0,\n",
        "    )\n",
        "\n",
        "    result[\"H_layer\"] = float(\n",
        "        H_used\n",
        "    )\n",
        "\n",
        "    result[\"H_mean\"] = float(\n",
        "        np.mean(height)\n",
        "    )\n",
        "\n",
        "    result[\"H_min\"] = float(\n",
        "        np.min(height)\n",
        "    )\n",
        "\n",
        "    result[\"H_max\"] = float(\n",
        "        np.max(height)\n",
        "    )\n",
        "\n",
        "    result[\"H_std\"] = float(\n",
        "        np.std(height)\n",
        "    )\n",
        "\n",
        "    result[\"std_data\"] = float(\n",
        "        np.std(values)\n",
        "    )\n",
        "\n",
        "    result[\"trend_share\"] = float(\n",
        "        trend_share\n",
        "    )\n",
        "\n",
        "    result[\"terrain_surface\"] = bool(\n",
        "        terrain_surface\n",
        "    )\n",
        "\n",
        "    result[\"height_layer_ignored\"] = bool(\n",
        "        terrain_surface\n",
        "        and height_layer is not None\n",
        "    )\n",
        "\n",
        "    result[\"height_fit_mode\"] = (\n",
        "        \"terrain_surface_zPQ_zero_approximation\"\n",
        "        if terrain_surface\n",
        "        else \"constant_height_layer\"\n",
        "    )\n",
        "\n",
        "    return result"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "wakspkUnsOWz"
      },
      "source": [
        "# Согласованные кросс-ковариации"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "7UBaMenosTCg"
      },
      "outputs": [],
      "source": [
        "# =========================================================\n",
        "# СОГЛАСОВАННЫЕ КОВАРИАЦИИ из модели Форсберга\n",
        "# Версия для одной поверхности рельефа ETOPO1.\n",
        "#\n",
        "# H_P, H_Q — радиальные высоты точек над опорным радиусом R, м.\n",
        "# Сохраняется принятое соглашение z=-H:\n",
        "# Z_i = D + i*T - H_P - H_Q.\n",
        "#\n",
        "# C0_dg задаёт C_dg,dg(0) на опорном уровне H_P=H_Q=0.\n",
        "# Диагональная дисперсия зависит от радиальной высоты.\n",
        "#\n",
        "# ВНИМАНИЕ: физическая корректность ковариаций xi/eta ещё проверяется.\n",
        "# Исправлен предел при s=0: диагонали C_xixi и C_etaeta больше\n",
        "# не обнуляются из-за неопределённого азимута.\n",
        "# =========================================================\n",
        "\n",
        "import numpy as np\n",
        "\n",
        "A_COEF = np.array([1.0, -3.0, 3.0, -1.0], dtype=float)\n",
        "MGAL = 1e5\n",
        "ARCSEC = 4.84813681109536e-6\n",
        "R_EARTH = 6371008.8\n",
        "\n",
        "def _scale_from_dg(C0_dg_mgal2, D, T, strict=True):\n",
        "    \"\"\"Масштаб f по C_dg,dg(0) на уровне H=0.\"\"\"\n",
        "    D, T = float(D), float(T)\n",
        "    if D <= 0 or T <= 0:\n",
        "        raise ValueError(\"D и T должны быть положительными.\")\n",
        "\n",
        "    tau = T / D\n",
        "    Z0 = D + np.arange(4) * T\n",
        "    kernel0 = float(np.sum(A_COEF / Z0**2))\n",
        "\n",
        "    if strict and tau < 0.10:\n",
        "        raise ValueError(\n",
        "            f\"Вырождение Форсберга: T/D={tau:.4f}; \"\n",
        "            f\"D={D/1000:.2f} км, T={T/1000:.3f} км, \"\n",
        "            f\"kernel0={kernel0:.3e}.\"\n",
        "        )\n",
        "\n",
        "    if not np.isfinite(kernel0) or abs(kernel0) < 1e-25:\n",
        "        raise ValueError(f\"Вырожденная нормировка Форсберга: {kernel0:.3e}.\")\n",
        "\n",
        "    return (float(C0_dg_mgal2) / MGAL**2) / kernel0\n",
        "\n",
        "def _Z_levels(D, T, H_P=0.0, H_Q=0.0):\n",
        "    \"\"\"Z_i=D+iT-H_P-H_Q при принятом соглашении z=-H.\"\"\"\n",
        "    H_P = np.asarray(H_P, float)\n",
        "    H_Q = np.asarray(H_Q, float)\n",
        "    Z = -(H_P + H_Q)[..., None] + float(D) + np.arange(4) * float(T)\n",
        "\n",
        "    if np.any(~np.isfinite(Z)):\n",
        "        raise ValueError(\"В Z_i обнаружены NaN или inf.\")\n",
        "\n",
        "    if np.any(Z <= 0):\n",
        "        zmin = float(np.min(Z))\n",
        "        raise ValueError(\n",
        "            f\"Некорректная геометрия Форсберга: min(Z_i)={zmin:.3f} м <= 0. \"\n",
        "            \"При соглашении z=-H должно выполняться D > H_P+H_Q \"\n",
        "            \"для верхнего слоя.\"\n",
        "        )\n",
        "\n",
        "    return Z\n",
        "\n",
        "def _derivs(s, C0_dg, D, T, H_P=0.0, H_Q=0.0):\n",
        "    \"\"\"C_TT, dC/ds и d²C/ds²; расстояния в метрах, результат в СИ.\"\"\"\n",
        "    s = np.asarray(s, float)\n",
        "    f = _scale_from_dg(C0_dg, D, T)\n",
        "    Z = _Z_levels(D, T, H_P, H_Q)\n",
        "\n",
        "    sb = s[..., None]\n",
        "    R = np.sqrt(sb**2 + Z**2)\n",
        "    arg = Z + R\n",
        "\n",
        "    if np.any(arg <= 0):\n",
        "        raise ValueError(\"Неположительный аргумент логарифма в модели Форсберга.\")\n",
        "\n",
        "    C = -f * np.sum(A_COEF * np.log(arg), axis=-1)\n",
        "    C1 = -f * np.sum(A_COEF * sb / (R * arg), axis=-1)\n",
        "\n",
        "    num = R * arg - sb**2 * (Z + 2.0 * R) / R\n",
        "    C2 = -f * np.sum(A_COEF * num / (R * arg)**2, axis=-1)\n",
        "\n",
        "    return C, C1, C2\n",
        "\n",
        "def latlon_to_xy(lat_deg, lon_deg, R=R_EARTH, lat0=None, lon0=None):\n",
        "    \"\"\"Локальные X — восток, Y — север, м.\"\"\"\n",
        "    lat = np.radians(np.asarray(lat_deg, float))\n",
        "    lon = np.radians(np.asarray(lon_deg, float))\n",
        "\n",
        "    lat0 = float(lat.mean()) if lat0 is None else float(np.radians(lat0))\n",
        "    lon0 = float(lon.mean()) if lon0 is None else float(np.radians(lon0))\n",
        "\n",
        "    X = R * (lon - lon0) * np.cos(lat0)\n",
        "    Y = R * (lat - lat0)\n",
        "    return X, Y\n",
        "\n",
        "def geom(Xp, Yp, Xq, Yq):\n",
        "    \"\"\"Матрицы расстояний и направляющих косинусов: север, восток.\"\"\"\n",
        "    Xp, Yp = np.atleast_1d(Xp), np.atleast_1d(Yp)\n",
        "    Xq, Yq = np.atleast_1d(Xq), np.atleast_1d(Yq)\n",
        "\n",
        "    dE = Xp[:, None] - Xq[None, :]\n",
        "    dN = Yp[:, None] - Yq[None, :]\n",
        "    s = np.hypot(dE, dN)\n",
        "\n",
        "    cos_a = np.divide(dN, s, out=np.zeros_like(s), where=s > 0)\n",
        "    sin_a = np.divide(dE, s, out=np.zeros_like(s), where=s > 0)\n",
        "\n",
        "    return s, cos_a, sin_a\n",
        "\n",
        "# =========================================================\n",
        "# 1. ПОТЕНЦИАЛ\n",
        "# =========================================================\n",
        "\n",
        "def cov_T_T(s, C0_dg, D, T, H_P=0.0, H_Q=0.0):\n",
        "    \"\"\"C_TT [(м²/с²)²].\"\"\"\n",
        "    return _derivs(s, C0_dg, D, T, H_P, H_Q)[0]\n",
        "\n",
        "# =========================================================\n",
        "# 2. АНОМАЛИИ СИЛЫ ТЯЖЕСТИ\n",
        "# =========================================================\n",
        "\n",
        "def cov_T_dg(s, C0_dg, D, T, H_P=0.0, H_Q=0.0):\n",
        "    \"\"\"C_T,dg [мГал·м²/с²].\"\"\"\n",
        "    s = np.asarray(s, float)\n",
        "    f = _scale_from_dg(C0_dg, D, T)\n",
        "    Z = _Z_levels(D, T, H_P, H_Q)\n",
        "    R = np.sqrt(s[..., None]**2 + Z**2)\n",
        "\n",
        "    return f * np.sum(A_COEF / R, axis=-1) * MGAL\n",
        "\n",
        "def cov_dg_dg(s, C0_dg, D, T, H_P=0.0, H_Q=0.0):\n",
        "    \"\"\"C_dg,dg [мГал²]; C(0)=C0 только при H_P=H_Q=0.\"\"\"\n",
        "    s = np.asarray(s, float)\n",
        "    f = _scale_from_dg(C0_dg, D, T)\n",
        "    Z = _Z_levels(D, T, H_P, H_Q)\n",
        "    R = np.sqrt(s[..., None]**2 + Z**2)\n",
        "\n",
        "    return f * np.sum(A_COEF * Z / R**3, axis=-1) * MGAL**2\n",
        "\n",
        "# =========================================================\n",
        "# 3. АНОМАЛИИ ВЫСОТЫ\n",
        "# =========================================================\n",
        "\n",
        "def cov_T_zeta(s, C0_dg, D, T, gamma_Q, H_P=0.0, H_Q=0.0):\n",
        "    \"\"\"C_T,zeta [м·м²/с²].\"\"\"\n",
        "    return cov_T_T(s, C0_dg, D, T, H_P, H_Q) / np.asarray(gamma_Q, float)\n",
        "\n",
        "def cov_zeta_zeta(s, C0_dg, D, T, gamma_P, gamma_Q, H_P=0.0, H_Q=0.0):\n",
        "    \"\"\"C_zeta,zeta [м²].\"\"\"\n",
        "    gamma_P = np.asarray(gamma_P, float)\n",
        "    gamma_Q = np.asarray(gamma_Q, float)\n",
        "\n",
        "    return cov_T_T(s, C0_dg, D, T, H_P, H_Q) / (gamma_P * gamma_Q)\n",
        "\n",
        "def cov_zeta_dg(s, C0_dg, D, T, gamma_P, H_P=0.0, H_Q=0.0):\n",
        "    \"\"\"C_zeta,dg [м·мГал], zeta в P, dg в Q.\"\"\"\n",
        "    return cov_T_dg(s, C0_dg, D, T, H_P, H_Q) / np.asarray(gamma_P, float)\n",
        "\n",
        "# =========================================================\n",
        "# 4. УКЛОНЕНИЯ ОТВЕСА\n",
        "# =========================================================\n",
        "\n",
        "def cov_T_xi(s, cos_a, C0_dg, D, T, gamma_Q, H_P=0.0, H_Q=0.0):\n",
        "    \"\"\"C_T,xi [рад·м²/с²].\"\"\"\n",
        "    C1 = _derivs(s, C0_dg, D, T, H_P, H_Q)[1]\n",
        "    return -C1 * np.asarray(cos_a, float) / np.asarray(gamma_Q, float)\n",
        "\n",
        "def cov_T_eta(s, sin_a, C0_dg, D, T, gamma_Q, H_P=0.0, H_Q=0.0):\n",
        "    \"\"\"C_T,eta [рад·м²/с²].\"\"\"\n",
        "    C1 = _derivs(s, C0_dg, D, T, H_P, H_Q)[1]\n",
        "    return -C1 * np.asarray(sin_a, float) / np.asarray(gamma_Q, float)\n",
        "\n",
        "def _C1_over_s(s, C1, C2):\n",
        "    \"\"\"Предел C'(s)/s при s→0 равен C''(0).\"\"\"\n",
        "    s = np.asarray(s, float)\n",
        "    C1 = np.asarray(C1, float)\n",
        "    C2 = np.asarray(C2, float)\n",
        "\n",
        "    return np.divide(C1, s, out=C2.copy(), where=s >= 1e-6)\n",
        "\n",
        "def cov_xi_xi(\n",
        "    s, cos_a, sin_a, C0_dg, D, T,\n",
        "    gamma_P, gamma_Q, H_P=0.0, H_Q=0.0\n",
        "):\n",
        "    \"\"\"C_xi,xi [рад²], включая изотропный предел при s=0.\"\"\"\n",
        "    s = np.asarray(s, float)\n",
        "    _, C1, C2 = _derivs(s, C0_dg, D, T, H_P, H_Q)\n",
        "\n",
        "    cos_a = np.asarray(cos_a, float)\n",
        "    sin_a = np.asarray(sin_a, float)\n",
        "    den = np.asarray(gamma_P, float) * np.asarray(gamma_Q, float)\n",
        "\n",
        "    Cs = _C1_over_s(s, C1, C2)\n",
        "    out = -(C2 * cos_a**2 + Cs * sin_a**2) / den\n",
        "\n",
        "    return np.where(s < 1e-6, -C2 / den, out)\n",
        "\n",
        "def cov_eta_eta(\n",
        "    s, cos_a, sin_a, C0_dg, D, T,\n",
        "    gamma_P, gamma_Q, H_P=0.0, H_Q=0.0\n",
        "):\n",
        "    \"\"\"C_eta,eta [рад²], включая изотропный предел при s=0.\"\"\"\n",
        "    s = np.asarray(s, float)\n",
        "    _, C1, C2 = _derivs(s, C0_dg, D, T, H_P, H_Q)\n",
        "\n",
        "    cos_a = np.asarray(cos_a, float)\n",
        "    sin_a = np.asarray(sin_a, float)\n",
        "    den = np.asarray(gamma_P, float) * np.asarray(gamma_Q, float)\n",
        "\n",
        "    Cs = _C1_over_s(s, C1, C2)\n",
        "    out = -(C2 * sin_a**2 + Cs * cos_a**2) / den\n",
        "\n",
        "    return np.where(s < 1e-6, -C2 / den, out)\n",
        "\n",
        "def cov_xi_eta(\n",
        "    s, cos_a, sin_a, C0_dg, D, T,\n",
        "    gamma_P, gamma_Q, H_P=0.0, H_Q=0.0\n",
        "):\n",
        "    \"\"\"C_xi,eta [рад²]; при s=0 ковариация равна нулю.\"\"\"\n",
        "    s = np.asarray(s, float)\n",
        "    _, C1, C2 = _derivs(s, C0_dg, D, T, H_P, H_Q)\n",
        "\n",
        "    den = np.asarray(gamma_P, float) * np.asarray(gamma_Q, float)\n",
        "    Cs = _C1_over_s(s, C1, C2)\n",
        "\n",
        "    out = (\n",
        "        -(C2 - Cs)\n",
        "        * np.asarray(sin_a, float)\n",
        "        * np.asarray(cos_a, float)\n",
        "        / den\n",
        "    )\n",
        "\n",
        "    return np.where(s < 1e-6, 0.0, out)\n",
        "\n",
        "def _dC_Tdg_ds(s, C0_dg, D, T, H_P=0.0, H_Q=0.0):\n",
        "    \"\"\"dC_T,dg/ds в СИ.\"\"\"\n",
        "    s = np.asarray(s, float)\n",
        "    f = _scale_from_dg(C0_dg, D, T)\n",
        "    Z = _Z_levels(D, T, H_P, H_Q)\n",
        "    R = np.sqrt(s[..., None]**2 + Z**2)\n",
        "\n",
        "    return f * np.sum(A_COEF * (-s[..., None] / R**3), axis=-1)\n",
        "\n",
        "def cov_dg_xi(s, cos_a, C0_dg, D, T, gamma_Q, H_P=0.0, H_Q=0.0):\n",
        "    \"\"\"C_dg,xi [мГал·рад].\"\"\"\n",
        "    return (\n",
        "        _dC_Tdg_ds(s, C0_dg, D, T, H_P, H_Q)\n",
        "        * MGAL\n",
        "        * np.asarray(cos_a, float)\n",
        "        / np.asarray(gamma_Q, float)\n",
        "    )\n",
        "\n",
        "def cov_dg_eta(s, sin_a, C0_dg, D, T, gamma_Q, H_P=0.0, H_Q=0.0):\n",
        "    \"\"\"C_dg,eta [мГал·рад].\"\"\"\n",
        "    return (\n",
        "        _dC_Tdg_ds(s, C0_dg, D, T, H_P, H_Q)\n",
        "        * MGAL\n",
        "        * np.asarray(sin_a, float)\n",
        "        / np.asarray(gamma_Q, float)\n",
        "    )\n",
        "\n",
        "def cov_zeta_xi(\n",
        "    s, cos_a, C0_dg, D, T,\n",
        "    gamma_P, gamma_Q, H_P=0.0, H_Q=0.0\n",
        "):\n",
        "    \"\"\"C_zeta,xi [м·рад].\"\"\"\n",
        "    return (\n",
        "        cov_T_xi(s, cos_a, C0_dg, D, T, gamma_Q, H_P, H_Q)\n",
        "        / np.asarray(gamma_P, float)\n",
        "    )\n",
        "\n",
        "def cov_zeta_eta(\n",
        "    s, sin_a, C0_dg, D, T,\n",
        "    gamma_P, gamma_Q, H_P=0.0, H_Q=0.0\n",
        "):\n",
        "    \"\"\"C_zeta,eta [м·рад].\"\"\"\n",
        "    return (\n",
        "        cov_T_eta(s, sin_a, C0_dg, D, T, gamma_Q, H_P, H_Q)\n",
        "        / np.asarray(gamma_P, float)\n",
        "    )"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "t-E73YDVoZxl"
      },
      "source": [
        "# Датасет 3-го уровня и относительный ML-датасет\n",
        "\n",
        "Уровень 3 содержит наблюдения на физической поверхности $P_i$ и вектор $b$.\n",
        "Положение поверхности задаётся $r_{P_i}=R+H_i^{\\mathrm{sph}}$.\n",
        "\n",
        "Для каждой точки сначала строится абсолютная ковариационная палетка\n",
        "$\\boldsymbol{\\Phi}(P_i)$. После пространственного разделения выбирается одна\n",
        "опорная точка $P_0$ только из train.\n",
        "\n",
        "В ML передаются исключительно разности\n",
        "$\\Delta\\boldsymbol{\\Phi}_{i0}=\\boldsymbol{\\Phi}(P_i)-\\boldsymbol{\\Phi}(P_0)$,\n",
        "а целевой величиной является относительная поправка к LSC, сформированная через\n",
        "$W^{200}(P)$ и $W^{200}(Q^{\\mathrm{obs}})$.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "gbjUGGTzoism"
      },
      "outputs": [],
      "source": [
        "# =========================================================\n",
        "# ДАТАСЕТ 3-го УРОВНЯ И ДАТАСЕТ 4-го УРОВНЯ ДЛЯ ML\n",
        "#\n",
        "# Уровень 3:\n",
        "#   исходные наблюдения + координаты + высоты + gamma + тип + b_i,\n",
        "#   где b = (C_dd + C_nn)^-1 l.\n",
        "#\n",
        "# Уровень 4:\n",
        "#   одна строка на физическую точку P_i;\n",
        "#   одна фиксированная опорная точка P_0 выбирается только из train;\n",
        "#   абсолютная палетка Phi(P_i) сохраняется только для диагностики;\n",
        "#   ML получает только d_Phi = Phi(P_i)-Phi(P_0);\n",
        "#   target — относительная поправка к разности LSC.\n",
        "#\n",
        "# Дополнительно сохраняется JSON с параметрами генерации, порядком\n",
        "# признаков и границами применимости будущей обученной модели.\n",
        "# =========================================================\n",
        "import hashlib\n",
        "import json\n",
        "import warnings\n",
        "from datetime import datetime, timezone\n",
        "from pathlib import Path\n",
        "\n",
        "import numpy as np\n",
        "import pandas as pd\n",
        "from sklearn.model_selection import GroupShuffleSplit, train_test_split\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# ВСПОМОГАТЕЛЬНЫЕ ПРОВЕРКИ И СФЕРИЧЕСКИЕ РАССТОЯНИЯ\n",
        "# =========================================================\n",
        "def _as_1d(x, name):\n",
        "    a = np.asarray(x, dtype=float).ravel()\n",
        "    if not np.all(np.isfinite(a)):\n",
        "        raise ValueError(f\"{name} содержит NaN или inf.\")\n",
        "    return a\n",
        "\n",
        "\n",
        "def _check_same_length(group_name, **arrays):\n",
        "    lengths = {name: len(value) for name, value in arrays.items()}\n",
        "    if len(set(lengths.values())) != 1:\n",
        "        raise ValueError(\n",
        "            f\"Массивы группы {group_name} имеют разную длину: {lengths}.\"\n",
        "        )\n",
        "\n",
        "\n",
        "def _validate_rings(rings_km):\n",
        "    rings = np.asarray(rings_km, dtype=float).ravel()\n",
        "\n",
        "    if rings.size == 0:\n",
        "        raise ValueError(\n",
        "            \"Нужно задать хотя бы одну внешнюю границу кольца.\"\n",
        "        )\n",
        "\n",
        "    if not np.all(np.isfinite(rings)) or np.any(rings <= 0):\n",
        "        raise ValueError(\n",
        "            \"Границы колец должны быть конечными положительными числами.\"\n",
        "        )\n",
        "\n",
        "    if np.any(np.diff(rings) <= 0):\n",
        "        raise ValueError(\n",
        "            \"Границы колец должны строго возрастать.\"\n",
        "        )\n",
        "\n",
        "    return tuple(float(x) for x in rings)\n",
        "\n",
        "\n",
        "def _validate_nonnegative(name, value):\n",
        "    value = float(value)\n",
        "\n",
        "    if not np.isfinite(value) or value < 0:\n",
        "        raise ValueError(\n",
        "            f\"{name} должно быть конечным неотрицательным числом.\"\n",
        "        )\n",
        "\n",
        "    return value\n",
        "\n",
        "\n",
        "def _wrap_delta_lon_rad(delta):\n",
        "    return (\n",
        "        delta + np.pi\n",
        "    ) % (\n",
        "        2.0 * np.pi\n",
        "    ) - np.pi\n",
        "\n",
        "\n",
        "def spherical_distance_m(\n",
        "    lat1_deg,\n",
        "    lon1_deg,\n",
        "    lat2_deg,\n",
        "    lon2_deg,\n",
        "    R=6371008.8\n",
        "):\n",
        "    \"\"\"Сферическое расстояние по большой окружности, м.\"\"\"\n",
        "    lat1 = np.radians(\n",
        "        np.asarray(lat1_deg, dtype=float)\n",
        "    )\n",
        "    lon1 = np.radians(\n",
        "        np.asarray(lon1_deg, dtype=float)\n",
        "    )\n",
        "    lat2 = np.radians(\n",
        "        np.asarray(lat2_deg, dtype=float)\n",
        "    )\n",
        "    lon2 = np.radians(\n",
        "        np.asarray(lon2_deg, dtype=float)\n",
        "    )\n",
        "\n",
        "    dlat = lat2 - lat1\n",
        "    dlon = _wrap_delta_lon_rad(\n",
        "        lon2 - lon1\n",
        "    )\n",
        "\n",
        "    a = (\n",
        "        np.sin(dlat / 2.0) ** 2\n",
        "        + np.cos(lat1)\n",
        "        * np.cos(lat2)\n",
        "        * np.sin(dlon / 2.0) ** 2\n",
        "    )\n",
        "\n",
        "    return (\n",
        "        2.0\n",
        "        * float(R)\n",
        "        * np.arctan2(\n",
        "            np.sqrt(\n",
        "                np.clip(a, 0.0, 1.0)\n",
        "            ),\n",
        "            np.sqrt(\n",
        "                np.clip(1.0 - a, 0.0, 1.0)\n",
        "            )\n",
        "        )\n",
        "    )\n",
        "\n",
        "\n",
        "def pairwise_spherical_distance_m(\n",
        "    lat_p,\n",
        "    lon_p,\n",
        "    lat_q,\n",
        "    lon_q,\n",
        "    R=6371008.8\n",
        "):\n",
        "    \"\"\"Матрица сферических расстояний P x Q, м.\"\"\"\n",
        "    lat_p = np.radians(\n",
        "        _as_1d(lat_p, \"lat_p\")\n",
        "    )[:, None]\n",
        "\n",
        "    lon_p = np.radians(\n",
        "        _as_1d(lon_p, \"lon_p\")\n",
        "    )[:, None]\n",
        "\n",
        "    lat_q = np.radians(\n",
        "        _as_1d(lat_q, \"lat_q\")\n",
        "    )[None, :]\n",
        "\n",
        "    lon_q = np.radians(\n",
        "        _as_1d(lon_q, \"lon_q\")\n",
        "    )[None, :]\n",
        "\n",
        "    dlat = lat_q - lat_p\n",
        "    dlon = _wrap_delta_lon_rad(\n",
        "        lon_q - lon_p\n",
        "    )\n",
        "\n",
        "    a = (\n",
        "        np.sin(dlat / 2.0) ** 2\n",
        "        + np.cos(lat_p)\n",
        "        * np.cos(lat_q)\n",
        "        * np.sin(dlon / 2.0) ** 2\n",
        "    )\n",
        "\n",
        "    return (\n",
        "        2.0\n",
        "        * float(R)\n",
        "        * np.arctan2(\n",
        "            np.sqrt(\n",
        "                np.clip(a, 0.0, 1.0)\n",
        "            ),\n",
        "            np.sqrt(\n",
        "                np.clip(1.0 - a, 0.0, 1.0)\n",
        "            )\n",
        "        )\n",
        "    )\n",
        "\n",
        "\n",
        "def edge_distance(\n",
        "    lat,\n",
        "    lon,\n",
        "    lat_range,\n",
        "    lon_range,\n",
        "    R=6371008.8\n",
        "):\n",
        "    \"\"\"\n",
        "    Приближённое подписанное расстояние до ближайшей границы\n",
        "    прямоугольной области, м.\n",
        "\n",
        "    Внутри области значение положительное, снаружи — отрицательное.\n",
        "    \"\"\"\n",
        "    lat = _as_1d(\n",
        "        lat,\n",
        "        \"lat\"\n",
        "    )\n",
        "\n",
        "    lon = _as_1d(\n",
        "        lon,\n",
        "        \"lon\"\n",
        "    )\n",
        "\n",
        "    lat_min, lat_max = map(\n",
        "        float,\n",
        "        lat_range\n",
        "    )\n",
        "\n",
        "    lon_min, lon_max = map(\n",
        "        float,\n",
        "        lon_range\n",
        "    )\n",
        "\n",
        "    if lat_min >= lat_max or lon_min >= lon_max:\n",
        "        raise ValueError(\n",
        "            \"Некорректные границы области.\"\n",
        "        )\n",
        "\n",
        "    m_lat = (\n",
        "        np.minimum(\n",
        "            lat - lat_min,\n",
        "            lat_max - lat\n",
        "        )\n",
        "        * np.pi\n",
        "        / 180.0\n",
        "        * R\n",
        "    )\n",
        "\n",
        "    cos_lat = np.maximum(\n",
        "        np.cos(\n",
        "            np.radians(lat)\n",
        "        ),\n",
        "        1e-12\n",
        "    )\n",
        "\n",
        "    m_lon = (\n",
        "        np.minimum(\n",
        "            lon - lon_min,\n",
        "            lon_max - lon\n",
        "        )\n",
        "        * np.pi\n",
        "        / 180.0\n",
        "        * R\n",
        "        * cos_lat\n",
        "    )\n",
        "\n",
        "    return np.minimum(\n",
        "        m_lat,\n",
        "        m_lon\n",
        "    )\n",
        "\n",
        "\n",
        "def thin_by_spherical_distance(\n",
        "    lat,\n",
        "    lon,\n",
        "    min_dist_m,\n",
        "    R=6371008.8,\n",
        "    seed=42\n",
        "):\n",
        "    \"\"\"Жадное воспроизводимое прореживание по сферическому расстоянию.\"\"\"\n",
        "    lat = _as_1d(\n",
        "        lat,\n",
        "        \"lat\"\n",
        "    )\n",
        "\n",
        "    lon = _as_1d(\n",
        "        lon,\n",
        "        \"lon\"\n",
        "    )\n",
        "\n",
        "    _check_same_length(\n",
        "        \"target\",\n",
        "        lat=lat,\n",
        "        lon=lon\n",
        "    )\n",
        "\n",
        "    min_dist_m = _validate_nonnegative(\n",
        "        \"min_dist_m\",\n",
        "        min_dist_m\n",
        "    )\n",
        "\n",
        "    if min_dist_m == 0 or len(lat) <= 1:\n",
        "        return np.arange(\n",
        "            len(lat),\n",
        "            dtype=int\n",
        "        )\n",
        "\n",
        "    rng = np.random.default_rng(\n",
        "        seed\n",
        "    )\n",
        "\n",
        "    kept = []\n",
        "\n",
        "    for i in rng.permutation(\n",
        "        len(lat)\n",
        "    ):\n",
        "        if not kept:\n",
        "            kept.append(\n",
        "                int(i)\n",
        "            )\n",
        "            continue\n",
        "\n",
        "        kept_array = np.asarray(\n",
        "            kept,\n",
        "            dtype=int\n",
        "        )\n",
        "\n",
        "        s = spherical_distance_m(\n",
        "            lat[i],\n",
        "            lon[i],\n",
        "            lat[kept_array],\n",
        "            lon[kept_array],\n",
        "            R\n",
        "        )\n",
        "\n",
        "        if np.min(s) >= min_dist_m:\n",
        "            kept.append(\n",
        "                int(i)\n",
        "            )\n",
        "\n",
        "    return np.sort(\n",
        "        np.asarray(\n",
        "            kept,\n",
        "            dtype=int\n",
        "        )\n",
        "    )\n",
        "\n",
        "\n",
        "def select_target_points(\n",
        "    lat,\n",
        "    lon,\n",
        "    lat_range,\n",
        "    lon_range,\n",
        "    edge_buffer_km=0.0,\n",
        "    min_dist_km=0.0,\n",
        "    R=6371008.8,\n",
        "    seed=42,\n",
        "    verbose=True\n",
        "):\n",
        "    \"\"\"Отбор результативных точек: краевой буфер и прореживание.\"\"\"\n",
        "    lat = _as_1d(\n",
        "        lat,\n",
        "        \"lat\"\n",
        "    )\n",
        "\n",
        "    lon = _as_1d(\n",
        "        lon,\n",
        "        \"lon\"\n",
        "    )\n",
        "\n",
        "    _check_same_length(\n",
        "        \"target\",\n",
        "        lat=lat,\n",
        "        lon=lon\n",
        "    )\n",
        "\n",
        "    edge_buffer_km = _validate_nonnegative(\n",
        "        \"edge_buffer_km\",\n",
        "        edge_buffer_km\n",
        "    )\n",
        "\n",
        "    min_dist_km = _validate_nonnegative(\n",
        "        \"min_dist_km\",\n",
        "        min_dist_km\n",
        "    )\n",
        "\n",
        "    d_edge = edge_distance(\n",
        "        lat,\n",
        "        lon,\n",
        "        lat_range,\n",
        "        lon_range,\n",
        "        R\n",
        "    )\n",
        "\n",
        "    idx = np.where(\n",
        "        d_edge\n",
        "        >= edge_buffer_km\n",
        "        * 1000.0\n",
        "    )[0]\n",
        "\n",
        "    n_after_edge = len(idx)\n",
        "\n",
        "    if n_after_edge == 0:\n",
        "        raise ValueError(\n",
        "            \"После применения краевого буфера \"\n",
        "            \"не осталось результативных точек.\"\n",
        "        )\n",
        "\n",
        "    local = thin_by_spherical_distance(\n",
        "        lat[idx],\n",
        "        lon[idx],\n",
        "        min_dist_km * 1000.0,\n",
        "        R,\n",
        "        seed\n",
        "    )\n",
        "\n",
        "    idx = idx[local]\n",
        "\n",
        "    if verbose:\n",
        "        print(\n",
        "            f\"    точек: {len(lat)}\"\n",
        "            f\" -> буфер {edge_buffer_km:g} км: {n_after_edge}\"\n",
        "            f\" -> прореж. {min_dist_km:g} км: {len(idx)}\"\n",
        "        )\n",
        "\n",
        "    return idx, d_edge\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# УРОВЕНЬ 3: b = (C_dd + C_nn)^-1 l\n",
        "# =========================================================\n",
        "def compute_b_vector(\n",
        "    lat_g,\n",
        "    lon_g,\n",
        "    H_g,\n",
        "    gamma_g,\n",
        "    dg_mgal,\n",
        "    lat_z,\n",
        "    lon_z,\n",
        "    H_z,\n",
        "    gamma_z,\n",
        "    zeta_m,\n",
        "    C0_dg,\n",
        "    D,\n",
        "    T,\n",
        "    noise_dg=0.0,\n",
        "    noise_zeta=0.0,\n",
        "    R=6371008.8\n",
        "):\n",
        "    \"\"\"\n",
        "    Строит неоднородную систему для dg и zeta и вычисляет b.\n",
        "\n",
        "    noise_dg:\n",
        "        дисперсия ошибки dg в мГал².\n",
        "\n",
        "    noise_zeta:\n",
        "        дисперсия ошибки zeta в м².\n",
        "\n",
        "    Нулевые дисперсии допустимы.\n",
        "    \"\"\"\n",
        "    lat_g = _as_1d(\n",
        "        lat_g,\n",
        "        \"lat_g\"\n",
        "    )\n",
        "\n",
        "    lon_g = _as_1d(\n",
        "        lon_g,\n",
        "        \"lon_g\"\n",
        "    )\n",
        "\n",
        "    H_g = _as_1d(\n",
        "        H_g,\n",
        "        \"H_g\"\n",
        "    )\n",
        "\n",
        "    gamma_g = _as_1d(\n",
        "        gamma_g,\n",
        "        \"gamma_g\"\n",
        "    )\n",
        "\n",
        "    dg_mgal = _as_1d(\n",
        "        dg_mgal,\n",
        "        \"dg_mgal\"\n",
        "    )\n",
        "\n",
        "    lat_z = _as_1d(\n",
        "        lat_z,\n",
        "        \"lat_z\"\n",
        "    )\n",
        "\n",
        "    lon_z = _as_1d(\n",
        "        lon_z,\n",
        "        \"lon_z\"\n",
        "    )\n",
        "\n",
        "    H_z = _as_1d(\n",
        "        H_z,\n",
        "        \"H_z\"\n",
        "    )\n",
        "\n",
        "    gamma_z = _as_1d(\n",
        "        gamma_z,\n",
        "        \"gamma_z\"\n",
        "    )\n",
        "\n",
        "    zeta_m = _as_1d(\n",
        "        zeta_m,\n",
        "        \"zeta_m\"\n",
        "    )\n",
        "\n",
        "    _check_same_length(\n",
        "        \"dg\",\n",
        "        lat=lat_g,\n",
        "        lon=lon_g,\n",
        "        H=H_g,\n",
        "        gamma=gamma_g,\n",
        "        value=dg_mgal\n",
        "    )\n",
        "\n",
        "    _check_same_length(\n",
        "        \"zeta\",\n",
        "        lat=lat_z,\n",
        "        lon=lon_z,\n",
        "        H=H_z,\n",
        "        gamma=gamma_z,\n",
        "        value=zeta_m\n",
        "    )\n",
        "\n",
        "    noise_dg = _validate_nonnegative(\n",
        "        \"noise_dg\",\n",
        "        noise_dg\n",
        "    )\n",
        "\n",
        "    noise_zeta = _validate_nonnegative(\n",
        "        \"noise_zeta\",\n",
        "        noise_zeta\n",
        "    )\n",
        "\n",
        "    ng = len(lat_g)\n",
        "    nz = len(lat_z)\n",
        "\n",
        "    if ng + nz == 0:\n",
        "        raise ValueError(\n",
        "            \"Нет наблюдений dg или zeta.\"\n",
        "        )\n",
        "\n",
        "    mean_dg = (\n",
        "        float(\n",
        "            np.mean(dg_mgal)\n",
        "        )\n",
        "        if ng\n",
        "        else 0.0\n",
        "    )\n",
        "\n",
        "    mean_zeta = (\n",
        "        float(\n",
        "            np.mean(zeta_m)\n",
        "        )\n",
        "        if nz\n",
        "        else 0.0\n",
        "    )\n",
        "\n",
        "    dg_centered = (\n",
        "        dg_mgal\n",
        "        - mean_dg\n",
        "    )\n",
        "\n",
        "    zeta_centered = (\n",
        "        zeta_m\n",
        "        - mean_zeta\n",
        "    )\n",
        "\n",
        "    C = np.zeros(\n",
        "        (\n",
        "            ng + nz,\n",
        "            ng + nz\n",
        "        ),\n",
        "        dtype=float\n",
        "    )\n",
        "\n",
        "    if ng:\n",
        "        s_gg = pairwise_spherical_distance_m(\n",
        "            lat_g,\n",
        "            lon_g,\n",
        "            lat_g,\n",
        "            lon_g,\n",
        "            R\n",
        "        )\n",
        "\n",
        "        C[:ng, :ng] = cov_dg_dg(\n",
        "            s_gg,\n",
        "            C0_dg,\n",
        "            D,\n",
        "            T,\n",
        "            H_g[:, None],\n",
        "            H_g[None, :]\n",
        "        )\n",
        "\n",
        "    if nz:\n",
        "        s_zz = pairwise_spherical_distance_m(\n",
        "            lat_z,\n",
        "            lon_z,\n",
        "            lat_z,\n",
        "            lon_z,\n",
        "            R\n",
        "        )\n",
        "\n",
        "        C[ng:, ng:] = cov_zeta_zeta(\n",
        "            s_zz,\n",
        "            C0_dg,\n",
        "            D,\n",
        "            T,\n",
        "            gamma_z[:, None],\n",
        "            gamma_z[None, :],\n",
        "            H_z[:, None],\n",
        "            H_z[None, :]\n",
        "        )\n",
        "\n",
        "    if ng and nz:\n",
        "        s_gz = pairwise_spherical_distance_m(\n",
        "            lat_g,\n",
        "            lon_g,\n",
        "            lat_z,\n",
        "            lon_z,\n",
        "            R\n",
        "        )\n",
        "\n",
        "        C_gz = cov_zeta_dg(\n",
        "            s_gz,\n",
        "            C0_dg,\n",
        "            D,\n",
        "            T,\n",
        "            gamma_z[None, :],\n",
        "            H_g[:, None],\n",
        "            H_z[None, :]\n",
        "        )\n",
        "\n",
        "        C[:ng, ng:] = C_gz\n",
        "        C[ng:, :ng] = C_gz.T\n",
        "\n",
        "    C = 0.5 * (\n",
        "        C\n",
        "        + C.T\n",
        "    )\n",
        "\n",
        "    noise_diag = np.concatenate([\n",
        "        np.full(\n",
        "            ng,\n",
        "            noise_dg,\n",
        "            dtype=float\n",
        "        ),\n",
        "        np.full(\n",
        "            nz,\n",
        "            noise_zeta,\n",
        "            dtype=float\n",
        "        )\n",
        "    ])\n",
        "\n",
        "    C_total = (\n",
        "        C\n",
        "        + np.diag(\n",
        "            noise_diag\n",
        "        )\n",
        "    )\n",
        "\n",
        "    l = np.concatenate([\n",
        "        dg_centered,\n",
        "        zeta_centered\n",
        "    ])\n",
        "\n",
        "    if not np.all(\n",
        "        np.isfinite(C_total)\n",
        "    ):\n",
        "        raise ValueError(\n",
        "            \"Матрица C_dd + C_nn содержит NaN или inf.\"\n",
        "        )\n",
        "\n",
        "    cond = float(\n",
        "        np.linalg.cond(\n",
        "            C_total\n",
        "        )\n",
        "    )\n",
        "\n",
        "    solver = \"solve\"\n",
        "\n",
        "    try:\n",
        "        b = np.linalg.solve(\n",
        "            C_total,\n",
        "            l\n",
        "        )\n",
        "\n",
        "    except np.linalg.LinAlgError:\n",
        "        warnings.warn(\n",
        "            \"Матрица системы вырождена; используется \"\n",
        "            \"минимально-нормовое решение lstsq.\",\n",
        "            RuntimeWarning\n",
        "        )\n",
        "\n",
        "        b = np.linalg.lstsq(\n",
        "            C_total,\n",
        "            l,\n",
        "            rcond=None\n",
        "        )[0]\n",
        "\n",
        "        solver = \"lstsq\"\n",
        "\n",
        "    lat_all = np.concatenate([\n",
        "        lat_g,\n",
        "        lat_z\n",
        "    ])\n",
        "\n",
        "    lon_all = np.concatenate([\n",
        "        lon_g,\n",
        "        lon_z\n",
        "    ])\n",
        "\n",
        "    H_all = np.concatenate([\n",
        "        H_g,\n",
        "        H_z\n",
        "    ])\n",
        "\n",
        "    gamma_all = np.concatenate([\n",
        "        gamma_g,\n",
        "        gamma_z\n",
        "    ])\n",
        "\n",
        "    type_all = np.concatenate([\n",
        "        np.zeros(\n",
        "            ng,\n",
        "            dtype=np.int8\n",
        "        ),\n",
        "        np.ones(\n",
        "            nz,\n",
        "            dtype=np.int8\n",
        "        )\n",
        "    ])\n",
        "\n",
        "    raw_all = np.concatenate([\n",
        "        dg_mgal,\n",
        "        zeta_m\n",
        "    ])\n",
        "\n",
        "    centered_all = np.concatenate([\n",
        "        dg_centered,\n",
        "        zeta_centered\n",
        "    ])\n",
        "\n",
        "    meta = {\n",
        "        \"ng\": ng,\n",
        "        \"nz\": nz,\n",
        "        \"cond\": cond,\n",
        "        \"solver\": solver,\n",
        "        \"lat_all\": lat_all,\n",
        "        \"lon_all\": lon_all,\n",
        "        \"H_all\": H_all,\n",
        "        \"gamma_all\": gamma_all,\n",
        "        \"type_all\": type_all,\n",
        "        \"raw_all\": raw_all,\n",
        "        \"centered_all\": centered_all,\n",
        "        \"noise_variance_all\": noise_diag,\n",
        "        \"mean_dg_mgal\": mean_dg,\n",
        "        \"mean_zeta_m\": mean_zeta\n",
        "    }\n",
        "\n",
        "    return b, meta\n",
        "\n",
        "\n",
        "def make_level3_context_dataframe(\n",
        "    b,\n",
        "    meta\n",
        "):\n",
        "    \"\"\"Таблица уровня 3: одна строка на исходное наблюдение.\"\"\"\n",
        "    b = _as_1d(\n",
        "        b,\n",
        "        \"b\"\n",
        "    )\n",
        "\n",
        "    n = len(b)\n",
        "\n",
        "    if n != len(meta[\"lat_all\"]):\n",
        "        raise ValueError(\n",
        "            \"Размер b не совпадает с числом наблюдений.\"\n",
        "        )\n",
        "\n",
        "    types = np.asarray(\n",
        "        meta[\"type_all\"],\n",
        "        dtype=np.int8\n",
        "    )\n",
        "\n",
        "    units = np.where(\n",
        "        types == 0,\n",
        "        \"mGal\",\n",
        "        \"m\"\n",
        "    )\n",
        "\n",
        "    names = np.where(\n",
        "        types == 0,\n",
        "        \"dg\",\n",
        "        \"zeta\"\n",
        "    )\n",
        "\n",
        "    idx_within = np.empty(\n",
        "        n,\n",
        "        dtype=int\n",
        "    )\n",
        "\n",
        "    idx_within[types == 0] = np.arange(\n",
        "        np.sum(types == 0)\n",
        "    )\n",
        "\n",
        "    idx_within[types == 1] = np.arange(\n",
        "        np.sum(types == 1)\n",
        "    )\n",
        "\n",
        "    return pd.DataFrame({\n",
        "        \"observation_id\": np.arange(\n",
        "            n,\n",
        "            dtype=int\n",
        "        ),\n",
        "        \"index_within_type\": idx_within,\n",
        "        \"transformant\": names,\n",
        "        \"type_code\": types,\n",
        "        \"latitude_deg\": meta[\"lat_all\"],\n",
        "        \"longitude_deg\": meta[\"lon_all\"],\n",
        "        # Наблюдения расположены на фиксированной физической поверхности P,\n",
        "        # поэтому в ковариации используется H_sph=H_ETOPO1.\n",
        "        \"height_observation_obs_m\": meta[\"H_all\"],\n",
        "        \"gamma_m_s2\": meta[\"gamma_all\"],\n",
        "        \"value_observed\": meta[\"raw_all\"],\n",
        "        \"value_centered\": meta[\"centered_all\"],\n",
        "        \"value_unit\": units,\n",
        "        \"noise_variance\": meta[\"noise_variance_all\"],\n",
        "        \"b_i\": b\n",
        "    })\n",
        "\n",
        "def meta_from_level3_context(\n",
        "    df_level3\n",
        "):\n",
        "    \"\"\"Восстанавливает рабочий meta из сохранённого CSV уровня 3.\"\"\"\n",
        "    height_column = (\n",
        "        \"height_observation_obs_m\"\n",
        "        if \"height_observation_obs_m\" in df_level3.columns\n",
        "        else \"H_phys_m\"\n",
        "    )\n",
        "\n",
        "    required = {\n",
        "        \"observation_id\",\n",
        "        \"type_code\",\n",
        "        \"latitude_deg\",\n",
        "        \"longitude_deg\",\n",
        "        height_column,\n",
        "        \"gamma_m_s2\",\n",
        "        \"value_observed\",\n",
        "        \"value_centered\",\n",
        "        \"noise_variance\",\n",
        "        \"b_i\"\n",
        "    }\n",
        "\n",
        "    missing = required - set(df_level3.columns)\n",
        "\n",
        "    if missing:\n",
        "        raise KeyError(\n",
        "            \"В датасете уровня 3 отсутствуют колонки: \"\n",
        "            f\"{sorted(missing)}\"\n",
        "        )\n",
        "\n",
        "    d = (\n",
        "        df_level3\n",
        "        .sort_values(\"observation_id\")\n",
        "        .reset_index(drop=True)\n",
        "    )\n",
        "\n",
        "    types = d[\"type_code\"].to_numpy(np.int8)\n",
        "\n",
        "    if not np.all(np.isin(types, [0, 1])):\n",
        "        raise ValueError(\n",
        "            \"Поддерживаются type_code 0=dg и 1=zeta.\"\n",
        "        )\n",
        "\n",
        "    if np.any(np.diff(types) < 0):\n",
        "        raise ValueError(\n",
        "            \"В уровне 3 наблюдения должны идти блоками [dg | zeta].\"\n",
        "        )\n",
        "\n",
        "    return {\n",
        "        \"ng\": int(np.sum(types == 0)),\n",
        "        \"nz\": int(np.sum(types == 1)),\n",
        "        \"lat_all\": d[\"latitude_deg\"].to_numpy(float),\n",
        "        \"lon_all\": d[\"longitude_deg\"].to_numpy(float),\n",
        "        \"H_all\": d[height_column].to_numpy(float),\n",
        "        \"gamma_all\": d[\"gamma_m_s2\"].to_numpy(float),\n",
        "        \"type_all\": types,\n",
        "        \"raw_all\": d[\"value_observed\"].to_numpy(float),\n",
        "        \"centered_all\": d[\"value_centered\"].to_numpy(float),\n",
        "        \"noise_variance_all\": d[\"noise_variance\"].to_numpy(float),\n",
        "        \"b\": d[\"b_i\"].to_numpy(float)\n",
        "    }\n",
        "\n",
        "def products_for_point(\n",
        "    lat_p,\n",
        "    lon_p,\n",
        "    H_p,\n",
        "    b,\n",
        "    meta,\n",
        "    C0_dg,\n",
        "    D,\n",
        "    T,\n",
        "    R=6371008.8\n",
        "):\n",
        "    \"\"\"\n",
        "    Произведения p_i и сферические расстояния до наблюдений.\n",
        "\n",
        "    H_p — высота H_sph физической точки P над опорной сферой.\n",
        "    В относительной схеме все продукты вычисляются только в P_i.\n",
        "    \"\"\"\n",
        "    b = _as_1d(\n",
        "        b,\n",
        "        \"b\"\n",
        "    )\n",
        "\n",
        "    s = spherical_distance_m(\n",
        "        float(lat_p),\n",
        "        float(lon_p),\n",
        "        meta[\"lat_all\"],\n",
        "        meta[\"lon_all\"],\n",
        "        R\n",
        "    )\n",
        "\n",
        "    ng = int(\n",
        "        meta[\"ng\"]\n",
        "    )\n",
        "\n",
        "    H_all = np.asarray(\n",
        "        meta[\"H_all\"],\n",
        "        dtype=float\n",
        "    )\n",
        "\n",
        "    gamma_all = np.asarray(\n",
        "        meta[\"gamma_all\"],\n",
        "        dtype=float\n",
        "    )\n",
        "\n",
        "    C_P = np.zeros(\n",
        "        len(s),\n",
        "        dtype=float\n",
        "    )\n",
        "\n",
        "    if ng:\n",
        "        C_P[:ng] = cov_T_dg(\n",
        "            s[:ng],\n",
        "            C0_dg,\n",
        "            D,\n",
        "            T,\n",
        "            np.full(\n",
        "                ng,\n",
        "                float(H_p)\n",
        "            ),\n",
        "            H_all[:ng]\n",
        "        )\n",
        "\n",
        "    if len(s) > ng:\n",
        "        C_P[ng:] = cov_T_zeta(\n",
        "            s[ng:],\n",
        "            C0_dg,\n",
        "            D,\n",
        "            T,\n",
        "            gamma_all[ng:],\n",
        "            np.full(\n",
        "                len(s) - ng,\n",
        "                float(H_p)\n",
        "            ),\n",
        "            H_all[ng:]\n",
        "        )\n",
        "\n",
        "    if len(C_P) != len(b):\n",
        "        raise ValueError(\n",
        "            \"Размер C_P не совпадает с размером b.\"\n",
        "        )\n",
        "\n",
        "    return (\n",
        "        C_P * b,\n",
        "        s\n",
        "    )\n",
        "\n",
        "\n",
        "\n",
        "def build_adaptive_log_palette_from_parameters(\n",
        "    D_m,\n",
        "    lat_range,\n",
        "    lon_range,\n",
        "    *,\n",
        "    R=6371008.8,\n",
        "    alpha=1.6,\n",
        "    n_rings=12,\n",
        "    max_radius_mode=\"minimum\",\n",
        "    correlation_radius_factor=6.0,\n",
        "    near_radius_D_factor=0.05,\n",
        "    verbose=True\n",
        "):\n",
        "    \"\"\"\n",
        "    Строит адаптивную логарифмическую палетку напрямую из\n",
        "    параметра Форсберга D и геометрии области.\n",
        "\n",
        "    Функция вызывается ПОСЛЕ оценки D, но ДО build_level3(),\n",
        "    поэтому рассчитанные границы действительно участвуют\n",
        "    в формировании датасета 4-го уровня.\n",
        "    \"\"\"\n",
        "    D_km = float(D_m) / 1000.0\n",
        "    alpha = float(alpha)\n",
        "    n_rings = int(n_rings)\n",
        "    correlation_radius_factor = float(correlation_radius_factor)\n",
        "    near_radius_D_factor = float(near_radius_D_factor)\n",
        "\n",
        "    if D_km <= 0:\n",
        "        raise ValueError(\"D_m должен быть положительным.\")\n",
        "    if alpha <= 0:\n",
        "        raise ValueError(\"alpha должно быть положительным.\")\n",
        "    if n_rings < 2:\n",
        "        raise ValueError(\"n_rings должно быть не меньше 2.\")\n",
        "    if correlation_radius_factor <= 0:\n",
        "        raise ValueError(\"correlation_radius_factor должен быть положительным.\")\n",
        "    if near_radius_D_factor <= 0:\n",
        "        raise ValueError(\"near_radius_D_factor должен быть положительным.\")\n",
        "\n",
        "    lat_min, lat_max = map(float, lat_range)\n",
        "    lon_min, lon_max = map(float, lon_range)\n",
        "    corner_lat = np.array([lat_min, lat_min, lat_max, lat_max], dtype=float)\n",
        "    corner_lon = np.array([lon_min, lon_max, lon_min, lon_max], dtype=float)\n",
        "\n",
        "    diameter_km = float(\n",
        "        np.max(\n",
        "            pairwise_spherical_distance_m(\n",
        "                corner_lat,\n",
        "                corner_lon,\n",
        "                corner_lat,\n",
        "                corner_lon,\n",
        "                R=R\n",
        "            )\n",
        "        ) / 1000.0\n",
        "    )\n",
        "\n",
        "    correlation_radius_km = correlation_radius_factor * D_km\n",
        "\n",
        "    if max_radius_mode == \"diameter\":\n",
        "        r_max_km = diameter_km\n",
        "        r_max_description = \"диаметр области\"\n",
        "    elif max_radius_mode == \"correlation\":\n",
        "        r_max_km = correlation_radius_km\n",
        "        r_max_description = f\"{correlation_radius_factor:g} * D\"\n",
        "    elif max_radius_mode == \"minimum\":\n",
        "        r_max_km = min(diameter_km, correlation_radius_km)\n",
        "        r_max_description = (\n",
        "            f\"min(диаметр области, {correlation_radius_factor:g} * D)\"\n",
        "        )\n",
        "    else:\n",
        "        raise ValueError(\n",
        "            \"max_radius_mode должен быть 'diameter', 'correlation' или 'minimum'.\"\n",
        "        )\n",
        "\n",
        "    if not np.isfinite(r_max_km) or r_max_km <= 0:\n",
        "        raise ValueError(\"Не удалось определить положительный R_max.\")\n",
        "\n",
        "    r_near_km = near_radius_D_factor * D_km\n",
        "    if r_near_km >= r_max_km:\n",
        "        r_near_km = r_max_km / max(2.0 * n_rings, 4.0)\n",
        "    r_near_km = max(r_near_km, 1e-6)\n",
        "\n",
        "    k = np.arange(1, n_rings + 1, dtype=float)\n",
        "    u = (k / n_rings) ** alpha\n",
        "    rings_km = r_near_km * (r_max_km / r_near_km) ** u\n",
        "    rings_km[-1] = r_max_km\n",
        "\n",
        "    if np.any(np.diff(rings_km) <= 0):\n",
        "        raise ValueError(\"Получены нестрого возрастающие границы колец.\")\n",
        "\n",
        "    rings_tuple = tuple(float(v) for v in rings_km)\n",
        "    metadata = {\n",
        "        \"family\": \"warped_logarithmic_bounded\",\n",
        "        \"alpha\": alpha,\n",
        "        \"n_rings\": n_rings,\n",
        "        \"D_km\": D_km,\n",
        "        \"near_radius_D_factor\": near_radius_D_factor,\n",
        "        \"r_near_km\": r_near_km,\n",
        "        \"max_radius_mode\": max_radius_mode,\n",
        "        \"correlation_radius_factor\": correlation_radius_factor,\n",
        "        \"correlation_radius_km\": correlation_radius_km,\n",
        "        \"diameter_source\": \"domain_corners\",\n",
        "        \"diameter_km\": diameter_km,\n",
        "        \"r_max_km\": r_max_km,\n",
        "        \"r_max_description\": r_max_description,\n",
        "        \"include_tail_beyond_r_max\": False,\n",
        "        \"rings_outer_radii_km\": list(rings_tuple)\n",
        "    }\n",
        "\n",
        "    if verbose:\n",
        "        print(\"\\n--- Активная адаптивная палетка ---\")\n",
        "        print(f\"  D = {D_km:.3f} км\")\n",
        "        print(f\"  диаметр области = {diameter_km:.3f} км\")\n",
        "        print(\n",
        "            f\"  корреляционный предел = {correlation_radius_factor:g} * D \"\n",
        "            f\"= {correlation_radius_km:.3f} км\"\n",
        "        )\n",
        "        print(f\"  R_max = {r_max_km:.3f} км ({r_max_description})\")\n",
        "        print(f\"  alpha = {alpha:g}; колец = {n_rings}\")\n",
        "        print(\"  хвостовое кольцо [R_max, inf) отключено\")\n",
        "        print(\"  границы, км:\", np.round(rings_km, 3))\n",
        "\n",
        "    return {\n",
        "        \"rings_km\": rings_tuple,\n",
        "        \"r_max_km\": float(r_max_km),\n",
        "        \"r_near_km\": float(r_near_km),\n",
        "        \"metadata\": metadata\n",
        "    }\n",
        "\n",
        "\n",
        "def _ring_name(\n",
        "    value_km\n",
        "):\n",
        "    if np.isinf(\n",
        "        value_km\n",
        "    ):\n",
        "        return \"inf\"\n",
        "\n",
        "    value = float(\n",
        "        value_km\n",
        "    )\n",
        "\n",
        "    if value.is_integer():\n",
        "        return str(\n",
        "            int(value)\n",
        "        )\n",
        "\n",
        "    return str(\n",
        "        value\n",
        "    ).replace(\n",
        "        \".\",\n",
        "        \"p\"\n",
        "    )\n",
        "\n",
        "\n",
        "def make_palette(\n",
        "    p,\n",
        "    s,\n",
        "    types,\n",
        "    near_exclude_km=0.0,\n",
        "    rings_km=(10, 15, 30, 50, 80, 120, 200),\n",
        "    k_individual=8,\n",
        "    agg=\"both\",\n",
        "    include_tail_beyond_r_max=True\n",
        "):\n",
        "    \"\"\"\n",
        "    Фиксированная строка признаков:\n",
        "\n",
        "    - полная и раздельные LSC-суммы;\n",
        "    - сумма, среднее и число наблюдений по сферическим кольцам;\n",
        "    - k ближайших индивидуальных вкладов;\n",
        "    - диагностика геометрии и эффективного числа вкладов.\n",
        "    \"\"\"\n",
        "    p = _as_1d(\n",
        "        p,\n",
        "        \"p\"\n",
        "    )\n",
        "\n",
        "    s = _as_1d(\n",
        "        s,\n",
        "        \"s\"\n",
        "    )\n",
        "\n",
        "    types = np.asarray(\n",
        "        types,\n",
        "        dtype=np.int8\n",
        "    ).ravel()\n",
        "\n",
        "    _check_same_length(\n",
        "        \"palette\",\n",
        "        p=p,\n",
        "        s=s,\n",
        "        types=types\n",
        "    )\n",
        "\n",
        "    rings_km = _validate_rings(\n",
        "        rings_km\n",
        "    )\n",
        "\n",
        "    near_exclude_km = _validate_nonnegative(\n",
        "        \"near_exclude_km\",\n",
        "        near_exclude_km\n",
        "    )\n",
        "\n",
        "    k_individual = int(\n",
        "        k_individual\n",
        "    )\n",
        "\n",
        "    if k_individual < 0:\n",
        "        raise ValueError(\n",
        "            \"k_individual не может быть отрицательным.\"\n",
        "        )\n",
        "\n",
        "    if agg not in {\n",
        "        \"sum\",\n",
        "        \"mean\",\n",
        "        \"both\"\n",
        "    }:\n",
        "        raise ValueError(\n",
        "            \"agg должен быть 'sum', 'mean' или 'both'.\"\n",
        "        )\n",
        "\n",
        "    use = (\n",
        "        s\n",
        "        >= near_exclude_km\n",
        "        * 1000.0\n",
        "    )\n",
        "\n",
        "    if not include_tail_beyond_r_max:\n",
        "        use &= (\n",
        "            s\n",
        "            <= rings_km[-1]\n",
        "            * 1000.0\n",
        "        )\n",
        "\n",
        "    p_used = np.where(\n",
        "        use,\n",
        "        p,\n",
        "        0.0\n",
        "    )\n",
        "\n",
        "    feats = {\n",
        "        \"lsc_full\": float(\n",
        "            np.sum(\n",
        "                p_used\n",
        "            )\n",
        "        ),\n",
        "        \"lsc_dg\": float(\n",
        "            np.sum(\n",
        "                p_used[\n",
        "                    types == 0\n",
        "                ]\n",
        "            )\n",
        "        ),\n",
        "        \"lsc_zeta\": float(\n",
        "            np.sum(\n",
        "                p_used[\n",
        "                    types == 1\n",
        "                ]\n",
        "            )\n",
        "        )\n",
        "    }\n",
        "\n",
        "    def add_aggregate(\n",
        "        mask,\n",
        "        name\n",
        "    ):\n",
        "        vals = p[mask]\n",
        "\n",
        "        if agg in {\n",
        "            \"sum\",\n",
        "            \"both\"\n",
        "        }:\n",
        "            feats[\n",
        "                f\"{name}_sum\"\n",
        "            ] = (\n",
        "                float(\n",
        "                    np.sum(vals)\n",
        "                )\n",
        "                if len(vals)\n",
        "                else 0.0\n",
        "            )\n",
        "\n",
        "        if agg in {\n",
        "            \"mean\",\n",
        "            \"both\"\n",
        "        }:\n",
        "            feats[\n",
        "                f\"{name}_mean\"\n",
        "            ] = (\n",
        "                float(\n",
        "                    np.mean(vals)\n",
        "                )\n",
        "                if len(vals)\n",
        "                else 0.0\n",
        "            )\n",
        "\n",
        "        feats[\n",
        "            f\"{name}_n\"\n",
        "        ] = int(\n",
        "            len(vals)\n",
        "        )\n",
        "\n",
        "    edges_km = [\n",
        "        0.0,\n",
        "        *rings_km\n",
        "    ]\n",
        "\n",
        "    if include_tail_beyond_r_max:\n",
        "        edges_km.append(\n",
        "            np.inf\n",
        "        )\n",
        "\n",
        "    for k in range(\n",
        "        len(edges_km) - 1\n",
        "    ):\n",
        "        lo = edges_km[k]\n",
        "        hi = edges_km[k + 1]\n",
        "\n",
        "        mask = (\n",
        "            use\n",
        "            & (\n",
        "                s\n",
        "                >= lo\n",
        "                * 1000.0\n",
        "            )\n",
        "        )\n",
        "\n",
        "        if not np.isinf(hi):\n",
        "            mask &= (\n",
        "                s\n",
        "                < hi\n",
        "                * 1000.0\n",
        "            )\n",
        "\n",
        "        name = (\n",
        "            f\"ring_{_ring_name(lo)}_\"\n",
        "            f\"{_ring_name(hi)}km\"\n",
        "        )\n",
        "\n",
        "        add_aggregate(\n",
        "            mask,\n",
        "            name\n",
        "        )\n",
        "\n",
        "        add_aggregate(\n",
        "            mask\n",
        "            & (\n",
        "                types == 0\n",
        "            ),\n",
        "            f\"{name}_dg\"\n",
        "        )\n",
        "\n",
        "        add_aggregate(\n",
        "            mask\n",
        "            & (\n",
        "                types == 1\n",
        "            ),\n",
        "            f\"{name}_zeta\"\n",
        "        )\n",
        "\n",
        "    nearest = np.argsort(\n",
        "        s\n",
        "    )\n",
        "\n",
        "    nearest = nearest[\n",
        "        use[nearest]\n",
        "    ][\n",
        "        :k_individual\n",
        "    ]\n",
        "\n",
        "    for j in range(\n",
        "        k_individual\n",
        "    ):\n",
        "        if j < len(nearest):\n",
        "            i = nearest[j]\n",
        "\n",
        "            feats[\n",
        "                f\"nn{j}_distance_m\"\n",
        "            ] = float(\n",
        "                s[i]\n",
        "            )\n",
        "\n",
        "            feats[\n",
        "                f\"nn{j}_p\"\n",
        "            ] = float(\n",
        "                p[i]\n",
        "            )\n",
        "\n",
        "            feats[\n",
        "                f\"nn{j}_type\"\n",
        "            ] = int(\n",
        "                types[i]\n",
        "            )\n",
        "\n",
        "        else:\n",
        "            feats[\n",
        "                f\"nn{j}_distance_m\"\n",
        "            ] = -1.0\n",
        "\n",
        "            feats[\n",
        "                f\"nn{j}_p\"\n",
        "            ] = 0.0\n",
        "\n",
        "            feats[\n",
        "                f\"nn{j}_type\"\n",
        "            ] = -1\n",
        "\n",
        "    feats[\n",
        "        \"n_used\"\n",
        "    ] = int(\n",
        "        np.sum(use)\n",
        "    )\n",
        "\n",
        "    feats[\n",
        "        \"distance_nearest_m\"\n",
        "    ] = (\n",
        "        float(\n",
        "            np.min(\n",
        "                s[use]\n",
        "            )\n",
        "        )\n",
        "        if np.any(use)\n",
        "        else -1.0\n",
        "    )\n",
        "\n",
        "    sum_sq = float(\n",
        "        np.sum(\n",
        "            p_used ** 2\n",
        "        )\n",
        "    )\n",
        "\n",
        "    feats[\n",
        "        \"effective_n_contrib\"\n",
        "    ] = (\n",
        "        float(\n",
        "            np.sum(p_used) ** 2\n",
        "            / sum_sq\n",
        "        )\n",
        "        if sum_sq > 1e-30\n",
        "        else 0.0\n",
        "    )\n",
        "\n",
        "    feats[\n",
        "        \"palette_r_max_km\"\n",
        "    ] = float(\n",
        "        rings_km[-1]\n",
        "    )\n",
        "\n",
        "    feats[\n",
        "        \"n_excluded_beyond_r_max\"\n",
        "    ] = (\n",
        "        int(\n",
        "            np.sum(\n",
        "                s > rings_km[-1] * 1000.0\n",
        "            )\n",
        "        )\n",
        "        if not include_tail_beyond_r_max\n",
        "        else 0\n",
        "    )\n",
        "\n",
        "    return feats\n",
        "\n",
        "\n",
        "\n",
        "def build_level4_feature_row(\n",
        "    lat_deg,\n",
        "    lon_deg,\n",
        "    H_sph_m,\n",
        "    d_edge_m,\n",
        "    b,\n",
        "    meta,\n",
        "    C0_dg,\n",
        "    D,\n",
        "    T,\n",
        "    near_exclude_km=0.0,\n",
        "    rings_km=(10, 15, 30, 50, 80, 120, 200),\n",
        "    k_individual=8,\n",
        "    agg=\"both\",\n",
        "    R=6371008.8,\n",
        "    include_tail_beyond_r_max=True\n",
        "):\n",
        "    \"\"\"\n",
        "    Формирует абсолютную ковариационную палетку Phi(P_i)\n",
        "    на физической поверхности r_P=R+H_sph.\n",
        "\n",
        "    Абсолютная палетка сохраняется только для диагностики.\n",
        "    После выбора единственной опорной точки P0 в ML передаются\n",
        "    поэлементные разности d_Phi=Phi(P_i)-Phi(P0).\n",
        "    \"\"\"\n",
        "    H_sph_m = float(H_sph_m)\n",
        "\n",
        "    p_P, s_P = products_for_point(\n",
        "        lat_deg,\n",
        "        lon_deg,\n",
        "        H_sph_m,\n",
        "        b,\n",
        "        meta,\n",
        "        C0_dg,\n",
        "        D,\n",
        "        T,\n",
        "        R\n",
        "    )\n",
        "\n",
        "    T_LSC_P_residual = float(np.sum(p_P))\n",
        "    palette = make_palette(\n",
        "        p_P,\n",
        "        s_P,\n",
        "        meta[\"type_all\"],\n",
        "        near_exclude_km,\n",
        "        rings_km,\n",
        "        k_individual,\n",
        "        agg,\n",
        "        include_tail_beyond_r_max=include_tail_beyond_r_max\n",
        "    )\n",
        "\n",
        "    row = {\n",
        "        \"latitude_deg\": float(lat_deg),\n",
        "        \"longitude_deg\": float(lon_deg),\n",
        "        \"H_sph_m\": H_sph_m,\n",
        "        \"distance_to_edge_m\": float(d_edge_m),\n",
        "        \"T_LSC_P_residual (m2/s2)\": T_LSC_P_residual,\n",
        "        **palette\n",
        "    }\n",
        "    return row, p_P, palette\n",
        "\n",
        "def _json_ready(\n",
        "    value\n",
        "):\n",
        "    if isinstance(\n",
        "        value,\n",
        "        dict\n",
        "    ):\n",
        "        return {\n",
        "            str(k): _json_ready(v)\n",
        "            for k, v in value.items()\n",
        "        }\n",
        "\n",
        "    if isinstance(\n",
        "        value,\n",
        "        (list, tuple)\n",
        "    ):\n",
        "        return [\n",
        "            _json_ready(v)\n",
        "            for v in value\n",
        "        ]\n",
        "\n",
        "    if isinstance(\n",
        "        value,\n",
        "        np.ndarray\n",
        "    ):\n",
        "        return value.tolist()\n",
        "\n",
        "    if isinstance(\n",
        "        value,\n",
        "        np.integer\n",
        "    ):\n",
        "        return int(value)\n",
        "\n",
        "    if isinstance(\n",
        "        value,\n",
        "        np.floating\n",
        "    ):\n",
        "        value = float(value)\n",
        "\n",
        "        return (\n",
        "            None\n",
        "            if not np.isfinite(value)\n",
        "            else value\n",
        "        )\n",
        "\n",
        "    if isinstance(\n",
        "        value,\n",
        "        Path\n",
        "    ):\n",
        "        return str(value)\n",
        "\n",
        "    return value\n",
        "\n",
        "\n",
        "def _feature_schema_hash(\n",
        "    feature_columns\n",
        "):\n",
        "    payload = json.dumps(\n",
        "        list(feature_columns),\n",
        "        ensure_ascii=False,\n",
        "        separators=(\n",
        "            \",\",\n",
        "            \":\"\n",
        "        )\n",
        "    )\n",
        "\n",
        "    return hashlib.sha256(\n",
        "        payload.encode(\n",
        "            \"utf-8\"\n",
        "        )\n",
        "    ).hexdigest()\n",
        "\n",
        "\n",
        "def save_json(\n",
        "    config,\n",
        "    path\n",
        "):\n",
        "    path = Path(\n",
        "        path\n",
        "    )\n",
        "\n",
        "    path.parent.mkdir(\n",
        "        parents=True,\n",
        "        exist_ok=True\n",
        "    )\n",
        "\n",
        "    with path.open(\n",
        "        \"w\",\n",
        "        encoding=\"utf-8\"\n",
        "    ) as f:\n",
        "        json.dump(\n",
        "            _json_ready(config),\n",
        "            f,\n",
        "            ensure_ascii=False,\n",
        "            indent=2\n",
        "        )\n",
        "\n",
        "    return str(\n",
        "        path\n",
        "    )\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# ГЛАВНАЯ ФУНКЦИЯ\n",
        "# Имя build_level3 сохранено для совместимости с панелью.\n",
        "# =========================================================\n",
        "def _make_spatial_groups_relative(\n",
        "    latitude,\n",
        "    longitude,\n",
        "    blocks_per_axis=4\n",
        "):\n",
        "    latitude = np.asarray(latitude, dtype=float)\n",
        "    longitude = np.asarray(longitude, dtype=float)\n",
        "    blocks_per_axis = int(blocks_per_axis)\n",
        "\n",
        "    if blocks_per_axis < 2:\n",
        "        raise ValueError(\"blocks_per_axis должно быть не меньше 2.\")\n",
        "\n",
        "    lat_span = max(float(latitude.max() - latitude.min()), 1e-12)\n",
        "    lon_span = max(float(longitude.max() - longitude.min()), 1e-12)\n",
        "\n",
        "    lat_bin = np.floor(\n",
        "        (latitude - latitude.min()) / lat_span * blocks_per_axis\n",
        "    ).astype(int)\n",
        "    lon_bin = np.floor(\n",
        "        (longitude - longitude.min()) / lon_span * blocks_per_axis\n",
        "    ).astype(int)\n",
        "\n",
        "    lat_bin = np.clip(lat_bin, 0, blocks_per_axis - 1)\n",
        "    lon_bin = np.clip(lon_bin, 0, blocks_per_axis - 1)\n",
        "\n",
        "    return lat_bin * blocks_per_axis + lon_bin\n",
        "\n",
        "\n",
        "def _assign_spatial_splits_relative(\n",
        "    df,\n",
        "    test_fraction=0.20,\n",
        "    validation_fraction=0.20,\n",
        "    blocks_per_axis=4,\n",
        "    seed=42\n",
        "):\n",
        "    \"\"\"\n",
        "    Выполняет пространственное разделение до выбора P0.\n",
        "    Возвращает одну метку split для каждой строки.\n",
        "    \"\"\"\n",
        "    if test_fraction <= 0 or validation_fraction <= 0:\n",
        "        raise ValueError(\"Доли validation и test должны быть положительными.\")\n",
        "    if test_fraction + validation_fraction >= 1:\n",
        "        raise ValueError(\"Сумма долей validation и test должна быть меньше 1.\")\n",
        "\n",
        "    groups = _make_spatial_groups_relative(\n",
        "        df[\"latitude_deg\"].to_numpy(float),\n",
        "        df[\"longitude_deg\"].to_numpy(float),\n",
        "        blocks_per_axis\n",
        "    )\n",
        "    indices = np.arange(len(df))\n",
        "    mode = \"spatial_groups\"\n",
        "\n",
        "    try:\n",
        "        if len(np.unique(groups)) < 3:\n",
        "            raise ValueError(\"Слишком мало пространственных блоков.\")\n",
        "\n",
        "        split_test = GroupShuffleSplit(\n",
        "            n_splits=1,\n",
        "            test_size=test_fraction,\n",
        "            random_state=seed\n",
        "        )\n",
        "        train_val_idx, test_idx = next(\n",
        "            split_test.split(indices, groups=groups)\n",
        "        )\n",
        "\n",
        "        validation_relative = validation_fraction / (1.0 - test_fraction)\n",
        "        groups_train_val = groups[train_val_idx]\n",
        "\n",
        "        if len(np.unique(groups_train_val)) < 2:\n",
        "            raise ValueError(\n",
        "                \"После выделения test осталось слишком мало блоков.\"\n",
        "            )\n",
        "\n",
        "        split_valid = GroupShuffleSplit(\n",
        "            n_splits=1,\n",
        "            test_size=validation_relative,\n",
        "            random_state=seed + 1\n",
        "        )\n",
        "        train_local, validation_local = next(\n",
        "            split_valid.split(\n",
        "                train_val_idx,\n",
        "                groups=groups_train_val\n",
        "            )\n",
        "        )\n",
        "        train_idx = train_val_idx[train_local]\n",
        "        validation_idx = train_val_idx[validation_local]\n",
        "\n",
        "    except Exception as error:\n",
        "        warnings.warn(\n",
        "            \"Пространственное разбиение не удалось; \"\n",
        "            f\"используется случайное: {error}\",\n",
        "            RuntimeWarning\n",
        "        )\n",
        "        mode = \"random_fallback\"\n",
        "        train_val_idx, test_idx = train_test_split(\n",
        "            indices,\n",
        "            test_size=test_fraction,\n",
        "            random_state=seed\n",
        "        )\n",
        "        validation_relative = validation_fraction / (1.0 - test_fraction)\n",
        "        train_idx, validation_idx = train_test_split(\n",
        "            train_val_idx,\n",
        "            test_size=validation_relative,\n",
        "            random_state=seed + 1\n",
        "        )\n",
        "\n",
        "    labels = np.full(len(df), \"\", dtype=object)\n",
        "    labels[np.asarray(train_idx, dtype=int)] = \"train\"\n",
        "    labels[np.asarray(validation_idx, dtype=int)] = \"validation\"\n",
        "    labels[np.asarray(test_idx, dtype=int)] = \"test\"\n",
        "\n",
        "    if np.any(labels == \"\"):\n",
        "        raise AssertionError(\"Не всем строкам назначен split.\")\n",
        "\n",
        "    return labels, groups, mode\n",
        "\n",
        "\n",
        "def _select_reference_from_train(df, split_column=\"split\"):\n",
        "    \"\"\"\n",
        "    Выбирает одну фиксированную опорную точку из train:\n",
        "    ближайшую к центроиду обучающих координат.\n",
        "    \"\"\"\n",
        "    train_mask = df[split_column].eq(\"train\").to_numpy()\n",
        "\n",
        "    if not np.any(train_mask):\n",
        "        raise ValueError(\"Обучающая область пуста; P0 выбрать невозможно.\")\n",
        "\n",
        "    train_positions = np.flatnonzero(train_mask)\n",
        "    lat_train = df.loc[train_mask, \"latitude_deg\"].to_numpy(float)\n",
        "    lon_train = df.loc[train_mask, \"longitude_deg\"].to_numpy(float)\n",
        "\n",
        "    lat0 = float(np.mean(lat_train))\n",
        "    lon0 = float(np.mean(lon_train))\n",
        "\n",
        "    distance = spherical_distance_m(\n",
        "        lat0,\n",
        "        lon0,\n",
        "        lat_train,\n",
        "        lon_train\n",
        "    )\n",
        "\n",
        "    return int(train_positions[int(np.argmin(distance))])\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# ГЛАВНАЯ ФУНКЦИЯ\n",
        "# Имя build_level3 сохранено для совместимости с панелью.\n",
        "# =========================================================\n",
        "def build_level3(\n",
        "    data,\n",
        "    H_WORK,\n",
        "    lat_range,\n",
        "    lon_range,\n",
        "    C0_dg,\n",
        "    D,\n",
        "    T,\n",
        "    i_g,\n",
        "    i_z,\n",
        "    min_dist_km=10.0,\n",
        "    edge_buffer_km=20.0,\n",
        "    near_exclude_km=0.0,\n",
        "    rings_km=(10, 15, 30, 50, 80, 120, 200),\n",
        "    k_individual=8,\n",
        "    agg=\"both\",\n",
        "    include_tail_beyond_r_max=True,\n",
        "    palette_metadata=None,\n",
        "    noise_dg=0.0,\n",
        "    noise_zeta=0.0,\n",
        "    geo_cols=None,\n",
        "    R=6371008.8,\n",
        "    seed=42,\n",
        "    raw_products=False,\n",
        "    verbose=True,\n",
        "    level3_csv_path=\"dataset_level3_context.csv\",\n",
        "    level4_csv_path=\"dataset_level4_ml.csv\",\n",
        "    config_json_path=\"dataset_level4_config.json\",\n",
        "    save_outputs=True,\n",
        "    test_fraction=0.20,\n",
        "    validation_fraction=0.20,\n",
        "    spatial_blocks_per_axis=4,\n",
        "    split_seed=42\n",
        "):\n",
        "    \"\"\"\n",
        "    Формирует относительную ML-поправку к LSC.\n",
        "\n",
        "    Для каждой физической точки P_i:\n",
        "        dC200_i0 = W200(P0) - W200(P_i)\n",
        "        dClev_i0 = W200(Q0_obs) - W200(Qi_obs)\n",
        "        dTlev_i0 = dC200_i0 - dClev_i0\n",
        "        dTlsc_i0 = Tlsc(P_i) - Tlsc(P0)\n",
        "        y_i = dTlev_i0 - dTlsc_i0\n",
        "\n",
        "    ML-вход:\n",
        "        d_Phi_i0 = Phi(P_i) - Phi(P0)\n",
        "\n",
        "    Одна и та же опорная точка P0 выбирается только из train\n",
        "    и используется для train, validation и test.\n",
        "    \"\"\"\n",
        "    if H_WORK is not None and verbose:\n",
        "        print(\n",
        "            \"  H_WORK игнорируется: используется индивидуальная \"\n",
        "            \"высота физической поверхности P_i.\"\n",
        "        )\n",
        "\n",
        "    if geo_cols:\n",
        "        raise ValueError(\n",
        "            \"В относительной схеме дополнительные geo_cols запрещены: \"\n",
        "            \"ML получает только d_Phi.\"\n",
        "        )\n",
        "\n",
        "    rings_km = _validate_rings(rings_km)\n",
        "    min_dist_km = _validate_nonnegative(\"min_dist_km\", min_dist_km)\n",
        "    edge_buffer_km = _validate_nonnegative(\n",
        "        \"edge_buffer_km\", edge_buffer_km\n",
        "    )\n",
        "    near_exclude_km = _validate_nonnegative(\n",
        "        \"near_exclude_km\", near_exclude_km\n",
        "    )\n",
        "    noise_dg = _validate_nonnegative(\"noise_dg\", noise_dg)\n",
        "    noise_zeta = _validate_nonnegative(\"noise_zeta\", noise_zeta)\n",
        "\n",
        "    if agg not in {\"sum\", \"mean\", \"both\"}:\n",
        "        raise ValueError(\"agg должен быть 'sum', 'mean' или 'both'.\")\n",
        "\n",
        "    d = data.copy().reset_index(drop=True)\n",
        "\n",
        "    required = {\n",
        "        \"Latitude (deg)\",\n",
        "        \"Longitude (deg)\",\n",
        "        \"H_sph (m)\",\n",
        "        \"H_normal_true (m)\",\n",
        "        \"H_leveling_obs (m)\",\n",
        "        \"r_Q_true (m)\",\n",
        "        \"r_Q_obs (m)\",\n",
        "        \"W_full_P (m2/s2)\",\n",
        "        \"W_200_P (m2/s2)\",\n",
        "        \"W_200_Q_true (m2/s2)\",\n",
        "        \"W_200_Q_obs (m2/s2)\",\n",
        "        \"energy_consistency_error_true (m2/s2)\",\n",
        "        \"gamma (m/s2)\",\n",
        "        \"dg (m/s2)\",\n",
        "        \"zeta_obs (m)\",\n",
        "        \"T_P_true_residual (m2/s2)\"\n",
        "    }\n",
        "\n",
        "    missing = required - set(d.columns)\n",
        "    if missing:\n",
        "        raise KeyError(\n",
        "            \"В датасете уровня 2 отсутствуют колонки: \"\n",
        "            f\"{sorted(missing)}\"\n",
        "        )\n",
        "\n",
        "    lat = d[\"Latitude (deg)\"].to_numpy(float)\n",
        "    lon = d[\"Longitude (deg)\"].to_numpy(float)\n",
        "    H_sph = d[\"H_sph (m)\"].to_numpy(float)\n",
        "    H_normal_true = d[\"H_normal_true (m)\"].to_numpy(float)\n",
        "    H_leveling = d[\"H_leveling_obs (m)\"].to_numpy(float)\n",
        "    r_Q_true = d[\"r_Q_true (m)\"].to_numpy(float)\n",
        "    r_Q_obs = d[\"r_Q_obs (m)\"].to_numpy(float)\n",
        "    W_full_P = d[\"W_full_P (m2/s2)\"].to_numpy(float)\n",
        "    W_200_P = d[\"W_200_P (m2/s2)\"].to_numpy(float)\n",
        "    W_200_Q_true = d[\"W_200_Q_true (m2/s2)\"].to_numpy(float)\n",
        "    W_200_Q_obs = d[\"W_200_Q_obs (m2/s2)\"].to_numpy(float)\n",
        "    energy_error_true = d[\n",
        "        \"energy_consistency_error_true (m2/s2)\"\n",
        "    ].to_numpy(float)\n",
        "    gamma = d[\"gamma (m/s2)\"].to_numpy(float)\n",
        "    dg_si = d[\"dg (m/s2)\"].to_numpy(float)\n",
        "    dg_mgal = dg_si * MGAL\n",
        "    zeta_obs = d[\"zeta_obs (m)\"].to_numpy(float)\n",
        "    T_true = d[\"T_P_true_residual (m2/s2)\"].to_numpy(float)\n",
        "\n",
        "    energy_atol = float(\n",
        "        data.attrs.get(\"energy_solver\", {}).get(\n",
        "            \"residual_atol_m2_s2\", 1e-4\n",
        "        )\n",
        "    )\n",
        "    if float(np.max(np.abs(energy_error_true))) > energy_atol:\n",
        "        raise AssertionError(\n",
        "            \"Нарушено W200(Q_true)=WN(P) в датасете уровня 2.\"\n",
        "        )\n",
        "\n",
        "    n = len(d)\n",
        "    i_g = np.asarray(i_g, dtype=int).ravel()\n",
        "    i_z = np.asarray(i_z, dtype=int).ravel()\n",
        "\n",
        "    if (\n",
        "        np.any(i_g < 0) or np.any(i_g >= n)\n",
        "        or np.any(i_z < 0) or np.any(i_z >= n)\n",
        "    ):\n",
        "        raise IndexError(\"Индексы наблюдений выходят за границы датасета.\")\n",
        "\n",
        "    if len(np.intersect1d(i_g, i_z)):\n",
        "        raise ValueError(\"Наборы dg и zeta пересекаются.\")\n",
        "\n",
        "    # LSC-наблюдения и прогнозы относятся к физической поверхности\n",
        "    # P с r_P=R+H_sph. Нивелирная точка Q в ковариации не используется.\n",
        "    b, meta = compute_b_vector(\n",
        "        lat[i_g],\n",
        "        lon[i_g],\n",
        "        H_sph[i_g],\n",
        "        gamma[i_g],\n",
        "        dg_mgal[i_g],\n",
        "\n",
        "        lat[i_z],\n",
        "        lon[i_z],\n",
        "        H_sph[i_z],\n",
        "        gamma[i_z],\n",
        "        zeta_obs[i_z],\n",
        "\n",
        "        C0_dg,\n",
        "        D,\n",
        "        T,\n",
        "        noise_dg,\n",
        "        noise_zeta,\n",
        "        R\n",
        "    )\n",
        "\n",
        "    df_level3 = make_level3_context_dataframe(b, meta)\n",
        "\n",
        "    if verbose:\n",
        "        print(\n",
        "            f\"  Уровень 3: {len(df_level3)} наблюдений \"\n",
        "            f\"({meta['ng']} dg + {meta['nz']} zeta), \"\n",
        "            f\"cond={meta['cond']:.2e}, solver={meta['solver']}\"\n",
        "        )\n",
        "\n",
        "    idx_target, d_edge = select_target_points(\n",
        "        lat,\n",
        "        lon,\n",
        "        lat_range,\n",
        "        lon_range,\n",
        "        edge_buffer_km,\n",
        "        min_dist_km,\n",
        "        R,\n",
        "        seed,\n",
        "        verbose\n",
        "    )\n",
        "\n",
        "    rows = []\n",
        "    raw_list = [] if raw_products else None\n",
        "    palette_columns = None\n",
        "\n",
        "    for source_index in idx_target:\n",
        "        row, p_P, palette = build_level4_feature_row(\n",
        "            lat_deg=lat[source_index],\n",
        "            lon_deg=lon[source_index],\n",
        "            H_sph_m=H_sph[source_index],\n",
        "            d_edge_m=d_edge[source_index],\n",
        "            b=b,\n",
        "            meta=meta,\n",
        "            C0_dg=C0_dg,\n",
        "            D=D,\n",
        "            T=T,\n",
        "            near_exclude_km=near_exclude_km,\n",
        "            rings_km=rings_km,\n",
        "            k_individual=k_individual,\n",
        "            agg=agg,\n",
        "            R=R,\n",
        "            include_tail_beyond_r_max=include_tail_beyond_r_max\n",
        "        )\n",
        "\n",
        "        current_palette_columns = list(palette.keys())\n",
        "        if palette_columns is None:\n",
        "            palette_columns = current_palette_columns\n",
        "        elif current_palette_columns != palette_columns:\n",
        "            raise AssertionError(\n",
        "                \"Структура абсолютной палетки различается между точками.\"\n",
        "            )\n",
        "\n",
        "        row[\"source_index\"] = int(source_index)\n",
        "        row[\"H_normal_true_m\"] = float(H_normal_true[source_index])\n",
        "        row[\"H_leveling_obs_m\"] = float(H_leveling[source_index])\n",
        "        row[\"r_Q_true_m\"] = float(r_Q_true[source_index])\n",
        "        row[\"r_Q_obs_m\"] = float(r_Q_obs[source_index])\n",
        "        row[\"W_full_P_m2_s2\"] = float(W_full_P[source_index])\n",
        "        row[\"W_200_P_m2_s2\"] = float(W_200_P[source_index])\n",
        "        row[\"W_200_Q_true_m2_s2\"] = float(W_200_Q_true[source_index])\n",
        "        row[\"W_200_Q_obs_m2_s2\"] = float(W_200_Q_obs[source_index])\n",
        "        row[\"energy_consistency_error_true_m2_s2\"] = float(\n",
        "            energy_error_true[source_index]\n",
        "        )\n",
        "        row[\"T_true_P_control\"] = float(T_true[source_index])\n",
        "\n",
        "        rows.append(row)\n",
        "\n",
        "        if raw_products:\n",
        "            raw_list.append(p_P)\n",
        "\n",
        "    if not rows:\n",
        "        raise ValueError(\"После отбора не осталось результативных точек.\")\n",
        "\n",
        "    df_level4 = pd.DataFrame(rows)\n",
        "    palette_columns = list(palette_columns)\n",
        "\n",
        "    # Пространственное разделение выполняется ДО выбора P0.\n",
        "    split_labels, spatial_groups, split_mode = (\n",
        "        _assign_spatial_splits_relative(\n",
        "            df_level4,\n",
        "            test_fraction=test_fraction,\n",
        "            validation_fraction=validation_fraction,\n",
        "            blocks_per_axis=spatial_blocks_per_axis,\n",
        "            seed=split_seed\n",
        "        )\n",
        "    )\n",
        "    df_level4[\"split\"] = split_labels\n",
        "    df_level4[\"spatial_group\"] = spatial_groups\n",
        "\n",
        "    reference_row_position = _select_reference_from_train(df_level4)\n",
        "    reference_row = df_level4.iloc[reference_row_position].copy()\n",
        "    reference_source_index = int(reference_row[\"source_index\"])\n",
        "\n",
        "    if reference_row[\"split\"] != \"train\":\n",
        "        raise AssertionError(\"Опорная точка P0 должна принадлежать train.\")\n",
        "\n",
        "    T_lsc_0 = float(reference_row[\"T_LSC_P_residual (m2/s2)\"])\n",
        "    W_200_P0 = float(reference_row[\"W_200_P_m2_s2\"])\n",
        "    W_200_Q_obs0 = float(reference_row[\"W_200_Q_obs_m2_s2\"])\n",
        "    T_true_0 = float(reference_row[\"T_true_P_control\"])\n",
        "\n",
        "    # Все разности имеют направление «текущая точка минус опорная».\n",
        "    # Для геопотенциальных чисел это даёт W(Q0)-W(Qi).\n",
        "    df_level4[\"dC_200_i0_m2_s2\"] = (\n",
        "        W_200_P0 - df_level4[\"W_200_P_m2_s2\"].to_numpy(float)\n",
        "    )\n",
        "    df_level4[\"dC_leveling_i0_m2_s2\"] = (\n",
        "        W_200_Q_obs0 - df_level4[\"W_200_Q_obs_m2_s2\"].to_numpy(float)\n",
        "    )\n",
        "    df_level4[\"dT_leveling_i0_m2_s2\"] = (\n",
        "        df_level4[\"dC_200_i0_m2_s2\"].to_numpy(float)\n",
        "        - df_level4[\"dC_leveling_i0_m2_s2\"].to_numpy(float)\n",
        "    )\n",
        "    df_level4[\"dT_LSC_i0_m2_s2\"] = (\n",
        "        df_level4[\"T_LSC_P_residual (m2/s2)\"].to_numpy(float)\n",
        "        - T_lsc_0\n",
        "    )\n",
        "\n",
        "    target_column = \"relative_LSC_correction_label_m2_s2\"\n",
        "    df_level4[target_column] = (\n",
        "        df_level4[\"dT_leveling_i0_m2_s2\"].to_numpy(float)\n",
        "        - df_level4[\"dT_LSC_i0_m2_s2\"].to_numpy(float)\n",
        "    )\n",
        "\n",
        "    # Чистый относительный контроль на фиксированной поверхности P.\n",
        "    df_level4[\"dT_true_i0_control_m2_s2\"] = (\n",
        "        df_level4[\"T_true_P_control\"].to_numpy(float) - T_true_0\n",
        "    )\n",
        "    df_level4[\"relative_LSC_error_control_m2_s2\"] = (\n",
        "        df_level4[\"dT_LSC_i0_m2_s2\"].to_numpy(float)\n",
        "        - df_level4[\"dT_true_i0_control_m2_s2\"].to_numpy(float)\n",
        "    )\n",
        "    true_correction_column = \"true_relative_correction_control_m2_s2\"\n",
        "    df_level4[true_correction_column] = (\n",
        "        df_level4[\"dT_true_i0_control_m2_s2\"].to_numpy(float)\n",
        "        - df_level4[\"dT_LSC_i0_m2_s2\"].to_numpy(float)\n",
        "    )\n",
        "    df_level4[\"label_error_to_true_correction_m2_s2\"] = (\n",
        "        df_level4[target_column].to_numpy(float)\n",
        "        - df_level4[true_correction_column].to_numpy(float)\n",
        "    )\n",
        "\n",
        "    # Абсолютный результат, исправленный самой обучающей меткой.\n",
        "    df_level4[\"T_corrected_by_label_m2_s2\"] = (\n",
        "        df_level4[\"T_LSC_P_residual (m2/s2)\"].to_numpy(float)\n",
        "        + df_level4[target_column].to_numpy(float)\n",
        "    )\n",
        "\n",
        "    # Разностная палетка — единственный ML-вход.\n",
        "    differential_feature_columns = []\n",
        "    feature_mapping = {}\n",
        "\n",
        "    for column in palette_columns:\n",
        "        d_column = f\"d_{column}\"\n",
        "        differential_feature_columns.append(d_column)\n",
        "        feature_mapping[d_column] = (\n",
        "            f\"{column}(P_i) - {column}(P_0)\"\n",
        "        )\n",
        "        df_level4[d_column] = (\n",
        "            df_level4[column].to_numpy(float)\n",
        "            - float(reference_row[column])\n",
        "        )\n",
        "\n",
        "    df_level4[\"is_reference\"] = (\n",
        "        df_level4[\"source_index\"].to_numpy(int)\n",
        "        == reference_source_index\n",
        "    ).astype(int)\n",
        "    df_level4[\"use_for_ml\"] = 1 - df_level4[\"is_reference\"]\n",
        "\n",
        "    # Обязательные проверки.\n",
        "    reference_mask = df_level4[\"is_reference\"].eq(1).to_numpy()\n",
        "\n",
        "    if np.sum(reference_mask) != 1:\n",
        "        raise AssertionError(\"В датасете должна быть ровно одна точка P0.\")\n",
        "\n",
        "    if df_level4.loc[reference_mask, \"split\"].iloc[0] != \"train\":\n",
        "        raise AssertionError(\"P0 должна принадлежать train.\")\n",
        "\n",
        "    reference_target_abs = float(\n",
        "        np.max(np.abs(df_level4.loc[reference_mask, target_column]))\n",
        "    )\n",
        "    if reference_target_abs > 1e-12:\n",
        "        raise AssertionError(f\"Для P0 нарушено y0=0: {reference_target_abs:.3e}\")\n",
        "\n",
        "    reference_dphi_abs = float(\n",
        "        np.max(\n",
        "            np.abs(\n",
        "                df_level4.loc[\n",
        "                    reference_mask,\n",
        "                    differential_feature_columns\n",
        "                ].to_numpy(float)\n",
        "            )\n",
        "        )\n",
        "    )\n",
        "    if reference_dphi_abs > 1e-12:\n",
        "        raise AssertionError(\n",
        "            \"Для P0 нарушено dPhi_00=0: \"\n",
        "            f\"{reference_dphi_abs:.3e}\"\n",
        "        )\n",
        "\n",
        "    if len(differential_feature_columns) != len(palette_columns):\n",
        "        raise AssertionError(\n",
        "            \"Число разностных признаков не совпадает \"\n",
        "            \"с числом признаков абсолютной палетки.\"\n",
        "        )\n",
        "\n",
        "    max_feature_difference_error = 0.0\n",
        "    for source_column, d_column in zip(\n",
        "        palette_columns,\n",
        "        differential_feature_columns\n",
        "    ):\n",
        "        residual = (\n",
        "            df_level4[d_column].to_numpy(float)\n",
        "            - (\n",
        "                df_level4[source_column].to_numpy(float)\n",
        "                - float(reference_row[source_column])\n",
        "            )\n",
        "        )\n",
        "        max_feature_difference_error = max(\n",
        "            max_feature_difference_error,\n",
        "            float(np.max(np.abs(residual)))\n",
        "        )\n",
        "\n",
        "    if max_feature_difference_error > 1e-12:\n",
        "        raise AssertionError(\n",
        "            \"Нарушено dPhi=Phi(P_i)-Phi(P0): \"\n",
        "            f\"{max_feature_difference_error:.3e}\"\n",
        "        )\n",
        "\n",
        "    target_construction_columns = [\n",
        "        \"W_200_P_m2_s2\",\n",
        "        \"W_200_Q_obs_m2_s2\",\n",
        "        \"dC_200_i0_m2_s2\",\n",
        "        \"dC_leveling_i0_m2_s2\",\n",
        "        \"dT_leveling_i0_m2_s2\",\n",
        "        \"T_LSC_P_residual (m2/s2)\",\n",
        "        \"dT_LSC_i0_m2_s2\",\n",
        "        target_column\n",
        "    ]\n",
        "\n",
        "    leaked_target_columns = sorted(\n",
        "        set(differential_feature_columns).intersection(\n",
        "            target_construction_columns\n",
        "        )\n",
        "    )\n",
        "    if leaked_target_columns:\n",
        "        raise AssertionError(\n",
        "            \"Компоненты target попали в ML-признаки: \"\n",
        "            f\"{leaked_target_columns}\"\n",
        "        )\n",
        "\n",
        "    if not all(\n",
        "        column.startswith(\"d_\")\n",
        "        for column in differential_feature_columns\n",
        "    ):\n",
        "        raise AssertionError(\n",
        "            \"Все ML-признаки должны иметь единый префикс d_.\"\n",
        "        )\n",
        "\n",
        "    # Диагностическая тождественность для метки.\n",
        "    corrected_relative_by_label = (\n",
        "        df_level4[\"dT_LSC_i0_m2_s2\"].to_numpy(float)\n",
        "        + df_level4[target_column].to_numpy(float)\n",
        "    )\n",
        "    max_label_identity_error = float(\n",
        "        np.max(\n",
        "            np.abs(\n",
        "                corrected_relative_by_label\n",
        "                - df_level4[\"dT_leveling_i0_m2_s2\"].to_numpy(float)\n",
        "            )\n",
        "        )\n",
        "    )\n",
        "    if max_label_identity_error > 1e-12:\n",
        "        raise AssertionError(\n",
        "            \"Нарушено dT_LSC+y=dT_leveling: \"\n",
        "            f\"{max_label_identity_error:.3e}\"\n",
        "        )\n",
        "\n",
        "    diagnostic_columns = [\n",
        "        *palette_columns,\n",
        "        \"latitude_deg\",\n",
        "        \"longitude_deg\",\n",
        "        \"H_sph_m\",\n",
        "        \"distance_to_edge_m\",\n",
        "        \"H_normal_true_m\",\n",
        "        \"H_leveling_obs_m\",\n",
        "        \"r_Q_true_m\",\n",
        "        \"r_Q_obs_m\",\n",
        "        \"W_full_P_m2_s2\",\n",
        "        \"W_200_Q_true_m2_s2\",\n",
        "        \"energy_consistency_error_true_m2_s2\",\n",
        "        *target_construction_columns,\n",
        "        \"T_true_P_control\",\n",
        "        \"dT_true_i0_control_m2_s2\",\n",
        "        \"relative_LSC_error_control_m2_s2\",\n",
        "        true_correction_column,\n",
        "        \"label_error_to_true_correction_m2_s2\",\n",
        "        \"T_corrected_by_label_m2_s2\",\n",
        "        \"split\",\n",
        "        \"spatial_group\",\n",
        "        \"is_reference\",\n",
        "        \"use_for_ml\"\n",
        "    ]\n",
        "\n",
        "    ordered_columns = list(dict.fromkeys([\n",
        "        \"source_index\",\n",
        "        *differential_feature_columns,\n",
        "        target_column,\n",
        "        *diagnostic_columns\n",
        "    ]))\n",
        "    df_level4 = df_level4[ordered_columns]\n",
        "\n",
        "    noise_attrs = {\n",
        "        \"sigma_dg_mgal\": data.attrs.get(\"sigma_dg_mgal\"),\n",
        "        \"sigma_zeta_m\": data.attrs.get(\"sigma_zeta_m\"),\n",
        "        \"sigma_H_leveling_m\": data.attrs.get(\"sigma_H_leveling_m\"),\n",
        "        \"leveling_common_bias_m\": data.attrs.get(\n",
        "            \"leveling_common_bias_m\"\n",
        "        ),\n",
        "        \"sigma_defl_arcsec\": data.attrs.get(\"sigma_defl_arcsec\"),\n",
        "        \"noise_seed\": data.attrs.get(\"noise_seed\"),\n",
        "        \"add_noise\": data.attrs.get(\"add_noise\")\n",
        "    }\n",
        "\n",
        "    reference_palette = {\n",
        "        column: float(reference_row[column])\n",
        "        for column in palette_columns\n",
        "    }\n",
        "\n",
        "    reference_config = {\n",
        "        \"source_index\": reference_source_index,\n",
        "        \"row_position\": int(reference_row_position),\n",
        "        \"split\": \"train\",\n",
        "        \"selection_rule\": \"nearest_to_train_coordinate_centroid\",\n",
        "        \"latitude_deg\": float(reference_row[\"latitude_deg\"]),\n",
        "        \"longitude_deg\": float(reference_row[\"longitude_deg\"]),\n",
        "        \"T_LSC_P0_m2_s2\": T_lsc_0,\n",
        "        \"W_200_P0_m2_s2\": W_200_P0,\n",
        "        \"W_200_Q0_obs_m2_s2\": W_200_Q_obs0,\n",
        "        \"T_true_P0_m2_s2\": T_true_0,\n",
        "        \"palette_P0\": reference_palette\n",
        "    }\n",
        "\n",
        "    config = {\n",
        "        \"format_version\": \"4.0-spherical-etopo-energy-relative-lsc\",\n",
        "        \"created_utc\": datetime.now(timezone.utc).isoformat(),\n",
        "        \"description\": (\n",
        "            \"Относительная ML-поправка к LSC на физической поверхности. \"\n",
        "            \"ETOPO1 задаёт H_sph над опорной сферой; Q используется только \"\n",
        "            \"для энергетической нормальной высоты и нивелирной разности. \"\n",
        "            \"ML получает только d_Phi относительно одного P0 из train.\"\n",
        "        ),\n",
        "        \"files\": {\n",
        "            \"level3_context_csv\": str(level3_csv_path),\n",
        "            \"level4_dataset_csv\": str(level4_csv_path),\n",
        "            \"level4_config_json\": str(config_json_path)\n",
        "        },\n",
        "        \"target\": {\n",
        "            \"column\": target_column,\n",
        "            \"quantity\": \"relative_correction_to_LSC\",\n",
        "            \"unit\": \"m2/s2\",\n",
        "            \"formula\": (\n",
        "                \"y_i=[dC200_i0-dClev_i0]-\"\n",
        "                \"[T_LSC(P_i)-T_LSC(P0)]\"\n",
        "            ),\n",
        "            \"difference_direction\": \"current_minus_reference\",\n",
        "            \"construction_columns\": target_construction_columns,\n",
        "            \"construction_columns_are_features\": False,\n",
        "            \"true_correction_control_column\": true_correction_column,\n",
        "            \"relative_true_control_column\": (\n",
        "                \"dT_true_i0_control_m2_s2\"\n",
        "            ),\n",
        "            \"relative_lsc_column\": \"dT_LSC_i0_m2_s2\",\n",
        "            \"absolute_lsc_column\": \"T_LSC_P_residual (m2/s2)\",\n",
        "            \"absolute_true_control_column\": \"T_true_P_control\",\n",
        "            \"absolute_corrected_formula\": (\n",
        "                \"T_hat(P_i)=T_LSC(P_i)+y_hat_i\"\n",
        "            )\n",
        "        },\n",
        "        \"reference_point\": reference_config,\n",
        "        \"split\": {\n",
        "            \"column\": \"split\",\n",
        "            \"mode\": split_mode,\n",
        "            \"performed_before_reference_selection\": True,\n",
        "            \"test_fraction\": float(test_fraction),\n",
        "            \"validation_fraction\": float(validation_fraction),\n",
        "            \"spatial_blocks_per_axis\": int(spatial_blocks_per_axis),\n",
        "            \"seed\": int(split_seed),\n",
        "            \"same_reference_for_all_splits\": True,\n",
        "            \"reference_excluded_from_ml\": True\n",
        "        },\n",
        "\n",
        "\"leveling\": {\n",
        "    \"H_sph_definition\": \"H_sph=H_ETOPO1\",\n",
        "    \"r_P_definition\": \"r_P=R+H_sph\",\n",
        "    \"zeta_sign_convention\": (\n",
        "        \"standard_Bruns_zeta=T/gamma; H_normal_true=H_sph-zeta_true\"\n",
        "    ),\n",
        "    \"energy_equation\": \"W200(Q_true)=WN(P)\",\n",
        "    \"H_leveling_observation_model\": (\n",
        "        \"H_leveling_obs=H_normal_true+b_H+epsilon_i\"\n",
        "    ),\n",
        "    \"r_Q_obs_definition\": \"r_Q_obs=R+H_leveling_obs\",\n",
        "    \"dC_leveling_formula\": (\n",
        "        \"dClev_i0=W200(Q0_obs)-W200(Qi_obs)\"\n",
        "    ),\n",
        "    \"dC_200_formula\": (\n",
        "        \"dC200_i0=W200(P0)-W200(P_i)\"\n",
        "    ),\n",
        "    \"relative_residual_formula\": (\n",
        "        \"dTlev_i0=dC200_i0-dClev_i0\"\n",
        "    ),\n",
        "    \"common_height_bias_cancellation_is_not_exact\": True,\n",
        "    \"spatial_accumulation_error\": None\n",
        "},\n",
        "\"covariance_model\": {\n",
        "            \"name\": \"Forsberg\",\n",
        "            \"C0_dg_mgal2\": float(C0_dg),\n",
        "            \"D_m\": float(D),\n",
        "            \"T_m\": float(T),\n",
        "            \"R_sphere_m\": float(R),\n",
        "            \"observation_geometry\": \"physical_surface_P_at_R_plus_H_sph\",\n",
        "            \"palette_geometry\": \"physical_surface_P_at_R_plus_H_sph\"\n",
        "        },\n",
        "        \"observation_system\": {\n",
        "            \"n_dg\": int(meta[\"ng\"]),\n",
        "            \"n_zeta\": int(meta[\"nz\"]),\n",
        "            \"n_total\": int(len(b)),\n",
        "            \"condition_number\": float(meta[\"cond\"]),\n",
        "            \"solver\": meta[\"solver\"],\n",
        "            \"noise\": noise_attrs\n",
        "        },\n",
        "        \"level4_generation\": {\n",
        "            \"rings_outer_radii_km\": list(rings_km),\n",
        "            \"include_tail_beyond_r_max\": bool(\n",
        "                include_tail_beyond_r_max\n",
        "            ),\n",
        "            \"palette_metadata\": (\n",
        "                palette_metadata if palette_metadata is not None else {}\n",
        "            ),\n",
        "            \"aggregation\": agg,\n",
        "            \"near_exclude_km\": float(near_exclude_km),\n",
        "            \"k_individual\": int(k_individual),\n",
        "            \"minimum_target_spacing_km\": float(min_dist_km),\n",
        "            \"edge_buffer_km\": float(edge_buffer_km),\n",
        "            \"selection_seed\": int(seed)\n",
        "        },\n",
        "        \"schema\": {\n",
        "            \"feature_columns\": differential_feature_columns,\n",
        "            \"feature_schema_sha256\": _feature_schema_hash(\n",
        "                differential_feature_columns\n",
        "            ),\n",
        "            \"absolute_palette_columns_retained_for_diagnostics\": (\n",
        "                palette_columns\n",
        "            ),\n",
        "            \"differential_feature_mapping\": feature_mapping,\n",
        "            \"target_column\": target_column,\n",
        "            \"diagnostic_columns\": diagnostic_columns,\n",
        "            \"target_construction_columns\": target_construction_columns,\n",
        "            \"reference_row_flag_column\": \"is_reference\",\n",
        "            \"use_for_ml_column\": \"use_for_ml\",\n",
        "            \"split_column\": \"split\"\n",
        "        },\n",
        "        \"checks\": {\n",
        "            \"difference_direction\": \"current_minus_reference\",\n",
        "            \"reference_target_abs\": reference_target_abs,\n",
        "            \"reference_dphi_max_abs\": reference_dphi_abs,\n",
        "            \"n_absolute_palette_features\": len(palette_columns),\n",
        "            \"n_differential_features\": len(\n",
        "                differential_feature_columns\n",
        "            ),\n",
        "            \"max_feature_difference_error\": (\n",
        "                max_feature_difference_error\n",
        "            ),\n",
        "            \"max_label_identity_error\": max_label_identity_error,\n",
        "            \"target_leakage_columns\": leaked_target_columns\n",
        "        }\n",
        "    }\n",
        "\n",
        "    if save_outputs:\n",
        "        df_level3.to_csv(level3_csv_path, index=False)\n",
        "        df_level4.to_csv(level4_csv_path, index=False)\n",
        "        save_json(config, config_json_path)\n",
        "\n",
        "    if verbose:\n",
        "        counts = df_level4[\"split\"].value_counts().to_dict()\n",
        "        print(\n",
        "            f\"  Уровень 4: {len(df_level4)} строк x \"\n",
        "            f\"{len(differential_feature_columns)} разностных признаков\"\n",
        "        )\n",
        "        print(\n",
        "            f\"  split: {counts}; режим={split_mode}\"\n",
        "        )\n",
        "        print(\n",
        "            \"  P0: source_index=\"\n",
        "            f\"{reference_source_index}, \"\n",
        "            f\"lat={reference_config['latitude_deg']:.6f}, \"\n",
        "            f\"lon={reference_config['longitude_deg']:.6f}\"\n",
        "        )\n",
        "        print(\n",
        "            \"  max|y0|=\"\n",
        "            f\"{reference_target_abs:.3e}; \"\n",
        "            \"max|dPhi00|=\"\n",
        "            f\"{reference_dphi_abs:.3e}\"\n",
        "        )\n",
        "        print(\n",
        "            \"  схема признаков: \"\n",
        "            f\"{config['schema']['feature_schema_sha256'][:12]}...\"\n",
        "        )\n",
        "\n",
        "    meta_out = {\n",
        "        **meta,\n",
        "        \"config\": config,\n",
        "        \"reference\": reference_config,\n",
        "        \"palette_columns\": palette_columns,\n",
        "        \"differential_feature_columns\": differential_feature_columns,\n",
        "        \"split_mode\": split_mode\n",
        "    }\n",
        "\n",
        "    if raw_products:\n",
        "        return (\n",
        "            df_level4,\n",
        "            np.vstack(raw_list),\n",
        "            b,\n",
        "            meta_out\n",
        "        )\n",
        "\n",
        "    return df_level4, b, meta_out\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "lDksFkZ6z9zl"
      },
      "outputs": [],
      "source": [
        "def split_points(n_total, frac_g=0.5, frac_z=0.3, frac_v=0.2, seed=42):\n",
        "    \"\"\"Разделяет точки на непересекающиеся наборы: Δg, ζ и ξ/η.\"\"\"\n",
        "    n_total = int(n_total)\n",
        "    fractions = np.array([frac_g, frac_z, frac_v], dtype=float)\n",
        "\n",
        "    if n_total < 1:\n",
        "        raise ValueError(\"n_total должно быть положительным.\")\n",
        "    if np.any(fractions < 0):\n",
        "        raise ValueError(\"Доли точек не могут быть отрицательными.\")\n",
        "    if not np.isclose(fractions.sum(), 1.0):\n",
        "        raise ValueError(\"frac_g + frac_z + frac_v должно быть равно 1.\")\n",
        "\n",
        "    rng = np.random.default_rng(seed)\n",
        "    idx = rng.permutation(n_total)\n",
        "\n",
        "    n_g = int(np.floor(frac_g * n_total))\n",
        "    n_z = int(np.floor(frac_z * n_total))\n",
        "    n_v = n_total - n_g - n_z\n",
        "\n",
        "    if frac_g > 0 and n_g == 0:\n",
        "        raise ValueError(\"Слишком мало точек для набора Δg.\")\n",
        "    if frac_z > 0 and n_z == 0:\n",
        "        raise ValueError(\"Слишком мало точек для набора ζ.\")\n",
        "    if frac_v > 0 and n_v == 0:\n",
        "        raise ValueError(\"Слишком мало точек для набора ξ/η.\")\n",
        "\n",
        "    i_g = np.sort(idx[:n_g])\n",
        "    i_z = np.sort(idx[n_g:n_g + n_z])\n",
        "    i_v = np.sort(idx[n_g + n_z:])\n",
        "\n",
        "    return i_g, i_z, i_v\n",
        "\n",
        "def build_hetero_system(lat_g, lon_g, H_g, gamma_g,\n",
        "                        lat_z, lon_z, H_z, gamma_z,\n",
        "                        lat_v, lon_v, H_v, gamma_v,\n",
        "                        lat_p, lon_p, H_p,\n",
        "                        C0_dg, D, T, R=R_EARTH):\n",
        "    \"\"\"Строит C_dd и C_pd. Порядок наблюдений: [dg | zeta | xi | eta].\"\"\"\n",
        "    arr = lambda x: np.asarray(x, float).ravel()\n",
        "\n",
        "    lat_g, lon_g, H_g, gamma_g = map(arr, (lat_g, lon_g, H_g, gamma_g))\n",
        "    lat_z, lon_z, H_z, gamma_z = map(arr, (lat_z, lon_z, H_z, gamma_z))\n",
        "    lat_v, lon_v, H_v, gamma_v = map(arr, (lat_v, lon_v, H_v, gamma_v))\n",
        "    lat_p, lon_p, H_p = map(arr, (lat_p, lon_p, H_p))\n",
        "\n",
        "    for name, arrays in {\n",
        "        \"dg\": (lat_g, lon_g, H_g, gamma_g),\n",
        "        \"zeta\": (lat_z, lon_z, H_z, gamma_z),\n",
        "        \"xi/eta\": (lat_v, lon_v, H_v, gamma_v),\n",
        "        \"target\": (lat_p, lon_p, H_p)\n",
        "    }.items():\n",
        "        lengths = {len(a) for a in arrays}\n",
        "        if len(lengths) != 1:\n",
        "            raise ValueError(f\"Массивы группы {name} имеют разную длину.\")\n",
        "\n",
        "    ng, nz, nv, npnt = len(H_g), len(H_z), len(H_v), len(H_p)\n",
        "    if ng + nz + nv == 0:\n",
        "        raise ValueError(\"Нет ни одной точки наблюдений.\")\n",
        "\n",
        "    all_lat = np.concatenate([a for a in (lat_g, lat_z, lat_v) if len(a)])\n",
        "    all_lon = np.concatenate([a for a in (lon_g, lon_z, lon_v) if len(a)])\n",
        "    lat0, lon0 = float(all_lat.mean()), float(all_lon.mean())\n",
        "\n",
        "    Xg, Yg = latlon_to_xy(lat_g, lon_g, R, lat0, lon0)\n",
        "    Xz, Yz = latlon_to_xy(lat_z, lon_z, R, lat0, lon0)\n",
        "    Xv, Yv = latlon_to_xy(lat_v, lon_v, R, lat0, lon0)\n",
        "    Xp, Yp = latlon_to_xy(lat_p, lon_p, R, lat0, lon0)\n",
        "\n",
        "    n = ng + nz + 2 * nv\n",
        "    i_g = slice(0, ng)\n",
        "    i_z = slice(ng, ng + nz)\n",
        "    i_x = slice(ng + nz, ng + nz + nv)\n",
        "    i_e = slice(ng + nz + nv, n)\n",
        "    C = np.zeros((n, n), dtype=float)\n",
        "\n",
        "    if ng:\n",
        "        s_gg, _, _ = geom(Xg, Yg, Xg, Yg)\n",
        "        C[i_g, i_g] = cov_dg_dg(\n",
        "            s_gg, C0_dg, D, T, H_g[:, None], H_g[None, :]\n",
        "        )\n",
        "\n",
        "    if nz:\n",
        "        s_zz, _, _ = geom(Xz, Yz, Xz, Yz)\n",
        "        C[i_z, i_z] = cov_zeta_zeta(\n",
        "            s_zz, C0_dg, D, T,\n",
        "            gamma_z[:, None], gamma_z[None, :],\n",
        "            H_z[:, None], H_z[None, :]\n",
        "        )\n",
        "\n",
        "    if nv:\n",
        "        s_vv, ca_vv, sa_vv = geom(Xv, Yv, Xv, Yv)\n",
        "        C[i_x, i_x] = cov_xi_xi(\n",
        "            s_vv, ca_vv, sa_vv, C0_dg, D, T,\n",
        "            gamma_v[:, None], gamma_v[None, :],\n",
        "            H_v[:, None], H_v[None, :]\n",
        "        )\n",
        "        C[i_e, i_e] = cov_eta_eta(\n",
        "            s_vv, ca_vv, sa_vv, C0_dg, D, T,\n",
        "            gamma_v[:, None], gamma_v[None, :],\n",
        "            H_v[:, None], H_v[None, :]\n",
        "        )\n",
        "        C_xe = cov_xi_eta(\n",
        "            s_vv, ca_vv, sa_vv, C0_dg, D, T,\n",
        "            gamma_v[:, None], gamma_v[None, :],\n",
        "            H_v[:, None], H_v[None, :]\n",
        "        )\n",
        "        C[i_x, i_e] = C_xe\n",
        "        C[i_e, i_x] = C_xe.T\n",
        "\n",
        "    if ng and nz:\n",
        "        s_zg, _, _ = geom(Xz, Yz, Xg, Yg)\n",
        "        C_zg = cov_zeta_dg(\n",
        "            s_zg, C0_dg, D, T, gamma_z[:, None],\n",
        "            H_z[:, None], H_g[None, :]\n",
        "        )\n",
        "        C[i_z, i_g] = C_zg\n",
        "        C[i_g, i_z] = C_zg.T\n",
        "\n",
        "    if ng and nv:\n",
        "        s_gv, ca_gv, sa_gv = geom(Xg, Yg, Xv, Yv)\n",
        "        C_gx = cov_dg_xi(\n",
        "            s_gv, ca_gv, C0_dg, D, T, gamma_v[None, :],\n",
        "            H_g[:, None], H_v[None, :]\n",
        "        )\n",
        "        C_ge = cov_dg_eta(\n",
        "            s_gv, sa_gv, C0_dg, D, T, gamma_v[None, :],\n",
        "            H_g[:, None], H_v[None, :]\n",
        "        )\n",
        "        C[i_g, i_x], C[i_x, i_g] = C_gx, C_gx.T\n",
        "        C[i_g, i_e], C[i_e, i_g] = C_ge, C_ge.T\n",
        "\n",
        "    if nz and nv:\n",
        "        s_zv, ca_zv, sa_zv = geom(Xz, Yz, Xv, Yv)\n",
        "        C_zx = cov_zeta_xi(\n",
        "            s_zv, ca_zv, C0_dg, D, T,\n",
        "            gamma_z[:, None], gamma_v[None, :],\n",
        "            H_z[:, None], H_v[None, :]\n",
        "        )\n",
        "        C_ze = cov_zeta_eta(\n",
        "            s_zv, sa_zv, C0_dg, D, T,\n",
        "            gamma_z[:, None], gamma_v[None, :],\n",
        "            H_z[:, None], H_v[None, :]\n",
        "        )\n",
        "        C[i_z, i_x], C[i_x, i_z] = C_zx, C_zx.T\n",
        "        C[i_z, i_e], C[i_e, i_z] = C_ze, C_ze.T\n",
        "\n",
        "    pred_blocks = []\n",
        "\n",
        "    if ng:\n",
        "        s_pg, _, _ = geom(Xp, Yp, Xg, Yg)\n",
        "        pred_blocks.append(cov_T_dg(\n",
        "            s_pg, C0_dg, D, T, H_p[:, None], H_g[None, :]\n",
        "        ))\n",
        "    else:\n",
        "        pred_blocks.append(np.empty((npnt, 0)))\n",
        "\n",
        "    if nz:\n",
        "        s_pz, _, _ = geom(Xp, Yp, Xz, Yz)\n",
        "        pred_blocks.append(cov_T_zeta(\n",
        "            s_pz, C0_dg, D, T, gamma_z[None, :],\n",
        "            H_p[:, None], H_z[None, :]\n",
        "        ))\n",
        "    else:\n",
        "        pred_blocks.append(np.empty((npnt, 0)))\n",
        "\n",
        "    if nv:\n",
        "        s_pv, ca_pv, sa_pv = geom(Xp, Yp, Xv, Yv)\n",
        "        pred_blocks.append(cov_T_xi(\n",
        "            s_pv, ca_pv, C0_dg, D, T, gamma_v[None, :],\n",
        "            H_p[:, None], H_v[None, :]\n",
        "        ))\n",
        "        pred_blocks.append(cov_T_eta(\n",
        "            s_pv, sa_pv, C0_dg, D, T, gamma_v[None, :],\n",
        "            H_p[:, None], H_v[None, :]\n",
        "        ))\n",
        "    else:\n",
        "        pred_blocks.extend([np.empty((npnt, 0)), np.empty((npnt, 0))])\n",
        "\n",
        "    C = 0.5 * (C + C.T)\n",
        "    C_pd = np.hstack(pred_blocks)\n",
        "\n",
        "    if C.shape != (n, n) or C_pd.shape != (npnt, n):\n",
        "        raise RuntimeError(\n",
        "            f\"Ошибка размеров: C={C.shape}, C_pd={C_pd.shape}, ожидалось {(n, n)} и {(npnt, n)}.\"\n",
        "        )\n",
        "    if not np.all(np.isfinite(C)) or not np.all(np.isfinite(C_pd)):\n",
        "        raise ValueError(\"В ковариационных матрицах обнаружены NaN или inf.\")\n",
        "\n",
        "    return C, C_pd, (ng, nz, nv)\n",
        "\n",
        "\n",
        "def lsc_hetero(lat_g, lon_g, H_g, gamma_g, dg_mgal,\n",
        "               lat_z, lon_z, H_z, gamma_z, zeta_m,\n",
        "               lat_v, lon_v, H_v, gamma_v, xi_rad, eta_rad,\n",
        "               lat_p, lon_p, H_p, C0_dg, D, T,\n",
        "               noise_dg=0.25, noise_zeta=0.01, noise_defl=None,\n",
        "               use=(\"dg\", \"zeta\"), R=R_EARTH, verbose=False):\n",
        "    \"\"\"\n",
        "    Разнородная LSC-оценка потенциала T в точках P.\n",
        "\n",
        "    H_g, H_z, H_v — индивидуальные физические высоты наблюдений.\n",
        "    H_p — индивидуальные высоты физических результативных точек P_i.\n",
        "    use — используемые трансформанты: dg, zeta, xi, eta.\n",
        "    \"\"\"\n",
        "    allowed = {\"dg\", \"zeta\", \"xi\", \"eta\"}\n",
        "    use = tuple(use)\n",
        "\n",
        "    if not use:\n",
        "        raise ValueError(\"Нужно выбрать хотя бы одну трансформанту.\")\n",
        "    unknown = set(use) - allowed\n",
        "    if unknown:\n",
        "        raise ValueError(f\"Неизвестные трансформанты: {sorted(unknown)}\")\n",
        "    if noise_defl is None:\n",
        "        noise_defl = (0.3 * ARCSEC) ** 2\n",
        "\n",
        "    C, C_pd, (ng, nz, nv) = build_hetero_system(\n",
        "        lat_g, lon_g, H_g, gamma_g,\n",
        "        lat_z, lon_z, H_z, gamma_z,\n",
        "        lat_v, lon_v, H_v, gamma_v,\n",
        "        lat_p, lon_p, H_p,\n",
        "        C0_dg, D, T, R=R\n",
        "    )\n",
        "\n",
        "    n = ng + nz + 2 * nv\n",
        "    mask = np.zeros(n, dtype=bool)\n",
        "    if \"dg\" in use:\n",
        "        mask[:ng] = True\n",
        "    if \"zeta\" in use:\n",
        "        mask[ng:ng + nz] = True\n",
        "    if \"xi\" in use:\n",
        "        mask[ng + nz:ng + nz + nv] = True\n",
        "    if \"eta\" in use:\n",
        "        mask[ng + nz + nv:] = True\n",
        "\n",
        "    l = np.concatenate([\n",
        "        np.asarray(dg_mgal, float).ravel(),\n",
        "        np.asarray(zeta_m, float).ravel(),\n",
        "        np.asarray(xi_rad, float).ravel(),\n",
        "        np.asarray(eta_rad, float).ravel()\n",
        "    ])\n",
        "\n",
        "    noise = np.concatenate([\n",
        "        np.full(ng, float(noise_dg)),\n",
        "        np.full(nz, float(noise_zeta)),\n",
        "        np.full(nv, float(noise_defl)),\n",
        "        np.full(nv, float(noise_defl))\n",
        "    ])\n",
        "\n",
        "    if len(l) != n:\n",
        "        raise ValueError(f\"Размер вектора наблюдений {len(l)} не совпадает с размером системы {n}.\")\n",
        "\n",
        "    C_use = C[np.ix_(mask, mask)]\n",
        "    C_use = 0.5 * (C_use + C_use.T)\n",
        "    C_use += np.diag(noise[mask])\n",
        "\n",
        "    if not np.all(np.isfinite(C_use)):\n",
        "        raise ValueError(\"Ковариационная матрица содержит NaN или inf.\")\n",
        "    if not np.all(np.isfinite(l[mask])):\n",
        "        raise ValueError(\"Вектор наблюдений содержит NaN или inf.\")\n",
        "\n",
        "    cond = np.linalg.cond(C_use)\n",
        "    if verbose:\n",
        "        print(f\"    {'+'.join(use)}: система {C_use.shape[0]}x{C_use.shape[1]}, cond={cond:.2e}\")\n",
        "    if not np.isfinite(cond) or cond > 1e14:\n",
        "        raise np.linalg.LinAlgError(f\"Ковариационная матрица плохо обусловлена: cond={cond:.2e}\")\n",
        "\n",
        "    b = np.linalg.solve(C_use, l[mask])\n",
        "    return C_pd[:, mask] @ b"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "_sPqaLaSvxsV"
      },
      "source": [
        "# Панель управления: относительная ML-поправка к LSC"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/"
        },
        "id": "3awXUSN5t38W",
        "outputId": "f07b3339-fbc1-428c-9fce-36d37f4a446d"
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "========================================================================\n",
            "Регион: широта (35.0, 39.0), долгота (250.0, 254.0)\n",
            "Сетка: 100x100 = 10000 точек\n",
            "ГГМ: степень 2190; опорное поле: n <= 200\n",
            "Шумы: dg=0.01 мГал; zeta=0.2 м; H_leveling=0.01 м; bias_H_leveling=0.2 м\n",
            "========================================================================\n",
            "Сетка: 100x100 = 10000 точек\n",
            "ETOPO1 найден: /content/data/etopo1/ETOPO1_Ice_g_geotiff.tif\n",
            "Высоты ETOPO1 над сферой: min=1261.5 м, mean=2225.9 м, max=3956.4 м\n",
            "Готово: 10000 физических точек P; наблюдения зашумлены=True.\n",
            "Энергетическая согласованность: max|W200(Q_true)-WN(P)|=1.707e-05 м²/с²; итераций=1.\n",
            "\n",
            "Физическая поверхность ETOPO1: 10000 точек\n",
            "  H_sph: min=1261.5 м, mean=2225.9 м, max=3956.4 м\n",
            "  zeta_true: min=-1.2216 м, mean=-0.0202 м, max=1.8429 м\n",
            "  H_normal_true: min=1262.5 м, mean=2225.9 м, max=3956.4 м\n",
            "  H_leveling_obs: min=1262.7 м, mean=2226.1 м, max=3956.6 м\n",
            "  проверка W200(Q_true)=WN(P): max=1.707e-05 м²/с²; RMS=6.182e-06 м²/с²\n",
            "  СКО наблюдений: dg=31.03 мГал; zeta=0.5640 м; xi=4.49\"; eta=4.64\"\n",
            "\n",
            "--- Ковариация Форсберга ---\n",
            "  C0=963.12 мГал²; D=29.095 км; T=4.364 км; tau=0.1500\n",
            "  R²=0.9578\n",
            "  ВНИМАНИЕ: tau находится у нижней границы.\n",
            "\n",
            "--- Активная адаптивная палетка ---\n",
            "  D = 29.095 км\n",
            "  диаметр области = 569.080 км\n",
            "  корреляционный предел = 6 * D = 174.570 км\n",
            "  R_max = 174.570 км (min(диаметр области, 6 * D))\n",
            "  alpha = 1.6; колец = 12\n",
            "  хвостовое кольцо [R_max, inf) отключено\n",
            "  границы, км: [  1.591   1.91    2.449   3.321   4.733   7.058  10.978  17.766  29.851\n",
            "  51.988  93.703 174.57 ]\n",
            "\n",
            "--- Разделение наблюдений ---\n",
            "  dg: 7000\n",
            "  zeta: 3000\n",
            "  xi/eta: 0\n",
            "\n",
            "--- Относительная схема ---\n",
            "  P_i задаётся r_P=R+H_sph, где H_sph=H_ETOPO1.\n",
            "  Пространственный split выполняется до выбора P0.\n",
            "  P0 выбирается только из train и едина для всех выборок.\n",
            "  ML-вход: d_Phi(P_i,P0).\n",
            "  dClev_i0=W200(Q0_obs)-W200(Qi_obs).\n",
            "  ML-цель: относительная поправка y_i к dT_LSC(P_i,P0).\n",
            "\n",
            "--- Формирование относительного ML-датасета ---\n",
            "  Уровень 3: 10000 наблюдений (7000 dg + 3000 zeta), cond=3.95e+07, solver=solve\n",
            "    точек: 10000 -> буфер 20 км: 7920 -> прореж. 10 км: 711\n"
          ]
        },
        {
          "output_type": "stream",
          "name": "stderr",
          "text": [
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2078: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[d_column] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2083: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[\"is_reference\"] = (\n",
            "/tmp/ipykernel_2952/3196182828.py:2087: PerformanceWarning: DataFrame is highly fragmented.  This is usually the result of calling `frame.insert` many times, which has poor performance.  Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`\n",
            "  df_level4[\"use_for_ml\"] = 1 - df_level4[\"is_reference\"]\n"
          ]
        },
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "  Уровень 4: 711 строк x 140 разностных признаков\n",
            "  split: {'train': 401, 'test': 177, 'validation': 133}; режим=spatial_groups\n",
            "  P0: source_index=4751, lat=36.898990, lon=252.060606\n",
            "  max|y0|=0.000e+00; max|dPhi00|=0.000e+00\n",
            "  схема признаков: b8e60719ffad...\n",
            "\n",
            "--- Опорная точка P0 ---\n",
            "  source_index=4751; split=train; lat=36.898990; lon=252.060606\n",
            "\n",
            "--- Диагностика относительной конструкции ---\n",
            "  метка y vs чистая требуемая поправка: bias=0.230 см; MAE=0.786 см; RMS=0.981 см\n",
            "  исходная dT_LSC vs dT_true: bias=-2.064 см; MAE=2.280 см; RMS=2.877 см\n",
            "  dT_LSC + y_label vs dT_true: bias=0.230 см; MAE=0.786 см; RMS=0.981 см\n",
            "  абсолютный LSC vs T_true(P): bias=2.345 см; MAE=2.650 см; RMS=3.084 см\n",
            "  T_LSC + y_label vs T_true(P): bias=4.639 см; MAE=4.639 см; RMS=4.736 см\n",
            "  компоненты target среди ML-признаков: []\n",
            "  число абсолютных признаков палетки: 140\n",
            "  число разностных ML-признаков: 140\n",
            "  max|y0|= 0.000e+00\n",
            "  max|dPhi00|= 0.000e+00\n",
            "  сохранено: dataset_level3_relative_lsc_correction.csv; размер=(711, 310)\n",
            "\n",
            "Готово.\n"
          ]
        }
      ],
      "source": [
        "# =========================================================\n",
        "# ПАНЕЛЬ УПРАВЛЕНИЯ И ЗАПУСК\n",
        "# ОТНОСИТЕЛЬНАЯ ML-ПОПРАВКА К LSC НА ФИЗИЧЕСКОЙ ПОВЕРХНОСТИ\n",
        "# =========================================================\n",
        "import numpy as np\n",
        "import pandas as pd\n",
        "\n",
        "# --- РЕГИОН И СЕТКА ---\n",
        "LAT_RANGE = (35.0, 39.0)\n",
        "LON_RANGE = (250.0, 254.0)\n",
        "NUM_POINTS = 10000\n",
        "R_SPHERE = 6371008.8\n",
        "\n",
        "# --- ГГМ И ОПОРНОЕ ПОЛЕ ---\n",
        "GGFM_PARAM = {\n",
        "    \"file_path\": \"XGM2019e_2159.gfc\",\n",
        "    \"degree\": 2190\n",
        "}\n",
        "NMAX_NORM = 200\n",
        "\n",
        "# --- ETOPO1 ---\n",
        "ETOPO_TIF_PATH = None\n",
        "ETOPO_INTERPOLATION = \"bilinear\"\n",
        "OCEAN_TO_ZERO = True\n",
        "FORCE_ETOPO_DOWNLOAD = False\n",
        "\n",
        "# --- СИНТЕТИЧЕСКИЕ ОШИБКИ НАБЛЮДЕНИЙ ---\n",
        "ADD_SYNTHETIC_NOISE = True\n",
        "SIGMA_DG_MGAL = 0.01\n",
        "SIGMA_ZETA_M = 0.20\n",
        "SIGMA_DEFL_ARCSEC = 0.20\n",
        "NOISE_SEED = 42\n",
        "\n",
        "# Ошибки нормальной высоты геометрического нивелирования.\n",
        "SIGMA_H_LEVELING_M = 0.01\n",
        "LEVELING_COMMON_BIAS_M = 0.2\n",
        "\n",
        "# --- ЭНЕРГЕТИЧЕСКОЕ УТОЧНЕНИЕ zeta_true ---\n",
        "ENERGY_MATCH_ATOL_M2_S2 = 1e-4\n",
        "ENERGY_MATCH_MAX_ITERATIONS = 8\n",
        "ENERGY_DERIVATIVE_STEP_M = 0.5\n",
        "\n",
        "# Дисперсии, добавляемые в C_nn при LSC.\n",
        "NOISE_DG = SIGMA_DG_MGAL**2\n",
        "NOISE_ZETA = SIGMA_ZETA_M**2\n",
        "NOISE_DEFL = (SIGMA_DEFL_ARCSEC * ARCSEC)**2\n",
        "\n",
        "# --- АДАПТИВНАЯ ПАЛЕТКА ---\n",
        "USE_ADAPTIVE_PALETTE = True\n",
        "PALETTE_ALPHA = 1.6\n",
        "PALETTE_N_RINGS = 12\n",
        "PALETTE_MAX_RADIUS_MODE = \"minimum\"\n",
        "PALETTE_CORRELATION_RADIUS_FACTOR = 6.0\n",
        "PALETTE_NEAR_RADIUS_D_FACTOR = 0.05\n",
        "\n",
        "FIXED_RING_OUTER_RADII_KM = (\n",
        "    10, 15, 30, 50, 80, 120, 200\n",
        ")\n",
        "\n",
        "# --- РАЗДЕЛЕНИЕ НАБЛЮДЕНИЙ ПО ТИПАМ ---\n",
        "FRAC_G = 0.7\n",
        "FRAC_Z = 0.3\n",
        "FRAC_V = 0.0\n",
        "SPLIT_SEED = 42\n",
        "\n",
        "# --- МОДЕЛЬ ФОРСБЕРГА ---\n",
        "TAU_RANGE = (0.15, 5.0)\n",
        "D_RANGE = (200.0, 100000.0)\n",
        "\n",
        "# --- ОТБОР РЕЗУЛЬТАТИВНЫХ ТОЧЕК И ПАЛЕТКА ---\n",
        "BUILD_LEVEL3 = True\n",
        "TARGET_MIN_DIST_KM = 10.0\n",
        "EDGE_BUFFER_KM = 20.0\n",
        "NEAR_EXCLUDE_KM = 0.0\n",
        "K_INDIVIDUAL = 8\n",
        "AGG = \"both\"\n",
        "LEVEL3_SEED = 42\n",
        "RAW_PRODUCTS = False\n",
        "\n",
        "# --- ПРОСТРАНСТВЕННОЕ РАЗДЕЛЕНИЕ ДО ВЫБОРА P0 ---\n",
        "TEST_FRACTION = 0.20\n",
        "VALIDATION_FRACTION = 0.20\n",
        "SPATIAL_BLOCKS_PER_AXIS = 4\n",
        "ML_SPLIT_SEED = 42\n",
        "\n",
        "# --- ФАЙЛЫ ---\n",
        "LEVEL2_CSV = \"dataset_level2_physical_surface.csv\"\n",
        "LEVEL3_CSV = \"dataset_level3_relative_lsc_correction.csv\"\n",
        "\n",
        "if int(np.sqrt(NUM_POINTS))**2 != NUM_POINTS:\n",
        "    raise ValueError(\"NUM_POINTS должно быть полным квадратом.\")\n",
        "\n",
        "if not np.isclose(FRAC_G + FRAC_Z + FRAC_V, 1.0):\n",
        "    raise ValueError(\"FRAC_G + FRAC_Z + FRAC_V должно быть равно 1.\")\n",
        "\n",
        "for name, value in {\n",
        "    \"SIGMA_DG_MGAL\": SIGMA_DG_MGAL,\n",
        "    \"SIGMA_ZETA_M\": SIGMA_ZETA_M,\n",
        "    \"SIGMA_H_LEVELING_M\": SIGMA_H_LEVELING_M,\n",
        "    \"SIGMA_DEFL_ARCSEC\": SIGMA_DEFL_ARCSEC\n",
        "}.items():\n",
        "    if value < 0:\n",
        "        raise ValueError(f\"{name} не может быть отрицательным.\")\n",
        "\n",
        "print(\"=\" * 72)\n",
        "print(f\"Регион: широта {LAT_RANGE}, долгота {LON_RANGE}\")\n",
        "print(\n",
        "    f\"Сетка: {int(np.sqrt(NUM_POINTS))}x\"\n",
        "    f\"{int(np.sqrt(NUM_POINTS))} = {NUM_POINTS} точек\"\n",
        ")\n",
        "print(\n",
        "    f\"ГГМ: степень {GGFM_PARAM['degree']}; \"\n",
        "    f\"опорное поле: n <= {NMAX_NORM}\"\n",
        ")\n",
        "print(\n",
        "    f\"Шумы: dg={SIGMA_DG_MGAL} мГал; \"\n",
        "    f\"zeta={SIGMA_ZETA_M} м; \"\n",
        "    f\"H_leveling={SIGMA_H_LEVELING_M} м; \"\n",
        "    f\"bias_H_leveling={LEVELING_COMMON_BIAS_M} м\"\n",
        ")\n",
        "print(\"=\" * 72)\n",
        "\n",
        "# 1. Датасет второго уровня только на физической поверхности.\n",
        "data, ggm_runtime = sphere_dataset_maker(\n",
        "    lat_range=LAT_RANGE,\n",
        "    lon_range=LON_RANGE,\n",
        "    num_of_points=NUM_POINTS,\n",
        "    heights=None,\n",
        "    R_sphere=R_SPHERE,\n",
        "    ggfm_param=GGFM_PARAM,\n",
        "    nmax_norm=NMAX_NORM,\n",
        "    etopo_tif_path=ETOPO_TIF_PATH,\n",
        "    interpolation=ETOPO_INTERPOLATION,\n",
        "    ocean_to_zero=OCEAN_TO_ZERO,\n",
        "    force_etopo_download=FORCE_ETOPO_DOWNLOAD,\n",
        "    sigma_dg_mgal=SIGMA_DG_MGAL,\n",
        "    sigma_zeta_m=SIGMA_ZETA_M,\n",
        "    sigma_H_leveling_m=SIGMA_H_LEVELING_M,\n",
        "    leveling_common_bias_m=LEVELING_COMMON_BIAS_M,\n",
        "    sigma_defl_arcsec=SIGMA_DEFL_ARCSEC,\n",
        "    noise_seed=NOISE_SEED,\n",
        "    add_noise=ADD_SYNTHETIC_NOISE,\n",
        "    energy_match_atol_m2_s2=ENERGY_MATCH_ATOL_M2_S2,\n",
        "    energy_match_max_iterations=ENERGY_MATCH_MAX_ITERATIONS,\n",
        "    energy_derivative_step_m=ENERGY_DERIVATIVE_STEP_M\n",
        ")\n",
        "\n",
        "# В attrs допускаются только сериализуемые метаданные.\n",
        "# Runtime-объект pyharm.Shc хранится отдельно в ggm_runtime.\n",
        "for attr_key, attr_value in data.attrs.items():\n",
        "    if attr_key in {\"ggm200_leveling_model\", \"shcs\"}:\n",
        "        raise AssertionError(\n",
        "            \"Объект pyharm.Shc не должен храниться в DataFrame.attrs.\"\n",
        "        )\n",
        "\n",
        "data.to_csv(LEVEL2_CSV, index=False)\n",
        "\n",
        "d = data.copy().reset_index(drop=True)\n",
        "d.attrs.update(data.attrs)\n",
        "\n",
        "lat = d[\"Latitude (deg)\"].to_numpy(float)\n",
        "lon = d[\"Longitude (deg)\"].to_numpy(float)\n",
        "H_sph = d[\"H_sph (m)\"].to_numpy(float)\n",
        "H_normal_true = d[\"H_normal_true (m)\"].to_numpy(float)\n",
        "H_leveling = d[\"H_leveling_obs (m)\"].to_numpy(float)\n",
        "zeta_true = d[\"zeta_true (m)\"].to_numpy(float)\n",
        "energy_error = d[\n",
        "    \"energy_consistency_error_true (m2/s2)\"\n",
        "].to_numpy(float)\n",
        "gam = d[\"gamma (m/s2)\"].to_numpy(float)\n",
        "dg = d[\"dg (m/s2)\"].to_numpy(float) * MGAL\n",
        "zeta_obs = d[\"zeta_obs (m)\"].to_numpy(float)\n",
        "xi = d[\"xi (rad)\"].to_numpy(float)\n",
        "eta = d[\"eta (rad)\"].to_numpy(float)\n",
        "\n",
        "n = len(d)\n",
        "\n",
        "print(f\"\\nФизическая поверхность ETOPO1: {n} точек\")\n",
        "print(\n",
        "    f\"  H_sph: min={H_sph.min():.1f} м, \"\n",
        "    f\"mean={H_sph.mean():.1f} м, max={H_sph.max():.1f} м\"\n",
        ")\n",
        "print(\n",
        "    f\"  zeta_true: min={zeta_true.min():.4f} м, \"\n",
        "    f\"mean={zeta_true.mean():.4f} м, max={zeta_true.max():.4f} м\"\n",
        ")\n",
        "print(\n",
        "    f\"  H_normal_true: min={H_normal_true.min():.1f} м, \"\n",
        "    f\"mean={H_normal_true.mean():.1f} м, \"\n",
        "    f\"max={H_normal_true.max():.1f} м\"\n",
        ")\n",
        "print(\n",
        "    f\"  H_leveling_obs: min={H_leveling.min():.1f} м, \"\n",
        "    f\"mean={H_leveling.mean():.1f} м, max={H_leveling.max():.1f} м\"\n",
        ")\n",
        "print(\n",
        "    \"  проверка W200(Q_true)=WN(P): \"\n",
        "    f\"max={np.max(np.abs(energy_error)):.3e} м²/с²; \"\n",
        "    f\"RMS={np.sqrt(np.mean(energy_error**2)):.3e} м²/с²\"\n",
        ")\n",
        "print(\n",
        "    f\"  СКО наблюдений: dg={dg.std():.2f} мГал; \"\n",
        "    f\"zeta={zeta_obs.std():.4f} м; \"\n",
        "    f\"xi={xi.std()/ARCSEC:.2f}\\\"; \"\n",
        "    f\"eta={eta.std()/ARCSEC:.2f}\\\"\"\n",
        ")\n",
        "\n",
        "# 2. Центрирование наблюдений для LSC.\n",
        "dgc = dg - dg.mean()\n",
        "zc = zeta_obs - zeta_obs.mean()\n",
        "xc = xi - xi.mean()\n",
        "ec = eta - eta.mean()\n",
        "\n",
        "# 3. Подгонка модели Форсберга.\n",
        "D_FIT_MIN = max(\n",
        "    float(D_RANGE[0]),\n",
        "    3.0 * float(H_sph.max()) + 100.0\n",
        ")\n",
        "\n",
        "if D_FIT_MIN >= D_RANGE[1]:\n",
        "    raise ValueError(\"Верхняя граница D_RANGE слишком мала.\")\n",
        "\n",
        "d[\"dg_res (m/s2)\"] = dgc / MGAL\n",
        "\n",
        "r = fit_forsberg_from_dataset(\n",
        "    d,\n",
        "    value_col=\"dg_res (m/s2)\",\n",
        "    height_layer=None,\n",
        "    to_mgal=True,\n",
        "    tau_range=TAU_RANGE,\n",
        "    D_range=(D_FIT_MIN, D_RANGE[1])\n",
        ")\n",
        "\n",
        "C0_dg = r[\"C0\"]\n",
        "D_par = r[\"D\"]\n",
        "T_par = r[\"T\"]\n",
        "\n",
        "print(\"\\n--- Ковариация Форсберга ---\")\n",
        "print(\n",
        "    f\"  C0={C0_dg:.2f} мГал²; \"\n",
        "    f\"D={D_par/1000:.3f} км; \"\n",
        "    f\"T={T_par/1000:.3f} км; \"\n",
        "    f\"tau={r['tau']:.4f}\"\n",
        ")\n",
        "print(f\"  R²={r['r2']:.4f}\")\n",
        "\n",
        "if np.isclose(r[\"tau\"], TAU_RANGE[0], rtol=0.02):\n",
        "    print(\"  ВНИМАНИЕ: tau находится у нижней границы.\")\n",
        "\n",
        "# 3a. Активная палетка.\n",
        "if USE_ADAPTIVE_PALETTE:\n",
        "    PALETTE_SPEC = build_adaptive_log_palette_from_parameters(\n",
        "        D_m=D_par,\n",
        "        lat_range=LAT_RANGE,\n",
        "        lon_range=LON_RANGE,\n",
        "        R=R_SPHERE,\n",
        "        alpha=PALETTE_ALPHA,\n",
        "        n_rings=PALETTE_N_RINGS,\n",
        "        max_radius_mode=PALETTE_MAX_RADIUS_MODE,\n",
        "        correlation_radius_factor=PALETTE_CORRELATION_RADIUS_FACTOR,\n",
        "        near_radius_D_factor=PALETTE_NEAR_RADIUS_D_FACTOR,\n",
        "        verbose=True\n",
        "    )\n",
        "    RING_OUTER_RADII_KM_ACTIVE = PALETTE_SPEC[\"rings_km\"]\n",
        "    PALETTE_INCLUDE_TAIL = False\n",
        "    PALETTE_GENERATION_METADATA = PALETTE_SPEC[\"metadata\"]\n",
        "else:\n",
        "    RING_OUTER_RADII_KM_ACTIVE = FIXED_RING_OUTER_RADII_KM\n",
        "    PALETTE_INCLUDE_TAIL = True\n",
        "    PALETTE_GENERATION_METADATA = {\n",
        "        \"family\": \"fixed_with_tail\",\n",
        "        \"rings_outer_radii_km\": list(FIXED_RING_OUTER_RADII_KM),\n",
        "        \"include_tail_beyond_r_max\": True\n",
        "    }\n",
        "\n",
        "# 4. Разделение наблюдений по типам.\n",
        "i_g, i_z, i_v = split_points(\n",
        "    n,\n",
        "    FRAC_G,\n",
        "    FRAC_Z,\n",
        "    FRAC_V,\n",
        "    SPLIT_SEED\n",
        ")\n",
        "\n",
        "print(\"\\n--- Разделение наблюдений ---\")\n",
        "print(f\"  dg: {len(i_g)}\")\n",
        "print(f\"  zeta: {len(i_z)}\")\n",
        "print(f\"  xi/eta: {len(i_v)}\")\n",
        "\n",
        "print(\"\\n--- Относительная схема ---\")\n",
        "print(\"  P_i задаётся r_P=R+H_sph, где H_sph=H_ETOPO1.\")\n",
        "print(\"  Пространственный split выполняется до выбора P0.\")\n",
        "print(\"  P0 выбирается только из train и едина для всех выборок.\")\n",
        "print(\"  ML-вход: d_Phi(P_i,P0).\")\n",
        "print(\"  dClev_i0=W200(Q0_obs)-W200(Qi_obs).\")\n",
        "print(\"  ML-цель: относительная поправка y_i к dT_LSC(P_i,P0).\")\n",
        "\n",
        "# 5. Датасеты третьего и четвёртого уровней.\n",
        "if BUILD_LEVEL3:\n",
        "    print(\"\\n--- Формирование относительного ML-датасета ---\")\n",
        "\n",
        "    result3 = build_level3(\n",
        "        data=d,\n",
        "        H_WORK=None,\n",
        "        lat_range=LAT_RANGE,\n",
        "        lon_range=LON_RANGE,\n",
        "        C0_dg=C0_dg,\n",
        "        D=D_par,\n",
        "        T=T_par,\n",
        "        i_g=i_g,\n",
        "        i_z=i_z,\n",
        "        min_dist_km=TARGET_MIN_DIST_KM,\n",
        "        edge_buffer_km=EDGE_BUFFER_KM,\n",
        "        near_exclude_km=NEAR_EXCLUDE_KM,\n",
        "        rings_km=RING_OUTER_RADII_KM_ACTIVE,\n",
        "        k_individual=K_INDIVIDUAL,\n",
        "        agg=AGG,\n",
        "        include_tail_beyond_r_max=PALETTE_INCLUDE_TAIL,\n",
        "        palette_metadata=PALETTE_GENERATION_METADATA,\n",
        "        noise_dg=NOISE_DG,\n",
        "        noise_zeta=NOISE_ZETA,\n",
        "        geo_cols=None,\n",
        "        R=R_SPHERE,\n",
        "        seed=LEVEL3_SEED,\n",
        "        raw_products=RAW_PRODUCTS,\n",
        "        verbose=True,\n",
        "        test_fraction=TEST_FRACTION,\n",
        "        validation_fraction=VALIDATION_FRACTION,\n",
        "        spatial_blocks_per_axis=SPATIAL_BLOCKS_PER_AXIS,\n",
        "        split_seed=ML_SPLIT_SEED\n",
        "    )\n",
        "\n",
        "    if RAW_PRODUCTS:\n",
        "        df3, P_raw, b_level3, meta_level3 = result3\n",
        "    else:\n",
        "        df3, b_level3, meta_level3 = result3\n",
        "\n",
        "    df3.to_csv(LEVEL3_CSV, index=False)\n",
        "\n",
        "    GAMMA_REPORT = 9.81\n",
        "\n",
        "    def _report_error(name, values_m2s2):\n",
        "        values_m2s2 = np.asarray(values_m2s2, dtype=float)\n",
        "        values_cm = values_m2s2 / GAMMA_REPORT * 100.0\n",
        "        print(\n",
        "            f\"  {name}: \"\n",
        "            f\"bias={values_cm.mean():.3f} см; \"\n",
        "            f\"MAE={np.mean(np.abs(values_cm)):.3f} см; \"\n",
        "            f\"RMS={np.sqrt(np.mean(values_cm**2)):.3f} см\"\n",
        "        )\n",
        "\n",
        "    ref = meta_level3[\"reference\"]\n",
        "    print(\"\\n--- Опорная точка P0 ---\")\n",
        "    print(\n",
        "        f\"  source_index={ref['source_index']}; \"\n",
        "        f\"split={ref['split']}; \"\n",
        "        f\"lat={ref['latitude_deg']:.6f}; \"\n",
        "        f\"lon={ref['longitude_deg']:.6f}\"\n",
        "    )\n",
        "\n",
        "    print(\"\\n--- Диагностика относительной конструкции ---\")\n",
        "\n",
        "    _report_error(\n",
        "        \"метка y vs чистая требуемая поправка\",\n",
        "        df3[\"label_error_to_true_correction_m2_s2\"].to_numpy(float)\n",
        "    )\n",
        "\n",
        "    _report_error(\n",
        "        \"исходная dT_LSC vs dT_true\",\n",
        "        df3[\"relative_LSC_error_control_m2_s2\"].to_numpy(float)\n",
        "    )\n",
        "\n",
        "    corrected_relative_error = (\n",
        "        df3[\"dT_LSC_i0_m2_s2\"].to_numpy(float)\n",
        "        + df3[\"relative_LSC_correction_label_m2_s2\"].to_numpy(float)\n",
        "        - df3[\"dT_true_i0_control_m2_s2\"].to_numpy(float)\n",
        "    )\n",
        "    _report_error(\n",
        "        \"dT_LSC + y_label vs dT_true\",\n",
        "        corrected_relative_error\n",
        "    )\n",
        "\n",
        "    absolute_lsc_error = (\n",
        "        df3[\"T_LSC_P_residual (m2/s2)\"].to_numpy(float)\n",
        "        - df3[\"T_true_P_control\"].to_numpy(float)\n",
        "    )\n",
        "    _report_error(\n",
        "        \"абсолютный LSC vs T_true(P)\",\n",
        "        absolute_lsc_error\n",
        "    )\n",
        "\n",
        "    absolute_corrected_error = (\n",
        "        df3[\"T_corrected_by_label_m2_s2\"].to_numpy(float)\n",
        "        - df3[\"T_true_P_control\"].to_numpy(float)\n",
        "    )\n",
        "    _report_error(\n",
        "        \"T_LSC + y_label vs T_true(P)\",\n",
        "        absolute_corrected_error\n",
        "    )\n",
        "\n",
        "    config = meta_level3[\"config\"]\n",
        "    feature_columns = config[\"schema\"][\"feature_columns\"]\n",
        "    target_columns = config[\"schema\"][\"target_construction_columns\"]\n",
        "\n",
        "    print(\n",
        "        \"  компоненты target среди ML-признаков:\",\n",
        "        sorted(set(feature_columns).intersection(target_columns))\n",
        "    )\n",
        "    print(\n",
        "        \"  число абсолютных признаков палетки:\",\n",
        "        len(config[\"schema\"][\n",
        "            \"absolute_palette_columns_retained_for_diagnostics\"\n",
        "        ])\n",
        "    )\n",
        "    print(\n",
        "        \"  число разностных ML-признаков:\",\n",
        "        len(feature_columns)\n",
        "    )\n",
        "    print(\n",
        "        \"  max|y0|=\",\n",
        "        f\"{config['checks']['reference_target_abs']:.3e}\"\n",
        "    )\n",
        "    print(\n",
        "        \"  max|dPhi00|=\",\n",
        "        f\"{config['checks']['reference_dphi_max_abs']:.3e}\"\n",
        "    )\n",
        "    print(\n",
        "        f\"  сохранено: {LEVEL3_CSV}; размер={df3.shape}\"\n",
        "    )\n",
        "\n",
        "print(\"\\nГотово.\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "C8Ck_9tcjcMm"
      },
      "source": [
        "# Проверка опорной точки, разностной палетки и защиты от утечки"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "SdC3R7vf1tDz"
      },
      "source": [
        "### Проверка энергетической согласованности нормальной высоты\n",
        "\n",
        "Для каждой точки контролируется равенство:\n",
        "\n",
        "$W^{200}(R+H_i^{\\mathrm{sph}}-\\zeta_{i,\\mathrm{true}})=W^N(R+H_i^{\\mathrm{sph}}).$\n",
        "\n",
        "Одномерное решение останавливается, когда максимальный остаток $|e_i^H|$ не превышает заданный допуск `ENERGY_MATCH_ATOL_M2_S2`. В диагностике отдельно выводятся начальный остаток, итоговый остаток и максимальная поправка к исходной оценке $\\zeta$.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/"
        },
        "id": "Ph6nAD0KjjJM",
        "outputId": "0e294c8a-bbb6-468b-90e7-1352ed4a50ae"
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "--- Проверка сферической относительной ML-поправки к LSC ---\n",
            "max|HN_true-(H_sph-zeta_true)| = 0.000e+00 м\n",
            "max|rP-(R+H_sph)| = 0.000e+00 м\n",
            "max|rQ_true-(R+HN_true)| = 0.000e+00 м\n",
            "max|rQ_obs-(R+H_leveling)| = 0.000e+00 м\n",
            "max|zeta_initial-[WN(P)-W200(P)]/g200(P)| = 0.000e+00 м\n",
            "max|eH-[W200(Q_true)-WN(P)]| = 0.000e+00 м²/с²\n",
            "max|W200(Q_true)-WN(P)| = 1.707e-05 м²/с²; допуск=1.000e-04\n",
            "max|y0| = 0.000e+00 м²/с²\n",
            "max|dPhi00| = 0.000e+00\n",
            "max|dPhi-[Phi(Pi)-Phi(P0)]| = 0.000e+00\n",
            "компоненты target среди ML-признаков: []\n",
            "контрольные значения среди ML-признаков: []\n",
            "max|y-y_formula| = 0.000e+00 м²/с²\n",
            "max|T_corrected-[T_LSC+y]| = 0.000e+00 м²/с²\n",
            "Проверка пройдена: ETOPO1 используется как H_sph над сферой, zeta_true энергетически согласована, а относительная ML-схема и разностная палетка сохранены.\n"
          ]
        }
      ],
      "source": [
        "# =========================================================\n",
        "# ПРОВЕРКА СФЕРИЧЕСКОЙ ГЕОМЕТРИИ И ОТНОСИТЕЛЬНОЙ КОНСТРУКЦИИ\n",
        "# =========================================================\n",
        "config_check = meta_level3[\"config\"]\n",
        "schema_check = config_check[\"schema\"]\n",
        "reference_check = config_check[\"reference_point\"]\n",
        "\n",
        "feature_columns_check = schema_check[\"feature_columns\"]\n",
        "absolute_palette_columns_check = schema_check[\n",
        "    \"absolute_palette_columns_retained_for_diagnostics\"\n",
        "]\n",
        "target_construction_columns_check = schema_check[\n",
        "    \"target_construction_columns\"\n",
        "]\n",
        "target_column_check = schema_check[\"target_column\"]\n",
        "\n",
        "print(\"--- Проверка сферической относительной ML-поправки к LSC ---\")\n",
        "\n",
        "# 1. Физическая конвенция ETOPO1.\n",
        "assert d.attrs[\"etopo_height_convention\"] == (\n",
        "    \"H_sph=H_ETOPO1 above reference sphere\"\n",
        ")\n",
        "assert d.attrs[\"zeta_sign_convention\"] == (\n",
        "    \"standard_Bruns_zeta=T/gamma; H_normal_true=H_sph-zeta_true\"\n",
        ")\n",
        "assert d.attrs[\"energy_equation\"] == \"W200(Q_true)=WN(P)\"\n",
        "\n",
        "H_sph_check = d[\"H_sph (m)\"].to_numpy(float)\n",
        "zeta_true_check = d[\"zeta_true (m)\"].to_numpy(float)\n",
        "H_normal_true_check = d[\"H_normal_true (m)\"].to_numpy(float)\n",
        "r_P_check = d[\"r_P (m)\"].to_numpy(float)\n",
        "r_Q_true_check = d[\"r_Q_true (m)\"].to_numpy(float)\n",
        "H_leveling_check = d[\"H_leveling_obs (m)\"].to_numpy(float)\n",
        "r_Q_obs_check = d[\"r_Q_obs (m)\"].to_numpy(float)\n",
        "\n",
        "max_HN_identity = float(np.max(np.abs(\n",
        "    H_normal_true_check - (H_sph_check - zeta_true_check)\n",
        ")))\n",
        "max_rP_identity = float(np.max(np.abs(\n",
        "    r_P_check - (R_SPHERE + H_sph_check)\n",
        ")))\n",
        "max_rQ_true_identity = float(np.max(np.abs(\n",
        "    r_Q_true_check - (R_SPHERE + H_normal_true_check)\n",
        ")))\n",
        "max_rQ_obs_identity = float(np.max(np.abs(\n",
        "    r_Q_obs_check - (R_SPHERE + H_leveling_check)\n",
        ")))\n",
        "\n",
        "print(f\"max|HN_true-(H_sph-zeta_true)| = {max_HN_identity:.3e} м\")\n",
        "print(f\"max|rP-(R+H_sph)| = {max_rP_identity:.3e} м\")\n",
        "print(f\"max|rQ_true-(R+HN_true)| = {max_rQ_true_identity:.3e} м\")\n",
        "print(f\"max|rQ_obs-(R+H_leveling)| = {max_rQ_obs_identity:.3e} м\")\n",
        "\n",
        "assert max_HN_identity <= 1e-12\n",
        "assert max_rP_identity <= 1e-9\n",
        "assert max_rQ_true_identity <= 1e-9\n",
        "assert max_rQ_obs_identity <= 1e-9\n",
        "\n",
        "# 1a. Явная проверка стандартного знака теоремы Брунса.\n",
        "zeta_initial_saved = d[\"zeta_initial (m)\"].to_numpy(float)\n",
        "zeta_initial_bruns = (\n",
        "    d[\"W_full_P (m2/s2)\"].to_numpy(float)\n",
        "    - d[\"W_200_P (m2/s2)\"].to_numpy(float)\n",
        ") / d[\"g_200_P (m/s2)\"].to_numpy(float)\n",
        "max_zeta_initial_identity = float(np.max(np.abs(\n",
        "    zeta_initial_saved - zeta_initial_bruns\n",
        ")))\n",
        "print(\n",
        "    \"max|zeta_initial-[WN(P)-W200(P)]/g200(P)| = \"\n",
        "    f\"{max_zeta_initial_identity:.3e} м\"\n",
        ")\n",
        "assert max_zeta_initial_identity <= 1e-12\n",
        "assert d.attrs[\"energy_solver\"][\"zeta_definition\"] == (\n",
        "    \"standard_Bruns_zeta=T/gamma\"\n",
        ")\n",
        "\n",
        "# 2. Главная энергетическая проверка.\n",
        "energy_error_saved = d[\n",
        "    \"energy_consistency_error_true (m2/s2)\"\n",
        "].to_numpy(float)\n",
        "energy_error_direct = (\n",
        "    d[\"W_200_Q_true (m2/s2)\"].to_numpy(float)\n",
        "    - d[\"W_full_P (m2/s2)\"].to_numpy(float)\n",
        ")\n",
        "max_energy_saved_identity = float(np.max(np.abs(\n",
        "    energy_error_saved - energy_error_direct\n",
        ")))\n",
        "max_energy_error = float(np.max(np.abs(energy_error_saved)))\n",
        "energy_atol = float(\n",
        "    d.attrs[\"energy_solver\"][\"residual_atol_m2_s2\"]\n",
        ")\n",
        "print(\n",
        "    \"max|eH-[W200(Q_true)-WN(P)]| = \"\n",
        "    f\"{max_energy_saved_identity:.3e} м²/с²\"\n",
        ")\n",
        "print(\n",
        "    \"max|W200(Q_true)-WN(P)| = \"\n",
        "    f\"{max_energy_error:.3e} м²/с²; допуск={energy_atol:.3e}\"\n",
        ")\n",
        "assert max_energy_saved_identity <= 1e-12\n",
        "assert max_energy_error <= energy_atol\n",
        "\n",
        "# 3. Нивелирная модель высоты.\n",
        "if (\n",
        "    float(SIGMA_H_LEVELING_M) == 0.0\n",
        "    and float(LEVELING_COMMON_BIAS_M) == 0.0\n",
        "):\n",
        "    max_Hlev_true = float(np.max(np.abs(\n",
        "        H_leveling_check - H_normal_true_check\n",
        "    )))\n",
        "    print(f\"max|H_leveling-HN_true| при нулевых ошибках = {max_Hlev_true:.3e} м\")\n",
        "    assert max_Hlev_true <= 1e-12\n",
        "\n",
        "# 4. Единственная опорная точка из train.\n",
        "reference_rows = df3[df3[\"is_reference\"] == 1]\n",
        "assert len(reference_rows) == 1\n",
        "assert reference_rows[\"split\"].iloc[0] == \"train\"\n",
        "assert int(reference_rows[\"source_index\"].iloc[0]) == int(\n",
        "    reference_check[\"source_index\"]\n",
        ")\n",
        "\n",
        "# 5. Нулевые разности в P0.\n",
        "max_y0 = float(np.max(np.abs(\n",
        "    reference_rows[target_column_check].to_numpy(float)\n",
        ")))\n",
        "max_dphi0 = float(np.max(np.abs(\n",
        "    reference_rows[feature_columns_check].to_numpy(float)\n",
        ")))\n",
        "print(f\"max|y0| = {max_y0:.3e} м²/с²\")\n",
        "print(f\"max|dPhi00| = {max_dphi0:.3e}\")\n",
        "assert max_y0 <= 1e-12\n",
        "assert max_dphi0 <= 1e-12\n",
        "\n",
        "# 6. Одна система отсчёта для всех split.\n",
        "assert set(df3[\"split\"].unique()) <= {\"train\", \"validation\", \"test\"}\n",
        "assert config_check[\"split\"][\"same_reference_for_all_splits\"] is True\n",
        "assert config_check[\"split\"][\"performed_before_reference_selection\"] is True\n",
        "\n",
        "# 7. Разностная палетка.\n",
        "assert len(feature_columns_check) == len(absolute_palette_columns_check)\n",
        "assert all(name.startswith(\"d_\") for name in feature_columns_check)\n",
        "max_difference_error = 0.0\n",
        "for absolute_name, difference_name in zip(\n",
        "    absolute_palette_columns_check,\n",
        "    feature_columns_check\n",
        "):\n",
        "    expected = (\n",
        "        df3[absolute_name].to_numpy(float)\n",
        "        - float(reference_rows[absolute_name].iloc[0])\n",
        "    )\n",
        "    actual = df3[difference_name].to_numpy(float)\n",
        "    max_difference_error = max(\n",
        "        max_difference_error,\n",
        "        float(np.max(np.abs(actual - expected)))\n",
        "    )\n",
        "print(\n",
        "    \"max|dPhi-[Phi(Pi)-Phi(P0)]| = \"\n",
        "    f\"{max_difference_error:.3e}\"\n",
        ")\n",
        "assert max_difference_error <= 1e-12\n",
        "\n",
        "# 8. Защита от утечки.\n",
        "leaked_target_columns = sorted(\n",
        "    set(feature_columns_check).intersection(\n",
        "        target_construction_columns_check\n",
        "    )\n",
        ")\n",
        "leaked_controls = sorted(\n",
        "    column for column in feature_columns_check\n",
        "    if \"control\" in column or \"true\" in column\n",
        ")\n",
        "print(\"компоненты target среди ML-признаков:\", leaked_target_columns)\n",
        "print(\"контрольные значения среди ML-признаков:\", leaked_controls)\n",
        "assert leaked_target_columns == []\n",
        "assert leaked_controls == []\n",
        "\n",
        "# 9. Точные формулы разностей.\n",
        "W_P0 = float(reference_rows[\"W_200_P_m2_s2\"].iloc[0])\n",
        "W_Q0_obs = float(reference_rows[\"W_200_Q_obs_m2_s2\"].iloc[0])\n",
        "\n",
        "dC200_expected = (\n",
        "    W_P0 - df3[\"W_200_P_m2_s2\"].to_numpy(float)\n",
        ")\n",
        "dClev_expected = (\n",
        "    W_Q0_obs - df3[\"W_200_Q_obs_m2_s2\"].to_numpy(float)\n",
        ")\n",
        "dTlsc_expected = (\n",
        "    df3[\"T_LSC_P_residual (m2/s2)\"].to_numpy(float)\n",
        "    - float(reference_rows[\"T_LSC_P_residual (m2/s2)\"].iloc[0])\n",
        ")\n",
        "\n",
        "assert np.allclose(\n",
        "    df3[\"dC_200_i0_m2_s2\"], dC200_expected,\n",
        "    rtol=0.0, atol=1e-12\n",
        ")\n",
        "assert np.allclose(\n",
        "    df3[\"dC_leveling_i0_m2_s2\"], dClev_expected,\n",
        "    rtol=0.0, atol=1e-12\n",
        ")\n",
        "assert np.allclose(\n",
        "    df3[\"dT_LSC_i0_m2_s2\"], dTlsc_expected,\n",
        "    rtol=0.0, atol=1e-12\n",
        ")\n",
        "\n",
        "# 10. Формула метки.\n",
        "target_expected = (\n",
        "    df3[\"dC_200_i0_m2_s2\"].to_numpy(float)\n",
        "    - df3[\"dC_leveling_i0_m2_s2\"].to_numpy(float)\n",
        "    - df3[\"dT_LSC_i0_m2_s2\"].to_numpy(float)\n",
        ")\n",
        "max_target_error = float(np.max(np.abs(\n",
        "    df3[target_column_check].to_numpy(float) - target_expected\n",
        ")))\n",
        "print(f\"max|y-y_formula| = {max_target_error:.3e} м²/с²\")\n",
        "assert max_target_error <= 1e-12\n",
        "\n",
        "# 11. Итоговая формула абсолютного исправления.\n",
        "absolute_identity = (\n",
        "    df3[\"T_corrected_by_label_m2_s2\"].to_numpy(float)\n",
        "    - (\n",
        "        df3[\"T_LSC_P_residual (m2/s2)\"].to_numpy(float)\n",
        "        + df3[target_column_check].to_numpy(float)\n",
        "    )\n",
        ")\n",
        "max_absolute_identity = float(np.max(np.abs(absolute_identity)))\n",
        "print(\n",
        "    \"max|T_corrected-[T_LSC+y]| = \"\n",
        "    f\"{max_absolute_identity:.3e} м²/с²\"\n",
        ")\n",
        "assert max_absolute_identity <= 1e-12\n",
        "\n",
        "# 12. P0 исключается из обучения.\n",
        "assert int(reference_rows[\"use_for_ml\"].iloc[0]) == 0\n",
        "assert np.all(df3.loc[df3[\"is_reference\"] == 0, \"use_for_ml\"] == 1)\n",
        "\n",
        "# 13. Конфигурация соответствует новой конструкции.\n",
        "assert config_check[\"leveling\"][\"H_sph_definition\"] == \"H_sph=H_ETOPO1\"\n",
        "assert config_check[\"leveling\"][\"energy_equation\"] == \"W200(Q_true)=WN(P)\"\n",
        "assert config_check[\"leveling\"][\"dC_leveling_formula\"] == (\n",
        "    \"dClev_i0=W200(Q0_obs)-W200(Qi_obs)\"\n",
        ")\n",
        "assert config_check[\"leveling\"][\"dC_200_formula\"] == (\n",
        "    \"dC200_i0=W200(P0)-W200(P_i)\"\n",
        ")\n",
        "\n",
        "print(\n",
        "    \"Проверка пройдена: ETOPO1 используется как H_sph над сферой, \"\n",
        "    \"zeta_true энергетически согласована, а относительная ML-схема \"\n",
        "    \"и разностная палетка сохранены.\"\n",
        ")\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "-6Of_LaRL1MM"
      },
      "source": [
        "# Обучение Keras для относительной поправки к LSC"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "N4GmZhN2L7Oj"
      },
      "outputs": [],
      "source": [
        "# =========================================================\n",
        "# ML-РЕАЛИЗАЦИЯ: ОТНОСИТЕЛЬНАЯ ПОПРАВКА К LSC\n",
        "#\n",
        "# Вход:\n",
        "#   dataset_level4_ml.csv (noisy ML label + clean control)\n",
        "#   dataset_level4_config.json\n",
        "#\n",
        "# Выход:\n",
        "#   keras_dense_model.keras\n",
        "#   ml_preprocessor.joblib\n",
        "#   keras_dense_metadata.json\n",
        "#   keras_dense_predictions.csv\n",
        "#   keras_tuner_dense/\n",
        "# =========================================================\n",
        "import sys\n",
        "import json\n",
        "import random\n",
        "import hashlib\n",
        "import subprocess\n",
        "from pathlib import Path\n",
        "\n",
        "import numpy as np\n",
        "import pandas as pd\n",
        "import matplotlib.pyplot as plt\n",
        "\n",
        "try:\n",
        "    import joblib\n",
        "except ImportError:\n",
        "    subprocess.check_call([\n",
        "        sys.executable,\n",
        "        \"-m\",\n",
        "        \"pip\",\n",
        "        \"install\",\n",
        "        \"-q\",\n",
        "        \"joblib\"\n",
        "    ])\n",
        "    import joblib\n",
        "\n",
        "try:\n",
        "    import tensorflow as tf\n",
        "except ImportError:\n",
        "    subprocess.check_call([\n",
        "        sys.executable,\n",
        "        \"-m\",\n",
        "        \"pip\",\n",
        "        \"install\",\n",
        "        \"-q\",\n",
        "        \"tensorflow\"\n",
        "    ])\n",
        "    import tensorflow as tf\n",
        "\n",
        "try:\n",
        "    import keras_tuner as kt\n",
        "except ImportError:\n",
        "    subprocess.check_call([\n",
        "        sys.executable,\n",
        "        \"-m\",\n",
        "        \"pip\",\n",
        "        \"install\",\n",
        "        \"-q\",\n",
        "        \"keras-tuner\"\n",
        "    ])\n",
        "    import keras_tuner as kt\n",
        "\n",
        "from sklearn.compose import ColumnTransformer\n",
        "from sklearn.impute import SimpleImputer\n",
        "from sklearn.model_selection import GroupShuffleSplit, train_test_split\n",
        "from sklearn.pipeline import Pipeline\n",
        "from sklearn.preprocessing import StandardScaler\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# ПАНЕЛЬ УПРАВЛЕНИЯ\n",
        "# =========================================================\n",
        "LEVEL4_CSV = \"dataset_level4_ml.csv\"\n",
        "LEVEL4_CONFIG_JSON = \"dataset_level4_config.json\"\n",
        "\n",
        "MODEL_PATH = \"keras_dense_model.keras\"\n",
        "PREPROCESSOR_PATH = \"ml_preprocessor.joblib\"\n",
        "METADATA_PATH = \"keras_dense_metadata.json\"\n",
        "PREDICTIONS_PATH = \"keras_dense_predictions.csv\"\n",
        "PREDICTIONS_ALL_DATUM_PATH = (\n",
        "    \"keras_dense_predictions_all_datum_corrected.csv\"\n",
        ")\n",
        "TUNER_DIRECTORY = \"keras_tuner_dense\"\n",
        "\n",
        "RANDOM_SEED = 42\n",
        "\n",
        "# Пространственное разбиение\n",
        "TEST_FRACTION = 0.20\n",
        "VALIDATION_FRACTION = 0.20\n",
        "SPATIAL_BLOCKS_PER_AXIS = 4\n",
        "\n",
        "# Поиск гиперпараметров\n",
        "MAX_TRIALS = 25\n",
        "SEARCH_EPOCHS = 400\n",
        "EARLY_STOPPING_PATIENCE = 40\n",
        "EXECUTIONS_PER_TRIAL = 1\n",
        "\n",
        "# Ограничение числа эпох финального обучения\n",
        "MIN_FINAL_EPOCHS = 10\n",
        "MAX_FINAL_EPOCHS = 400\n",
        "\n",
        "# Для перевода ошибок потенциала в эквивалент высоты\n",
        "GAMMA_REPORT = 9.81\n",
        "\n",
        "# Перезаписывать результаты предыдущего поиска\n",
        "OVERWRITE_TUNER = True\n",
        "\n",
        "# Сохранять графики в выводе ячейки\n",
        "SHOW_PLOTS = True\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# ВОСПРОИЗВОДИМОСТЬ\n",
        "# =========================================================\n",
        "def set_global_seed(seed):\n",
        "    seed = int(seed)\n",
        "\n",
        "    random.seed(seed)\n",
        "    np.random.seed(seed)\n",
        "    tf.keras.utils.set_random_seed(seed)\n",
        "\n",
        "    try:\n",
        "        tf.config.experimental.enable_op_determinism()\n",
        "    except Exception:\n",
        "        pass\n",
        "\n",
        "\n",
        "set_global_seed(RANDOM_SEED)\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# ЗАГРУЗКА ДАННЫХ И КОНФИГУРАЦИИ\n",
        "# =========================================================\n",
        "def load_level4_dataset(\n",
        "    csv_path,\n",
        "    config_path\n",
        "):\n",
        "    csv_path = Path(csv_path)\n",
        "    config_path = Path(config_path)\n",
        "\n",
        "    if not csv_path.exists():\n",
        "        raise FileNotFoundError(\n",
        "            f\"Не найден датасет четвёртого уровня: {csv_path}\"\n",
        "        )\n",
        "\n",
        "    if not config_path.exists():\n",
        "        raise FileNotFoundError(\n",
        "            f\"Не найден JSON конфигурации: {config_path}\"\n",
        "        )\n",
        "\n",
        "    df = pd.read_csv(csv_path)\n",
        "\n",
        "    with config_path.open(\n",
        "        \"r\",\n",
        "        encoding=\"utf-8\"\n",
        "    ) as f:\n",
        "        config = json.load(f)\n",
        "\n",
        "    target_column = config[\n",
        "        \"target\"\n",
        "    ][\n",
        "        \"column\"\n",
        "    ]\n",
        "\n",
        "    feature_columns = config[\n",
        "        \"schema\"\n",
        "    ][\n",
        "        \"feature_columns\"\n",
        "    ]\n",
        "\n",
        "    missing_features = [\n",
        "        column\n",
        "        for column in feature_columns\n",
        "        if column not in df.columns\n",
        "    ]\n",
        "\n",
        "    if missing_features:\n",
        "        raise KeyError(\n",
        "            \"В датасете отсутствуют признаки из JSON-схемы: \"\n",
        "            f\"{missing_features}\"\n",
        "        )\n",
        "\n",
        "    if target_column not in df.columns:\n",
        "        raise KeyError(\n",
        "            f\"В датасете отсутствует целевая колонка \"\n",
        "            f\"'{target_column}'.\"\n",
        "        )\n",
        "\n",
        "    for column in feature_columns:\n",
        "        df[column] = pd.to_numeric(\n",
        "            df[column],\n",
        "            errors=\"raise\"\n",
        "        )\n",
        "\n",
        "    df[target_column] = pd.to_numeric(\n",
        "        df[target_column],\n",
        "        errors=\"raise\"\n",
        "    )\n",
        "\n",
        "    expected_hash = config[\n",
        "        \"schema\"\n",
        "    ].get(\n",
        "        \"feature_schema_sha256\"\n",
        "    )\n",
        "\n",
        "    actual_hash = hashlib.sha256(\n",
        "        json.dumps(\n",
        "            feature_columns,\n",
        "            ensure_ascii=False,\n",
        "            separators=(\",\", \":\")\n",
        "        ).encode(\"utf-8\")\n",
        "    ).hexdigest()\n",
        "\n",
        "    if (\n",
        "        expected_hash is not None\n",
        "        and actual_hash != expected_hash\n",
        "    ):\n",
        "        raise ValueError(\n",
        "            \"Порядок признаков не совпадает \"\n",
        "            \"с сохранённой JSON-схемой.\"\n",
        "        )\n",
        "\n",
        "    return (\n",
        "        df,\n",
        "        config,\n",
        "        feature_columns,\n",
        "        target_column\n",
        "    )\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# ПРОСТРАНСТВЕННЫЕ БЛОКИ\n",
        "# =========================================================\n",
        "def make_spatial_groups(\n",
        "    latitude,\n",
        "    longitude,\n",
        "    blocks_per_axis=4\n",
        "):\n",
        "    latitude = np.asarray(\n",
        "        latitude,\n",
        "        dtype=float\n",
        "    )\n",
        "\n",
        "    longitude = np.asarray(\n",
        "        longitude,\n",
        "        dtype=float\n",
        "    )\n",
        "\n",
        "    blocks_per_axis = int(\n",
        "        blocks_per_axis\n",
        "    )\n",
        "\n",
        "    if blocks_per_axis < 2:\n",
        "        raise ValueError(\n",
        "            \"SPATIAL_BLOCKS_PER_AXIS должно быть не меньше 2.\"\n",
        "        )\n",
        "\n",
        "    lat_min = float(\n",
        "        np.min(latitude)\n",
        "    )\n",
        "\n",
        "    lat_max = float(\n",
        "        np.max(latitude)\n",
        "    )\n",
        "\n",
        "    lon_min = float(\n",
        "        np.min(longitude)\n",
        "    )\n",
        "\n",
        "    lon_max = float(\n",
        "        np.max(longitude)\n",
        "    )\n",
        "\n",
        "    lat_span = max(\n",
        "        lat_max - lat_min,\n",
        "        1e-12\n",
        "    )\n",
        "\n",
        "    lon_span = max(\n",
        "        lon_max - lon_min,\n",
        "        1e-12\n",
        "    )\n",
        "\n",
        "    lat_normalized = (\n",
        "        latitude - lat_min\n",
        "    ) / lat_span\n",
        "\n",
        "    lon_normalized = (\n",
        "        longitude - lon_min\n",
        "    ) / lon_span\n",
        "\n",
        "    lat_bin = np.floor(\n",
        "        lat_normalized\n",
        "        * blocks_per_axis\n",
        "    ).astype(int)\n",
        "\n",
        "    lon_bin = np.floor(\n",
        "        lon_normalized\n",
        "        * blocks_per_axis\n",
        "    ).astype(int)\n",
        "\n",
        "    lat_bin = np.clip(\n",
        "        lat_bin,\n",
        "        0,\n",
        "        blocks_per_axis - 1\n",
        "    )\n",
        "\n",
        "    lon_bin = np.clip(\n",
        "        lon_bin,\n",
        "        0,\n",
        "        blocks_per_axis - 1\n",
        "    )\n",
        "\n",
        "    return (\n",
        "        lat_bin\n",
        "        * blocks_per_axis\n",
        "        + lon_bin\n",
        "    )\n",
        "\n",
        "\n",
        "def spatial_train_val_test_split(\n",
        "    df,\n",
        "    test_fraction=0.20,\n",
        "    validation_fraction=0.20,\n",
        "    blocks_per_axis=4,\n",
        "    seed=42\n",
        "):\n",
        "    if not (\n",
        "        0 < test_fraction < 1\n",
        "    ):\n",
        "        raise ValueError(\n",
        "            \"TEST_FRACTION должно находиться между 0 и 1.\"\n",
        "        )\n",
        "\n",
        "    if not (\n",
        "        0 < validation_fraction < 1\n",
        "    ):\n",
        "        raise ValueError(\n",
        "            \"VALIDATION_FRACTION должно находиться между 0 и 1.\"\n",
        "        )\n",
        "\n",
        "    if (\n",
        "        test_fraction\n",
        "        + validation_fraction\n",
        "        >= 1\n",
        "    ):\n",
        "        raise ValueError(\n",
        "            \"Сумма TEST_FRACTION и VALIDATION_FRACTION \"\n",
        "            \"должна быть меньше 1.\"\n",
        "        )\n",
        "\n",
        "    if (\n",
        "        \"latitude_deg\" not in df.columns\n",
        "        or \"longitude_deg\" not in df.columns\n",
        "    ):\n",
        "        raise KeyError(\n",
        "            \"Для пространственного разбиения нужны \"\n",
        "            \"latitude_deg и longitude_deg.\"\n",
        "        )\n",
        "\n",
        "    groups = make_spatial_groups(\n",
        "        df[\"latitude_deg\"],\n",
        "        df[\"longitude_deg\"],\n",
        "        blocks_per_axis\n",
        "    )\n",
        "\n",
        "    indices = np.arange(\n",
        "        len(df)\n",
        "    )\n",
        "\n",
        "    unique_groups = np.unique(\n",
        "        groups\n",
        "    )\n",
        "\n",
        "    split_mode = \"spatial_groups\"\n",
        "\n",
        "    try:\n",
        "        if len(unique_groups) < 3:\n",
        "            raise ValueError(\n",
        "                \"Слишком мало пространственных блоков.\"\n",
        "            )\n",
        "\n",
        "        split_test = GroupShuffleSplit(\n",
        "            n_splits=1,\n",
        "            test_size=test_fraction,\n",
        "            random_state=seed\n",
        "        )\n",
        "\n",
        "        train_val_idx, test_idx = next(\n",
        "            split_test.split(\n",
        "                indices,\n",
        "                groups=groups\n",
        "            )\n",
        "        )\n",
        "\n",
        "        validation_relative = (\n",
        "            validation_fraction\n",
        "            / (\n",
        "                1.0\n",
        "                - test_fraction\n",
        "            )\n",
        "        )\n",
        "\n",
        "        groups_train_val = groups[\n",
        "            train_val_idx\n",
        "        ]\n",
        "\n",
        "        if len(\n",
        "            np.unique(\n",
        "                groups_train_val\n",
        "            )\n",
        "        ) < 2:\n",
        "            raise ValueError(\n",
        "                \"После выделения теста осталось \"\n",
        "                \"слишком мало пространственных блоков.\"\n",
        "            )\n",
        "\n",
        "        split_validation = GroupShuffleSplit(\n",
        "            n_splits=1,\n",
        "            test_size=validation_relative,\n",
        "            random_state=seed + 1\n",
        "        )\n",
        "\n",
        "        train_local, validation_local = next(\n",
        "            split_validation.split(\n",
        "                train_val_idx,\n",
        "                groups=groups_train_val\n",
        "            )\n",
        "        )\n",
        "\n",
        "        train_idx = train_val_idx[\n",
        "            train_local\n",
        "        ]\n",
        "\n",
        "        validation_idx = train_val_idx[\n",
        "            validation_local\n",
        "        ]\n",
        "\n",
        "    except Exception as error:\n",
        "        print(\n",
        "            \"ВНИМАНИЕ: пространственное разбиение \"\n",
        "            \"не удалось:\"\n",
        "        )\n",
        "\n",
        "        print(\n",
        "            f\"  {error}\"\n",
        "        )\n",
        "\n",
        "        print(\n",
        "            \"  Используется обычное случайное разбиение. \"\n",
        "            \"Для итогового эксперимента это нежелательно.\"\n",
        "        )\n",
        "\n",
        "        split_mode = \"random_fallback\"\n",
        "\n",
        "        train_val_idx, test_idx = train_test_split(\n",
        "            indices,\n",
        "            test_size=test_fraction,\n",
        "            random_state=seed\n",
        "        )\n",
        "\n",
        "        validation_relative = (\n",
        "            validation_fraction\n",
        "            / (\n",
        "                1.0\n",
        "                - test_fraction\n",
        "            )\n",
        "        )\n",
        "\n",
        "        train_idx, validation_idx = train_test_split(\n",
        "            train_val_idx,\n",
        "            test_size=validation_relative,\n",
        "            random_state=seed + 1\n",
        "        )\n",
        "\n",
        "    return {\n",
        "        \"train\": np.sort(\n",
        "            train_idx\n",
        "        ),\n",
        "        \"validation\": np.sort(\n",
        "            validation_idx\n",
        "        ),\n",
        "        \"test\": np.sort(\n",
        "            test_idx\n",
        "        ),\n",
        "        \"groups\": groups,\n",
        "        \"mode\": split_mode\n",
        "    }\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# ПРЕДОБРАБОТКА\n",
        "# =========================================================\n",
        "def make_preprocessor(\n",
        "    feature_columns\n",
        "):\n",
        "    numeric_pipeline = Pipeline([\n",
        "        (\n",
        "            \"imputer\",\n",
        "            SimpleImputer(\n",
        "                strategy=\"median\"\n",
        "            )\n",
        "        ),\n",
        "        (\n",
        "            \"scaler\",\n",
        "            StandardScaler()\n",
        "        )\n",
        "    ])\n",
        "\n",
        "    return ColumnTransformer(\n",
        "        transformers=[\n",
        "            (\n",
        "                \"numeric\",\n",
        "                numeric_pipeline,\n",
        "                feature_columns\n",
        "            )\n",
        "        ],\n",
        "        remainder=\"drop\",\n",
        "        verbose_feature_names_out=False\n",
        "    )\n",
        "\n",
        "\n",
        "def scale_target(\n",
        "    y,\n",
        "    mean_value,\n",
        "    std_value\n",
        "):\n",
        "    return (\n",
        "        np.asarray(\n",
        "            y,\n",
        "            dtype=float\n",
        "        )\n",
        "        - float(mean_value)\n",
        "    ) / float(std_value)\n",
        "\n",
        "\n",
        "def restore_target(\n",
        "    y_scaled,\n",
        "    mean_value,\n",
        "    std_value\n",
        "):\n",
        "    return (\n",
        "        np.asarray(\n",
        "            y_scaled,\n",
        "            dtype=float\n",
        "        )\n",
        "        * float(std_value)\n",
        "        + float(mean_value)\n",
        "    )\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# KERAS-МОДЕЛЬ И ПРОСТРАНСТВО ГИПЕРПАРАМЕТРОВ\n",
        "# =========================================================\n",
        "class DenseRegressionHyperModel(\n",
        "    kt.HyperModel\n",
        "):\n",
        "    def __init__(\n",
        "        self,\n",
        "        input_dim\n",
        "    ):\n",
        "        self.input_dim = int(\n",
        "            input_dim\n",
        "        )\n",
        "\n",
        "    def build(\n",
        "        self,\n",
        "        hp\n",
        "    ):\n",
        "        activation = hp.Choice(\n",
        "            \"activation\",\n",
        "            values=[\n",
        "                \"relu\",\n",
        "                \"elu\",\n",
        "                \"swish\"\n",
        "            ]\n",
        "        )\n",
        "\n",
        "        initializer = hp.Choice(\n",
        "            \"kernel_initializer\",\n",
        "            values=[\n",
        "                \"he_normal\",\n",
        "                \"he_uniform\",\n",
        "                \"glorot_uniform\"\n",
        "            ]\n",
        "        )\n",
        "\n",
        "        l2_value = hp.Float(\n",
        "            \"l2\",\n",
        "            min_value=1e-8,\n",
        "            max_value=1e-3,\n",
        "            sampling=\"log\"\n",
        "        )\n",
        "\n",
        "        model = tf.keras.Sequential(\n",
        "            name=\"dense_potential_regressor\"\n",
        "        )\n",
        "\n",
        "        model.add(\n",
        "            tf.keras.layers.Input(\n",
        "                shape=(\n",
        "                    self.input_dim,\n",
        "                ),\n",
        "                name=\"level4_features\"\n",
        "            )\n",
        "        )\n",
        "\n",
        "        n_layers = hp.Int(\n",
        "            \"n_hidden_layers\",\n",
        "            min_value=1,\n",
        "            max_value=4,\n",
        "            step=1\n",
        "        )\n",
        "\n",
        "        for layer_index in range(\n",
        "            n_layers\n",
        "        ):\n",
        "            units = hp.Int(\n",
        "                f\"units_{layer_index}\",\n",
        "                min_value=8,\n",
        "                max_value=128,\n",
        "                step=8\n",
        "            )\n",
        "\n",
        "            model.add(\n",
        "                tf.keras.layers.Dense(\n",
        "                    units,\n",
        "                    activation=activation,\n",
        "                    kernel_initializer=initializer,\n",
        "                    kernel_regularizer=\n",
        "                    tf.keras.regularizers.l2(\n",
        "                        l2_value\n",
        "                    ),\n",
        "                    name=f\"dense_{layer_index}\"\n",
        "                )\n",
        "            )\n",
        "\n",
        "            use_batch_norm = hp.Boolean(\n",
        "                f\"batch_norm_{layer_index}\",\n",
        "                default=False\n",
        "            )\n",
        "\n",
        "            if use_batch_norm:\n",
        "                model.add(\n",
        "                    tf.keras.layers.BatchNormalization(\n",
        "                        name=f\"batch_norm_{layer_index}\"\n",
        "                    )\n",
        "                )\n",
        "\n",
        "            dropout_rate = hp.Float(\n",
        "                f\"dropout_{layer_index}\",\n",
        "                min_value=0.0,\n",
        "                max_value=0.5,\n",
        "                step=0.1\n",
        "            )\n",
        "\n",
        "            if dropout_rate > 0:\n",
        "                model.add(\n",
        "                    tf.keras.layers.Dropout(\n",
        "                        dropout_rate,\n",
        "                        name=f\"dropout_{layer_index}\"\n",
        "                    )\n",
        "                )\n",
        "\n",
        "        model.add(\n",
        "            tf.keras.layers.Dense(\n",
        "                1,\n",
        "                activation=\"linear\",\n",
        "                name=\"relative_correction_scaled\"\n",
        "            )\n",
        "        )\n",
        "\n",
        "        learning_rate = hp.Float(\n",
        "            \"learning_rate\",\n",
        "            min_value=1e-5,\n",
        "            max_value=3e-2,\n",
        "            sampling=\"log\"\n",
        "        )\n",
        "\n",
        "        optimizer_name = hp.Choice(\n",
        "            \"optimizer\",\n",
        "            values=[\n",
        "                \"adam\",\n",
        "                \"nadam\"\n",
        "            ]\n",
        "        )\n",
        "\n",
        "        if optimizer_name == \"adam\":\n",
        "            optimizer = tf.keras.optimizers.Adam(\n",
        "                learning_rate=learning_rate,\n",
        "                clipnorm=5.0\n",
        "            )\n",
        "\n",
        "        else:\n",
        "            optimizer = tf.keras.optimizers.Nadam(\n",
        "                learning_rate=learning_rate,\n",
        "                clipnorm=5.0\n",
        "            )\n",
        "\n",
        "        model.compile(\n",
        "            optimizer=optimizer,\n",
        "            loss=\"mse\",\n",
        "            metrics=[\n",
        "                tf.keras.metrics.MeanAbsoluteError(\n",
        "                    name=\"mae\"\n",
        "                ),\n",
        "                tf.keras.metrics.RootMeanSquaredError(\n",
        "                    name=\"rmse\"\n",
        "                )\n",
        "            ]\n",
        "        )\n",
        "\n",
        "        return model\n",
        "\n",
        "    def fit(\n",
        "        self,\n",
        "        hp,\n",
        "        model,\n",
        "        *args,\n",
        "        **kwargs\n",
        "    ):\n",
        "        batch_size = hp.Choice(\n",
        "            \"batch_size\",\n",
        "            values=[\n",
        "                4,\n",
        "                8,\n",
        "                16,\n",
        "                32\n",
        "            ]\n",
        "        )\n",
        "\n",
        "        kwargs[\n",
        "            \"batch_size\"\n",
        "        ] = batch_size\n",
        "\n",
        "        return model.fit(\n",
        "            *args,\n",
        "            **kwargs\n",
        "        )\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# ПОИСК ОПТИМАЛЬНЫХ ГИПЕРПАРАМЕТРОВ\n",
        "# =========================================================\n",
        "def search_best_hyperparameters(\n",
        "    X_train,\n",
        "    y_train,\n",
        "    X_validation,\n",
        "    y_validation,\n",
        "    max_trials=25,\n",
        "    search_epochs=400,\n",
        "    patience=40,\n",
        "    executions_per_trial=1,\n",
        "    tuner_directory=\"keras_tuner_dense\",\n",
        "    seed=42,\n",
        "    overwrite=True\n",
        "):\n",
        "    hypermodel = DenseRegressionHyperModel(\n",
        "        input_dim=X_train.shape[1]\n",
        "    )\n",
        "\n",
        "    tuner = kt.RandomSearch(\n",
        "        hypermodel=hypermodel,\n",
        "        objective=kt.Objective(\n",
        "            \"val_loss\",\n",
        "            direction=\"min\"\n",
        "        ),\n",
        "        max_trials=int(\n",
        "            max_trials\n",
        "        ),\n",
        "        executions_per_trial=int(\n",
        "            executions_per_trial\n",
        "        ),\n",
        "        directory=tuner_directory,\n",
        "        project_name=\"residual_potential_Q_from_LSC_B\",\n",
        "        seed=int(seed),\n",
        "        overwrite=bool(\n",
        "            overwrite\n",
        "        )\n",
        "    )\n",
        "\n",
        "    early_stopping = tf.keras.callbacks.EarlyStopping(\n",
        "        monitor=\"val_loss\",\n",
        "        patience=int(\n",
        "            patience\n",
        "        ),\n",
        "        min_delta=1e-6,\n",
        "        restore_best_weights=True,\n",
        "        verbose=0\n",
        "    )\n",
        "\n",
        "    reduce_lr = tf.keras.callbacks.ReduceLROnPlateau(\n",
        "        monitor=\"val_loss\",\n",
        "        factor=0.5,\n",
        "        patience=max(\n",
        "            5,\n",
        "            int(patience) // 3\n",
        "        ),\n",
        "        min_lr=1e-7,\n",
        "        verbose=0\n",
        "    )\n",
        "\n",
        "    print(\n",
        "        \"\\n--- Поиск гиперпараметров Keras ---\"\n",
        "    )\n",
        "\n",
        "    tuner.search(\n",
        "        X_train,\n",
        "        y_train,\n",
        "        validation_data=(\n",
        "            X_validation,\n",
        "            y_validation\n",
        "        ),\n",
        "        epochs=int(\n",
        "            search_epochs\n",
        "        ),\n",
        "        callbacks=[\n",
        "            early_stopping,\n",
        "            reduce_lr\n",
        "        ],\n",
        "        verbose=1\n",
        "    )\n",
        "\n",
        "    best_hp = tuner.get_best_hyperparameters(\n",
        "        num_trials=1\n",
        "    )[0]\n",
        "\n",
        "    best_trial = tuner.oracle.get_best_trials(\n",
        "        num_trials=1\n",
        "    )[0]\n",
        "\n",
        "    best_epoch = best_trial.metrics.get_best_step(\n",
        "        \"val_loss\"\n",
        "    )\n",
        "\n",
        "    if best_epoch is None:\n",
        "        best_epoch = int(\n",
        "            search_epochs\n",
        "        )\n",
        "\n",
        "    else:\n",
        "        best_epoch = int(\n",
        "            best_epoch\n",
        "        ) + 1\n",
        "\n",
        "    best_epoch = int(\n",
        "        np.clip(\n",
        "            best_epoch,\n",
        "            MIN_FINAL_EPOCHS,\n",
        "            MAX_FINAL_EPOCHS\n",
        "        )\n",
        "    )\n",
        "\n",
        "    return (\n",
        "        tuner,\n",
        "        best_hp,\n",
        "        best_epoch\n",
        "    )\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# МЕТРИКИ\n",
        "# =========================================================\n",
        "def safe_correlation(\n",
        "    y_true,\n",
        "    y_pred\n",
        "):\n",
        "    y_true = np.asarray(\n",
        "        y_true,\n",
        "        dtype=float\n",
        "    )\n",
        "\n",
        "    y_pred = np.asarray(\n",
        "        y_pred,\n",
        "        dtype=float\n",
        "    )\n",
        "\n",
        "    if (\n",
        "        len(y_true) < 2\n",
        "        or np.std(y_true) == 0\n",
        "        or np.std(y_pred) == 0\n",
        "    ):\n",
        "        return float(\"nan\")\n",
        "\n",
        "    return float(\n",
        "        np.corrcoef(\n",
        "            y_true,\n",
        "            y_pred\n",
        "        )[0, 1]\n",
        "    )\n",
        "\n",
        "\n",
        "def regression_metrics(\n",
        "    y_true,\n",
        "    y_pred,\n",
        "    gamma=9.81\n",
        "):\n",
        "    y_true = np.asarray(\n",
        "        y_true,\n",
        "        dtype=float\n",
        "    )\n",
        "\n",
        "    y_pred = np.asarray(\n",
        "        y_pred,\n",
        "        dtype=float\n",
        "    )\n",
        "\n",
        "    error = (\n",
        "        y_pred\n",
        "        - y_true\n",
        "    )\n",
        "\n",
        "    gamma = np.asarray(\n",
        "        gamma,\n",
        "        dtype=float\n",
        "    )\n",
        "\n",
        "    if gamma.ndim == 0:\n",
        "        gamma = np.full(\n",
        "            len(error),\n",
        "            float(gamma)\n",
        "        )\n",
        "\n",
        "    height_error_cm = (\n",
        "        error\n",
        "        / gamma\n",
        "        * 100.0\n",
        "    )\n",
        "\n",
        "    return {\n",
        "        \"bias_m2_s2\": float(\n",
        "            np.mean(\n",
        "                error\n",
        "            )\n",
        "        ),\n",
        "        \"mae_m2_s2\": float(\n",
        "            np.mean(\n",
        "                np.abs(\n",
        "                    error\n",
        "                )\n",
        "            )\n",
        "        ),\n",
        "        \"rmse_m2_s2\": float(\n",
        "            np.sqrt(\n",
        "                np.mean(\n",
        "                    error ** 2\n",
        "                )\n",
        "            )\n",
        "        ),\n",
        "        \"bias_cm\": float(\n",
        "            np.mean(\n",
        "                height_error_cm\n",
        "            )\n",
        "        ),\n",
        "        \"mae_cm\": float(\n",
        "            np.mean(\n",
        "                np.abs(\n",
        "                    height_error_cm\n",
        "                )\n",
        "            )\n",
        "        ),\n",
        "        \"rmse_cm\": float(\n",
        "            np.sqrt(\n",
        "                np.mean(\n",
        "                    height_error_cm ** 2\n",
        "                )\n",
        "            )\n",
        "        ),\n",
        "        \"correlation\": safe_correlation(\n",
        "            y_true,\n",
        "            y_pred\n",
        "        )\n",
        "    }\n",
        "\n",
        "\n",
        "def centered_rmse(\n",
        "    y_true,\n",
        "    y_pred\n",
        "):\n",
        "    \"\"\"СКО ошибки после удаления её постоянной составляющей.\"\"\"\n",
        "    residual = (\n",
        "        np.asarray(\n",
        "            y_pred,\n",
        "            dtype=float\n",
        "        )\n",
        "        - np.asarray(\n",
        "            y_true,\n",
        "            dtype=float\n",
        "        )\n",
        "    )\n",
        "    residual_centered = residual - np.mean(residual)\n",
        "    return float(\n",
        "        np.sqrt(\n",
        "            np.mean(\n",
        "                residual_centered ** 2\n",
        "            )\n",
        "        )\n",
        "    )\n",
        "\n",
        "\n",
        "def print_metrics(\n",
        "    name,\n",
        "    metrics\n",
        "):\n",
        "    print(\n",
        "        f\"{name:<18}\"\n",
        "        f\"bias={metrics['bias_cm']:>8.3f} см  \"\n",
        "        f\"MAE={metrics['mae_cm']:>8.3f} см  \"\n",
        "        f\"RMS={metrics['rmse_cm']:>8.3f} см  \"\n",
        "        f\"corr={metrics['correlation']:>8.4f}\"\n",
        "    )\n",
        "\n",
        "\n",
        "# =========================================================\n",
        "# ПОЛНЫЙ ЗАПУСК ОБУЧЕНИЯ\n",
        "# =========================================================\n",
        "def run_keras_dense_experiment():\n",
        "    (\n",
        "        df,\n",
        "        config,\n",
        "        feature_columns,\n",
        "        target_column\n",
        "    ) = load_level4_dataset(\n",
        "        LEVEL4_CSV,\n",
        "        LEVEL4_CONFIG_JSON\n",
        "    )\n",
        "\n",
        "    split_column = config[\"schema\"].get(\"split_column\", \"split\")\n",
        "    use_for_ml_column = config[\"schema\"].get(\n",
        "        \"use_for_ml_column\",\n",
        "        \"use_for_ml\"\n",
        "    )\n",
        "\n",
        "    t_lsc_column = \"T_LSC_P_residual (m2/s2)\"\n",
        "\n",
        "    required_diagnostic_columns = [\n",
        "        split_column,\n",
        "        use_for_ml_column,\n",
        "        \"source_index\",\n",
        "        \"latitude_deg\",\n",
        "        \"longitude_deg\",\n",
        "        \"dT_LSC_i0_m2_s2\",\n",
        "        \"dT_true_i0_control_m2_s2\",\n",
        "        \"true_relative_correction_control_m2_s2\",\n",
        "        t_lsc_column,\n",
        "        \"T_true_P_control\"\n",
        "    ]\n",
        "\n",
        "    missing_diagnostics = [\n",
        "        column\n",
        "        for column in required_diagnostic_columns\n",
        "        if column not in df.columns\n",
        "    ]\n",
        "    if missing_diagnostics:\n",
        "        raise KeyError(\n",
        "            \"Не хватает диагностических колонок относительной схемы: \"\n",
        "            f\"{missing_diagnostics}\"\n",
        "        )\n",
        "\n",
        "    # P0 остаётся в CSV для проверки нулевых разностей,\n",
        "    # но полностью исключается из ML.\n",
        "    ml_mask = df[use_for_ml_column].astype(int).eq(1)\n",
        "    df_ml = df.loc[ml_mask].copy()\n",
        "\n",
        "    train_idx = df_ml.index[df_ml[split_column].eq(\"train\")].to_numpy()\n",
        "    validation_idx = df_ml.index[\n",
        "        df_ml[split_column].eq(\"validation\")\n",
        "    ].to_numpy()\n",
        "    test_idx = df_ml.index[df_ml[split_column].eq(\"test\")].to_numpy()\n",
        "\n",
        "    if min(len(train_idx), len(validation_idx), len(test_idx)) == 0:\n",
        "        raise ValueError(\n",
        "            \"После исключения P0 одна из выборок train/validation/test пуста.\"\n",
        "        )\n",
        "\n",
        "    reference_source_index = int(\n",
        "        config[\"reference_point\"][\"source_index\"]\n",
        "    )\n",
        "    if reference_source_index in set(\n",
        "        df.loc[train_idx, \"source_index\"].astype(int)\n",
        "    ):\n",
        "        raise AssertionError(\"Опорная точка P0 не исключена из train.\")\n",
        "\n",
        "    print(\"=\" * 78)\n",
        "    print(\"KERAS: ОТНОСИТЕЛЬНАЯ ML-ПОПРАВКА К LSC\")\n",
        "    print(\"=\" * 78)\n",
        "    print(f\"Датасет: {LEVEL4_CSV}\")\n",
        "    print(f\"Строк всего: {len(df)}\")\n",
        "    print(f\"Строк для ML без P0: {len(df_ml)}\")\n",
        "    print(f\"Разностных признаков d_Phi: {len(feature_columns)}\")\n",
        "    print(f\"ML-цель: {target_column}\")\n",
        "    print(\n",
        "        \"P0: source_index=\"\n",
        "        f\"{reference_source_index}, split=train\"\n",
        "    )\n",
        "\n",
        "    print(\"\\n--- Фиксированное пространственное разделение ---\")\n",
        "    print(f\"режим: {config['split']['mode']}\")\n",
        "    print(f\"train: {len(train_idx)}\")\n",
        "    print(f\"validation: {len(validation_idx)}\")\n",
        "    print(f\"test: {len(test_idx)}\")\n",
        "\n",
        "    X_train_df = df.loc[train_idx, feature_columns]\n",
        "    X_validation_df = df.loc[validation_idx, feature_columns]\n",
        "    X_test_df = df.loc[test_idx, feature_columns]\n",
        "\n",
        "    y_train = df.loc[train_idx, target_column].to_numpy(float)\n",
        "    y_validation = df.loc[validation_idx, target_column].to_numpy(float)\n",
        "    y_test = df.loc[test_idx, target_column].to_numpy(float)\n",
        "\n",
        "    # Предобработка и масштабирование цели только по train.\n",
        "    preprocessor_search = make_preprocessor(feature_columns)\n",
        "    X_train = preprocessor_search.fit_transform(\n",
        "        X_train_df\n",
        "    ).astype(np.float32)\n",
        "    X_validation = preprocessor_search.transform(\n",
        "        X_validation_df\n",
        "    ).astype(np.float32)\n",
        "\n",
        "    y_train_mean = float(np.mean(y_train))\n",
        "    y_train_std = float(np.std(y_train))\n",
        "\n",
        "    if not np.isfinite(y_train_std) or y_train_std < 1e-12:\n",
        "        raise ValueError(\"СКО обучающей поправки равно нулю или слишком мало.\")\n",
        "\n",
        "    y_train_scaled = scale_target(\n",
        "        y_train,\n",
        "        y_train_mean,\n",
        "        y_train_std\n",
        "    ).astype(np.float32)\n",
        "    y_validation_scaled = scale_target(\n",
        "        y_validation,\n",
        "        y_train_mean,\n",
        "        y_train_std\n",
        "    ).astype(np.float32)\n",
        "\n",
        "    (\n",
        "        tuner,\n",
        "        best_hp,\n",
        "        best_epoch\n",
        "    ) = search_best_hyperparameters(\n",
        "        X_train,\n",
        "        y_train_scaled,\n",
        "        X_validation,\n",
        "        y_validation_scaled,\n",
        "        max_trials=MAX_TRIALS,\n",
        "        search_epochs=SEARCH_EPOCHS,\n",
        "        patience=EARLY_STOPPING_PATIENCE,\n",
        "        executions_per_trial=EXECUTIONS_PER_TRIAL,\n",
        "        tuner_directory=TUNER_DIRECTORY,\n",
        "        seed=RANDOM_SEED,\n",
        "        overwrite=OVERWRITE_TUNER\n",
        "    )\n",
        "\n",
        "    print(\"\\n--- Лучшие гиперпараметры ---\")\n",
        "    for name, value in sorted(best_hp.values.items()):\n",
        "        print(f\"{name}: {value}\")\n",
        "    print(f\"Число эпох финального обучения: {best_epoch}\")\n",
        "\n",
        "    # Финальное обучение на train+validation.\n",
        "    train_val_idx = np.concatenate([train_idx, validation_idx])\n",
        "\n",
        "    X_train_val_df = df.loc[train_val_idx, feature_columns]\n",
        "    y_train_val = df.loc[train_val_idx, target_column].to_numpy(float)\n",
        "\n",
        "    final_preprocessor = make_preprocessor(feature_columns)\n",
        "    X_train_val = final_preprocessor.fit_transform(\n",
        "        X_train_val_df\n",
        "    ).astype(np.float32)\n",
        "    X_test_final = final_preprocessor.transform(\n",
        "        X_test_df\n",
        "    ).astype(np.float32)\n",
        "\n",
        "    y_mean_final = float(np.mean(y_train_val))\n",
        "    y_std_final = float(np.std(y_train_val))\n",
        "\n",
        "    if not np.isfinite(y_std_final) or y_std_final < 1e-12:\n",
        "        raise ValueError(\n",
        "            \"СКО поправки train+validation равно нулю или слишком мало.\"\n",
        "        )\n",
        "\n",
        "    y_train_val_scaled = scale_target(\n",
        "        y_train_val,\n",
        "        y_mean_final,\n",
        "        y_std_final\n",
        "    ).astype(np.float32)\n",
        "\n",
        "    set_global_seed(RANDOM_SEED)\n",
        "\n",
        "    final_hypermodel = DenseRegressionHyperModel(\n",
        "        input_dim=X_train_val.shape[1]\n",
        "    )\n",
        "    final_model = final_hypermodel.build(best_hp)\n",
        "\n",
        "    batch_size = int(best_hp.get(\"batch_size\"))\n",
        "\n",
        "    print(\"\\n--- Финальное обучение ---\")\n",
        "    history = final_model.fit(\n",
        "        X_train_val,\n",
        "        y_train_val_scaled,\n",
        "        epochs=best_epoch,\n",
        "        batch_size=batch_size,\n",
        "        shuffle=True,\n",
        "        verbose=1\n",
        "    )\n",
        "\n",
        "    y_test_scaled_pred = final_model.predict(\n",
        "        X_test_final,\n",
        "        verbose=0\n",
        "    ).ravel()\n",
        "    y_test_pred = restore_target(\n",
        "        y_test_scaled_pred,\n",
        "        y_mean_final,\n",
        "        y_std_final\n",
        "    )\n",
        "\n",
        "    # =========================================================\n",
        "    # РЕГИОНАЛЬНАЯ LSC-ПРИВЯЗКА АБСОЛЮТНОГО РЕЗУЛЬТАТА\n",
        "    #\n",
        "    # ВАЖНО:\n",
        "    #   * существующий y_test_pred не изменяется;\n",
        "    #   * относительные метрики используют сырой y_test_pred;\n",
        "    #   * сдвиг оценивается только как среднее прогноза модели\n",
        "    #     по всем строкам use_for_ml == 1;\n",
        "    #   * истинное поле и метки в оценке сдвига не участвуют.\n",
        "    # =========================================================\n",
        "    df_all_ml = df.loc[\n",
        "        df[use_for_ml_column].astype(int).eq(1)\n",
        "    ].copy()\n",
        "\n",
        "    if len(df_all_ml) == 0:\n",
        "        raise ValueError(\n",
        "            \"Нет результативных строк use_for_ml == 1 \"\n",
        "            \"для региональной LSC-привязки.\"\n",
        "        )\n",
        "\n",
        "    if not df_all_ml[\"source_index\"].is_unique:\n",
        "        raise ValueError(\n",
        "            \"source_index должен быть уникальным для сопоставления \"\n",
        "            \"общих и test-прогнозов.\"\n",
        "        )\n",
        "\n",
        "    # Тот же порядок признаков и тот же финальный preprocessing.\n",
        "    X_all_df = df_all_ml.loc[:, feature_columns]\n",
        "    X_all_scaled = final_preprocessor.transform(\n",
        "        X_all_df\n",
        "    ).astype(np.float32)\n",
        "\n",
        "    y_all_pred_scaled = final_model.predict(\n",
        "        X_all_scaled,\n",
        "        verbose=0\n",
        "    ).ravel()\n",
        "\n",
        "    # То же обратное масштабирование, что и для test.\n",
        "    y_pred_raw_all = restore_target(\n",
        "        y_all_pred_scaled,\n",
        "        y_mean_final,\n",
        "        y_std_final\n",
        "    )\n",
        "\n",
        "    # Оценка сдвига использует только доступные прогнозы модели.\n",
        "    estimated_reference_offset = float(\n",
        "        np.mean(y_pred_raw_all)\n",
        "    )\n",
        "\n",
        "    y_pred_datum_corrected_all = (\n",
        "        y_pred_raw_all\n",
        "        - estimated_reference_offset\n",
        "    )\n",
        "\n",
        "    T_lsc_all = df_all_ml[\n",
        "        t_lsc_column\n",
        "    ].to_numpy(float)\n",
        "\n",
        "    T_corrected_raw_all = (\n",
        "        T_lsc_all\n",
        "        + y_pred_raw_all\n",
        "    )\n",
        "\n",
        "    T_corrected_datum_all = (\n",
        "        T_lsc_all\n",
        "        + y_pred_datum_corrected_all\n",
        "    )\n",
        "\n",
        "    # Обязательные проверки нулевого среднего и уровня поля.\n",
        "    mean_centered_correction = float(\n",
        "        np.mean(y_pred_datum_corrected_all)\n",
        "    )\n",
        "\n",
        "    if abs(mean_centered_correction) > 1e-12:\n",
        "        raise AssertionError(\n",
        "            \"После удаления систематического сдвига средняя ML-поправка \"\n",
        "            f\"не равна нулю: {mean_centered_correction:.3e}\"\n",
        "        )\n",
        "\n",
        "    mean_lsc_all = float(\n",
        "        np.mean(T_lsc_all)\n",
        "    )\n",
        "    mean_final_all = float(\n",
        "        np.mean(T_corrected_datum_all)\n",
        "    )\n",
        "\n",
        "    if abs(mean_final_all - mean_lsc_all) > 1e-12:\n",
        "        raise AssertionError(\n",
        "            \"Средний уровень исправленного поля не совпадает \"\n",
        "            \"со средним уровнем LSC: \"\n",
        "            f\"{mean_final_all - mean_lsc_all:.3e}\"\n",
        "        )\n",
        "\n",
        "    max_raw_identity_all = float(\n",
        "        np.max(\n",
        "            np.abs(\n",
        "                T_corrected_raw_all\n",
        "                - (T_lsc_all + y_pred_raw_all)\n",
        "            )\n",
        "        )\n",
        "    )\n",
        "    if max_raw_identity_all > 1e-12:\n",
        "        raise AssertionError(\n",
        "            \"Нарушено T_raw=T_LSC+y_hat: \"\n",
        "            f\"{max_raw_identity_all:.3e}\"\n",
        "        )\n",
        "\n",
        "    max_datum_identity_all = float(\n",
        "        np.max(\n",
        "            np.abs(\n",
        "                T_corrected_datum_all\n",
        "                - (\n",
        "                    T_lsc_all\n",
        "                    + y_pred_raw_all\n",
        "                    - estimated_reference_offset\n",
        "                )\n",
        "            )\n",
        "        )\n",
        "    )\n",
        "    if max_datum_identity_all > 1e-12:\n",
        "        raise AssertionError(\n",
        "            \"Нарушено T_final=T_LSC+y_hat-b_ref: \"\n",
        "            f\"{max_datum_identity_all:.3e}\"\n",
        "        )\n",
        "\n",
        "    # Сопоставление test со всеми прогнозами выполняется по source_index.\n",
        "    all_prediction_by_source = pd.Series(\n",
        "        y_pred_raw_all,\n",
        "        index=df_all_ml[\"source_index\"].astype(int).to_numpy(),\n",
        "        name=\"y_pred_raw_m2_s2\"\n",
        "    )\n",
        "    all_datum_prediction_by_source = pd.Series(\n",
        "        y_pred_datum_corrected_all,\n",
        "        index=df_all_ml[\"source_index\"].astype(int).to_numpy(),\n",
        "        name=\"y_pred_datum_corrected_m2_s2\"\n",
        "    )\n",
        "\n",
        "    test_source_index = df.loc[\n",
        "        test_idx,\n",
        "        \"source_index\"\n",
        "    ].astype(int).to_numpy()\n",
        "\n",
        "    missing_test_sources = sorted(\n",
        "        set(test_source_index)\n",
        "        - set(all_prediction_by_source.index)\n",
        "    )\n",
        "    if missing_test_sources:\n",
        "        raise KeyError(\n",
        "            \"Test-строки не найдены среди общих прогнозов: \"\n",
        "            f\"{missing_test_sources[:10]}\"\n",
        "        )\n",
        "\n",
        "    y_pred_raw_test_from_all = all_prediction_by_source.loc[\n",
        "        test_source_index\n",
        "    ].to_numpy(float)\n",
        "    y_pred_datum_corrected_test = (\n",
        "        all_datum_prediction_by_source.loc[\n",
        "            test_source_index\n",
        "        ].to_numpy(float)\n",
        "    )\n",
        "\n",
        "    # y_test_pred сохраняется и остаётся прогнозом для относительных метрик.\n",
        "    max_test_prediction_alignment = float(\n",
        "        np.max(\n",
        "            np.abs(\n",
        "                y_pred_raw_test_from_all\n",
        "                - y_test_pred\n",
        "            )\n",
        "        )\n",
        "    )\n",
        "    if max_test_prediction_alignment > 1e-6:\n",
        "        raise AssertionError(\n",
        "            \"Прогноз test не согласован с прогнозом тех же строк \"\n",
        "            \"в общей области: \"\n",
        "            f\"{max_test_prediction_alignment:.3e} м²/с²\"\n",
        "        )\n",
        "\n",
        "    # =========================================================\n",
        "    # КОНТРОЛИ И ВОССТАНОВЛЕННЫЕ РЕЗУЛЬТАТЫ TEST\n",
        "    # =========================================================\n",
        "    y_true_correction = df.loc[\n",
        "        test_idx,\n",
        "        \"true_relative_correction_control_m2_s2\"\n",
        "    ].to_numpy(float)\n",
        "\n",
        "    dT_lsc_test = df.loc[\n",
        "        test_idx,\n",
        "        \"dT_LSC_i0_m2_s2\"\n",
        "    ].to_numpy(float)\n",
        "\n",
        "    dT_true_test = df.loc[\n",
        "        test_idx,\n",
        "        \"dT_true_i0_control_m2_s2\"\n",
        "    ].to_numpy(float)\n",
        "\n",
        "    T_lsc_test = df.loc[\n",
        "        test_idx,\n",
        "        t_lsc_column\n",
        "    ].to_numpy(float)\n",
        "\n",
        "    T_true_test = df.loc[\n",
        "        test_idx,\n",
        "        \"T_true_P_control\"\n",
        "    ].to_numpy(float)\n",
        "\n",
        "    # Главный относительный критерий остаётся без изменений:\n",
        "    # используется исходный сырой y_test_pred.\n",
        "    dT_corrected_test = (\n",
        "        dT_lsc_test\n",
        "        + y_test_pred\n",
        "    )\n",
        "\n",
        "    # Три абсолютных варианта.\n",
        "    T_corrected_raw_test = (\n",
        "        T_lsc_test\n",
        "        + y_pred_raw_test_from_all\n",
        "    )\n",
        "    # Обратная совместимость со старым именем.\n",
        "    T_corrected_test = T_corrected_raw_test\n",
        "\n",
        "    T_corrected_datum_test = (\n",
        "        T_lsc_test\n",
        "        + y_pred_datum_corrected_test\n",
        "    )\n",
        "\n",
        "    # Существующее тождество сырого результата не удаляется.\n",
        "    max_absolute_identity = float(\n",
        "        np.max(\n",
        "            np.abs(\n",
        "                T_corrected_raw_test\n",
        "                - (\n",
        "                    T_lsc_test\n",
        "                    + y_pred_raw_test_from_all\n",
        "                )\n",
        "            )\n",
        "        )\n",
        "    )\n",
        "    if max_absolute_identity > 1e-12:\n",
        "        raise AssertionError(\n",
        "            \"Нарушено T_raw(P)=T_LSC(P)+y_hat: \"\n",
        "            f\"{max_absolute_identity:.3e}\"\n",
        "        )\n",
        "\n",
        "    max_absolute_datum_identity = float(\n",
        "        np.max(\n",
        "            np.abs(\n",
        "                T_corrected_datum_test\n",
        "                - (\n",
        "                    T_lsc_test\n",
        "                    + y_pred_raw_test_from_all\n",
        "                    - estimated_reference_offset\n",
        "                )\n",
        "            )\n",
        "        )\n",
        "    )\n",
        "    if max_absolute_datum_identity > 1e-12:\n",
        "        raise AssertionError(\n",
        "            \"Нарушено T_final(P)=T_LSC(P)+y_hat-b_ref: \"\n",
        "            f\"{max_absolute_datum_identity:.3e}\"\n",
        "        )\n",
        "\n",
        "    metrics_target_fit = regression_metrics(\n",
        "        y_test,\n",
        "        y_test_pred,\n",
        "        GAMMA_REPORT\n",
        "    )\n",
        "    metrics_label_to_true = regression_metrics(\n",
        "        y_true_correction,\n",
        "        y_test,\n",
        "        GAMMA_REPORT\n",
        "    )\n",
        "    metrics_prediction_to_true = regression_metrics(\n",
        "        y_true_correction,\n",
        "        y_test_pred,\n",
        "        GAMMA_REPORT\n",
        "    )\n",
        "    metrics_relative_lsc = regression_metrics(\n",
        "        dT_true_test,\n",
        "        dT_lsc_test,\n",
        "        GAMMA_REPORT\n",
        "    )\n",
        "    metrics_relative_corrected = regression_metrics(\n",
        "        dT_true_test,\n",
        "        dT_corrected_test,\n",
        "        GAMMA_REPORT\n",
        "    )\n",
        "    metrics_absolute_lsc = regression_metrics(\n",
        "        T_true_test,\n",
        "        T_lsc_test,\n",
        "        GAMMA_REPORT\n",
        "    )\n",
        "    # Старое имя сохраняется: это сырой ML+LSC, привязанный к P0.\n",
        "    metrics_absolute_corrected = regression_metrics(\n",
        "        T_true_test,\n",
        "        T_corrected_raw_test,\n",
        "        GAMMA_REPORT\n",
        "    )\n",
        "    metrics_absolute_datum_corrected = regression_metrics(\n",
        "        T_true_test,\n",
        "        T_corrected_datum_test,\n",
        "        GAMMA_REPORT\n",
        "    )\n",
        "\n",
        "    centered_rmse_absolute_lsc = centered_rmse(\n",
        "        T_true_test,\n",
        "        T_lsc_test\n",
        "    )\n",
        "    centered_rmse_absolute_raw = centered_rmse(\n",
        "        T_true_test,\n",
        "        T_corrected_raw_test\n",
        "    )\n",
        "    centered_rmse_absolute_datum = centered_rmse(\n",
        "        T_true_test,\n",
        "        T_corrected_datum_test\n",
        "    )\n",
        "\n",
        "    if abs(\n",
        "        centered_rmse_absolute_raw\n",
        "        - centered_rmse_absolute_datum\n",
        "    ) > 1e-12:\n",
        "        raise AssertionError(\n",
        "            \"Удаление постоянного сдвига изменило \"\n",
        "            \"центрированную пространственную ошибку.\"\n",
        "        )\n",
        "\n",
        "    print(\"\\n--- Метрики test ---\")\n",
        "    print_metrics(\"ML vs label\", metrics_target_fit)\n",
        "    print_metrics(\"label vs y_true\", metrics_label_to_true)\n",
        "    print_metrics(\"ML vs y_true\", metrics_prediction_to_true)\n",
        "\n",
        "    print(\"\\n--- Главный относительный критерий ---\")\n",
        "    print_metrics(\"dT_LSC\", metrics_relative_lsc)\n",
        "    print_metrics(\"dT_LSC + ML\", metrics_relative_corrected)\n",
        "\n",
        "    print(\"\\n--- Оценка систематического сдвига, вызванного P0 ---\")\n",
        "    print(f\"Число результативных точек: {len(df_all_ml)}\")\n",
        "    print(\n",
        "        \"Оценённый сдвиг: \"\n",
        "        f\"{estimated_reference_offset:.9f} м²/с²\"\n",
        "    )\n",
        "    print(\n",
        "        \"Оценённый сдвиг в сантиметрах: \"\n",
        "        f\"{estimated_reference_offset / GAMMA_REPORT * 100.0:.6f} см\"\n",
        "    )\n",
        "    print(\n",
        "        \"Среднее сырой ML-поправки: \"\n",
        "        f\"{float(np.mean(y_pred_raw_all)):.9f} м²/с²\"\n",
        "    )\n",
        "    print(\n",
        "        \"Среднее исправленной ML-поправки: \"\n",
        "        f\"{mean_centered_correction:.3e} м²/с²\"\n",
        "    )\n",
        "    print(f\"Среднее T_LSC: {mean_lsc_all:.9f} м²/с²\")\n",
        "    print(\n",
        "        \"Среднее T_LSC + ML после привязки: \"\n",
        "        f\"{mean_final_all:.9f} м²/с²\"\n",
        "    )\n",
        "\n",
        "    print(\"\\n--- Абсолютный результат после региональной LSC-привязки ---\")\n",
        "    print_metrics(\"T_LSC\", metrics_absolute_lsc)\n",
        "    print_metrics(\n",
        "        \"T_LSC + ML, привязка к LSC(P0)\",\n",
        "        metrics_absolute_corrected\n",
        "    )\n",
        "    print_metrics(\n",
        "        \"T_LSC + ML, региональная LSC-привязка\",\n",
        "        metrics_absolute_datum_corrected\n",
        "    )\n",
        "\n",
        "    print(\"\\nЦентрированное СКО абсолютной ошибки:\")\n",
        "    print(\n",
        "        \"T_LSC: \"\n",
        "        f\"{centered_rmse_absolute_lsc / GAMMA_REPORT * 100.0:.6f} см\"\n",
        "    )\n",
        "    print(\n",
        "        \"T_LSC + ML, привязка к LSC(P0): \"\n",
        "        f\"{centered_rmse_absolute_raw / GAMMA_REPORT * 100.0:.6f} см\"\n",
        "    )\n",
        "    print(\n",
        "        \"T_LSC + ML, региональная LSC-привязка: \"\n",
        "        f\"{centered_rmse_absolute_datum / GAMMA_REPORT * 100.0:.6f} см\"\n",
        "    )\n",
        "\n",
        "    datum_bias_improved = bool(\n",
        "        abs(metrics_absolute_datum_corrected[\"bias_m2_s2\"])\n",
        "        <= abs(metrics_absolute_corrected[\"bias_m2_s2\"])\n",
        "    )\n",
        "    datum_rmse_improved = bool(\n",
        "        metrics_absolute_datum_corrected[\"rmse_m2_s2\"]\n",
        "        <= metrics_absolute_corrected[\"rmse_m2_s2\"]\n",
        "    )\n",
        "    print(\n",
        "        \"Уменьшение |bias| после региональной привязки: \"\n",
        "        f\"{datum_bias_improved}\"\n",
        "    )\n",
        "    print(\n",
        "        \"Уменьшение RMS после региональной привязки: \"\n",
        "        f\"{datum_rmse_improved}\"\n",
        "    )\n",
        "\n",
        "    relative_rmse_reduction_percent = float(\n",
        "        100.0\n",
        "        * (\n",
        "            metrics_relative_lsc[\"rmse_m2_s2\"]\n",
        "            - metrics_relative_corrected[\"rmse_m2_s2\"]\n",
        "        )\n",
        "        / max(metrics_relative_lsc[\"rmse_m2_s2\"], 1e-30)\n",
        "    )\n",
        "\n",
        "    print(\n",
        "        \"Изменение RMS относительного потенциала: \"\n",
        "        f\"{relative_rmse_reduction_percent:.2f}% \"\n",
        "        \"(положительное значение означает улучшение).\"\n",
        "    )\n",
        "\n",
        "    # =========================================================\n",
        "    # СИНТЕТИЧЕСКАЯ ДИАГНОСТИКА ОПОРНОЙ ОШИБКИ\n",
        "    # Выполняется только после оценки estimated_reference_offset.\n",
        "    # Эти величины не участвуют в региональной привязке.\n",
        "    # =========================================================\n",
        "    reference_rows = df.loc[\n",
        "        df[\"source_index\"].astype(int).eq(reference_source_index)\n",
        "    ]\n",
        "    if len(reference_rows) != 1:\n",
        "        raise ValueError(\n",
        "            \"Для синтетической диагностики должна существовать \"\n",
        "            \"ровно одна строка P0.\"\n",
        "        )\n",
        "\n",
        "    reference_row = reference_rows.iloc[0]\n",
        "    e0_true = float(\n",
        "        reference_row[t_lsc_column]\n",
        "        - reference_row[\"T_true_P_control\"]\n",
        "    )\n",
        "    mean_e_lsc_all = float(\n",
        "        np.mean(\n",
        "            T_lsc_all\n",
        "            - df_all_ml[\"T_true_P_control\"].to_numpy(float)\n",
        "        )\n",
        "    )\n",
        "    y_true_correction_all = df_all_ml[\n",
        "        \"true_relative_correction_control_m2_s2\"\n",
        "    ].to_numpy(float)\n",
        "    mean_ml_prediction_error_all = float(\n",
        "        np.mean(\n",
        "            y_pred_raw_all\n",
        "            - y_true_correction_all\n",
        "        )\n",
        "    )\n",
        "    ml_decomposition_term = -mean_ml_prediction_error_all\n",
        "    decomposition_rhs = float(\n",
        "        estimated_reference_offset\n",
        "        + mean_e_lsc_all\n",
        "        + ml_decomposition_term\n",
        "    )\n",
        "    decomposition_residual = float(\n",
        "        e0_true\n",
        "        - decomposition_rhs\n",
        "    )\n",
        "\n",
        "    print(\"\\n--- Синтетическая диагностика привязки P0 ---\")\n",
        "    print(f\"Истинная ошибка LSC в P0, e0: {e0_true:.9f} м²/с²\")\n",
        "    print(\n",
        "        \"Оценённый региональный сдвиг: \"\n",
        "        f\"{estimated_reference_offset:.9f} м²/с²\"\n",
        "    )\n",
        "    print(\n",
        "        \"Средняя ошибка LSC по области: \"\n",
        "        f\"{mean_e_lsc_all:.9f} м²/с²\"\n",
        "    )\n",
        "    print(\n",
        "        \"Диагностический ML-член: \"\n",
        "        f\"{ml_decomposition_term:.9f} м²/с²\"\n",
        "    )\n",
        "    print(\n",
        "        \"e0 - [b_ref + mean(e_LSC) + ML-член]: \"\n",
        "        f\"{decomposition_residual:.3e} м²/с²\"\n",
        "    )\n",
        "\n",
        "    # =========================================================\n",
        "    # СОХРАНЕНИЕ АРТЕФАКТОВ\n",
        "    # =========================================================\n",
        "    final_model.save(MODEL_PATH)\n",
        "    joblib.dump(final_preprocessor, PREPROCESSOR_PATH)\n",
        "\n",
        "    predictions = pd.DataFrame({\n",
        "        \"source_index\": test_source_index,\n",
        "        \"split\": \"test\",\n",
        "        \"relative_correction_label_m2_s2\": y_test,\n",
        "        \"relative_correction_prediction_m2_s2\": y_test_pred,\n",
        "        \"relative_correction_prediction_from_all_m2_s2\": (\n",
        "            y_pred_raw_test_from_all\n",
        "        ),\n",
        "        \"relative_correction_datum_corrected_m2_s2\": (\n",
        "            y_pred_datum_corrected_test\n",
        "        ),\n",
        "        \"estimated_reference_offset_m2_s2\": (\n",
        "            estimated_reference_offset\n",
        "        ),\n",
        "        \"true_relative_correction_m2_s2\": y_true_correction,\n",
        "        \"dT_LSC_i0_m2_s2\": dT_lsc_test,\n",
        "        \"dT_corrected_i0_m2_s2\": dT_corrected_test,\n",
        "        \"dT_true_i0_m2_s2\": dT_true_test,\n",
        "        \"T_LSC_P_i_m2_s2\": T_lsc_test,\n",
        "        \"T_corrected_P_i_m2_s2\": T_corrected_raw_test,\n",
        "        \"T_corrected_datum_P_i_m2_s2\": T_corrected_datum_test,\n",
        "        \"T_true_P_i_m2_s2\": T_true_test\n",
        "    })\n",
        "\n",
        "    predictions[\"relative_error_LSC_m2_s2\"] = (\n",
        "        predictions[\"dT_LSC_i0_m2_s2\"]\n",
        "        - predictions[\"dT_true_i0_m2_s2\"]\n",
        "    )\n",
        "    predictions[\"relative_error_corrected_m2_s2\"] = (\n",
        "        predictions[\"dT_corrected_i0_m2_s2\"]\n",
        "        - predictions[\"dT_true_i0_m2_s2\"]\n",
        "    )\n",
        "    predictions[\"absolute_error_LSC_m2_s2\"] = (\n",
        "        predictions[\"T_LSC_P_i_m2_s2\"]\n",
        "        - predictions[\"T_true_P_i_m2_s2\"]\n",
        "    )\n",
        "    predictions[\"absolute_error_corrected_m2_s2\"] = (\n",
        "        predictions[\"T_corrected_P_i_m2_s2\"]\n",
        "        - predictions[\"T_true_P_i_m2_s2\"]\n",
        "    )\n",
        "    predictions[\"absolute_error_datum_corrected_m2_s2\"] = (\n",
        "        predictions[\"T_corrected_datum_P_i_m2_s2\"]\n",
        "        - predictions[\"T_true_P_i_m2_s2\"]\n",
        "    )\n",
        "\n",
        "    predictions.to_csv(PREDICTIONS_PATH, index=False)\n",
        "\n",
        "    predictions_all_datum = pd.DataFrame({\n",
        "        \"source_index\": df_all_ml[\"source_index\"].astype(int).to_numpy(),\n",
        "        \"split\": df_all_ml[split_column].astype(str).to_numpy(),\n",
        "        \"latitude_deg\": df_all_ml[\"latitude_deg\"].to_numpy(float),\n",
        "        \"longitude_deg\": df_all_ml[\"longitude_deg\"].to_numpy(float),\n",
        "        \"T_LSC_P_m2_s2\": T_lsc_all,\n",
        "        \"y_pred_raw_m2_s2\": y_pred_raw_all,\n",
        "        \"estimated_reference_offset_m2_s2\": (\n",
        "            estimated_reference_offset\n",
        "        ),\n",
        "        \"y_pred_datum_corrected_m2_s2\": (\n",
        "            y_pred_datum_corrected_all\n",
        "        ),\n",
        "        \"T_LSC_plus_ML_raw_m2_s2\": T_corrected_raw_all,\n",
        "        \"T_LSC_plus_ML_datum_corrected_m2_s2\": (\n",
        "            T_corrected_datum_all\n",
        "        )\n",
        "    })\n",
        "    predictions_all_datum.to_csv(\n",
        "        PREDICTIONS_ALL_DATUM_PATH,\n",
        "        index=False\n",
        "    )\n",
        "\n",
        "    metadata = {\n",
        "        \"format_version\": \"3.1-relative-lsc-regional-datum\",\n",
        "        \"dataset_path\": LEVEL4_CSV,\n",
        "        \"config_path\": LEVEL4_CONFIG_JSON,\n",
        "        \"model_path\": MODEL_PATH,\n",
        "        \"preprocessor_path\": PREPROCESSOR_PATH,\n",
        "        \"predictions_path\": PREDICTIONS_PATH,\n",
        "        \"predictions_all_datum_path\": PREDICTIONS_ALL_DATUM_PATH,\n",
        "        \"feature_columns\": feature_columns,\n",
        "        \"target_column\": target_column,\n",
        "        \"reference_point\": config[\"reference_point\"],\n",
        "        \"split\": config[\"split\"],\n",
        "        \"target_scaling\": {\n",
        "            \"mean_train_validation\": y_mean_final,\n",
        "            \"std_train_validation\": y_std_final\n",
        "        },\n",
        "        \"best_hyperparameters\": dict(best_hp.values),\n",
        "        \"best_epoch\": int(best_epoch),\n",
        "        \"main_result_formula\": (\n",
        "            \"T_raw(P_i)=T_LSC(P_i)+y_hat_i\"\n",
        "        ),\n",
        "        \"regional_result_formula\": (\n",
        "            \"T_final(P_i)=T_LSC(P_i)+y_hat_i-mean(y_hat)\"\n",
        "        ),\n",
        "        \"regional_lsc_datum_correction\": {\n",
        "            \"enabled\": True,\n",
        "            \"method\": (\n",
        "                \"mean_ml_relative_correction_over_all_result_points\"\n",
        "            ),\n",
        "            \"reference_point_excluded\": True,\n",
        "            \"uses_true_field\": False,\n",
        "            \"uses_labels\": False,\n",
        "            \"assumption\": \"regional mean LSC error is small\",\n",
        "            \"estimated_offset_m2_s2\": estimated_reference_offset,\n",
        "            \"estimated_offset_cm\": (\n",
        "                estimated_reference_offset\n",
        "                / GAMMA_REPORT\n",
        "                * 100.0\n",
        "            ),\n",
        "            \"n_points\": int(len(df_all_ml)),\n",
        "            \"formula\": (\n",
        "                \"T_final(P_i)=T_LSC(P_i)+y_hat_i-mean(y_hat)\"\n",
        "            )\n",
        "        },\n",
        "        \"metrics_test\": {\n",
        "            \"fit_to_training_label\": metrics_target_fit,\n",
        "            \"label_vs_true_correction\": metrics_label_to_true,\n",
        "            \"prediction_vs_true_correction\": metrics_prediction_to_true,\n",
        "            \"relative_LSC_vs_true\": metrics_relative_lsc,\n",
        "            \"relative_corrected_vs_true\": metrics_relative_corrected,\n",
        "            \"absolute_LSC_vs_true\": metrics_absolute_lsc,\n",
        "            \"absolute_corrected_raw_vs_true\": metrics_absolute_corrected,\n",
        "            \"absolute_datum_corrected_vs_true\": (\n",
        "                metrics_absolute_datum_corrected\n",
        "            ),\n",
        "            \"centered_rmse_m2_s2\": {\n",
        "                \"absolute_LSC\": centered_rmse_absolute_lsc,\n",
        "                \"absolute_corrected_raw\": centered_rmse_absolute_raw,\n",
        "                \"absolute_datum_corrected\": (\n",
        "                    centered_rmse_absolute_datum\n",
        "                )\n",
        "            },\n",
        "            \"relative_rmse_reduction_percent\": (\n",
        "                relative_rmse_reduction_percent\n",
        "            ),\n",
        "            \"datum_bias_improved_vs_raw\": datum_bias_improved,\n",
        "            \"datum_rmse_improved_vs_raw\": datum_rmse_improved\n",
        "        },\n",
        "        \"synthetic_diagnostics_not_used_for_correction\": {\n",
        "            \"true_reference_error_e0_m2_s2\": e0_true,\n",
        "            \"mean_lsc_error_all_m2_s2\": mean_e_lsc_all,\n",
        "            \"ml_decomposition_term_m2_s2\": ml_decomposition_term,\n",
        "            \"decomposition_residual_m2_s2\": decomposition_residual\n",
        "        },\n",
        "        \"checks\": {\n",
        "            \"reference_excluded_from_ml\": True,\n",
        "            \"same_reference_for_all_splits\": True,\n",
        "            \"offset_uses_true_field\": False,\n",
        "            \"offset_uses_labels\": False,\n",
        "            \"mean_centered_correction_m2_s2\": (\n",
        "                mean_centered_correction\n",
        "            ),\n",
        "            \"mean_final_minus_mean_lsc_m2_s2\": (\n",
        "                mean_final_all - mean_lsc_all\n",
        "            ),\n",
        "            \"raw_absolute_result_identity_max_error\": (\n",
        "                max_absolute_identity\n",
        "            ),\n",
        "            \"datum_absolute_result_identity_max_error\": (\n",
        "                max_absolute_datum_identity\n",
        "            ),\n",
        "            \"all_raw_identity_max_error\": max_raw_identity_all,\n",
        "            \"all_datum_identity_max_error\": max_datum_identity_all,\n",
        "            \"test_prediction_alignment_max_error_m2_s2\": (\n",
        "                max_test_prediction_alignment\n",
        "            ),\n",
        "            \"centered_rmse_raw_minus_datum_m2_s2\": (\n",
        "                centered_rmse_absolute_raw\n",
        "                - centered_rmse_absolute_datum\n",
        "            )\n",
        "        }\n",
        "    }\n",
        "\n",
        "    with Path(METADATA_PATH).open(\"w\", encoding=\"utf-8\") as file:\n",
        "        json.dump(\n",
        "            metadata,\n",
        "            file,\n",
        "            ensure_ascii=False,\n",
        "            indent=2\n",
        "        )\n",
        "\n",
        "    if SHOW_PLOTS:\n",
        "        plt.figure(figsize=(8, 4))\n",
        "        plt.plot(history.history[\"loss\"], label=\"train loss\")\n",
        "        plt.xlabel(\"Эпоха\")\n",
        "        plt.ylabel(\"MSE масштабированной поправки\")\n",
        "        plt.title(\"Финальное обучение\")\n",
        "        plt.legend()\n",
        "        plt.grid(True, alpha=0.3)\n",
        "        plt.show()\n",
        "\n",
        "        plt.figure(figsize=(6, 6))\n",
        "        plt.scatter(\n",
        "            dT_true_test / GAMMA_REPORT * 100.0,\n",
        "            dT_corrected_test / GAMMA_REPORT * 100.0,\n",
        "            alpha=0.7\n",
        "        )\n",
        "        limits = [\n",
        "            float(\n",
        "                min(\n",
        "                    np.min(dT_true_test),\n",
        "                    np.min(dT_corrected_test)\n",
        "                ) / GAMMA_REPORT * 100.0\n",
        "            ),\n",
        "            float(\n",
        "                max(\n",
        "                    np.max(dT_true_test),\n",
        "                    np.max(dT_corrected_test)\n",
        "                ) / GAMMA_REPORT * 100.0\n",
        "            )\n",
        "        ]\n",
        "        plt.plot(limits, limits, \"--\")\n",
        "        plt.xlabel(\"Истинное dT, см\")\n",
        "        plt.ylabel(\"Исправленное dT, см\")\n",
        "        plt.title(\"Относительный остаточный потенциал\")\n",
        "        plt.grid(True, alpha=0.3)\n",
        "        plt.show()\n",
        "\n",
        "    print(\"\\n--- Сохранено ---\")\n",
        "    print(MODEL_PATH)\n",
        "    print(PREPROCESSOR_PATH)\n",
        "    print(METADATA_PATH)\n",
        "    print(PREDICTIONS_PATH)\n",
        "    print(PREDICTIONS_ALL_DATUM_PATH)\n",
        "\n",
        "    return {\n",
        "        \"model\": final_model,\n",
        "        \"preprocessor\": final_preprocessor,\n",
        "        \"history\": history,\n",
        "        \"predictions\": predictions,\n",
        "        \"predictions_all_datum\": predictions_all_datum,\n",
        "        \"estimated_reference_offset\": estimated_reference_offset,\n",
        "        \"metadata\": metadata,\n",
        "        \"config\": config\n",
        "    }\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/"
        },
        "id": "YS8qDEOOlet3",
        "outputId": "32adb243-39de-4f85-e043-e3d258a3f065"
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "Активная палетка: [  1.591   1.91    2.449   3.321   4.733   7.058  10.978  17.766  29.851\n",
            "  51.988  93.703 174.57 ] км\n",
            "Хвост за R_max: False\n",
            "Метаданные палетки: {'family': 'warped_logarithmic_bounded', 'alpha': 1.6, 'n_rings': 12, 'D_km': 29.095013055621212, 'near_radius_D_factor': 0.05, 'r_near_km': 1.4547506527810607, 'max_radius_mode': 'minimum', 'correlation_radius_factor': 6.0, 'correlation_radius_km': 174.57007833372728, 'diameter_source': 'domain_corners', 'diameter_km': 569.0797334758144, 'r_max_km': 174.57007833372728, 'r_max_description': 'min(диаметр области, 6 * D)', 'include_tail_beyond_r_max': False, 'rings_outer_radii_km': [1.5914790743000722, 1.910081760500499, 2.4493035466218056, 3.32120849491968, 4.7328632710405785, 7.057690124363638, 10.977794048860291, 17.765617820265344, 29.851033063684472, 51.987515135176714, 93.703452310279, 174.57007833372728]}\n"
          ]
        }
      ],
      "source": [
        "# Абсолютная палетка формируется на физической поверхности P_i.\n",
        "# После выбора одной P0 из train в ML передаются только разности d_Phi.\n",
        "print(\"Активная палетка:\", np.round(RING_OUTER_RADII_KM_ACTIVE, 3), \"км\")\n",
        "print(\"Хвост за R_max:\", PALETTE_INCLUDE_TAIL)\n",
        "print(\"Метаданные палетки:\", PALETTE_GENERATION_METADATA)\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "b7N37q-BRMH5"
      },
      "source": [
        "# Запуск Keras и региональная LSC-привязка\n",
        "\n",
        "После финального обучения модель формирует сырой прогноз относительной поправки для всех строк `use_for_ml == 1`. Постоянная составляющая оценивается только как среднее этих прогнозов:\n",
        "\n",
        "$\\widehat b_{\\mathrm{ref}}=\\operatorname{mean}(\\widehat y_i)$.\n",
        "\n",
        "Относительные метрики продолжают использовать сырой прогноз $\\widehat y_i$. Центрирование применяется только к абсолютному региональному полю:\n",
        "\n",
        "$T_i^{\\mathrm{final}}=T_{\\mathrm{LSC}}(P_i)+\\widehat y_i-\\widehat b_{\\mathrm{ref}}$.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/",
          "height": 1000
        },
        "id": "BdEr0JUqL-sU",
        "outputId": "210da748-10a0-4a56-e685-4213efe7eb97"
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "Trial 25 Complete [00h 00m 24s]\n",
            "val_loss: 1.0351074934005737\n",
            "\n",
            "Best val_loss So Far: 0.7928241491317749\n",
            "Total elapsed time: 00h 14m 53s\n",
            "\n",
            "--- Лучшие гиперпараметры ---\n",
            "activation: elu\n",
            "batch_norm_0: False\n",
            "batch_norm_1: True\n",
            "batch_norm_2: True\n",
            "batch_norm_3: False\n",
            "batch_size: 32\n",
            "dropout_0: 0.1\n",
            "dropout_1: 0.4\n",
            "dropout_2: 0.0\n",
            "dropout_3: 0.4\n",
            "kernel_initializer: he_normal\n",
            "l2: 2.7813284077718873e-06\n",
            "learning_rate: 0.0003213072001060169\n",
            "n_hidden_layers: 2\n",
            "optimizer: adam\n",
            "units_0: 88\n",
            "units_1: 96\n",
            "units_2: 32\n",
            "units_3: 112\n",
            "Число эпох финального обучения: 67\n",
            "\n",
            "--- Финальное обучение ---\n",
            "Epoch 1/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 5ms/step - loss: 3.1708 - mae: 1.4145 - rmse: 1.7804\n",
            "Epoch 2/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 2.8248 - mae: 1.3165 - rmse: 1.6804 \n",
            "Epoch 3/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 2.2920 - mae: 1.1901 - rmse: 1.5136 \n",
            "Epoch 4/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 2.0492 - mae: 1.1267 - rmse: 1.4311 \n",
            "Epoch 5/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 2.1256 - mae: 1.1439 - rmse: 1.4576 \n",
            "Epoch 6/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.8137 - mae: 1.0620 - rmse: 1.3464 \n",
            "Epoch 7/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.8614 - mae: 1.0797 - rmse: 1.3640 \n",
            "Epoch 8/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.5678 - mae: 0.9783 - rmse: 1.2517 \n",
            "Epoch 9/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.6156 - mae: 0.9903 - rmse: 1.2707 \n",
            "Epoch 10/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.5944 - mae: 0.9639 - rmse: 1.2623 \n",
            "Epoch 11/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.6026 - mae: 0.9971 - rmse: 1.2656 \n",
            "Epoch 12/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.7337 - mae: 1.0383 - rmse: 1.3163 \n",
            "Epoch 13/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.4950 - mae: 0.9579 - rmse: 1.2223 \n",
            "Epoch 14/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.5629 - mae: 0.9814 - rmse: 1.2498 \n",
            "Epoch 15/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 5ms/step - loss: 1.3663 - mae: 0.9121 - rmse: 1.1685\n",
            "Epoch 16/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 5ms/step - loss: 1.5600 - mae: 0.9718 - rmse: 1.2486 \n",
            "Epoch 17/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 5ms/step - loss: 1.3015 - mae: 0.8851 - rmse: 1.1404\n",
            "Epoch 18/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 5ms/step - loss: 1.3296 - mae: 0.9082 - rmse: 1.1527\n",
            "Epoch 19/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.3679 - mae: 0.9023 - rmse: 1.1692 \n",
            "Epoch 20/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.2962 - mae: 0.9146 - rmse: 1.1381 \n",
            "Epoch 21/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.1121 - mae: 0.8340 - rmse: 1.0541 \n",
            "Epoch 22/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 5ms/step - loss: 1.1121 - mae: 0.8322 - rmse: 1.0541\n",
            "Epoch 23/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.1885 - mae: 0.8590 - rmse: 1.0898 \n",
            "Epoch 24/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.1886 - mae: 0.8474 - rmse: 1.0898 \n",
            "Epoch 25/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.1854 - mae: 0.8851 - rmse: 1.0883 \n",
            "Epoch 26/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 6ms/step - loss: 1.0869 - mae: 0.8205 - rmse: 1.0421\n",
            "Epoch 27/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 5ms/step - loss: 1.0767 - mae: 0.8166 - rmse: 1.0372\n",
            "Epoch 28/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.1904 - mae: 0.8627 - rmse: 1.0906 \n",
            "Epoch 29/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.0520 - mae: 0.8077 - rmse: 1.0252 \n",
            "Epoch 30/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 5ms/step - loss: 1.0868 - mae: 0.8250 - rmse: 1.0420\n",
            "Epoch 31/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.0907 - mae: 0.8359 - rmse: 1.0439 \n",
            "Epoch 32/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.9488 - mae: 0.7698 - rmse: 0.9736 \n",
            "Epoch 33/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 1.0443 - mae: 0.8127 - rmse: 1.0215 \n",
            "Epoch 34/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.9360 - mae: 0.7695 - rmse: 0.9670 \n",
            "Epoch 35/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 5ms/step - loss: 0.9064 - mae: 0.7461 - rmse: 0.9516 \n",
            "Epoch 36/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 6ms/step - loss: 0.9589 - mae: 0.7808 - rmse: 0.9788\n",
            "Epoch 37/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.8768 - mae: 0.7248 - rmse: 0.9359 \n",
            "Epoch 38/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.8830 - mae: 0.7442 - rmse: 0.9392 \n",
            "Epoch 39/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 5ms/step - loss: 0.8569 - mae: 0.7229 - rmse: 0.9252 \n",
            "Epoch 40/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.8326 - mae: 0.7234 - rmse: 0.9120 \n",
            "Epoch 41/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.8779 - mae: 0.7480 - rmse: 0.9364 \n",
            "Epoch 42/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.9041 - mae: 0.7399 - rmse: 0.9504 \n",
            "Epoch 43/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.9227 - mae: 0.7584 - rmse: 0.9601 \n",
            "Epoch 44/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.8081 - mae: 0.6986 - rmse: 0.8984 \n",
            "Epoch 45/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.8362 - mae: 0.7101 - rmse: 0.9140 \n",
            "Epoch 46/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 6ms/step - loss: 0.7782 - mae: 0.6966 - rmse: 0.8816\n",
            "Epoch 47/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.8228 - mae: 0.6934 - rmse: 0.9066 \n",
            "Epoch 48/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.7433 - mae: 0.6782 - rmse: 0.8616 \n",
            "Epoch 49/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 5ms/step - loss: 0.7881 - mae: 0.7097 - rmse: 0.8872\n",
            "Epoch 50/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.7481 - mae: 0.6709 - rmse: 0.8644 \n",
            "Epoch 51/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.7930 - mae: 0.7132 - rmse: 0.8900 \n",
            "Epoch 52/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.7064 - mae: 0.6577 - rmse: 0.8400 \n",
            "Epoch 53/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.7102 - mae: 0.6636 - rmse: 0.8422 \n",
            "Epoch 54/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.7227 - mae: 0.6660 - rmse: 0.8496 \n",
            "Epoch 55/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.6765 - mae: 0.6537 - rmse: 0.8219 \n",
            "Epoch 56/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 5ms/step - loss: 0.6904 - mae: 0.6458 - rmse: 0.8304\n",
            "Epoch 57/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.7105 - mae: 0.6718 - rmse: 0.8424 \n",
            "Epoch 58/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.6158 - mae: 0.6103 - rmse: 0.7841 \n",
            "Epoch 59/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.6965 - mae: 0.6598 - rmse: 0.8340 \n",
            "Epoch 60/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.6564 - mae: 0.6426 - rmse: 0.8096 \n",
            "Epoch 61/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.6315 - mae: 0.6394 - rmse: 0.7941 \n",
            "Epoch 62/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 5ms/step - loss: 0.6434 - mae: 0.6268 - rmse: 0.8016\n",
            "Epoch 63/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.6230 - mae: 0.6258 - rmse: 0.7887 \n",
            "Epoch 64/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 5ms/step - loss: 0.6107 - mae: 0.6083 - rmse: 0.7809 \n",
            "Epoch 65/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 5ms/step - loss: 0.5976 - mae: 0.6088 - rmse: 0.7725 \n",
            "Epoch 66/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.6072 - mae: 0.6154 - rmse: 0.7786 \n",
            "Epoch 67/67\n",
            "\u001b[1m17/17\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 4ms/step - loss: 0.5678 - mae: 0.5912 - rmse: 0.7529 \n",
            "\n",
            "--- Метрики test ---\n",
            "ML vs label       bias=  -0.379 см  MAE=   1.229 см  RMS=   1.617 см  corr=  0.5142\n",
            "label vs y_true   bias=   0.162 см  MAE=   0.782 см  RMS=   0.980 см  corr=  0.8440\n",
            "ML vs y_true      bias=  -0.216 см  MAE=   0.898 см  RMS=   1.189 см  corr=  0.6756\n",
            "\n",
            "--- Главный относительный критерий ---\n",
            "dT_LSC            bias=  -2.126 см  MAE=   2.137 см  RMS=   2.643 см  corr=  0.9991\n",
            "dT_LSC + ML       bias=  -0.216 см  MAE=   0.898 см  RMS=   1.189 см  corr=  0.9995\n",
            "\n",
            "--- Оценка систематического сдвига, вызванного P0 ---\n",
            "Число результативных точек: 710\n",
            "Оценённый сдвиг: 0.219763690 м²/с²\n",
            "Оценённый сдвиг в сантиметрах: 2.240201 см\n",
            "Среднее сырой ML-поправки: 0.219763690 м²/с²\n",
            "Среднее исправленной ML-поправки: -2.127e-17 м²/с²\n",
            "Среднее T_LSC: 0.195211073 м²/с²\n",
            "Среднее T_LSC + ML после привязки: 0.195211073 м²/с²\n",
            "\n",
            "--- Абсолютный результат после региональной LSC-привязки ---\n",
            "T_LSC             bias=   2.283 см  MAE=   2.422 см  RMS=   2.771 см  corr=  0.9991\n",
            "T_LSC + ML, привязка к LSC(P0)bias=   4.193 см  MAE=   4.193 см  RMS=   4.353 см  corr=  0.9995\n",
            "T_LSC + ML, региональная LSC-привязкаbias=   1.952 см  MAE=   2.009 см  RMS=   2.276 см  corr=  0.9995\n",
            "\n",
            "Центрированное СКО абсолютной ошибки:\n",
            "T_LSC: 1.570349 см\n",
            "T_LSC + ML, привязка к LSC(P0): 1.169217 см\n",
            "T_LSC + ML, региональная LSC-привязка: 1.169217 см\n",
            "Уменьшение |bias| после региональной привязки: True\n",
            "Уменьшение RMS после региональной привязки: True\n",
            "Изменение RMS относительного потенциала: 55.01% (положительное значение означает улучшение).\n",
            "\n",
            "--- Синтетическая диагностика привязки P0 ---\n",
            "Истинная ошибка LSC в P0, e0: 0.432525692 м²/с²\n",
            "Оценённый региональный сдвиг: 0.219763690 м²/с²\n",
            "Средняя ошибка LSC по области: 0.229750012 м²/с²\n",
            "Диагностический ML-член: -0.016988009 м²/с²\n",
            "e0 - [b_ref + mean(e_LSC) + ML-член]: 0.000e+00 м²/с²\n"
          ]
        },
        {
          "output_type": "display_data",
          "data": {
            "text/plain": [
              "<Figure size 800x400 with 1 Axes>"
            ],
            "image/png": "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4UK88847CAkJgb6+Pn7//XfExsaid+/eGD16tErXEkURM2bMwN69e3HixIknPlz2iJ+fH44fP16m7ejRo/Dz81Pp3o1dawczmOjrILugBDcfZGo7HCIiIqJqUbmYDQsLw/jx4wEAOjo6yM/Ph7GxMT7++GN8+umnKl1r+vTp2LZtG7Zv3w4TExMkJCQgISEB+fn5yj7jx4/HwoULle9nzZqFv//+G1988QVu3bqFJUuWIDAwEDNmzFD1ozRqUomALs253iwRERHVbyoXs0ZGRsp5svb29rh3757yWEpKikrX2rBhAzIzM9GnTx/Y29srXzt37lT2iYmJQXx8vPJ9t27dsH37dnz//ffw8fHB7t27sW/fPq4xWw1dXZsAAM6xmCUiIqJ6SuU5s127dsXZs2fh6emJwYMHY968eQgJCcGePXvQtWtXla5VlSVuT506Va5t9OjRKk9poPL8WpQWs5ej0lAsV0BXyvk8REREVL+oXMx++eWXyMnJAQAsXboUOTk52LlzJ9zd3au9kgFph6edKcwNdZGRV4zr9zPRwdlC2yERERERqUTlYtbV1VX5s5GRUZV23aK6SSIR0LV5E/x9MwEXIlJZzBIREVG9U63fK+fl5Sl31YqJicHmzZtx/vx5tQZGtePRVAM+BEZERET1kcrF7LZt22BmZgY7Ozvs378fbdq0wXvvvYeePXtylLYeenzebGGJXMvREBEREalG5WJ2+fLlmDNnDj766COMGzcOS5cuRXJyMr799lusXr1aEzGSBrnbGMPKWA+FJQoEx2RoOxwiIiIilahczEZERODtt9/G7NmzUVhYCH9/fwCAv78/oqKi1B0faZggCOjycImu8xGcakBERET1i8rFbHFxMfT19SEIAvT09KCnpwegdAOFkpIStQdImufnWrV5s0UlChwKiUdUSm5thEVERET0VCqvZgAAEydOhEwmQ0FBAd58800YGRkpHwij+ufRvNmrMRkoKJZDX1dars+ZO8lYsv8mIlJy4WFrgsNzetV2mERERETlqFzMTpgwQfnzuHHjyhx7tM0t1S+uVkawNZUhMasQQdHp6OZmpTz2ICMf//srFAdDEpRttxOzcT89D00tDLURLhEREZGSysXsli1bNBEHaZEgCPBzbYJ9wQ9w7l4qurlZoahEgY1nI/DV8XDkF8shEYAJ3VxwJTod1+9n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2bIlwsPD+V2tJnt7e3h5eZVp8/T0VE7f4N9VmsdiVkP09PTQoUMHHD9+XNmmUChw/Phx+Pn5aTGyhqN58+aws7Mrk+OsrCxcvHiROX4CURQxY8YM7N27FydOnEDz5s3LHO/QoQN0dXXL5PX27duIiYlhXlWgUChQWFjIfFZTv379EBISguDgYOWrY8eOGDt2rPJn5rXmcnJycO/ePdjb2/O7Wk3du3cvt7zhnTt34OzsDIB/V9UKbT+B1pDt2LFDlMlk4tatW8XQ0FBx6tSporm5uZiQkKDt0OqN7Oxs8erVq+LVq1dFAOKXX34pXr16VYyOjhZFURQ/+eQT0dzcXPzjjz/E69eviyNGjBCbN28u5ufnaznyuuutt94SzczMxFOnTonx8fHKV15enrLPm2++KTZr1kw8ceKEGBgYKPr5+Yl+fn5ajLpuW7BggXj69GkxMjJSvH79urhgwQJREATxyJEjoigyn+ry+GoGosi8Vse8efPEU6dOiZGRkWJAQIDYv39/0crKSkxKShJFkTmtjkuXLok6Ojri8uXLxbt374q//PKLaGhoKG7btk3Zh39XaRaLWQ376quvxGbNmol6enpi586dxQsXLmg7pHrl5MmTIoByrwkTJoiiWLrkyYcffija2tqKMplM7Nevn3j79m3tBl3HVZRPAOKWLVuUffLz88Vp06aJFhYWoqGhofjcc8+J8fHx2gu6jps0aZLo7Ows6unpidbW1mK/fv2UhawoMp/q8t9ilnlV3Ysvvija29uLenp6oqOjo/jiiy+K4eHhyuPMafUcOHBA9Pb2FmUymdiqVSvx+++/L3Ocf1dpliCKoqidMWEiIiIioprhnFkiIiIiqrdYzBIRERFRvcViloiIiIjqLRazRERERFRvsZglIiIionqLxSwRERER1VssZomIiIio3mIxS0RERET1FotZIiIiIqq3WMwSEdWy4uJibN26FT169IC1tTUMDAzQtm1bfPrppygqKtJ2eERE9Qq3syUiqmXBwcGYN28epk2bBl9fXxQUFCAkJARLliyBvb09Dh8+DF1dXW2HSURUL3Bkloiolnl7e+P48eMYNWoUXF1d4eXlhRdffBFnzpzBjRs3sGbNGgCAIAgVvmbPnq28Vnp6OsaPHw8LCwsYGhrC398fd+/eVR6fNGkS2rZti8LCQgBAUVERfH19MX78eGWf9957Dy1btoShoSFcXV3x4Ycfori4uFZyQURUUyxmiYhqmY6OToXt1tbWeP755/HLL78o27Zs2YL4+Hjly8/Pr8w5EydORGBgIPbv34/z589DFEUMHjxYWYyuW7cOubm5WLBgAQBg0aJFyMjIwNdff628homJCbZu3YrQ0FCsXbsWP/zwA1avXq3uj01EpBEV/z8qERFpXOvWrREdHV2mrbi4GFKpVPne3NwcdnZ2yvd6enrKn+/evYv9+/cjICAA3bp1AwD88ssvcHJywr59+zB69GgYGxtj27Zt6N27N0xMTLBmzRqcPHkSpqamyut88MEHyp9dXFzwzjvvYMeOHZg/f77aPzMRkbqxmCUi0pKDBw+W+3X+qlWrsG3btiqdHxYWBh0dHXTp0kXZ1qRJE3h4eCAsLEzZ5ufnh3feeQfLli3De++9hx49epS5zs6dO7Fu3Trcu3cPOTk5KCkpKVPsEhHVZSxmiYi0xNnZuVzbvXv30LJlS7XeR6FQICAgAFKpFOHh4WWOnT9/HmPHjsXSpUsxcOBAmJmZYceOHfjiiy/UGgMRkaZwziwRUS1LS0tDdnZ2ufbAwECcPHkSr7zySpWu4+npiZKSEly8eFHZlpqaitu3b8PLy0vZ9tlnn+HWrVs4ffo0/v77b2zZskV57Ny5c3B2dsaiRYvQsWNHuLu7l5v6QERUl7GYJSKqZTExMWjXrh02bdqE8PBwRERE4Oeff8aIESPQs2fPMqsVPIm7uztGjBiBKVOm4OzZs7h27RrGjRsHR0dHjBgxAgBw9epVfPTRR9i4cSO6d++OL7/8ErNmzUJERITyGjExMdixYwfu3buHdevWYe/evZr66EREasdiloiolnl7e2Px4sXYunUrunbtitatW2PVqlWYMWMGjhw5UuYhr6fZsmULOnTogKFDh8LPzw+iKOLgwYPQ1dVFQUEBxo0bh4kTJ2LYsGEAgKlTp6Jv37549dVXIZfLMXz4cMyZMwczZsxAu3btcO7cOXz44Yea+uhERGrHTROIiIiIqN7iyCwRERER1VssZomIiIio3mIxS0RERET1FotZIiIiIqq3WMwSERERUb3FYpaIiIiI6i0Ws0RERERUb7GYJSIiIqJ6i8UsEREREdVbLGaJiIiIqN5iMUtERERE9db/AcFcHwu8fh/CAAAAAElFTkSuQmCC\n"
          },
          "metadata": {}
        },
        {
          "output_type": "display_data",
          "data": {
            "text/plain": [
              "<Figure size 600x600 with 1 Axes>"
            ],
            "image/png": "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M5v1+Y3lGMZq+/bt3H///cyfP5+UlJRG4WP37t28//77zJs3jxtvvDG6vL71Zm+rVq0CWtbZvrtSn49u7pprriEhIYGLLrqo0QewtLSU//u//yMxMZFrrrmmzWqYPn06brebBx54oEET+hNPPEF5eTnHH388AOPHj2fQoEHcd999jX4I2XsNVxOJRPjd737HL37xC8aOHfu96vv5z39OJBLh1ltvbbQuHA43qKWyspK33nprn03LB2JZFqZpxtwPYl/qj8uBAsV1113H4YcfzoknntjsfTscDn784x/zz3/+s8Gtizt37uT555/niCOOiLbWnHbaaXz11Ve89tpr+6wxlu+793P+/Oc/E4lEYtoP1PVPGTt2LM8880yD87hixQreffddjjvuuEbPyc3NZdq0aUyfPp3DDz885u95IPV/Ve/vnLXm+3tfJk6cSGZmJo888giBQCC6/D//+Q+rV6+Ofi6ba8aMGaSmpnL77bcTCoUard+1a9f3rrm55s2bR+/evfm///u/JtfXH/u932d7TkOwpxdffBG3293kH3DSNLV8dHNDhw7lmWee4ayzzuKQQw5pNMJpcXExL7zwQvSvlraQkZHB3LlzmTdvHsceeywnnngia9eu5S9/+QuHHnpo9C9L0zR5+OGHOeGEExg7diznnnsuffr0Yc2aNaxcuZJ33nknus+tW7fidrsbXb5oiaOOOoqLLrqI+fPns2zZMn784x/jcrlYv349L7/8Mvfffz8//elPeemll5g3bx67d+/muuuua9a+ly1bRnJyMuFwmLy8PJ599llOOumkZrc+7C0SifD2228DdUHoqaeeorq6mpNPPnm/z3v33Xf59NNPY/5+f/jDH1iwYAFHHHEEF198MU6nk0cffZRAIBAdGwbqQu4rr7zCz372M8477zwmTJhAaWkp//rXv3jkkUcYM2ZMs7/nT37yE/72t7/h8/k4+OCDWbRoEe+9916zWpqacvfddzNz5kwmT57M+eefH73V1ufzNRgTpa1UVVVFz1lpaSkPPPAALpdrv7/cW/P9vS8ul4s777yTc889l6OOOoozzzwzeqvtwIEDD3jpaW+pqak8/PDDnHPOOYwfP54zzjiDjIwMNm/ezJtvvsnhhx/Ogw8+2KJa9zyG9epbs/73v//hcrno27dvdN27777Lc889h9vt3metRx55JHfddRehUCjaEpWfn99gu/Xr13PTTTfxwgsvcN1117Xo0nR3pfAh/OxnP2P48OHMnz8/Gjh69uzJMcccw+9+97uYOni21M0330xGRgYPPvggV1xxBenp6Vx44YXcfvvtDcbQmDFjBh988AHz5s3jT3/6E5ZlcdBBB3HBBRc02uevfvWrVhs34JFHHmHChAk8+uij/O53v8PpdDJw4EDOPvvs6F+/L774Ijk5OTz11FPN/mu0fvwJp9NJ3759+dWvfsW8efNaXGdtbS0zZ84E6jqw5ubm8re//e2Af6WedNJJ0XEvYjFy5Eg+/vhj5s6dy/z587Esi0mTJvH//t//i47xUV/Lxx9/zE033cRrr73GM888Q2ZmJtOmTaNfv34xfc/7778fh8PBc889R21tLYcffjjvvfceM2bMiLl+qGt5e/vtt7npppu48cYbcblcHHXUUdx5550MGjSoRfuMRUFBQfScpaWlMXLkSP71r38xduzY/Q4Y1prv732ZM2cOiYmJ3HHHHVx77bUkJSVxyimncOedd7ZoFNxf/OIXZGdnc8cdd3D33XcTCATo27cvU6dO5dxzz21xnXsew6Zew1NPPcWcOXOiy8aOHcuZZ565330+//zzXHrppTz00EPYts2Pf/xj/vOf/5CdnR3dJi8vj6+//pr777+fSy+9tMX1d0caXl1ERLqsgQMHcvPNNzcIHxJ/6vMhIiIi7UrhQ0REuqyjjjqqQX8P6Rh02UVERETalVo+REREpF0pfIiIiEi7UvgQERGRdqVxPvZgWRbbt28nJSWl1UaYFBER6Q5s26ayspLs7OwGM5Q3ReFjD9u3b4/ORSEiIiKx27JlywEHD1T42EP9lPFbtmzptsPkWpbFrl27yMjIOGBylbahcxBfOv7xp3MQXy09/hUVFfTv3z/6u3R/FD72UH+pJTU1tVuHj9raWlJTU/WhjxOdg/jS8Y8/nYP4+r7HvzndFnRWRUREpF0pfIiIiEi7UvgQERGRdqXwISIiIu1K4UNERETalcKHiIiItCuFDxEREWlXHSZ8fPTRR5xwwglkZ2djGAavv/56g/Vz5szBMIwGX8cee2yDbUpLSznrrLNITU0lLS2N888/n6qqqnZ8FSIiInIgHSZ8VFdXM2bMGB566KF9bnPssceyY8eO6NcLL7zQYP1ZZ53FypUrWbBgAW+88QYfffQRF154YVuXLiIiIjHoMCOczpw5k5kzZ+53G4/HQ1ZWVpPrVq9ezdtvv82SJUuYOHEiAH/+85857rjj+OMf/0h2dnar1ywiIiKx6zDhozk+/PBDMjMz6dGjBz/84Q/5wx/+QM+ePQFYtGgRaWlp0eABMH36dEzTZPHixZxyyimN9hcIBAgEAtHHFRUVQN3QspZltfGr6Zgsy8K27W77+jsCnYP40vGPP52D+Grp8Y9l+04TPo499lhOPfVUBg0axMaNG/nd737HzJkzWbRoEQ6Hg8LCQjIzMxs8x+l0kp6eTmFhYZP7nD9/PvPmzWu0fNeuXdTW1rbJ6+joLMuivLwc27Y1p0Kc6BzEl45//OkcxFdLj39lZWWzt+004eOMM86I/v+QQw5h9OjRHHTQQXz44YdMmzatRfucO3cuV155ZfRx/Yx8GRkZ3XpiOcMwNJtkHOkcxJeOf/zpHMRXS4+/1+tt9radJnzsbfDgwfTq1YsNGzYwbdo0srKyKCoqarBNOBymtLR0n/1EPB4PHo+n0XLTNLv1G94wjG5/DOJN5yC+dPzjT+cgvlpy/GPatiVFdQRbt26lpKSEPn36ADB58mTKysrIy8uLbvPf//4Xy7KYNGlSvMoUERGRvXSYlo+qqio2bNgQfZyfn8+yZctIT08nPT2defPmcdppp5GVlcXGjRv57W9/y5AhQ5gxYwYAI0aM4Nhjj+WCCy7gkUceIRQKcckll3DGGWfoThcREZEOpMO0fHzxxReMGzeOcePGAXDllVcybtw4brzxRhwOB8uXL+fEE08kNzeX888/nwkTJvDxxx83uGzy3HPPMXz4cKZNm8Zxxx3HEUccwWOPPRavlyQiIiJN6DAtH0cffTS2be9z/TvvvHPAfaSnp/P888+3ZlkiIiLSyjpM+BAREZG2Z1k264oqKfeH8CW6yM1MwTSNdq1B4UNERKQb2FLq59p/LMeX4KKgxE8wHMHtdDAkM5nZU3KYkJPebrV0mD4fIiIi0jY2l/g55S+fsnBjCR+vLybV66Rfj0RSvU5Wbi/ntjdXk1dQ2m71KHyIiIh0YQUl1Zz+2CKKq4K4HAZj+qWS5HHiMA2SPE5y0hMprwnx7MICLGvffS9bk8KHiIhIF7WpuJozHvuMHeW1uB0mh2T78Loa9rgwDIOMZA/ri6pYV9T8IdK/D/X5EBER6YLyi6s587HPKKyopW9aAi4HpCa4mtzW63JQXBWg3B9ql9rU8iEiItLF2LbNJc9/SWFFLUMzk/njz0aT6HZRG4o0uX1tqK7zqS+x6XDS2hQ+REREuhjDMLj39LFMOagnL1z4AyYN6smQzGR2VQUajall2za7qgIMzUwmNzOlXepT+BAREekiQhEr+v/c3ik8f8EP6JXswTQNZk/JqbvNttRPdSBMxLKpDoQpKPXjS3Axa0pOu433ofAhIiLSBWwoqmL6Pf9j0caSJtdPyEnn+uNHMDLbR0VtmK27/VTUhhmV7eP640e06zgf6nAqIiLSyW0oquSMxxZTXBXg7nfW8I9fTcEwGrdiTMhJZ1z/HhrhVERERFpu/c5KzvxrXfAY0SeVx2cf2mTwqGeaBsOzUtuxwiZqiOt3FxERkRZbt7OSM//6GcVVAQ7uk8rzv5xEepI73mUdkFo+REREOqG1hZX84q+fUVIdZGR2Kv/v/En06ATBAxQ+REREOqWnF+ZTUh1kVN+64JGW2DmCByh8iIiIdEq3nDSKHoluLjxycKcKHqA+HyIiIp3GtrKa6ORvLofJb48d3umCByh8iIiIdAort5fzkwc+5oZ/rmi32WfbisKHiIhIB7diWzlnPb6Y3f4QK7ZXULOPOVo6C/X5EBER6cDqg0d5TYix/dN49vzDSPJ07l/fnbt6ERGRLuzrreWc/URd8Bg3II1nzzuMFG/7zDzblhQ+REREOqDlW8s4+/HFVNSGGT8gjWe6SPAAhQ8REZEOaXtZDdXBCBNzevD0eYeR3Mkvteyp67wSERGRLuTYUX14+lwn4wb06FLBA3S3i4iISIfx1ZYytpXVRB9PHZrR5YIHKHyIiIh0CF9u3s3Zjy/mzMc+o7C8Nt7ltCmFDxERkTjLK9jNrCc+pzIQJjvNS2pC12vt2FPXfnUiIiIdXF5BKbOe+JzqYITJg3vyxJyJJLq79q/nrv3qREREOrAvNpUy+8m64DHloJ48MftQEtyOeJfV5hQ+RERE2pll2fzzq21c94+vCYStbhU8QOFDRESkXeUVlPLMwgLW7KjAsm2S3A56JLpZtaOcCTnp8S6vXajDqYiISDvJKyjltjdXs2JbOelJbg7NSWdkn1TWFFZw25urySsojXeJ7ULhQ0REpB1Yls2f3l3HllI/A3smkuRxkuB2kJLgIic9kfKaEM8uLMCy7HiX2uYUPkRERNrBS3lbWLSxhF1VQUqrgw3WGYZBRrKH9UVVrCuqjFOF7UfhQ0REpI19uqGYG19fiQ2kJ7pJS3Q32sbrchAMRyj3h9q/wHam8CEiItKGPllfzHlPLyEYsUj2OBmSmYTDNBptVxuK4HY68CV2jZlr90d3u4iIiLSRj9fv4pfPfEEgbHHMsAwS3U7WFFaQ7HFiGN8FENu22VUVYFS2j9zMlDhW3D7U8iEiItIGvtlVFQ0e00dk8sg5EzjviIH4ElwUlPqpDoSJWDbVgTAFpX58CS5mTcnBbKJVpKtRy4eIiEgbGNQridlTBvLNrmr+ctZ43E6TCTnpXH/8CJ5ZWMCGoiqKqwK4nQ5GZfuYNSWn24zzofAhIiLSBgzDYO7M4UQsG6fjuwsNE3LSGde/B+uKKin3h/AlusjNTOkWLR71FD5ERERayQdrinj+8838+cxxeF0ODMPA6WgcKkzTYHhWahwq7BgUPkRERFrAsuwGrRdbS2u4+LkvCUYsnvw0n4uPHhLvEjsshQ8REZEY1c/PsqGoimA4QiBssbWsBtuGmaOyuGDq4HiX2KEpfIiIiMSgfn6WMn+IzBQPVQHYuKsaG0hLdDFnSg4uh24m3R8dHRERkWayLJtnFhZQ5g8xsGci/mCEldsrsIHMFA/piS6eX7ylW8zP8n0ofIiIiDTTuqJKNhRVkZniIWxZrNpRFzx6Jrk5uE8KmSnebjM/y/eh8CEiItJM5f5QtI/H2sIqXA4Dp2lQGwqzekclgbDVbeZn+T7U50NERKSZfIkughGL9UWVWDZ4nA4SXRCxoTIQZn1RJb2SPd1ifpbvQ+FDRESkmdYVVrJ1dw2mAaleF+a387M4DTANk8pAmFDEYkiv5DhX2rHpsouIiEgzvLF8O5f/fRl1fUkNasMWYcvGBsKWTW3Ywu0wcTlMNhRXxbnajk3hQ0RE5AD+/dV2fvNiXfBI8TgZ1TeVFK+TsGVTE4oQtmxSvE5ye6fgNA31+TgAXXYRERHZj399tZ3LX1yKZcOPDu7N1lI/XqeDkdk+qr+9zOJymCR5nPgDYQJOh/p8HIBaPkRERPbhn8u2RYPHzyf24+FfjGdo7xR2VQXAtkn2OOmR6CbZ4wTbZldVgKGZyeRmpsS79A5N4UNERKQJtm3zSt5WLBtOn9ifO04djdNpMntKDr4EFwWlfqoDYSKWTXUgTEGpH1+Ci1lTcrrVDLUtocsuIiIiTTAMg8fOmcjzn2/m3CkDo4FiQk461x8/Ijq3S3FVALfTwahsH7Om5DAhJz3OlXd8Ch8iIiJ7WLGtnJHZqRiGQYLbwflHDGq0zYScdMb179FgVtvczBS1eDSTLruIiIh865W8rZzw4Cf88d212Pb+52cxTYPhWalMGtyT4VmpCh4xUPgQEREBXvpiC9e88hW2DWW6VbZN6bKLiIh0ey8t2cK1ry7HtuGcH+Rwy0kjMQy1ZLQVhQ8REenWXvx8M9e9+jUAsyfncPOJCh5tTeFDRES6rRc+38zcb4PHnCkDuemEgxU82oHCh4iIdFv1MePcwwdy408UPNqLwoeIiHRbZxw2gKG9Uxg/IE3Box3pbhcREemSLMtmTWEFi78pYU1hBVbddLT8c9k2iqsC0e0m5PRQ8GhnavkQEZEuJ6+gNDoCaTAcwe10MCQzmcxUD49/nM/wrBRe+dWUujlZpN3pqIuISJeSV1DKbW+upswfIjPFg9floTYUYeHGYoqrggAcNSyDJLcjzpV2XwofIiLSZViWzTMLCyjzhxjYMxGA6kCEbWU10eAxJCOZ3/54mC61xJHCh4iIdBnriirZUFRFZoqH8powW3f72e0PEozU9fdIcJmEIhHW76pieFZqnKvtvtThVEREuoxyf4hgOEIgbLG+qLJB8PA4TWwbCisCfP5NaZwr7d4UPkREpMvwJbpwOUw2l/oJWzaJLgemUdfikex2RAPIB2uLone/SPtT+BARkS4jNzOF3qleKmtDuB0mTodJWoKLRJcDDIOQZZPidbCzIsC6osp4l9ttKXyIiEiXYZoGbldd60YgHCFs2RiGQcSGmlAEp2kwID2JYDhCuWaujRt1OBURkS7jLx9u4N2VOwFI8jgIWzbBiIVpGKR4nfTrkYjLNAg4HfgSXXGutvtS+BARkS7hoQ82cPc7awEYlpVCOGzRM9lN2LJxOUySPE6wbQpK/YzK9pGbmRLnirsvXXYREZFO78H/ro8Gj6t/nMvtp4zCl+iipDqI22GS4HLgD4QpKPXjS3Axa0oOpqlxPuJFLR8iItKpPfD+eu5ZsA6Aa2YM49fHDAHg+uNHRIdYL64K4HY6GJXtY9aUHCbkpMez5G5P4UNERDqt91btjAaP3x47jIuPHhJdNyEnnXH9e7CuqJJyfwhfoovczBS1eHQACh8iItJp/XB4Jj+f2I/BGcn831EHNVpvmoZGMu2AFD5ERKRTsW0bywaHaWCaBneeNlrztHQy6nAqIiKdhm3b3LNgHZe9uJRwxAJQ8OiE1PIhIiIdnmXZrN1ZwWMf5fPa0m0AnDa+Lz8c3jvOlUlLKHyIiEiHlldQytOfbmLhxhJKqoMAjMxOxZegQcI6K112ERGRDiuvoJQ/vLGKj9YXR4NHTnoitaEIt725mrwCzU7bGSl8iIhIh2RZNk9/uolviv2U19TNw5KbmcyQzGRy0hMprwnx7MICzU7bCSl8iIhIh7R+VyWrdlRQUftt8OidTP/0RKCuk2lGsof1RVWanbYT6jDh46OPPuKEE04gOzsbwzB4/fXXG6y3bZsbb7yRPn36kJCQwPTp01m/fn2DbUpLSznrrLNITU0lLS2N888/n6qqqnZ8FSIi8n3UdyxdW1jBiq0VAByS7WNY7xT690hssK3X5dDstJ1Uhwkf1dXVjBkzhoceeqjJ9XfddRcPPPAAjzzyCIsXLyYpKYkZM2ZQW1sb3eass85i5cqVLFiwgDfeeIOPPvqICy+8sL1egoiIfA95BaVc/vdlXPX3r3j8k0088r+N7KoMELFt+vVIaLR9bSiCW7PTdkod5m6XmTNnMnPmzCbX2bbNfffdx+9//3tOOukkAJ599ll69+7N66+/zhlnnMHq1at5++23WbJkCRMnTgTgz3/+M8cddxx//OMfyc7ObrfXIiIisVmyqYQbX1/Jbn+QcMTim0CYYZleCm2bDUVVeJwmPRLd0e1t22ZXVUCz03ZSHSZ87E9+fj6FhYVMnz49uszn8zFp0iQWLVrEGWecwaJFi0hLS4sGD4Dp06djmiaLFy/mlFNOabTfQCBAIBCIPq6oqGvisywLy7La8BV1XJZl1Y0e2E1ff0egcxBfOv7t74tNu7nypaXsqghgAaFvD31FbZgRvZNZub2CDTsrOTg7lQSXg9pQhF1VAdISXJwzeQBgq9NpK2rpZyCW7TtF+CgsLASgd++Gg8n07t07uq6wsJDMzMwG651OJ+np6dFt9jZ//nzmzZvXaPmuXbsaXM7pTizLory8HNu2Mc0Oc1WuW9E5iC8d//a1oaiS/7eogHQzQNADRTV1y8ekW3icNfTxGmQMcFNcFSTJqsYIQJrDZER/Lz8cnkk/b4iioqL4vogupqWfgcrK5nf87RTho63MnTuXK6+8Mvq4oqKC/v37k5GRQWpq95yIyLKsul7kGRn6wRsnOgfxpePffizLZv4H21leYlNUBeFv/3BOcZskuQ1WlMLm2jDDe6ew2/Lz84lD6J+eiC/RydAMzU7bVlr6GfB6vc3etlOEj6ysLAB27txJnz59ost37tzJ2LFjo9vsnX7D4TClpaXR5+/N4/Hg8XgaLTdNs1v/0DEMo9sfg3jTOYgvHf/2sa6ognU7qwhEvgseyW4HXpeJTQSnw6QqEKHUH8LtcnFI/zTNUNtOWvIZiGnblhTV3gYNGkRWVhbvv/9+dFlFRQWLFy9m8uTJAEyePJmysjLy8vKi2/z3v//FsiwmTZrU7jWLiMj+lftDBMIRgt9OEOd2GHhcjuh60wDLhuKqIEMzk9WxtAvpMC0fVVVVbNiwIfo4Pz+fZcuWkZ6ezoABA7j88sv5wx/+wNChQxk0aBA33HAD2dnZnHzyyQCMGDGCY489lgsuuIBHHnmEUCjEJZdcwhlnnKE7XUREOiBfoguvy8mwTDe7a0LsqgxQE4rgddRdTglFbMIRC1+Ci1lTcnSZpQvpMOHjiy++4Jhjjok+ru+LMXv2bJ5++ml++9vfUl1dzYUXXkhZWRlHHHEEb7/9doNrTM899xyXXHIJ06ZNwzRNTjvtNB544IF2fy0iIrJvlmXz6cZiDj+oF0Myk1m5vZyc9ETSEt1s3e2nJhAmELYIhA0yUjzccvJIJuSkx7tsaUWGbdu6P+lbFRUV+Hw+ysvLu3WH06KiIjIzM3W9O050DuJLx79tWZbN7177mheXbOH3x49g3IA0bntzNeU1ITKSPXhcDsqqA6Tip8TyMu/EQzh0kIJHe2rpZyCW36H6ZImISLuwLJvrXl3Oi0u2YBqQkeJhQk461x8/gpHZPipqw2zb7Sds2QztncwtJ41S8OiiOsxlFxER6Rosy2ZdUSXl/hApXicYUF4d4smFm1iwaiemAfeePpaTxvYFYEJOOuP694g+JzXBgc+uISurR5xfibQVhQ8REWk1eQWlPLOwgA1FVZTXBKmsDWPbNhHLxh+yMIDLpw+NBo96pmlEb6Ota/bvngM9dhe67CIiIq0ir6CU295czYpt5RgGVNSECIQiVAcj+L8dMz0zxcOHa3eRV1Aa52olnhQ+RETke7Msm2cWFlDmD5HTM5HiqgARC5K9LrzOul81vgQnB/dJobwmxLMLCzQfSzem8CEiIt/buqJKNhRVkZniwR+MUB2I4HaaGECi20mKx1l36SVokZHsYX1RFeuKmj8XiHQtCh8iIvK9lftDBMMRvC4HwXCEQDiCsceYYG6HgWVDyLKi25T7Q/ErWOJK4UNERL43X6ILt9NBTTDM5tIaQhE72tkUIGLXDZfuMk1qQxHcTge+RFecq5Z4UfgQEZHvLTczhcEZSazYUUFJdRCou4MFw8C2bYIRiySPk0S3ya6qgOZq6eYUPkRE5HuL2DYl1UGqAxEABqQn4HEYVAfDVIciOE2DXkluNu+u0VwtonE+RETk+wlFLH7z4lIWbSzBaRqMz+lBVW2YiFV36QUgxevExmBUto9ZU3I0V0s3p/AhIiLfy03/WslbXxfidpg8cs54js7NbDTCaWVNGF+ii9zMFLV4iMKHiIh8P3OmDOTDNUXcdsohHDM8EyA6WqlIUxQ+RETke8ntncJ/rz4ar8sR71Kkk1CHUxERiUkwbHHpC0tZuLE4ukzBQ2Kh8CEiIs0WCEe4+Lk8/v3Vdn793JdUBcLxLkk6IV12ERGRZgmEI/zq/33Jf9cU4XGaPHDmOJI9+jUisdO7RkREmmRZdvSuFY/T5La3VrNk0248TpPHZ01k6tCMeJconZTCh4iINJJXUMozCwvYUFRFmT9AUWWQsGVjAFmpXl7O20qix6HxOqRF1OdDREQayCso5bY3V7NiWzmGAcVVdcEDIMFlkuxxsHJ7Obe9uZq8gtI4VyudkcKHiIhEWZbNMwsLKPOHyOmZSHFVAIdh4HaYpHocYBgUVwcZ0COB8poQzy4swPo2mIg0l8KHiIhErSuqZENRFb2S3FQHwlQHInhcDlK9TtxOB26HSXUgjD9okZHsYX1RFeuKKuNdtnQy6vMhIiJAXavH11vL2e0PsHV3BLfTJGJZeJzf/apwGBC0IWRZpHpdFFcFKPeH4li1dEYKHyIiEu1gunxrGYXlAeovpHicBhHLxvntfCwRG0wDXKZJbSiC2+nAl+iKX+HSKSl8iIh0c/UdTEurg5RWB6PBw+0Ay4LaUIQkjxNsm2DEIsXrJNFtsnl3DaOyfeRmpsS1ful8FD5ERLqx+g6mpdVByvwhKmrDmMZ3w6WHIxbhiEV1IAwGuB0mvZLcbN5dgy/BxawpOZqlVmKm8CEi0g3VDyD29dZylm8to6QqSGUgjMM0GNsvDYCtu/1U1oYJ2RYOh4HDMEjxOrExGJXtY9aUHI3zIS2i8CEi0o1Yls3ry7bxj7yt7KiopTYUoaiilrBV15djXP80fAl1fTh8CalU1IbZUVbDZdOHckg/H5U1YXyJLnIzU9TiIS2m8CEi0k3kFZRy74L15BXsJhSxcJjgcTpwmAaGAS6Hib3HkB2GYeA0DXyJbsb0T2N4Vmr8ipcuReFDRKQbWJJfyg2vf803xdWELRvbhogFwXDdrLSOb0d92lrmx5eQimEY2LbNrqqAOpVKq1P4EBHp4pZsKuHyvy+jsLyWyF6DkZoG2DaELTAiFpU1YSpqwzhNg11VAXUqlTah8CEi0oXlFZRyw2srKKoI0NQo6JYN0Vhh24Qtmx1lNfgS3epUKm1G4UNEpIuyLJt7F6znm+JqQvuZf+W7NQaZqR5+M62uc6k6lUpbUfgQEemCLMvmLx9u4LNvSqIz0h5I2LIZkJ7IaeP7KXRIm1L4EBHpYvIKSnn60028v6ao2cGj3pQhPRU8pM0pfIiIdCF5BaX84Y3VbCqpxh+MNPt5BnWjmk4coP4d0vYUPkREOqn6UUrLqkPsrgni87q4+501rN5RSW3YavZ+nGbdsOmZqR7SkjRJnLQ9hQ8RkU5oz1loi6uChCJ1YSMQQ+iol+p1keB2MKZfmsbzkHah8CEi0snUz0K7s6KWMn8Iy7JxO0yqg+GY95XkdpCW6CYtUeN5SPuJOXwMHjx4v+u/+eabFhcjIiL7Vz8LbZk/RChiYduQ6HES+XbU0lj5El0c0lfjeUj7ijl8bNq0iX79+nHOOeeQmZnZFjWJiMg+rCuqZENRFckeJ0WVAdxOEwOwYkweLgeMyPJxx2mHMDwrVS0e0q5iDh/Lli3j0Ucf5bHHHuPoo4/mwgsv5Ec/+lFb1CYiInsp94cIhiO4vU4s28Y0TcKWTcSyaE78MA3wOAyGZaVwwwkHc3C2r81rFtmbGesTRo8ezUMPPcTmzZs57rjjuOGGGxgyZAgLFixoi/pERGQPvkQXbqcDywLbtqmsDVFRG6I62LyOph6nycSBPbnhhJG6zCJxE3P4qJeQkMBRRx3FMcccQ3FxMVu3bm3NukREpAm5mSkMyUympDpAKGwRjtgY1LVo7G3vRb4EJ7eePIpnzjtMwUPiKubwEQ6Heemll5g+fTpHHnkkDoeDZcuWce6557ZFfSIisgfTNDjnBzl1A4gZBqZRNzlcU10+XA6DBJdJj0QXB/VKJNuXwKi+PvXvkLiLuc9H37598Xg8nHfeedx11104nU4qKipYvnw5UHdZRkREWlf9gGLl/hCl/iBJbif+YAQDCFk21rdXXUzAYRoYhsFBGUmkel0keRxYNmzd7afcH4rnyxABWhA+du3aBcAtt9zCrbfeCtRddwQwDINIpPnD+YqIyP5Zls3ry7byj7xt7CivxcAmGLHZXl6DZUOvZDf9eyRQVRtmy24/LocDl8OgNmyR4HaQ7K37MV8bDON2OvAlagRTib+Yw0d+fn5b1CEiIntZkl/KbW+tYuX2CizLxuUwSfI4qagNYdl1fTp6p3hJT/LQI9FNRW2YykAY0wLTMHA56q6s27bNrqoAo7J9GsFUOoSYw0dOTk5b1CEiInt4bnEBd7y1hsrAd6OWRsIWteEgAIYBDgOKqwL0TvVgGAb9eiSyvqgSfyhCqteJ1+WgOhBmV1UAX4JGMJWOI+YOp/Pnz+fJJ59stPzJJ5/kzjvvbJWiRES6syWbSpj/5qoGwWNviS4HbofJbn+Q4qoAEcvG5TBI8TpJ9jhJ8brYtttPRW2YUdk+rj9+hO5wkQ4j5paPRx99lOeff77R8pEjR3LGGWdw7bXXtkphIiLdkWXZ3PbGaqoOMG5HbTjC0IxktpbVUl4Txh+M4HY6mJiTztmTB5DidVHuD+FLdJGbmaIWD+lQYg4fhYWF9OnTp9HyjIwMduzY0SpFiYh0V68v28bKHRUH3M6yIBixGZCeyKXThpCe6FbQkE4j5vDRv39/Pv30UwYNGtRg+aeffkp2dnarFSYi0t1Yls0/8rY2a54WG6isDXFIXx8zDs5S4JBOJebwccEFF3D55ZcTCoX44Q9/CMD777/Pb3/7W6666qpWL1BEpKuzLJs1Oyt4b9VO1u2sxGEYRA4wU4sB9Ex2qxOpdEoxh49rrrmGkpISLr74YoLBul7XXq+Xa6+9lrlz57Z6gSIiXcWeA4XVXyJZumU39y5Yz/KtZfiDYcLNm6KFnkkubj3pEHUilU4p5vBhGAZ33nknN9xwA6tXryYhIYGhQ4fi8Xjaoj4RkS4hr6CUZxYWsKGoqm5WWqeD9CQXBSV+iioDGAYkuJxUBcIHnJ02PdHFw+dM4NCBCh7SOcUcPuolJydz6KGHtmYtIiJdUl5BKbe9uZoyf4jMFA9el4eaUISlW8qoCUbqBg9zOQiE93+xxaBuRNO/nD2eQwf2bK/yRVpdi8OHiIgcmGXZPLOwgDJ/iIE9EzGM7/pnWBEb2wbLtgmEI/u8vdblMEhyO8nyebn1pFFq8ZBOT+FDRKQNrSuqZENRFZkpngbBIxSxoq0cEcumOvJdm4dp1M1SW7/EtiHJ46gLHoMUPKTzU/gQEWlD5f4QwXAEr+u7fnE234UPm7pwsSebuuHTscHtNBmQnojTNEhJ0I9s6Rr0ThYRaUO+RBdup4PaUIQkj5OymhBbd/upDkQI7OPWlvpWD9OAtEQX2WkJbNvtp9wfat/iRdpIzHO77M9HH31EeXl5a+5SRKRTy81MYUhmMruqAuz2B1m/s5LKmjCWfeB7ag3DoF9aIoFQ3d0xvkRXO1Qs0vZaNXwcffTRDB48mD/96U+tuVsRkU7LNA1mT8kh1eti9Y5KakIRTMPGf4C5WwzAZYLDhF1VAYZmJpObmdI+RYu0sVa97JKfn88333zDf/7zn9bcrYhIlxAIR7BsCEX2fUOtwbf9PQAL2Lq7hiyfVyOZSpfS7PDx0UcfMWXKFJzOfT8lJyeHnJwcjjnmmFYpTkSks6sf42NHeQ2mAVYTuePbvqUAmOa3fT6+/RqWlcIlPxyikUylS2l2+DjmmGPYsWMHmZmZbVmPiEintucQ6ileJ09/uonC8lpqQpH9Dp1eH0BMDDzuug6qY/ul8ddzJuJ0tuoVcpG4a3b4sJsxy6KISHe25xDqlbUhIpZNqT8Itt1ki0e9+lUGELZs7FCEFK+T384cpuAhXVJMfT72HCBHRES+U395ZWdFLbWhCLUhi2DEivbv8DhNDOwDzttiAwkuB1fPGKYh1KXLiil8zJkz54ATyL366qvfqyARkc6mfgj1nRW1VNSEiFh1g4PZth0NH/sa06OeaYDLYeJ0GNx04sH8dEL/9ihdJC5iCh8pKSkkJCS0VS0iIp1S/RDqtaEIEQsS3A4ilk1N6MBjeQB4nQaGYZCa4GLSwHROHdevjSsWia+YwscDDzygDqciInsp94eorA1RG7JwO00ilkV5TbjJSywOA/a809Y0wGmaBCIWqV4Xsw8fqFtqpctrdk8m9fcQEWmaL9GFwzSI2DY2djR4OEwDl6Phz07LrgscBt99RSybBJeDXx09WLfUSregu11ERFqo/rba3dVBeqV42Lq7hkDou+Dh8zoJWzYRKxy92yXBZeIwTSK2jWlAH5+X2pDFuAE9OHmsLrdI99Ds8PHBBx+Qnq5ELiICDW+rDYYjVAcihL9NGA7DINXjwLLrbp1NcDkIRSwilk0wYuHGINnrJCPZTW3YIiPFw2yNYCrdSLPCxwMPPADAV199dcBtL7vssu9XkYhIB2ZZNq8v28bDH26kJhShb1oCCS4PZf4gOytrsWwwsKkJWzhMgwSXA6/LJDPVyxFDevJlQRk7ymu/ve3WYFS2j1lTcnS5RbqVZoWPe++9t8HjXbt24ff7SUtLA6CsrIzExEQyMzMVPkSky8orKOXpTzfxv3W7qAlFcDtMQhGLfj0S6ZnsYdKgdPKLq7BsSHA5sWybZI+T3N4p0YCx5wiovkQXuZkpavGQbqdZ4SM/Pz/6/+eff56//OUvPPHEEwwbNgyAtWvXcsEFF3DRRRe1TZUiInGWV1DKH95YzfbyGgJhC4/TAcBuf4iaYCVDe6eQluBiYM9kymtCXDZ9KOmJ7kYBwzQNhmelxvOliMRdzLPa3nDDDbzyyivR4AEwbNgw7r33Xn76059y1llntWqBIiLxZFk2aworuPlfK9lQVI1lWQQjNsGw1WBY9K27/fgSfHhdDoqrAqQnupk0WCOUijQl5vCxY8cOwuFwo+WRSISdO3e2SlEiIh1BfafSLwpK2VFWW3cXi0Gj8TucpkF1IEJ1IIwBuJ0OfImuOFQs0jnEPGPRtGnTuOiii/jyyy+jy/Ly8vjVr37F9OnTW7U4EZF4+XLzbm57czUrtpVT7g9FA0dkr+RhAKGIhWXbBMMRdlUFGJqZTG5mSnuXLNJpxBw+nnzySbKyspg4cSIejwePx8Nhhx1G7969efzxx9uiRhGRdmVZNn9bVECZP4THaVITiuxz27rwYWPZNsVVQXwJLmbptlmR/Yr5sktGRgZvvfUW69evZ/Xq1QAMHz6c3NzcVi9ORCQetpb5WbG9HK/LQUGJPzpAWFPqZ29xGAbjBvRgtm6bFTmgmMNHvaFDhzJ06NDWrEVEJO6+3Lybl5ZsZVtpDREbQvtJHgZ1/T+cpsGvjj6Ii48eohYPkWZocfgQEelq8gpKmf/WapKtAE7TIBTe/6y0NnUB5OA+qQoeIjGIuc+HiEhXZFk2zyys6+eR7fPgdTsadS5tittpMudw9fEQiUWnCR8333wzhmE0+Bo+fHh0fW1tLb/+9a/p2bMnycnJnHbaabr1V0SabV1RJRuKqshI8RCI2DgOECYMwDAgt3eKJoQTiVGnuuwycuRI3nvvvehjp/O78q+44grefPNNXn75ZXw+H5dccgmnnnoqn376aTxKFZEObu9hzsuqQ5TXBCmpirDbEWJnZdPPcxjgcpjYgGnAuYcPVKuHSIxaFD4+/vhjHn30UTZu3Mgrr7xC3759+dvf/sagQYM44ogjWrvGKKfTSVZWVqPl5eXlPPHEEzz//PP88Ic/BOCpp55ixIgRfPbZZ/zgBz9os5pEpPPZe0Zat9OBx2lSXBUE22LXt1096juUGtQ1E1vUdS51OurCxrj+PTh5bN/4vAiRTizm8PGPf/yDc845h7POOoulS5cSCASAugBw++2389Zbb7V6kfXWr19PdnY2Xq+XyZMnM3/+fAYMGEBeXh6hUKjBIGfDhw9nwIABLFq0aJ/hIxAIROsHqKioAMCyLCxr/x3NuirLsrBtu9u+/o5A56Btfbl5N/PfWk2ZP0Rmigevy01NMMyK7eWEwxYR6oKFyzRIcptUB8NErLpLLOkJLnqneKgMRshIdvOb6UMAG2t/9+JKzPQZiK+WHv9Yto85fPzhD3/gkUceYdasWbz44ovR5Ycffjh/+MMfYt1ds02aNImnn36aYcOGsWPHDubNm8fUqVNZsWIFhYWFuN3u6Cy79Xr37k1hYeE+9zl//nzmzZvXaPmuXbuora1t7ZfQKViWRXl5ObZtY5qdpktQl6Jz0HYsy+atz78hzaghN8uDYUSwiVBhBTkoxWZFKWBDhtdmUCo4TJuQZRIIRbABX4JFqjdCn75efjg8k37eEEVFRfF+WV2OPgPx1dLjX1m5j2uVTYg5fKxdu5Yjjzyy0XKfz0dZWVmsu2u2mTNnRv8/evRoJk2aRE5ODi+99BIJCQkt2ufcuXO58soro48rKiro378/GRkZpKZ2z1knLcvCMAwyMjL0oY8TnYO2s3ZnBZ9tD+M0PewotvEHQ1TWhqgJWgTCdl0/DmxyUgzWlRuEbTANgwS3F7A5bfwQfnBQOkMzUtTPow3pMxBfLT3+Xq+32dvGHD6ysrLYsGEDAwcObLD8k08+YfDgwbHursXS0tLIzc1lw4YN/OhHPyIYDFJWVtag9WPnzp1N9hGpVz88/N5M0+zWb3jDMLr9MYg3nYO2sSS/jPwSP8GITWSPSyXGt/+aBmDbWDb0TU/G43LgMk3ApjIQYfKQXgzP6p5/mLQ3fQbiqyXHP6ZtYy3oggsu4De/+Q2LFy/GMAy2b9/Oc889x9VXX82vfvWrWHfXYlVVVWzcuJE+ffowYcIEXC4X77//fnT92rVr2bx5M5MnT263mkSk48orKOWJT/OpDVkNggfUdSq1qZs0zvh21tqS6gBpiW6SPA6Kq4OaLE6kFcXc8nHddddhWRbTpk3D7/dz5JFH4vF4uPrqq7n00kvbokYArr76ak444QRycnLYvn07N910Ew6HgzPPPBOfz8f555/PlVdeSXp6OqmpqVx66aVMnjxZd7qICJZl8/SnmyiuCtCcrqEO06C6NkxxZYDqYFiTxYm0spjDh2EYXH/99VxzzTVs2LCBqqoqDj74YJKTk9uivqitW7dy5plnUlJSQkZGBkcccQSfffYZGRkZANx7772Ypslpp51GIBBgxowZ/OUvf2nTmkSk49pzHI9Sf5AvCkrxB/Y9O2090zCwbJtgxKaiNsSYfmnM0mRxIq2qxYOMud1uUlNTSU1NbfPgATS4s6YpXq+Xhx56iIceeqjNaxGRjm3PcTwC4QjlNaG6MTyaweUw6Jnkok/EwbUzhzPj4Cy1eIi0spj7fFiWxS233ILP5yMnJ4ecnBzS0tK49dZbdU+2iMRdXkEpt725mhXbygmEI5RUBShpZvCoH1SsNmQxtn+agodIG4m55eP666/niSee4I477uDwww8H6u50ufnmm6mtreW2225r9SJFRJqjfnK4wvJayvxBakJWs/p41DONun0kuR2cPVl9PETaSszh45lnnuHxxx/nxBNPjC4bPXo0ffv25eKLL1b4EJG4WVdUyacbiimpbl5LRyOGQa8UN2f9YADjB/Ro3eJEJCrm8FFaWtpgNtl6w4cPp7S0tFWKEhFpiZeXbG1x8DANGJCeyJ2nHsKAxFArVyYie4q5z8eYMWN48MEHGy1/8MEHGTNmTKsUJSISq3DY4tUvt7T4+elJbu46bTQTB6rFQ6Stxdzycdddd3H88cfz3nvvRQfwWrRoEVu2bGnTSeVERPa05620vkQXC1YVsrsm3KJ9OR0G180czqGD0tVxXqQdxBw+jjrqKNatW8dDDz3EmjVrADj11FO5+OKLyc7ObvUCRUT2tuettMFwhLBls7MicOAnNsHjNDhsYE9OHdevlasUkX1p0Tgf2dnZ6lgqInFRfyttmT9EkseBaRrsLKshEG5+i4VB3SimLofJsN5JXP6jobqzRaQdxRw+li9fvt/1o0ePbnExIiL7U38r7c6KWqoDYTaVhLFiuZeWhsFjYk4PLv/RUI1eKtLOYg4fY8eOxTAMbNvGMOr+UrDtuk+/YRhEIgcevlhEpCXWFVWyfGsZpdVBamMcwwMgxeskI9lDH5+X0yb04+SxfdXiIRIHMYeP/Px8oC5wjBo1irfeeoucnJxWL0xEZG9l1SGKqwIxBw/TgJ9P7M8p4/riS3SRm5mi0CESRzGHjz2DhmEY9OvXT+FDRNrFFwUlVAcizQoebodBls9LMGxx6MB0bj/lEAUOkQ4i5nE+RETiIa+glBc+3xJDi4dBOGKTnZbAnMMHKniIdCAtntUW6lo+6vt9iIi0FcuyuXfBenZW1MbwLJthvVO4ZNoQdSgV6WBiDh89evSIBo6qqirGjRuHaX7XgKIh1kWkta0qLOfLglIizbib1gC8LpNJg3ry11kTcTrVwCvS0cQcPu677742KENEpGl5BaVc94+v8YeaN46HwzTon57IpdOGKHiIdFAxh4/Zs2e3RR0iIo0syS/lqpe/Ykupv9nPGdEnlZtPPFiXWkQ6sJjDR0VFxX7Xp6amtrgYEZF6i/OLOf/pL6gKNG/sIAPom5bAnT89hIP7+Nq2OBH5XmIOH2lpaU12Mq0fdEyDjInI9/Xc4gJu/fcqamMYMt3rMhmf04PhvfUHkEhHF3P4+OCDD4C6sHHcccfx+OOP07dv31YvTES6pyX5pfzxnbUxBQ/nt/08Zk/J0S21Ip1Ai2a1redwOPjBD37A4MGDW7UoEemeLMvmoQ82UFkbbvZzDGB0Px/XHz9C/TxEOonvNc6HiEhrWrOzgnU7K6PzRR2Iw4AjhvTkidmH6c4WkU7ke4cPDTImIq0hr6CUe95dx87KAJFmDmOamerhsum5Ch4inUzM4WPcuHHRwFFTU8MJJ5yA2+2Orv/yyy9brzoR6RbyCkq57c3VbNldQ8RqXvJwO+C+08fpUotIJxRz+Dj55JOj/z/ppJNasxYR6YYsy+aZhQWU+UMEQ827W85pwI0njGTS4J5tXJ2ItIWYw8dNN93UFnWISDe1rqiSDUVVJHuc7KyoxWGC9e2NLk21gaR4HFx33AjOmqTZtEU6qxZdKC0rK+Pxxx9n7ty50blcvvzyS7Zt29aqxYlI11fuDxEIhTHNurDh87pI8ThxOUwcZt3dLAAeh0mPRCePnjNRwUOkk4u55WP58uVMnz4dn8/Hpk2buOCCC0hPT+fVV19l8+bNPPvss21Rp4h0IpZls66oknJ/CF+ii9zMFEzTaHL5h+uKKPWHSHQ7MQ0Dywa308TlNIlYNqGIhW3DQRlJRGxIT3YfuAAR6dBiDh9XXnklc+bM4a677iIlJSW6/LjjjuMXv/hFqxYnIp1PXkEpzywsYENRFcFwBLfTwZDMZCYNTmfxN6UNlpsGrNheN2WDx2GS6DapCkRwmA4M6m6lDQIpXifVwTCH9E0jNzNlv99fRDq+mMPHkiVLePTRRxst79u3L4WFha1SlIh0TvV3rZT5Q2SmePC6PNSEInz2TQnvriwkJcHJ4F7JJLg85BdXU/DthHFH5faioiZEUWUAgwj+YBinaRK2bBwmOB0GaYluZmkEU5EuIebw4fF4mpxcbt26dWRkZLRKUSLS+ex518rAnokYhkFZTZD84mp2V4ewgWBVEMOuwu1ysHV3DQBpiS58XheXHDOUv31WwPKtZRRXBQlGLFwOg17JHsb0S2PWlBzdVivSRcQcPk488URuueUWXnrpJaBukLHNmzdz7bXXctppp7V6gSLSOdTftZKZ4sEwDLaW1bChqJLIHlO0WDYUVQWjjwf2TKR3iocNu6pJSXBy3+ljWVdUSVl1iN01QdISXPRIckf7jIhI1xBz+PjTn/7ET3/6UzIzM6mpqeGoo46isLCQyZMnc9ttt7VFjSLSCZT7QwTDEbwuD2X+EBuKqhoEj715XSaDeiVh21BSHaTcH8I0DYZnaVZaka4u5vDh8/lYsGABn3zyCcuXL6eqqorx48czffr0tqhPRDoJX6ILt9NBTShCfknVAUcqDYUtqgNhTMPA7XTgS3S1U6UiEm8tntvliCOO4IgjjmjNWkSkE8vNTGFIZjJfbt5NZU3ogNtHbKioDRGK2IzK9ukuFpFupEW32u7PPffc0+JiRKTzMk2DSYPT+e+anYT2c7llTyXVIfr3SNBdLCLdTMzh47777mPy5MkNJpOrpxluRbony7J5fdk2Hv5wIzXNnJ/FAEb2SeXSaUN0F4tIN9Oiyy6vvfYamZmZrV2LiHRCeQWlPPVJPu+tKaK2uU0eQE56Ao/PmojT2aJZHkSkE4s5fBiGoRYOEQHqgsfcV79mU0k1wfD+O5ju7Ycjeit4iHRTMYcP27aZM2cOycnJJCUlkZ2dzbhx45g5cyaJiYltUaOIdECWZXPvgvXkF1cTisQWPJymwaGDdKlFpLuK+c+OWbNmkZGRgdPpZNeuXfznP//hnHPOYejQoaxevbotahSRDmjNzgqWby0jHGPwAEhPcvOj4b3boCoR6Qxibvl4+umnGy2rrq7mzDPP5JprruGNN95ojbpEpIN7c/kOKmvDxBo9HCZcNm2ILrmIdGOt8ulPSkri7rvvbjDLrYh0TZZl8+qXW3h5yZaYg4fLYXD+EYM5+wcD26I0EekkWjzI2N6GDRvGCy+80Fq7E5EOqP7Olg/W7qI62LxbagFMA4b1TuHmE0cyaXDPNqxQRDqDmMPH7t27ueuuu0hLS+PKK6/kqquu4vXXX2fEiBH89a9/ZcCAAW1Rp4jEiWXZrCuq5PP8Up74OJ+iytqYbqlNdBnccvIhnDqunwYSExGgBeHjl7/8JZ9//jkJCQksWLCAsrIyrr32Wl544QUuvfRS/vnPf7ZFnSISB3kFpTyzsIANRVXkF1dTG4pgGMR0uWXO4YP46YT+bVajiHQ+MYePDz/8kLfeeoucnByys7P55JNPmDJlClOnTuWYY45pixpFJA6WbCrhhtdWUFwdxOMwCYTrLrMcYL64BpLcDn4yOruNKhSRzqpFl10GDRpEZmYmSUlJZGVlAdC7d2/Kyspauz4RiYMl+aX86rkvKakKAg1bOgya1/LhcZqMz0ljeFZqW5QoIp1Yizqcrlq1isLCQmzbZs2aNVRVVVFcXNzatYlIHOQVlHL1y19RUhXEMOpuiYvY3wWOAwURA/C6HPRPT+Dy6bnq5yEijbQofEybNg3brvuR85Of/ATDMLBtW8Oui3RylmXz1Cf5bC/314UKG8L72NYEHKZBxLajl2I8ToOeyR4m5qQze0qOJowTkSbFHD7y8/Pbog4R6QBeX7aNBat2Uj8x7f4ur9gGeN0OguEIlg0HZSRz408OJj3ZTW5milo8RGSfYg4fOTk5bVGHiMRZXkEpt7+1ikAzh0u3bQhHLFwOB9lpXm47ZZRaOkSkWVo8yNiqVavYvHkzwWCwwfITTzzxexclIu3Lsmxu/tdKiqtCzX6OaUDPZA/jB/TQJRYRiUnM4eObb77hlFNO4euvv4729QCi/T0ikeaPeigiHcOrS7eycntFs7dP8ThJ8Tr4zbShnDZeg4eJSGxintvlN7/5DYMGDaKoqIjExERWrlzJRx99xMSJE/nwww/boEQRaUvhsMVjH33T7PE7vE6TAekJpCV6OKSfT8FDRGIWc8vHokWL+O9//0uvXr0wTRPTNDniiCOYP38+l112GUuXLm2LOkWkldQPl15WHWLJpmJe+mIbW3bXNOu5BjC4VxK1YYtR2T5yMzWZpIjELubwEYlEorPX9urVi+3btzNs2DBycnJYu3ZtqxcoIq2nfrj05VvL2F5WQ7CZnUvrpXgc1IYjpCW6mTUlR60eItIiMYePUaNG8dVXXzFo0CAmTZrEXXfdhdvt5rHHHmPw4MFtUaOItIK8glL+8MYqtpXVUFodJNz8ueGikjwuDumbxix1MBWR7yHm8PH73/+e6upqAG655RZ+8pOfMHXqVHr27Mnf//73Vi9QRL4/y7K5d8F61u2soiYUiWl+lnouh8HlPxrKzyb0V4uHiHwvMYePGTNmRP8/ZMgQ1qxZQ2lpKT169NAIpyId1OvLtpFXsLvBaKSx8DgNwCA1waXgISLfW4vH+QDYsmULAP37a7pskY7KsmxeydtKKGJhNGtKuIaSPA4sy8ZhmvRIcLdBhSLS3cR8q204HOaGG27A5/MxcOBABg4ciM/n4/e//z2hUPMHKBKR9vH6sq18tbWMsGUTirGfh8s0sC0wDYNeyW7SklxtU6SIdCsxt3xceumlvPrqq9x1111MnjwZqLv99uabb6akpISHH3641YsUkZZZkl/K/e+tpzYU++B/BuB0GCR7HbgcJmP6penWWhFpFTGHj+eff54XX3yRmTNnRpeNHj2a/v37c+aZZyp8iHQAlmXz6tKt3PGfNeyuDhLjHbUAZKd56ZnkoToQxpfo0q21ItJqYg4fHo+HgQMHNlo+aNAg3G5dDxaJt7yCUu5dsI4lm0oJhGNPHQ4DnA4T0zAIWzaj+vp0a62ItKqYw8cll1zCrbfeylNPPYXH4wEgEAhw2223cckll7R6gSLSfHVjeaxmxfZyQi1o7vA4DXomuRnWO5WLjjqItCQXuZkpavEQkVYVc/hYunQp77//Pv369WPMmDEAfPXVVwSDQaZNm8app54a3fbVV19tvUpFZL/qxvJY1+Lg4XYY9Er20DvVyyXThqilQ0TaTMzhIy0tjdNOO63BMt1qKxJ/r365lSWbdrcoeLhMg+y0BMb00+ilItL2Yg4fTz31VFvUISLfw5JNJdzx9hoCMY6Z7nUaGIbBmYcN4OeH9tclFhFpF99rkDERiY/6mWnL/SG2lfl57H/fUOYPYkCzhxHrl5ZAepKLUMTm54f2Z3hWaluWLCISFXP4GD9+/H7Xf/nlly0uRkQOrH5m2g1FVQTCEXZVBghGLEzDwDbsZt1W63IYZKd5KakOMirbp/E7RKRdxRw+li1bxlVXXUVycnJb1CMi+5FXUMptb66mzB8iM8VDOGKyrayGiGUTiaGvR4rHSXFVgLREt8bvEJF216LLLtdccw2ZmZmtXYuI7Idl2TyzsIAyf4iBPRMprw3zTXE1gW/HTI+lm6nX7eCQvupcKiLxoT4fIp3EuqJKNhRVkZHiobAiQEFJNSHLxjDqhkJvTsOHAaR6nVwxPZfTxvdTi4eIxEWLwodh6AeWSHsr94corwlSXFlLqT9E5NvgYdtwoHtc3A5wmSaWDT2TPRzSz6fgISJx06LwccMNN5CYmNjkunvuued7FSQiTdtW5qe0OkjEsrFsG4dpYNv2PoOHAZgGJLnrJoarCVs4TBiVnaoOpiISVzGHjyOPPJK1a9c2uU4tIiJtw7Js/ru6CDCwsbFtABtrr0stDqPh5Revy4FpmlSHItg29OuZyOzDB6rVQ0TiKubw8eGHH7ZBGSKyP68v28pH64uxLJtwxMYG9pwzzgAMA3J6JmLZsLO8FsOAUMQmFAnjdTkY0y+Ny380VB1MRSTuYg4f5eXlRCIR0tMb/gArLS3F6XSSmqqBikRaU15BKQ9/+A01wQiJbgcuB1QFv7vYYlA3bodhGPgSXFTUhvnRyCwuPHIQq3dUAjCybyrDe6eqxUNEOgQz1iecccYZvPjii42Wv/TSS5xxxhmtUpSI1AmHLR54bz3FlXUtGRHLxmma7BkhHCZ4nSYOw6C4KogvwcXsKTmM6pvGzyb252cT+3NwH3UwFZGOI+bwsXjxYo455phGy48++mgWL17cKkWJCHy5eTc/e3QRH28opqwmTDBiUxWMUBUMk+x2fNuR1CBigT9k4XQYjBvQg+uPH6FLKyLSocV82SUQCBAOhxstD4VC1NTUtEpRIt2ZZdl8smEXd3ywg1J/CMsGB3W309pA2IKqYIREl4nbYWJZEXqneLhqRi4nj9XYHSLS8cXc8nHYYYfx2GOPNVr+yCOPMGHChFYpSqS7yiso5dS/fMpTn26itDoYvZslQl3wqM8VNlAdsvAHI9hAps9LTs8kBQ8R6RRibvn4wx/+wPTp0/nqq6+YNm0aAO+//z5Llizh3XffbfUCRbqLJZtKuOS5peyqrGVEj6a3sfe6w8XpMEj1Oqnwh7jtzdW65CIinULMLR+HH344ixYton///rz00kv8+9//ZsiQISxfvpypU6e2RY0iXd6S/FIue/5LdlYGosuaGi1972Vuh8nAXsnk9EykvCbEswsLsPYe/ENEpINp0QinY8eO5bnnnmvtWlrNQw89xN13301hYSFjxozhz3/+M4cddli8yxJpUl5BKTf+cwWFFcHosoh94IniTLNuXI+0BBcAGcke1hdVsa6okuFZuuVdRDqumFs+Orq///3vXHnlldx00018+eWXjBkzhhkzZlBUVBTv0kQaqZ+pdldlIBo2bGBzlQHsu/+GaUCvJA9ZvoToMq/LQTAcodwfatOaRUS+r2aHD4fD0ayveLvnnnu44IILOPfcczn44IN55JFHSExM5Mknn4x3aSKNrCuqZPnWMqoCDe8gc5uwv7aPBJeDfumJDeJJbSiC2+nAl+hqk1pFRFpLsy+7uFwuHA4Hl156KZMnT27LmlosGAySl5fH3Llzo8tM02T69OksWrSo0faBQIBA4Ltr7BUVFQBYloVlHWie0K7Jsqy6ycq66etvT5Zl89Xm3eyqqMGyrOhfAiY2fRNtKoNGk/EjwWUyPCuZHglO6gOKbdsUV9UyKtvHkF5JOn/fgz4D8adzEF8tPf6xbN/s8LFu3Tp+//vf88c//pGTTjqJ+fPnk5ubG1Nhba24uJhIJELv3r0bLO/duzdr1qxptP38+fOZN29eo+W7du2itra2zersyCzLory8HNu2Mc0ud1Wuw9hQVMl/1xSxsaiagckRIjaU1EKGt25yuH7JdZdWrL3ix9h+Pipqw9QEa0gljNthEoxYVNSGOLiHg5+OTKG4eFecXlXXoM9A/OkcxFdLj39lZWWzt212+BgwYADPPvss11xzDddddx2jRo1izpw5zJs3jz59+jT7G3Ykc+fO5corr4w+rqiooH///mRkZHTbOWosy8IwDDIyMvShbwOWZfOvr7bxyIdbqagN43YYbCmrb+Ew2FVr46Ru4rg1u8Ha48LK6L4+5p0+hWVby/jbogLWFlURDNddahma2ZOzJ+cwfsA+7tGVZtNnIP50DuKrpcff6/U2e9uY73Y55JBDePPNN/noo4+47rrrGDJkCJdddhnXXXcdPp8v1t21ql69euFwONi5c2eD5Tt37iQrK6vR9h6PB4/H02i5aZrd+g1vGEa3PwatzbJsXl+2jVfytrJ0825qQxaGwbeDiO3Zc8OIjmRqYUTDx8D0BG44cSROp4OJA3syfkA664oqKfeH8CW6yM1M0QBjrUifgfjTOYivlhz/mLZtSVEARx55JAsXLuS5557jX//6F4MHD+buu+9u6e5ahdvtZsKECbz//vvRZZZl8f7773fYfirS9eUVlDLryc+5/rUVLM4voSZUdzGlucNxOE2Dc48Y1GDwMNM0GJ6VyqTBPRmepdlqRaRzaXbLx6mnnrrPdQcddBD5+flcd911XHPNNa1SWEtdeeWVzJ49m4kTJ3LYYYdx3333UV1dzbnnnhvXuqR7yiso5Q9vrGLdzioitk1z+mMZgNs08TgNDMMgM9XLpME927xWEZH20uzwcaBLKqeffvr3LqY1nH766ezatYsbb7yRwsJCxo4dy9tvv92oE6pIW6sfw2Prbj81wQix9Bs3TEhNcOJ0OBjTL43czJQ2q1NEpL01O3w89dRTbVlHq7rkkku45JJL4l2GdHPriir5PL+E4qrQAUcr3VOS20GfVDcVtklqoptZU3J0WUVEupQWDa8uIge2+JtSdlYEYgoeBpDgcWLZMKqvj3OmDNREcSLS5Sh8iLSB+ltqY53ibUhmMrecOBJXqIKxQ3NwOuM/arCISGtT+BBpA+uKKtleFttAdQZw0rhsJg1Op6gorEstItJlKXyItIFyf4hwpPldTB0GuJ0mEzRImIh0AwofIm3Al+jCNJrXcuFygNthkpHipUeSu40rExGJP4UPkTawdbefXVWBA25nGtAjwY3Lae5xS22sPUVERDoXhQ+RFrIsu8EQ50N6JbOhuIqy6hDXv7YCy67rx7GvKOF2GPTvkYANpO1xS63V3KFPRUQ6KYUPkRbIKyjlmYUFbPh2crewZROKWLgcJhHLZnd1EJfDoF9aImU1QSpqQ1jWd0HEANISXXhcToZmJjNrSo5uqRWRbkPhQyRGdUOmr2ZXVYBUrxOnw2RHuZ/aUAS30yQjxYNpgGGYlFQHGJqZjMMwqAyEAUh0meysDHD+1MFMPqinJoUTkW5H4UMkBpZlc++CdawvqsK2bUqrAgTCFpFvmzRqQhbbdtcA4HXZhC3YWlbDyD6ppCS4AKgOhElNcDP5oLpJ4UREuhvNVSwSg9eXbSWvYDfhby+xGIYRDR71LLvu8kpNyMKkLmxUByIA2LbNrqq61hDN1yIi3ZVaPkSaKRy2eHZhAcGITbLbgWXbVAcjjbYzDPC6HNSGIvi/vRQTCEcwArCrKoAvwaX5WkSkW1P4EGmGvIJS/vz+BlbuqMCybCpqw/u8i8WywTQMEt1OaoJhLMumpDpIitfFqGyfOpeKSLen8CFyAHkFpdz25moKy2vBroscB7oZNhCOkOxxEjANRman8ttjh9Mjya3OpSIiKHyI7Jdl2TyzsIAyfwhfgpPCiuYNARaxbPzBCE7T5JzJA5l8UK82r1VEpLNQ+BCh8YBh9S0UaworWLG9HK/LpLCi+RPF2XZd34+JOT04eWzfNqxcRKTzUfiQbm/vAcPcTgdDMpOZNDidt5bvYGtpDbZtE4ph5FGHCQN7JnL5j4bqMouIyF4UPqRbq+/PUeYPkZniwevyUBuKkFdQynurd5LkdmAaULv3/bT7UD+celqim1tPOkQdS0VEmqDwId3Wnv05BvZMxPh2FtpEj5NQxCIUsTAMJxHLOuC+TACjbqI4h2ly3czhHDpIwUNEpCkKH9JtrSuqZENRFZkpnmjwgLpBwfxBC6/TQUVNiNCBswc2dcHD4zQZPyCdU8f1a7vCRUQ6OYUP6bbK/SGC4Qhel6fB8lDEwrJtXKZBbbgueZhGXetGeK+rLyZ1wcPlMPAluuiXlqB+HiIiB6DwId2WL9GF21k3EmmS57uPgstRN+tARW3dRHAGkOxx4jINAmGbmlCYiP1d/w6AXskeDh2YrgHERESaQeFDuq3czBSGZCazcns5iW5H9NJLkseJ12niD0ZwGAZpiU5qQnVzuXicYOEgweWgfw8vOyoCHNQrid+fcDDDe6eqxUNEpBk0sZx0W6ZpMHtKDr4EFwWlfqoD4brBwQJhEtwOElwmGSlusnwJOAyoDoapDkVwmgZZqR4qAxH6piVw1YxhHNzHp+AhItJMavmQbm1CTjrXHz+CZxYWsGp7Bf5gmLRENxNz0jlscDqLvyllQ1EVqQkuKr+9DJPidWJjaJ4WEZEWUviQbm9CTjppCW5Of2wRFTVhrvxxLqeO64dpGpx56IDoyKcpXicYUFkTbjAKqoiIxEbhQ7o1y7L5YG0RV7/8Fbv9IYZmJnNUbmY0VJimwfCs1DhXKSLStSh8SLeVV1DKQx9s5KN1uwhbNh6nyaBeSWwurSYjxXPgHYiISIuow6l0aZZls6awgsXflLCmsALr2/lZ8gpKueH1Ffzv2+CR6HYwKjuVjbuquO3N1eQVlMa5chGRrkstH9Jl7WvCuHN+kMOjH21kbWEVEdsm2eNgXP8euJ1m9M6XZxcWMK5/D/XpEBFpAwof0iXta8K4ldvLufGfK/AHw/gSnAQjNuP6p+F21jUCGoZBRrKH9UVVrCuqVH8PEZE2oPAhXc6+JoxL8jhJdDtYU1hJRW2Icf3TMAwjOqJpPa/LQXFVgHJ/KB7li4h0eerzIV3OviaMqwqE2birmp5JbkIRm/KaUKPgAVAbqrtE40t0tWfZIiLdhsKHdDnfTRjniC6rCoT5cvNuCkr9VAUjuBwmxVVBbLvhTHG2bbOrKsDQzGRyM1Pau3QRkW5Bl12ky9l7wriq2jBfbtlNKGKT4nHSM9GFbdskuBwUlPrJSPbgddVtv6sqgC/BxawpOepsKiLSRtTyIV1O/YRxu6oCVNaEvgseXidj+/vYXRNiTL80bjl5JCOzfVTUhtm6209FbZhR2T6uP36EhkwXEWlDavmQLqd+wrjrX1vBF5t3Y9mQ4nEyLDOZ7eW10ZaNCTnpTBiQHh0+XUOmi4i0D4UP6ZJG9EmlsKIWywavy6RXsht/yGo0GZyGTxcRaX8KH9IlJbqd3HHqIfz143zmzhxOxLLVsiEi0kEofEiXYtt29PbaY0f14ccHZylsiIh0MOpwKl3G11vLOeHBT9hWVhNdpuAhItLxKHxIl7B8axlnPf4ZK7ZVcOd/1sS7HBER2Q9ddpFO76stZZz9xGIqa8NMzOnB7aceEu+SRERkP9TyIZ3asi1lnP14XfA4dGAPnj7vMJI9ytQiIh2ZfkpLp7V0825mPfE5lYEwhw1M56lzDyVJwUNEpMPTT2rplGzb5tY3VtUFj0HpPDVHwUNEpLPQZRfplAzD4NFzJnLmYf15Wi0eIiKdisKHdCql1cHo/zNSPMw/dTSJbgUPEZHOROFDOo0lm0o56q4PeOmLLfEuRUREvgeFD+kUPs8vZfaTdZ1L//3VdizLjndJIiLSQmqvlg5v8TclnPv0EvzBCEcM6cVfZ03UyKUiIp2Ywod0aJ99U8K5Ty2hJhRh6tC64OF1OeJdloiIfA8KH9JhLdpYwnlP1wWPI3MzeOycCQoeIiJdgPp8SIe1aGMxNaEIRyl4iIh0KWr5kA7BsmzWFVVS7g/hS3SRm5nCFT/KpV96IieOyVbwEBHpQhQ+JO7yCkp5ZmEBG4qqqKgJkux1kds7hdlTcvj5xP7xLk9ERFqZLrtIXOUVlHLbm6tZsa0cy7bZXl7LjrIavt5Wxm1vriavoDTeJYqISCtT+JC4sSybZxYWUOYPkeJ1sm5nJZYNLqdJTo9EymtCPLuwQGN6iIh0MQofEjfriirZUFSF22Hw9bZyLBt6Jbs5JNuHw2GSkexhfVEV64oq412qiIi0IvX5kLgp94fYXR2gsDKAXR88+vowjboBxLwuB8VVAcr9oThXKiIirUnhQ+Jm3c5KCisC2EBGsptRewQPgNpQBLfTgS/RFb8iRUSk1emyi8TNqGwfDtMgye1gZHZqg+Bh2za7qgIMzUwmNzMljlWKiEhrU/iQuBmX04M//Xw0ub2T2bK7hupAmIhlUx0IU1Dqx5fgYtaUHM3jIiLSxeiyi7Sr91fvpFeyhzH90wA4aWw/+vVIjI7zUVwVwO10MCrbx6wpOUzISY9vwSIi0uoUPqTdvLuykF8//yVel4PXf304B2UkAzAhJ51x/Xs0GuFULR4iIl2Twoe0i3dWFnLJ818SitgcOyqTnPTEButN02B4VmqcqhMRkfak8CFt7u0VdcEjbNmcOCabe34+BqdD3Y1ERLorhQ9pU//5egeXvrCUsGVz0ths/vQzBQ8Rke5O4UNi0tTss6ZpNLl8cX4pl7ywlIhlc8q4vvzxZ2NwqB+HiEi3p/Ahzbbn7LPBcN0AYEMyk5k0OJ3F35Q2Wn7GYf2YPLgnmSke7lbwEBGRbyl8SLPUzz5b5g+RmeLB6/JQG4qQV1DKe6t30iPRRf8eidHlK7eX88d3/Fw9I5dJg3opeIiISJQuvssB7Tn77MCeiSR5nDhMg0S3g3DEJhSxCEUsEj1OdlUFKKyoZUCPBMprQvz9860odoiIyJ7U8iEHVD/7bGaKB2OPIdCrAxGqg2G8Tgf+oEVBSTUbd1UDkOJ1NpiVVrfRiohIPbV8yAGV+0MEwxG8LkeD5SHLwrLB5TAIhiPR4JHt85KR7MHrchAMRzQrrYiINKDwIQfkS3ThdjqoDUUaLHeZJqYB/mCEYMQG6oLH8KwUDMPQrLQiItIkhQ85oNzMFIZkJrOrKoBt29HlSR4HpmFQG7YAyE77LnhoVloREdkXhQ85INM0mD0lB1+Ci4JSf3T22dLqIBW1YQCS3A76pyVg2WhWWhER2S91OJVmmZCTzvXHj2g0++zYfj48bgeZyR427qqmpDqoWWlFRGS/FD6k2epnn125oxx/IHLAEU7V4iEiIk1R+JCYvPTFFp5euInnfjmJnsme6HLNSisiIs2lPh/SbM8v3sx1r37NmsJKXs7bGu9yRESkk1LLhzS4ZJKa4MC3xx0t9Z5bXMD1r60A4NzDB3LRkYPbu0wREekiFD66ub0ni/M4TSb1cXDcYW4mDuwJwN8+K+CG1+uCx/lHDOL3x49oMNKpiIhILBQ+urGmJosLhMJsLqlm/lur+d3xB7NqewU3/HMlAL88YhDXK3iIiMj3pPDRTe09WVx9oEjyOMlweti0M8STH+ezfFs5ABceOZi5M4creIiIyPfWaTqcDhw4EMMwGnzdcccdDbZZvnw5U6dOxev10r9/f+666644Vdvx7WuyOADDMMhI9pBf4ufWk0dx7bHDFTxERKTVdKqWj1tuuYULLrgg+jgl5bthuysqKvjxj3/M9OnTeeSRR/j6668577zzSEtL48ILL4xHuR3ad5PFeRqtqwpaeF1udlUFSXA5+NXRB8WhQhER6ao6VfhISUkhKyuryXXPPfccwWCQJ598ErfbzciRI1m2bBn33HPPPsNHIBAgEAhEH1dUVABgWRaWZbX+C+hAUhMceJwmgVCYJM93b4PNpX427KpmaIaFx+kgNcHR5Y9FR2NZFrZt67jHiY5//OkcxFdLj38s23eq8HHHHXdw6623MmDAAH7xi19wxRVX4HTWvYRFixZx5JFH4na7o9vPmDGDO++8k927d9OjR49G+5s/fz7z5s1rtHzXrl3U1ta23QvpAHy2zaQ+DjaXVJPhrLv0sqY4wLqiujAWDNRy+EGp+Owaioq69rHoaCzLory8HNu2Mc1Oc2W0y9Dxjz+dg/hq6fGvrKxs9radJnxcdtlljB8/nvT0dBYuXMjcuXPZsWMH99xzDwCFhYUMGjSowXN69+4dXddU+Jg7dy5XXnll9HFFRQX9+/cnIyOD1NSuP1rncYe5mf/WajbtDBGK2BSU1gWPQT6T9NQkjjtsOFlZjY+btC3Lsur63WRk6AdvHOj4x5/OQXy19Ph7vd5mbxvX8HHddddx55137neb1atXM3z48AYhYfTo0bjdbi666CLmz5+Px9O430JzeDyeJp9rmma3eMNPHNiT3x1/MDe8vpINu+ouOWUkuzl6cALHTxoRHedD2p9hGN3mfdgR6fjHn85BfLXk+MeybVzDx1VXXcWcOXP2u83gwU2PpDlp0iTC4TCbNm1i2LBhZGVlsXPnzgbb1D/eVz8Rgc/zd7NqR13w+On4vpw/dSA+u0YtHiIi0mbiGj4yMjLIyMho0XOXLVuGaZpkZmYCMHnyZK6//npCoRAulwuABQsWMGzYsCYvuQjYts3m0moALp8+lMun52JZlvp4iIhIm+oUfT4WLVrE4sWLOeaYY0hJSWHRokVcccUVnH322dFg8Ytf/IJ58+Zx/vnnc+2117JixQruv/9+7r333jhX33EZhsFtJx/C9BG9mTaid7zLERGRbqJThA+Px8OLL77IzTffTCAQYNCgQVxxxRUN+oH4fD7effddfv3rXzNhwgR69erFjTfeqDE+mvD2ih1MG9Ebl8PENA0FDxERaVedInyMHz+ezz777IDbjR49mo8//rgdKuq8/vz+ev60YB3HH9KHP585DtPUqKUiItK+OkX4kNZx/3vrufe9dQCM7Juq4CEiInGh8NGFWJbNuqJKyv0hfIkucjNTogHj3gXruP/99QBce+xwDZkuIiJxo/DRReQVlPLMwgI2FFURDEdwOx0MyUxm1uQBfLS+hAe+DR5zZw7noqMUPEREJH4UPrqAvIJSbntzNWX+EJkpHrwuD7WhCCu3l3PpC8vYUV536+z1x43ggiObHjdFRESkvWjouE7OsmyeWVhAmT/EwJ6JJHmcOEyDJI+TnPREAByGwe+OG67gISIiHYJaPjq5dUWVbCiqIjOlbnK4PRmGQU56IskeJ0fmtmwwNxERkdamlo9OrtwfIhiO4HU5gLpRSzcVV1MVCAPgdTmwbZtyfyieZYqIiEQpfHRyvkQXbqeD2lAE27bZuKuajcXVLN1cRjhiURuq63zqS3TFu1QRERFAl106vdzMFIZkJrNiWxnhiM3m3TUADOyViMM02FUVYFS2j9zMlDhXKiIiUkctH52caRrMmjyAqkAkGjyGZiaTnuimoNSPL8HFrCk5GlBMREQ6DLV8dHK2bfOfFTspqgwAkJXqxbZtKmrDjMr2MWtKDhNy0uNcpYiIyHcUPjq5pxdu4olP8gH4w0mjmDioR5MjnIqIiHQUCh+d3GkT+vGvr7bzswn9+cWkAfEuR0RE5IAUPjoh27ajY3qkel28fNFknA513xERkc5Bv7E6Gdu2ufGfK/nrR99Elyl4iIhIZ6KWj07Esmxu/NcK/t9nmzEMOGpYBrm9dQutiIh0LgofnYRl2fz+nyt4fnFd8LjrtNEKHiIi0ikpfHQClmVz/esreOHzuuDxx5+O4bQJ/eJdloiISIsofHRwlmXzu9e+5sUlWzAM+NPPxnDqeAUPERHpvBQ+OriPNxTz4pItmAb86edjOGWcgoeIiHRuCh8d3FG5GcydOZwsn5eTxvaNdzkiIiLfm8JHBxSxbGpDEZI8dafnoqMOinNFIiIirUcDRHQwEcvmt68s5+wnFlNZG4p3OSIiIq1O4aMDiVg217z8Ff/4civLt5bz5eayeJckIiLS6nTZpYOIWDZXv/wVry3dhsM0eOCMcRyVmxHvskRERFqdwkcHEI5YXPXyV/xz2XacpsGfzxzHzEP6xLssERGRNqHwEWfhiMWVL33Fv76qCx4P/mIcx45S8BARka5L4SPOCitqWbix5NvgMZ5jR2XFuyQREZE2pfARZ/16JPLCBZMoKPEz/eDe8S5HRESkzSl8tDHLsllXVEm5P4Qv0UVuZgoR22bdzkpGZvsAGNo7haGaJE5ERLoJhY82lFdQyjMLC9hQVEUwHMHtdDA4I4mSqgBLt5Tx5OxDmTKkV7zLFBERaVcKH20kr6CU295cTZk/RGaKB6/Lgz8Y5oM1RVQHIzhNg9pwJN5lioiItDuFjzZgWTbPLCygzB9iYM9EDMPAsm3yi/1UB+sCx8ScHhydmxnnSkVERNqfRjhtA+uKKtlQVEVmiicaPFZsK2dXVQDTgGG9k6moDbOuqDLepYqIiLQ7hY82UO4PEQxH8LocWLbN19vK2VUVxDRgdF8ffXwJBMMRyv2au0VERLofhY824Et04XY6qA3VXWIxDKMuePTz0TPZQ22orvOpL9EV50pFRETan8JHG8jNTGFIZjK7qgIYwKjsVCbk9KBnkgfbttlVFWBoZjK5mbq9VkREuh+FjzZgmgazp+TgS3BRUOqnJhghye2kOhCmoNSPL8HFrCk5mKYR71JFRETancJHG5mQk871x49gZLaPitowW3f7qagNMyrbx/XHj2BCTnq8SxQREYkL3WrbhibkpDOuf49GI5yqxUNERLozhY82ZpoGw7NS412GiIhIh6HLLiIiItKuFD5ERESkXSl8iIiISLtS+BAREZF2pfAhIiIi7UrhQ0RERNqVwoeIiIi0K4UPERERaVcKHyIiItKuFD5ERESkXSl8iIiISLtS+BAREZF2pfAhIiIi7UrhQ0RERNqVwoeIiIi0K2e8C+hIbNsGoKKiIs6VxI9lWVRWVuL1ejFNZdN40DmILx3/+NM5iK+WHv/63531v0v3R+FjD5WVlQD0798/zpWIiIh0TpWVlfh8vv1uY9jNiSjdhGVZbN++nZSUFAzDiHc5cVFRUUH//v3ZsmULqamp8S6nW9I5iC8d//jTOYivlh5/27aprKwkOzv7gC0mavnYg2ma9OvXL95ldAipqan60MeZzkF86fjHn85BfLXk+B+oxaOeLqaJiIhIu1L4EBERkXal8CENeDwebrrpJjweT7xL6bZ0DuJLxz/+dA7iqz2OvzqcioiISLtSy4eIiIi0K4UPERERaVcKHyIiItKuFD5ERESkXSl8SNTAgQMxDKPB1x133NFgm+XLlzN16lS8Xi/9+/fnrrvuilO1XdNDDz3EwIED8Xq9TJo0ic8//zzeJXVJN998c6P3+vDhw6Pra2tr+fWvf03Pnj1JTk7mtNNOY+fOnXGsuPP76KOPOOGEE8jOzsYwDF5//fUG623b5sYbb6RPnz4kJCQwffp01q9f32Cb0tJSzjrrLFJTU0lLS+P888+nqqqqHV9F53Wg4z9nzpxGn4ljjz22wTatefwVPqSBW265hR07dkS/Lr300ui6iooKfvzjH5OTk0NeXh533303N998M4899lgcK+46/v73v3PllVdy00038eWXXzJmzBhmzJhBUVFRvEvrkkaOHNngvf7JJ59E111xxRX8+9//5uWXX+Z///sf27dv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5JFH2rg6kfhT+BDpIiKRCNdffz2XXnop/fr1a3Kb0aNH8/777zNv3rwWfx+/38/111/Pgw8+iMvlanKbpKQkhgwZEn2cmZnZaBu3291gm61btzZYP2LECD799NMGyz799FNyc3NxOByMGzeOSCRCUVERU6dObVbtI0aMYMuWLezYsYM+ffoA8NlnnzXrubE45JBDsCyL//3vf9HLLvty0UUXccIJJzB27FiGDx/e6rWIdES620Wki3jvvffYsWMHc+fO3ec2c+fOZcmSJVx88cUsX76cNWvW8PDDDzfqCLk/zz//PAcddFCjO1pa21VXXcX777/Prbfeyrp163jmmWd48MEHufrqq4G6PiBnnXUWs2bN4tVXXyU/P5/PP/+c+fPn8+abbza5z+nTp5Obm8vs2bP56quv+Pjjj7n++utbvfaBAwcye/ZszjvvPF5//XXy8/P58MMPm2yROuqoo5g3bx533XVXq9ch0lEpfIh0EbW1tVx77bX06NFjn9vk5uby7rvv8tVXX3HYYYcxefJk/vnPf+J0Nr8R1O/3N7rltS2MHz+el156iRdffJFRo0Zx4403csstt0TvdAF46qmnmDVrFldddRXDhg3j5JNPZsmSJQwYMKDJfZqmyWuvvUZNTQ2HHXYYv/zlL5u8bHQg9bfK7m/AsYcffpif/vSnXHzxxQwfPpwLLriA6urqJre94oorone8iHQHhm3bdryLEBHpTOrvplm1atU+Lz2JyL6p5UNEJEZvvfUWt99+u4KHSAup5UNERETalVo+REREpF0pfIiIiEi7UvgQERGRdqXwISIiIu1K4UNERETalcKHiIiItCuFDxEREWlXCh8iIiLSrhQ+REREpF39fzehZkXDsufvAAAAAElFTkSuQmCC\n"
          },
          "metadata": {}
        },
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "\n",
            "--- Сохранено ---\n",
            "keras_dense_model.keras\n",
            "ml_preprocessor.joblib\n",
            "keras_dense_metadata.json\n",
            "keras_dense_predictions.csv\n",
            "keras_dense_predictions_all_datum_corrected.csv\n"
          ]
        }
      ],
      "source": [
        "# =========================================================\n",
        "# ЗАПУСК\n",
        "# =========================================================\n",
        "ml_result = run_keras_dense_experiment()"
      ]
    }
  ],
  "metadata": {
    "colab": {
      "provenance": [],
      "include_colab_link": true
    },
    "geometry_revision": {
      "B": "R + zeta",
      "P": "R + zeta + H",
      "Q": "R + zeta + 2H",
      "W_base_used": false,
      "control": "[W_N(B)-W_200(B)]-[W_N(Q)-W_200(Q)]",
      "lsc": "single point Q only; no direct baseline comparison to C_BQ",
      "target": "2*H_obs*dg_obs_residual(P)"
    },
    "kernelspec": {
      "display_name": "Python 3",
      "name": "python3"
    },
    "language_info": {
      "name": "python"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 0
}