#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
FOXHUNT — СИМУЛЯТОР ПЕЛЕНГАЦИИ «горячо-холодно» (уроки 1-2, без железа).

Зачем это до выхода на охоту: на реальной охоте ребёнок ищет спрятанную лису по
громкости зуммера охотника (ближе → выше RSSI → чаще пики). Но ПОЧЕМУ громкость
так себя ведёт и почему «горячо везде» при мощной лисе — видно только на карте
поля RSSI. Этот стенд рисует этаж, прячет лису, ведёт охотника мышкой и озвучивает
ТЕ ЖЕ зоны (RYADOM/GORYACHO/TEPLO/HOLODNO), что зашиты в FoxHunt_Hunter.ino —
включая усреднение RSSI и порог тишины.

Физика — та, что стоит за прошивкой:
  * лог-дистанционная модель RSSI(d) = P_tx − PL(d0) − 10·n·lg(d/d0) (n≈3 в здании);
  * усреднение RSSI по окну RSSI_AVG_N пакетов (в прошивке скользящее среднее)
    гасит скачки ±3-5 дБ и даёт плавную «громкость»;
  * зоны громкости ZONES[] и порог тишины RSSI_SILENCE — 1:1 со скетчем охотника.

Ключевой педагогический вывод виден руками: приглушённая лиса (2 дБм) даёт
контрастные зоны на масштабе этажа, а мощная (14 дБм) «затапливает» весь этаж в
RYADOM — искать нечем. Это ровно то, о чём METODIKA.md: «если горячо везде —
убавьте FOX_TX_PWR_DBM».

Управление: двигайте мышь над картой = ходите с охотником. Ползунки — мощность
лисы, показатель n, окно усреднения. Кнопка — спрятать лису заново (случайно).

Запуск:
    uv run foxhunt_sim.py
Без uv:
    pip install numpy matplotlib && python foxhunt_sim.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
from matplotlib.patches import Rectangle, Circle

# --- Радио-параметры (1:1 с foxhunt_common.h / FoxHunt_Hunter.ino) ---
FREQ_MHZ = 869.0
D0_M = 1.0

# Зоны громкости — КОПИЯ ZONES[] из FoxHunt_Hunter.ino (порог по усреднённому RSSI).
# (rssi_min, period_ms, name, цвет). period_ms=0 → сплошной тон (RYADOM).
ZONES = [
    (-55,    0, "RYADOM",  "#dc2626"),   # очень близко: сплошной тон
    (-70,  250, "GORYACHO", "#f97316"),  # горячо
    (-85,  600, "TEPLO",   "#eab308"),   # тепло
    (-100, 1400, "HOLODNO", "#3b82f6"),  # холодно
]
RSSI_SILENCE = -100      # тише — зона молчания (нет ни одной зоны)

# Сцена: этаж SIZE×SIZE метров.
SIZE_M = 40.0
GRID = 200


def rssi_at(dist_m, tx_dbm, n_exp):
    """
    Лог-дистанционная RSSI(d). PL(d0) на 869 МГц ≈ 32.45+20lg(f)+20lg(d0_km).
    Возвращает RSSI в дБм в точке на расстоянии dist_m от лисы.
    """
    dist_m = np.maximum(dist_m, D0_M)
    pl_d0 = 32.45 + 20 * np.log10(FREQ_MHZ) + 20 * np.log10(D0_M / 1000.0)
    pl = pl_d0 + 10 * n_exp * np.log10(dist_m / D0_M)
    # усиления антенн ~2 дБи с каждой стороны (как в остальных проектах)
    return tx_dbm + 2 + 2 - pl


def zone_for(rssi):
    """Возвращает (индекс зоны, имя, цвет, period_ms) или None (тишина)."""
    if rssi < RSSI_SILENCE:
        return None
    for i, (rmin, per, name, col) in enumerate(ZONES):
        if rssi >= rmin:
            return i, name, col, per
    return None


def main():
    st = {"tx": 2.0, "n": 3.0, "avg": 5, "fox": (28.0, 12.0),
          "rssi_buf": []}
    rng = np.random.default_rng(0)

    fig = plt.figure(figsize=(12, 7))
    fig.suptitle("FoxHunt: пеленгация «горячо-холодно» — веди охотника мышью по этажу",
                 fontsize=13, fontweight="bold")
    ax_map = fig.add_axes([0.06, 0.30, 0.52, 0.60])
    ax_prof = fig.add_axes([0.66, 0.55, 0.30, 0.35])
    ax_buzz = fig.add_axes([0.66, 0.32, 0.30, 0.14])

    xs = np.linspace(0, SIZE_M, GRID)
    ys = np.linspace(0, SIZE_M, GRID)
    gx, gy = np.meshgrid(xs, ys)

    hunter = {"pos": (5.0, 5.0)}

    def field():
        fx, fy = st["fox"]
        d = np.hypot(gx - fx, gy - fy)
        return rssi_at(d, st["tx"], st["n"])

    def draw(_=None):
        rssi_map = field()
        fx, fy = st["fox"]
        hx, hy = hunter["pos"]

        # --- карта поля RSSI, раскрашенная по ЗОНАМ (как слышит зуммер) ---
        ax_map.clear()
        # дискретная раскраска по зонам: строим индекс зоны в каждой точке
        zidx = np.full(rssi_map.shape, -1)
        for i, (rmin, _p, _n, _c) in enumerate(ZONES):
            zidx[(rssi_map >= rmin) & (zidx < 0)] = i
        # рисуем зоны от дальней к ближней
        from matplotlib.colors import ListedColormap, BoundaryNorm
        colors = ["#0b1020"] + [z[3] for z in ZONES]   # -1=молчание тёмный
        cmap = ListedColormap(colors)
        norm = BoundaryNorm([-1.5, -0.5, 0.5, 1.5, 2.5, 3.5], cmap.N)
        ax_map.imshow(zidx, origin="lower", extent=[0, SIZE_M, 0, SIZE_M],
                      cmap=cmap, norm=norm, aspect="equal", alpha=0.85)
        # лиса скрыта от «игрока», но для учителя покажем полупрозрачно
        ax_map.add_patch(Circle((fx, fy), 0.6, color="white", ec="black", lw=1.2))
        ax_map.text(fx, fy + 1.2, "лиса", color="white", ha="center", fontsize=8,
                    fontweight="bold")
        # охотник
        ax_map.add_patch(Circle((hx, hy), 0.7, color="#22d3ee", ec="black", lw=1.5))
        ax_map.set_xlim(0, SIZE_M)
        ax_map.set_ylim(0, SIZE_M)
        ax_map.set_xlabel("метры")
        ax_map.set_ylabel("метры")
        ax_map.set_title("Карта зон громкости (что слышит зуммер). "
                         "Двигай мышь = иди с охотником", fontsize=9)

        # --- профиль RSSI(расстояние) с порогами зон ---
        ax_prof.clear()
        dline = np.linspace(D0_M, SIZE_M, 200)
        ax_prof.plot(dline, rssi_at(dline, st["tx"], st["n"]),
                     color="#334155", lw=2)
        for rmin, _p, name, col in ZONES:
            ax_prof.axhline(rmin, color=col, ls=":", lw=1.2)
            ax_prof.text(SIZE_M, rmin + 0.5, name, color=col, ha="right", fontsize=7)
        ax_prof.axhline(RSSI_SILENCE, color="#64748b", ls="--", lw=1)
        # текущее расстояние охотника
        dh = np.hypot(hx - fx, hy - fy)
        ax_prof.axvline(dh, color="#22d3ee", lw=1.5)
        ax_prof.set_xlabel("расстояние до лисы, м")
        ax_prof.set_ylabel("RSSI, дБм")
        ax_prof.set_ylim(-115, -30)
        ax_prof.set_title("RSSI(расстояние) и пороги зон", fontsize=9)
        ax_prof.grid(alpha=0.25)

        # --- «зуммер»: текущая зона с усреднением, как в прошивке ---
        ax_buzz.clear()
        ax_buzz.axis("off")
        raw = rssi_at(dh, st["tx"], st["n"]) + rng.normal(0, 2.0)  # скачок ±дБ
        buf = st["rssi_buf"]
        buf.append(raw)
        while len(buf) > int(st["avg"]):
            buf.pop(0)
        avg = float(np.mean(buf))
        z = zone_for(avg)
        if z is None:
            name, col, per = "ТИШИНА", "#334155", None
        else:
            _i, name, col, per = z
        beep = "сплошной тон" if per == 0 else (f"пик каждые {per} мс" if per else "—")
        ax_buzz.add_patch(Rectangle((0, 0), 1, 1, transform=ax_buzz.transAxes,
                                    color=col, alpha=0.85))
        ax_buzz.text(0.5, 0.62, name, transform=ax_buzz.transAxes, ha="center",
                     va="center", fontsize=16, fontweight="bold", color="white")
        ax_buzz.text(0.5, 0.25,
                     f"RSSI(avg {int(st['avg'])})={avg:.0f} дБм   {beep}",
                     transform=ax_buzz.transAxes, ha="center", va="center",
                     fontsize=9, color="white")

        fig.canvas.draw_idle()

    def on_move(event):
        if event.inaxes is ax_map and event.xdata is not None:
            hunter["pos"] = (event.xdata, event.ydata)
            draw()

    fig.canvas.mpl_connect("motion_notify_event", on_move)

    ax_tx = fig.add_axes([0.12, 0.18, 0.40, 0.03])
    s_tx = Slider(ax_tx, "Мощность лисы, дБм", 0, 14, valinit=st["tx"], valstep=1)
    ax_n = fig.add_axes([0.12, 0.12, 0.40, 0.03])
    s_n = Slider(ax_n, "Показатель n (здание ~3)", 2.0, 4.0, valinit=st["n"], valstep=0.1)
    ax_av = fig.add_axes([0.12, 0.06, 0.40, 0.03])
    s_av = Slider(ax_av, "Окно усреднения RSSI_AVG_N", 1, 9, valinit=st["avg"], valstep=1)

    def on_tx(v): st["tx"] = v; draw()
    def on_n(v): st["n"] = v; draw()
    def on_av(v): st["avg"] = v; st["rssi_buf"].clear(); draw()
    s_tx.on_changed(on_tx)
    s_n.on_changed(on_n)
    s_av.on_changed(on_av)

    ax_hide = fig.add_axes([0.66, 0.13, 0.30, 0.05])
    b_hide = Button(ax_hide, "Спрятать лису заново")

    def hide(_):
        st["fox"] = (float(rng.uniform(4, SIZE_M - 4)),
                     float(rng.uniform(4, SIZE_M - 4)))
        st["rssi_buf"].clear()
        draw()
    b_hide.on_clicked(hide)

    draw()
    plt.show()


if __name__ == "__main__":
    main()
