Rename the package directory, imports, brand strings, env-var prefixes, console-script entry point, and the config/data dir (~/.config/meshterm). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
99 lines
3.2 KiB
Python
99 lines
3.2 KiB
Python
"""Plotly renderer for remote-admin TX-power optimization sweeps.
|
|
|
|
Produces a self-contained, interactive HTML chart: median SNR measured *at the target*
|
|
and trace success rate versus the admin node's TX power, with the chosen optimum marked.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import plotly.graph_objects as go
|
|
|
|
from ..core.models import TxOptResult
|
|
|
|
_TEMPLATE = "plotly_dark"
|
|
_BRAND = "#5eead4"
|
|
_MUTED = "#94a3b8"
|
|
|
|
|
|
def render_tx_optimization(result: TxOptResult, output_dir: Path) -> Path:
|
|
"""Render a TX-power optimization sweep to an interactive HTML file.
|
|
|
|
Args:
|
|
result: The optimization result to visualize.
|
|
output_dir: Directory the HTML file is written to (created if needed).
|
|
|
|
Returns:
|
|
Path to the written HTML file.
|
|
"""
|
|
levels = result.sorted_by_tx()
|
|
tx = [lv.tx_power for lv in levels]
|
|
snr = [lv.target_snr for lv in levels]
|
|
success = [lv.success_rate * 100 for lv in levels]
|
|
|
|
fig = go.Figure()
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=tx, y=snr, name="target SNR (dB)", mode="lines+markers",
|
|
line=dict(color=_BRAND, width=3), marker=dict(size=8),
|
|
connectgaps=False,
|
|
hovertemplate="TX %{x}<br>SNR %{y:+.1f} dB<extra></extra>",
|
|
)
|
|
)
|
|
fig.add_trace(
|
|
go.Bar(
|
|
x=tx, y=success, name="success rate (%)", yaxis="y2",
|
|
marker=dict(color=_MUTED), opacity=0.25,
|
|
hovertemplate="TX %{x}<br>%{y:.0f}% success<extra></extra>",
|
|
)
|
|
)
|
|
|
|
if result.best_snr is not None:
|
|
fig.add_vline(x=result.best_tx, line=dict(color="#4ade80", width=2, dash="dash"))
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=[result.best_tx], y=[result.best_snr],
|
|
name=f"optimum (TX {result.best_tx})", mode="markers",
|
|
marker=dict(color="#4ade80", size=16, symbol="star"),
|
|
hovertemplate=f"optimum<br>TX {result.best_tx}"
|
|
"<br>%{y:+.1f} dB<extra></extra>",
|
|
)
|
|
)
|
|
|
|
fig.update_layout(
|
|
template=_TEMPLATE,
|
|
title=dict(
|
|
text=f"TX-power optimization — {result.admin_node} → {result.target}"
|
|
f" · optimum TX {result.best_tx}",
|
|
font=dict(color=_BRAND),
|
|
),
|
|
xaxis=dict(title="admin-node TX power", dtick=1),
|
|
yaxis=dict(title="target SNR (dB)"),
|
|
yaxis2=dict(
|
|
title="success rate (%)", overlaying="y", side="right",
|
|
range=[0, 105], showgrid=False,
|
|
),
|
|
legend=dict(orientation="h", yanchor="bottom", y=1.02),
|
|
bargap=0.4,
|
|
)
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
path = output_dir / f"tx-optimize_{_slug(result.target)}_{stamp}.html"
|
|
fig.write_html(path, include_plotlyjs=True) # fully self-contained / offline
|
|
return path
|
|
|
|
|
|
def _slug(value: str) -> str:
|
|
"""Make a filesystem-safe slug from a node name.
|
|
|
|
Args:
|
|
value: Arbitrary node name or key prefix.
|
|
|
|
Returns:
|
|
A lowercase slug containing only alphanumerics, dashes, and underscores.
|
|
"""
|
|
return "".join(c if c.isalnum() or c in "-_" else "-" for c in value).strip("-").lower()
|