#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
warwalk_link_budget.py — интерактивный бюджет радиолинка (децибельная арифметика).

Вся радиосвязь — одна строка сложения и вычитания в децибелах (writeup 3.1):

    P_rx = P_tx + G_tx + G_rx − PL(d)      связь есть, пока P_rx > чувствительности

Этот стенд делает эту строку живой: ползунками задаём мощность маячка, усиления
антенн, показатель затухания n и SF (чувствительность приёмника), а график
RSSI(расстояние) показывает, где именно RSSI проваливается под порог — граница
покрытия. Рядом — «термометр» бюджета: из чего складывается запас в дБ.

Педагогический смысл: показать разницу между ФОРМУЛОЙ свободного пространства
(FSPL — сотни километров) и реальным городом (n≈3–4 — дыры в сотнях метров), и
что «плюс 6 дБ» (мощность ×4 или SF+2) двигают границу предсказуемо, а не на глаз.

Запуск:
    uv run warwalk_link_budget.py
Без uv:
    pip install numpy matplotlib && python warwalk_link_budget.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

FREQ_MHZ = 869.4625
D0_M = 1.0
SENS_BY_SF = {7: -123.0, 8: -126.0, 9: -129.0, 10: -132.0, 11: -134.5, 12: -137.0}


def fspl_db(freq_mhz, d_km):
    """Потери свободного пространства (FSPL) — writeup 3.1."""
    d_km = np.maximum(d_km, 1e-6)
    return 32.45 + 20 * np.log10(freq_mhz) + 20 * np.log10(d_km)


def path_loss_db(freq_mhz, d_m, n_exp):
    """Лог-дистанционная модель: PL(d) = FSPL(d0) + 10·n·lg(d/d0)."""
    d_m = np.maximum(d_m, D0_M)
    return fspl_db(freq_mhz, D0_M / 1000.0) + 10 * n_exp * np.log10(d_m / D0_M)


def coverage_range_m(p_tx, g_tx, g_rx, n_exp, sens):
    """Аналитически: расстояние, где P_rx падает ровно до чувствительности."""
    allowed_pl = p_tx + g_tx + g_rx - sens          # допустимые потери на трассе
    # allowed_pl = FSPL(d0) + 10n·lg(d/d0)  ->  d = d0 · 10^((allowed - FSPL(d0))/(10n))
    fspl_d0 = fspl_db(FREQ_MHZ, D0_M / 1000.0)
    exp = (allowed_pl - fspl_d0) / (10 * n_exp)
    return D0_M * (10 ** exp)


def main():
    st = {"ptx": 14.0, "gtx": 2.0, "grx": 2.0, "n": 3.0, "sf": 7}

    fig = plt.figure(figsize=(12, 7))
    fig.suptitle("WarWalk: бюджет радиолинка — где кончается покрытие",
                 fontsize=13, fontweight="bold")
    ax = fig.add_axes([0.08, 0.34, 0.56, 0.56])
    ax_bar = fig.add_axes([0.72, 0.34, 0.22, 0.56])

    d = np.logspace(0, np.log10(20000), 400)   # 1 м .. 20 км

    def redraw(_=None):
        sens = SENS_BY_SF[st["sf"]]
        pl = path_loss_db(FREQ_MHZ, d, st["n"])
        prx = st["ptx"] + st["gtx"] + st["grx"] - pl
        prx_fspl = st["ptx"] + st["gtx"] + st["grx"] - fspl_db(FREQ_MHZ, d / 1000.0)
        rng = coverage_range_m(st["ptx"], st["gtx"], st["grx"], st["n"], sens)

        ax.clear()
        ax.plot(d, prx, color="#2e86ab", lw=2, label=f"реальная модель (n={st['n']:.1f})")
        ax.plot(d, prx_fspl, color="#c9ccd1", lw=1.5, ls="--",
                label="свободное пространство (FSPL)")
        ax.axhline(sens, color="#d1495b", ls="--", lw=1.5)
        ax.text(1.2, sens + 2, f"чувствительность SF{st['sf']} = {sens:.0f} дБм",
                color="#d1495b", fontsize=8)
        ax.axvline(rng, color="#66a182", lw=1.5)
        rng_txt = f"{rng:.0f} м" if rng < 1000 else f"{rng/1000:.1f} км"
        ax.text(rng, -45, f"граница\n{rng_txt}", color="#66a182",
                ha="center", fontsize=8, fontweight="bold")
        ax.set_xscale("log")
        ax.set_ylim(-145, -30)
        ax.set_xlabel("расстояние, м (лог)")
        ax.set_ylabel("принимаемая мощность RSSI, дБм")
        ax.set_title("Разница между FSPL (сотни км) и городом (n≈3–4, дыры в сотнях м)\n"
                     "— и есть весь смысл проекта", fontsize=9)
        ax.legend(loc="upper right", fontsize=8)
        ax.grid(True, which="both", alpha=0.25)

        # --- термометр бюджета: из чего складывается запас над чувствительностью ---
        ax_bar.clear()
        eirp = st["ptx"] + st["gtx"]
        margin_1km = st["ptx"] + st["gtx"] + st["grx"] - path_loss_db(FREQ_MHZ, 1000.0, st["n"]) - sens
        segs = [
            ("P_tx", st["ptx"], "#edae49"),
            ("+G_tx", st["gtx"], "#66a182"),
            ("+G_rx", st["grx"], "#2e86ab"),
        ]
        bottom = 0
        for name, val, col in segs:
            ax_bar.bar(0, val, bottom=bottom, color=col, edgecolor="white", width=0.6)
            ax_bar.text(0, bottom + val / 2, f"{name}\n{val:+.0f}", ha="center",
                        va="center", fontsize=7, color="white")
            bottom += val
        ax_bar.set_title(f"EIRP = {eirp:.0f} дБм\nЗапас на 1 км: {margin_1km:+.0f} дБ",
                         fontsize=9)
        ax_bar.set_ylabel("дБ")
        ax_bar.set_xticks([])
        ax_bar.set_ylim(0, 22)

        fig.canvas.draw_idle()

    specs = [
        ("P_tx маячка, дБм", "ptx", 0, 20, 14, 1),
        ("Усиление G_tx, дБи", "gtx", 0, 6, 2, 0.5),
        ("Усиление G_rx, дБи", "grx", 0, 6, 2, 0.5),
        ("Показатель n", "n", 2.0, 4.5, 3.0, 0.1),
        ("SF", "sf", 7, 12, 7, 1),
    ]
    for i, (label, key, lo, hi, init, step) in enumerate(specs):
        axs = fig.add_axes([0.12, 0.24 - i * 0.045, 0.52, 0.025])
        s = Slider(axs, label, lo, hi, valinit=init, valstep=step)

        def make_cb(k):
            def cb(v):
                st[k] = int(v) if k == "sf" else v
                redraw()
            return cb
        s.on_changed(make_cb(key))

    redraw()
    plt.show()


if __name__ == "__main__":
    main()
