#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
warwalk_sim_map.py — СИМУЛЯТОР карты покрытия для урока (до выхода в поле).

Зачем нужен: реальная прогулка WarWalk даёт карту RSSI, на которой видны тень
здания, дифракция за углом, дыры покрытия. Но чтобы ученик ПОНЯЛ карту, полезно
сначала собрать её из физики самому: поставить маячок, поставить пару зданий и
посмотреть, какую карту предсказывает модель. Тогда настоящая карта из поля
читается не как магия, а как «модель + то, чего модель не знала».

Вся физика здесь — ровно та, что разобрана в WarWalk_writeup.md, раздел 3:
  * бюджет линка:      P_rx = P_tx + G_tx + G_rx − PL(d)
  * лог-дистанционная модель: PL(d) = PL(d0) + 10·n·lg(d/d0)
  * тень здания:       дифракция на кромке (knife-edge), потери растут с
                       глубиной геометрической тени за препятствием;
  * shadowing:         лог-нормальный шум σ дБ (случайная добавка в дБ);
  * цензура:           RSSI ниже чувствительности приёмника = ПАКЕТА НЕТ (дыра).
                       Модель это честно повторяет — «дыры» чёрные, как в поле.

Модель НАМЕРЕННО упрощена (одно препятствие = один экран, отражения не считаем):
её задача — не заменить измерение, а показать, ОТКУДА на карте берутся тени и
дыры. Расхождение симуляции с реальной прогулкой — тема для обсуждения на защите
проекта (отражения от ангара, волновод улицы модель не предскажет — а карта из
поля их покажет).

Запуск (зависимости ставит uv автоматически):
    uv run warwalk_sim_map.py
Без uv:
    pip install numpy matplotlib
    python warwalk_sim_map.py
"""

# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy>=1.26", "matplotlib>=3.8"]
# ///

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.widgets import Slider, Button

# --- Параметры радиолинка (значения из WarWalker.ino / писать 1:1 с прошивкой) ---
P_TX_DBM = 14.0        # мощность маячка, дБм (WW_POWER_DBM)
G_TX_DBI = 2.0         # усиление антенны маячка
G_RX_DBI = 2.0         # усиление антенны ходока
FREQ_MHZ = 869.4625    # WW_FREQ_MHZ

# Чувствительность SX126x зависит от SF: каждый шаг SF ~ +2.5..3 дБ (writeup 3.1).
SENS_BY_SF = {7: -123.0, 8: -126.0, 9: -129.0, 10: -132.0, 11: -134.5, 12: -137.0}

# Сцена: квадрат района SIZE×SIZE метров, маячок в центре.
SIZE_M = 500
GRID = 220             # разрешение карты (точек на сторону)
D0_M = 1.0             # опорное расстояние лог-дистанционной модели

# Здания-препятствия: (x, y, ширина, высота) в метрах, координаты от угла сцены.
BUILDINGS = [
    (300, 250, 70, 40),   # «пятиэтажка» справа от маячка
    (140, 320, 40, 90),   # длинный дом сверху-слева
]


def fspl_d0(freq_mhz, d0_km):
    """Потери свободного пространства на опорном d0 (формула FSPL из writeup 3.1)."""
    return 32.45 + 20 * np.log10(freq_mhz) + 20 * np.log10(max(d0_km, 1e-6))


def knife_edge_loss_db(clearance_m, freq_mhz):
    """
    Дополнительные потери на дифракции за кромкой препятствия (модель knife-edge).
    clearance_m: насколько луч ЗАГЛУБЛЁН в геометрическую тень (>0 = в тени).
    Приближение ITU для параметра Френеля v: 6.9 + 20·lg(√((v-0.1)²+1)+v-0.1).
    Чем глубже в тени — тем больше потери (0 дБ у самой кромки, десятки дБ в глубине).
    """
    if clearance_m <= 0:
        return 0.0
    # Параметр Френеля v грубо пропорционален глубине тени (упрощение для урока:
    # масштаб подобран так, чтобы «за углом» было ~6 дБ, в глубине двора 15-25 дБ).
    v = clearance_m / 6.0
    return 6.9 + 20 * np.log10(np.sqrt((v - 0.1) ** 2 + 1) + v - 0.1)


def shadow_depth_m(px, py, bx, by, bw, bh, beacon):
    """
    Насколько точка (px,py) заглублена в «радиотень» здания от маячка.
    Тень = геометрическая проекция здания в направлении ОТ маячка. Возвращает
    условную глубину в метрах (0 = вне тени), которую едят как knife-edge.
    """
    bcx, bcy = bx + bw / 2, by + bh / 2
    dirx, diry = bcx - beacon[0], bcy - beacon[1]
    n = np.hypot(dirx, diry)
    if n < 1e-6:
        return 0.0
    dirx, diry = dirx / n, diry / n            # единичный вектор маячок->здание
    # проекция точки на луч тени относительно дальней грани здания
    along = (px - bcx) * dirx + (py - bcy) * diry     # вдоль луча (за зданием >0)
    perp = abs(-(px - bcx) * diry + (py - bcy) * dirx)  # поперёк луча
    half_width = max(bw, bh) / 2
    if along <= 0 or perp > half_width:
        return 0.0                              # не за зданием или сбоку от тени
    # глубже в тень + ближе к оси тени = больше потери; сужаем к краям тени
    edge_fade = 1.0 - perp / half_width
    return along * edge_fade


def compute_rssi_grid(n_exp, sigma_db, sf, seed=0):
    """Считает карту RSSI SIZE×SIZE. Возвращает (rssi, xs, ys, sensitivity)."""
    beacon = (SIZE_M / 2, SIZE_M / 2)
    xs = np.linspace(0, SIZE_M, GRID)
    ys = np.linspace(0, SIZE_M, GRID)
    gx, gy = np.meshgrid(xs, ys)

    # расстояние маячок->точка (в метрах, потом в км для модели)
    dist_m = np.hypot(gx - beacon[0], gy - beacon[1])
    dist_m = np.maximum(dist_m, D0_M)
    pl = fspl_d0(FREQ_MHZ, D0_M / 1000.0) + 10 * n_exp * np.log10(dist_m / D0_M)

    # тени зданий (складываем потери, если точка в нескольких тенях)
    shadow = np.zeros_like(pl)
    for (bx, by, bw, bh) in BUILDINGS:
        depth = np.vectorize(lambda px, py: shadow_depth_m(px, py, bx, by, bw, bh, beacon))(gx, gy)
        shadow += np.vectorize(lambda c: knife_edge_loss_db(c, FREQ_MHZ))(depth)

    # лог-нормальный shadowing: случайная добавка в дБ, N(0, sigma)
    rng = np.random.default_rng(seed)
    noise = rng.normal(0.0, sigma_db, size=pl.shape)

    rssi = P_TX_DBM + G_TX_DBI + G_RX_DBI - pl - shadow + noise

    # внутри контура здания «сигнала нет» (стена глушит) — рисуем как дыру
    for (bx, by, bw, bh) in BUILDINGS:
        inside = (gx >= bx) & (gx <= bx + bw) & (gy >= by) & (gy <= by + bh)
        rssi[inside] = -200.0

    return rssi, xs, ys, SENS_BY_SF[sf], beacon


def main():
    n_exp0, sigma0, sf0 = 3.0, 7.0, 7

    fig = plt.figure(figsize=(13, 7))
    fig.suptitle("WarWalk: симулятор карты покрытия (физика из writeup, раздел 3)",
                 fontsize=13, fontweight="bold")
    ax_map = fig.add_axes([0.06, 0.30, 0.52, 0.60])
    ax_hist = fig.add_axes([0.64, 0.55, 0.32, 0.35])
    ax_prof = fig.add_axes([0.64, 0.12, 0.32, 0.30])

    state = {"n": n_exp0, "sigma": sigma0, "sf": sf0, "seed": 0}

    def redraw(_=None):
        rssi, xs, ys, sens, beacon = compute_rssi_grid(
            state["n"], state["sigma"], state["sf"], state["seed"])

        # --- Карта покрытия ---
        ax_map.clear()
        # цензура: ниже чувствительности = дыра. Маскируем такие клетки.
        heard = np.ma.masked_less(rssi, sens)
        cmap = plt.cm.RdYlGn.copy()
        cmap.set_bad("black")   # дыры и стены — чёрные, как в поле
        im = ax_map.imshow(heard, origin="lower", extent=[0, SIZE_M, 0, SIZE_M],
                           vmin=-125, vmax=-50, cmap=cmap, aspect="equal")
        ax_map.plot(beacon[0], beacon[1], marker="*", color="blue",
                    markersize=16, markeredgecolor="white")
        ax_map.text(beacon[0], beacon[1] + 14, "маячок", color="blue",
                    ha="center", fontsize=8, fontweight="bold")
        for (bx, by, bw, bh) in BUILDINGS:
            ax_map.add_patch(plt.Rectangle((bx, by), bw, bh, facecolor="#444",
                                           edgecolor="white", hatch="//"))
        pdr = 100.0 * np.mean(rssi >= sens)
        ax_map.set_title(f"Карта RSSI (SF{state['sf']}, чувств. {sens:.0f} дБм). "
                         f"Чёрное — дыры/стены. PDR={pdr:.0f}%", fontsize=9)
        ax_map.set_xlabel("метры")
        ax_map.set_ylabel("метры")

        # --- Гистограмма shadowing (только услышанные точки) ---
        ax_hist.clear()
        vals = rssi[rssi >= sens]
        if vals.size:
            ax_hist.hist(vals, bins=30, color="#2e86ab", edgecolor="white")
        ax_hist.set_title("Распределение RSSI услышанных точек\n"
                          "(shadowing → колокол в дБ)", fontsize=8)
        ax_hist.set_xlabel("RSSI, дБм")

        # --- Радиальный профиль RSSI(lg d) на восток от маячка ---
        ax_prof.clear()
        row = np.argmin(np.abs(ys - beacon[1]))
        east = xs >= beacon[0]
        d = xs[east] - beacon[0] + D0_M
        prof = rssi[row, east]
        ax_prof.plot(d, prof, ".", color="#66a182", markersize=3)
        ax_prof.axhline(sens, color="#d1495b", ls="--", lw=1)
        ax_prof.text(d[-1], sens + 2, "чувствительность", color="#d1495b",
                     ha="right", fontsize=7)
        ax_prof.set_xscale("log")
        ax_prof.set_ylim(-140, -40)
        ax_prof.set_title("Профиль RSSI vs lg(расстояние)\nнаклон = показатель n",
                          fontsize=8)
        ax_prof.set_xlabel("расстояние, м (лог)")
        ax_prof.set_ylabel("RSSI, дБм")

        fig.canvas.draw_idle()

    # --- Ползунки ---
    ax_n = fig.add_axes([0.12, 0.18, 0.40, 0.03])
    s_n = Slider(ax_n, "Показатель n\n(город 2.7–4.3)", 2.0, 4.5, valinit=n_exp0, valstep=0.1)
    ax_sig = fig.add_axes([0.12, 0.12, 0.40, 0.03])
    s_sig = Slider(ax_sig, "Shadowing σ, дБ", 0.0, 12.0, valinit=sigma0, valstep=0.5)
    ax_sf = fig.add_axes([0.12, 0.06, 0.40, 0.03])
    s_sf = Slider(ax_sf, "SF (чувствительность)", 7, 12, valinit=sf0, valstep=1)

    def on_n(v): state["n"] = v; redraw()
    def on_sig(v): state["sigma"] = v; redraw()
    def on_sf(v): state["sf"] = int(v); redraw()
    s_n.on_changed(on_n)
    s_sig.on_changed(on_sig)
    s_sf.on_changed(on_sf)

    ax_btn = fig.add_axes([0.64, 0.02, 0.14, 0.05])
    b_reseed = Button(ax_btn, "Новая прогулка")
    def reseed(_): state["seed"] += 1; redraw()
    b_reseed.on_clicked(reseed)

    redraw()
    plt.show()


if __name__ == "__main__":
    main()
