Sergey Revyakin 2 weeks ago
parent b8ab9b366d
commit ce43f5f8ff

@ -7,14 +7,14 @@ COMPOSE_FILE="${PROJECT_ROOT}/deploy/docker/docker-compose.yml"
SDR_UNITS=(
dronedetector-sdr-433.service
dronedetector-sdr-750.service
dronedetector-sdr-868.service
#dronedetector-sdr-868.service
dronedetector-sdr-3300.service
dronedetector-sdr-4500.service
#dronedetector-sdr-4500.service
dronedetector-sdr-5200.service
dronedetector-sdr-5800.service
dronedetector-sdr-915.service
dronedetector-sdr-1200.service
dronedetector-sdr-2400.service
#dronedetector-sdr-915.service
#dronedetector-sdr-1200.service
#dronedetector-sdr-2400.service
)
log() {

@ -1,5 +1,4 @@
import os
import math
import statistics
# Более лучшая версия кода есть в FRScanner
@ -12,28 +11,20 @@ class DataBuffer:
Атрибуты:
current_column: Указатель на текущий столбец буфера, который обновляем.
thinning_counter: Прореживающий множитель на текующей итерации.
current_counter: Указатель на количество чтений между последним обновлением столбца и предыдущим атрибутом.
num_of_thinning_iter: Прореживающий множитель. Раз в это количечество раз будет обнволяться столбец буфера.
current_counter: Указатель на количество чтений между последним обновлением столбца и предыдущим атрибутом.
num_of_thinning_iter: Прореживающий множитель. Раз в это количество раз будет обновляться столбец буфера.
line_size: Количество строк буфера = количеству каналов.
columns_size: Количество столбцов = фиксированное число.
multiply_factor: Процентный показатель превышения сигналом уровня шума.
multiply_factor: Процентный показатель превышения сигналом уровня шума (legacy).
num_for_alarm: Количество раз, превышающих шум, при которых триггеримся.
is_init: Флаг инициализации буфера. = True, если инициализирован.
is_init: Флаг инициализации буфера.
buffer: Массив для буфера.
buffer_medians: Массив для медиан столбцов букера.
buffer_alarms: Массив для количества тревог по столбца буфера.
buffer_medians: Массив медиан по каналам.
buffer_mads: Массив MAD по каналам.
buffer_alarms: Массив для количества тревог по каналам.
"""
def __init__(self, columns_size, num_of_thinning_iter, num_of_channels, multiply_factor, num_for_alarm):
"""
Инициализируем класс.
:param columns_size:
:param num_of_thinning_iter:
:param num_of_channels:
:param multiply_factor:
:param num_for_alarm:
"""
def __init__(self, columns_size, num_of_thinning_iter, num_of_channels, multiply_factor, num_for_alarm, freq_tag=None):
self.current_column = 0
self.thinning_counter = 1
self.current_counter = 1
@ -43,29 +34,44 @@ class DataBuffer:
self.multiply_factor = multiply_factor
self.num_for_alarm = num_for_alarm
self.is_init = False
self.buffer = [[0 for _ in range(self.columns_size)] for _ in range(self.line_size)]
self.buffer_medians = [0] * self.line_size
self.buffer_medians = [0.0] * self.line_size
self.buffer_mads = [0.0] * self.line_size
self.buffer_alarms = [0] * self.line_size
self.last_alarm_channels = []
self.prev_values = [None] * self.line_size
self.trend_streak = [0] * self.line_size
# Рост в 15% по линейной мощности относительно фоновой медианы в dBFS.
self.dbfs_delta_ratio = float(os.getenv('dbfs_delta_percent', 15)) / 100.0
# Допускаем небольшой обратный ход, чтобы не сбрасываться от микрошума.
self.dbfs_max_backstep_db = float(os.getenv('dbfs_max_backstep_db', 0.25))
# Минимум подряд "плавных" шагов перед учетом как устойчивого роста.
self.dbfs_min_trend_steps = int(os.getenv('dbfs_min_trend_steps', max(1, self.num_for_alarm)))
self.freq_tag = '' if freq_tag is None else str(freq_tag)
suffix = f'_{self.freq_tag}' if self.freq_tag else ''
# Параметры MAD-порогов (per-frequency с fallback на общие).
self.mad_k_on = float(os.getenv('mad_k_on' + suffix, os.getenv('mad_k_on', 5.0)))
self.mad_k_off = float(os.getenv('mad_k_off' + suffix, os.getenv('mad_k_off', 2.5)))
self.mad_eps = float(os.getenv('mad_eps' + suffix, os.getenv('mad_eps', 0.05)))
def get_buffer(self):
return self.buffer
def get_medians(self):
return self.buffer_medians
def get_mads(self):
return self.buffer_mads
def get_alarms(self):
return self.buffer_alarms
def get_last_alarm_channels(self):
return list(self.last_alarm_channels)
def check_init(self):
return self.is_init
@ -75,23 +81,46 @@ class DataBuffer:
print(self.buffer[i], end=' ')
print()
@staticmethod
def _calc_mad(values, median):
deviations = [abs(v - median) for v in values]
return statistics.median(deviations)
def medians(self):
"""
Вычислить медиану по строке буфера.
Вычислить медиану и MAD по строкам буфера.
:return: None
"""
if self.check_init():
for i in range(self.line_size):
self.buffer_medians[i] = statistics.median(self.buffer[i])
med = float(statistics.median(self.buffer[i]))
self.buffer_medians[i] = med
self.buffer_mads[i] = float(self._calc_mad(self.buffer[i], med))
def get_threshold(self, channel_idx, k=None):
"""
Получить динамический порог в dB для канала:
threshold = median + k * MAD.
До завершения инициализации возвращает None.
"""
if not self.check_init():
return None
coef = self.mad_k_on if k is None else float(k)
baseline = float(self.buffer_medians[channel_idx])
mad = max(float(self.buffer_mads[channel_idx]), self.mad_eps)
return baseline + coef * mad
def get_thresholds(self, k=None):
if not self.check_init():
return [None] * self.line_size
return [self.get_threshold(i, k) for i in range(self.line_size)]
def alarms_fill_zeros(self):
self.buffer_alarms = [0] * self.line_size
self.trend_streak = [0] * self.line_size
self.prev_values = [None] * self.line_size
@staticmethod
def _dbfs_growth_ratio(current_db, baseline_db):
return math.pow(10.0, (current_db - baseline_db) / 10.0) - 1.0
self.last_alarm_channels = []
def update(self, data):
"""
@ -128,15 +157,15 @@ class DataBuffer:
def check_alarm(self, data):
"""
Проверка триггера системы по dBFS во времени.
Триггер: устойчивый рост относительно фоновой медианы не меньше dbfs_delta_percent,
подтвержденный несколькими последовательными чтениями.
Триггер: превышение динамического MAD-порога
с подтверждением тренда и несколькими последовательными чтениями.
"""
if self.check_init():
self.last_alarm_channels = []
for i in range(len(data)):
baseline = self.buffer_medians[i]
current = data[i]
growth_ratio = self._dbfs_growth_ratio(current, baseline)
threshold_on = self.get_threshold(i, self.mad_k_on)
threshold_off = self.get_threshold(i, self.mad_k_off)
prev = self.prev_values[i]
delta_db = 0.0 if prev is None else current - prev
@ -147,8 +176,10 @@ class DataBuffer:
else:
self.trend_streak[i] = 0
# Hysteresis: после начала серии используем более мягкий порог отпускания.
active_threshold = threshold_off if self.buffer_alarms[i] > 0 else threshold_on
exceeding = (
growth_ratio >= self.dbfs_delta_ratio
current >= active_threshold
and self.trend_streak[i] >= self.dbfs_min_trend_steps
)
@ -160,6 +191,7 @@ class DataBuffer:
self.prev_values[i] = current
if self.buffer_alarms[i] >= self.num_for_alarm:
self.last_alarm_channels = [i]
self.buffer_alarms = [0] * self.line_size
self.trend_streak = [0] * self.line_size
return True
@ -168,15 +200,13 @@ class DataBuffer:
def check_single_alarm(self, median, cur_channel):
"""
Проверка, является ли текущая метрика по каналу превышающей порог роста.
Проверка, является ли текущая метрика по каналу превышающей MAD-порог.
:param median: текущая метрика в dBFS.
:param cur_channel: индекс канала внутри частоты.
:return: Да/нет.
"""
if self.check_init():
baseline = self.buffer_medians[cur_channel]
exceeding = self._dbfs_growth_ratio(median, baseline) >= self.dbfs_delta_ratio
if exceeding:
return True
else:
return False
threshold_on = self.get_threshold(cur_channel, self.mad_k_on)
return median >= threshold_on
return False

@ -155,7 +155,15 @@ class MultiChannel:
num_for_alarm = int(os.getenv('num_for_alarm_' + str(freq)))
num_chs = self.get_num_chs(i)
self.DB.append(
DataBuffer(buffer_columns_size, num_of_thinning_iter, num_chs, multiply_factor, num_for_alarm))
DataBuffer(
buffer_columns_size,
num_of_thinning_iter,
num_chs,
multiply_factor,
num_for_alarm,
freq_tag=str(freq),
)
)
def db_alarms_zeros(self, circle_buffer):
"""

@ -1,6 +1,5 @@
import os
import datetime
import math
import time
from common.runtime import load_root_env, validate_env, as_bool, as_str
from smb.SMBConnection import SMBConnection
@ -53,7 +52,6 @@ telemetry_host = os.getenv('telemetry_host', '127.0.0.1')
telemetry_port = os.getenv('telemetry_port', '5020')
telemetry_endpoint = os.getenv('telemetry_endpoint', 'telemetry')
telemetry_timeout_sec = float(os.getenv('telemetry_timeout_sec', '0.30'))
telemetry_delta_percent = float(os.getenv('dbfs_delta_percent', '15'))
elems_to_save = elems_to_save.split(',')
file_types_to_save = file_types_to_save.split(',')
@ -100,13 +98,9 @@ def work(lvl):
max_idx = max(range(len(sigs_array)), key=lambda idx: sigs_array[idx])
dbfs_current = float(sigs_array[max_idx])
dbfs_threshold = None
if circle_buffer.check_init():
medians = circle_buffer.get_medians()
baseline = float(medians[max_idx])
dbfs_threshold = baseline + 10.0 * math.log10(
1.0 + telemetry_delta_percent / 100.0
)
dbfs_threshold = circle_buffer.get_threshold(max_idx)
channel_thresholds = circle_buffer.get_thresholds()
alarm_channels = circle_buffer.get_last_alarm_channels() if alarm else []
send_telemetry(
data={
@ -117,6 +111,9 @@ def work(lvl):
"alarm": bool(alarm),
"channel_idx": int(max_idx),
"channels_total": int(len(sigs_array)),
"channel_values": [float(v) for v in sigs_array],
"channel_thresholds": channel_thresholds,
"alarm_channels": alarm_channels,
},
host=telemetry_host,
port=telemetry_port,

@ -1,15 +1,11 @@
import os
import datetime
import math
import time
from common.runtime import load_root_env, validate_env, as_bool, as_str
from smb.SMBConnection import SMBConnection
from utils.datas_processing import pack_elems, agregator, send_data, send_telemetry, save_data, remote_save_data
from core.sig_n_medi_collect import Signal, SignalsArray, get_signal_length
from core.multichannelswitcher import MultiChannel, get_centre_freq
import logging
logging.basicConfig(level=logging.INFO)
load_root_env(__file__)
freq_suffix = os.path.splitext(os.path.basename(__file__))[0].split("_")[-1]
@ -56,7 +52,6 @@ telemetry_host = os.getenv('telemetry_host', '127.0.0.1')
telemetry_port = os.getenv('telemetry_port', '5020')
telemetry_endpoint = os.getenv('telemetry_endpoint', 'telemetry')
telemetry_timeout_sec = float(os.getenv('telemetry_timeout_sec', '0.30'))
telemetry_delta_percent = float(os.getenv('dbfs_delta_percent', '15'))
elems_to_save = elems_to_save.split(',')
file_types_to_save = file_types_to_save.split(',')
@ -103,15 +98,12 @@ def work(lvl):
max_idx = max(range(len(sigs_array)), key=lambda idx: sigs_array[idx])
dbfs_current = float(sigs_array[max_idx])
dbfs_threshold = None
if circle_buffer.check_init():
medians = circle_buffer.get_medians()
baseline = float(medians[max_idx])
dbfs_threshold = baseline + 10.0 * math.log10(
1.0 + telemetry_delta_percent / 100.0
)
dbfs_threshold = circle_buffer.get_threshold(max_idx)
channel_thresholds = circle_buffer.get_thresholds()
alarm_channels = circle_buffer.get_last_alarm_channels() if alarm else []
data={
send_telemetry(
data={
"freq": str(freq),
"ts": time.time(),
"dbfs_current": dbfs_current,
@ -119,17 +111,15 @@ def work(lvl):
"alarm": bool(alarm),
"channel_idx": int(max_idx),
"channels_total": int(len(sigs_array)),
}
send_telemetry(
data=data
"channel_values": [float(v) for v in sigs_array],
"channel_thresholds": channel_thresholds,
"alarm_channels": alarm_channels,
},
host=telemetry_host,
port=telemetry_port,
endpoint=telemetry_endpoint,
timeout_sec=telemetry_timeout_sec,
)
logging.info(data)
except Exception as exc:
if debug_flag:
print(f"telemetry send failed: {exc}")

@ -1,6 +1,5 @@
import os
import datetime
import math
import time
from common.runtime import load_root_env, validate_env, as_bool, as_str
from smb.SMBConnection import SMBConnection
@ -53,7 +52,6 @@ telemetry_host = os.getenv('telemetry_host', '127.0.0.1')
telemetry_port = os.getenv('telemetry_port', '5020')
telemetry_endpoint = os.getenv('telemetry_endpoint', 'telemetry')
telemetry_timeout_sec = float(os.getenv('telemetry_timeout_sec', '0.30'))
telemetry_delta_percent = float(os.getenv('dbfs_delta_percent', '15'))
elems_to_save = elems_to_save.split(',')
file_types_to_save = file_types_to_save.split(',')
@ -100,13 +98,9 @@ def work(lvl):
max_idx = max(range(len(sigs_array)), key=lambda idx: sigs_array[idx])
dbfs_current = float(sigs_array[max_idx])
dbfs_threshold = None
if circle_buffer.check_init():
medians = circle_buffer.get_medians()
baseline = float(medians[max_idx])
dbfs_threshold = baseline + 10.0 * math.log10(
1.0 + telemetry_delta_percent / 100.0
)
dbfs_threshold = circle_buffer.get_threshold(max_idx)
channel_thresholds = circle_buffer.get_thresholds()
alarm_channels = circle_buffer.get_last_alarm_channels() if alarm else []
send_telemetry(
data={
@ -117,6 +111,9 @@ def work(lvl):
"alarm": bool(alarm),
"channel_idx": int(max_idx),
"channels_total": int(len(sigs_array)),
"channel_values": [float(v) for v in sigs_array],
"channel_thresholds": channel_thresholds,
"alarm_channels": alarm_channels,
},
host=telemetry_host,
port=telemetry_port,

@ -1,6 +1,5 @@
import os
import datetime
import math
import time
from common.runtime import load_root_env, validate_env, as_bool, as_str
from smb.SMBConnection import SMBConnection
@ -53,7 +52,6 @@ telemetry_host = os.getenv('telemetry_host', '127.0.0.1')
telemetry_port = os.getenv('telemetry_port', '5020')
telemetry_endpoint = os.getenv('telemetry_endpoint', 'telemetry')
telemetry_timeout_sec = float(os.getenv('telemetry_timeout_sec', '0.30'))
telemetry_delta_percent = float(os.getenv('dbfs_delta_percent', '15'))
elems_to_save = elems_to_save.split(',')
file_types_to_save = file_types_to_save.split(',')
@ -100,13 +98,9 @@ def work(lvl):
max_idx = max(range(len(sigs_array)), key=lambda idx: sigs_array[idx])
dbfs_current = float(sigs_array[max_idx])
dbfs_threshold = None
if circle_buffer.check_init():
medians = circle_buffer.get_medians()
baseline = float(medians[max_idx])
dbfs_threshold = baseline + 10.0 * math.log10(
1.0 + telemetry_delta_percent / 100.0
)
dbfs_threshold = circle_buffer.get_threshold(max_idx)
channel_thresholds = circle_buffer.get_thresholds()
alarm_channels = circle_buffer.get_last_alarm_channels() if alarm else []
send_telemetry(
data={
@ -117,6 +111,9 @@ def work(lvl):
"alarm": bool(alarm),
"channel_idx": int(max_idx),
"channels_total": int(len(sigs_array)),
"channel_values": [float(v) for v in sigs_array],
"channel_thresholds": channel_thresholds,
"alarm_channels": alarm_channels,
},
host=telemetry_host,
port=telemetry_port,

@ -1,6 +1,5 @@
import os
import datetime
import math
import time
from common.runtime import load_root_env, validate_env, as_bool, as_str
from smb.SMBConnection import SMBConnection
@ -53,7 +52,6 @@ telemetry_host = os.getenv('telemetry_host', '127.0.0.1')
telemetry_port = os.getenv('telemetry_port', '5020')
telemetry_endpoint = os.getenv('telemetry_endpoint', 'telemetry')
telemetry_timeout_sec = float(os.getenv('telemetry_timeout_sec', '0.30'))
telemetry_delta_percent = float(os.getenv('dbfs_delta_percent', '15'))
elems_to_save = elems_to_save.split(',')
file_types_to_save = file_types_to_save.split(',')
@ -100,13 +98,9 @@ def work(lvl):
max_idx = max(range(len(sigs_array)), key=lambda idx: sigs_array[idx])
dbfs_current = float(sigs_array[max_idx])
dbfs_threshold = None
if circle_buffer.check_init():
medians = circle_buffer.get_medians()
baseline = float(medians[max_idx])
dbfs_threshold = baseline + 10.0 * math.log10(
1.0 + telemetry_delta_percent / 100.0
)
dbfs_threshold = circle_buffer.get_threshold(max_idx)
channel_thresholds = circle_buffer.get_thresholds()
alarm_channels = circle_buffer.get_last_alarm_channels() if alarm else []
send_telemetry(
data={
@ -117,6 +111,9 @@ def work(lvl):
"alarm": bool(alarm),
"channel_idx": int(max_idx),
"channels_total": int(len(sigs_array)),
"channel_values": [float(v) for v in sigs_array],
"channel_thresholds": channel_thresholds,
"alarm_channels": alarm_channels,
},
host=telemetry_host,
port=telemetry_port,

@ -1,6 +1,5 @@
import os
import datetime
import math
import time
from common.runtime import load_root_env, validate_env, as_bool, as_str
from smb.SMBConnection import SMBConnection
@ -53,7 +52,6 @@ telemetry_host = os.getenv('telemetry_host', '127.0.0.1')
telemetry_port = os.getenv('telemetry_port', '5020')
telemetry_endpoint = os.getenv('telemetry_endpoint', 'telemetry')
telemetry_timeout_sec = float(os.getenv('telemetry_timeout_sec', '0.30'))
telemetry_delta_percent = float(os.getenv('dbfs_delta_percent', '15'))
elems_to_save = elems_to_save.split(',')
file_types_to_save = file_types_to_save.split(',')
@ -103,13 +101,9 @@ def work(lvl):
max_idx = max(range(len(sigs_array)), key=lambda idx: sigs_array[idx])
dbfs_current = float(sigs_array[max_idx])
dbfs_threshold = None
if circle_buffer.check_init():
medians = circle_buffer.get_medians()
baseline = float(medians[max_idx])
dbfs_threshold = baseline + 10.0 * math.log10(
1.0 + telemetry_delta_percent / 100.0
)
dbfs_threshold = circle_buffer.get_threshold(max_idx)
channel_thresholds = circle_buffer.get_thresholds()
alarm_channels = circle_buffer.get_last_alarm_channels() if alarm else []
send_telemetry(
data={
@ -120,6 +114,9 @@ def work(lvl):
"alarm": bool(alarm),
"channel_idx": int(max_idx),
"channels_total": int(len(sigs_array)),
"channel_values": [float(v) for v in sigs_array],
"channel_thresholds": channel_thresholds,
"alarm_channels": alarm_channels,
},
host=telemetry_host,
port=telemetry_port,

@ -1,6 +1,5 @@
import os
import datetime
import math
import time
from common.runtime import load_root_env, validate_env, as_bool, as_str
from smb.SMBConnection import SMBConnection
@ -53,7 +52,6 @@ telemetry_host = os.getenv('telemetry_host', '127.0.0.1')
telemetry_port = os.getenv('telemetry_port', '5020')
telemetry_endpoint = os.getenv('telemetry_endpoint', 'telemetry')
telemetry_timeout_sec = float(os.getenv('telemetry_timeout_sec', '0.30'))
telemetry_delta_percent = float(os.getenv('dbfs_delta_percent', '15'))
elems_to_save = elems_to_save.split(',')
file_types_to_save = file_types_to_save.split(',')
@ -100,13 +98,9 @@ def work(lvl):
max_idx = max(range(len(sigs_array)), key=lambda idx: sigs_array[idx])
dbfs_current = float(sigs_array[max_idx])
dbfs_threshold = None
if circle_buffer.check_init():
medians = circle_buffer.get_medians()
baseline = float(medians[max_idx])
dbfs_threshold = baseline + 10.0 * math.log10(
1.0 + telemetry_delta_percent / 100.0
)
dbfs_threshold = circle_buffer.get_threshold(max_idx)
channel_thresholds = circle_buffer.get_thresholds()
alarm_channels = circle_buffer.get_last_alarm_channels() if alarm else []
send_telemetry(
data={
@ -117,6 +111,9 @@ def work(lvl):
"alarm": bool(alarm),
"channel_idx": int(max_idx),
"channels_total": int(len(sigs_array)),
"channel_values": [float(v) for v in sigs_array],
"channel_thresholds": channel_thresholds,
"alarm_channels": alarm_channels,
},
host=telemetry_host,
port=telemetry_port,

@ -36,6 +36,9 @@ class TelemetryPoint(BaseModel):
alarm: bool = False
channel_idx: int = 0
channels_total: int = 1
channel_values: Optional[List[float]] = None
channel_thresholds: Optional[List[Optional[float]]] = None
alarm_channels: Optional[List[int]] = None
def _prune_freq_locked(freq: str, now_ts: float) -> None:
@ -136,15 +139,25 @@ MONITOR_HTML = """
--text: #1c232e;
--green: #12b76a;
--red: #ef4444;
--muted: #5b6574;
}
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, Segoe UI, sans-serif; }
.wrap { max-width: 1400px; margin: 0 auto; padding: 16px; }
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.meta { font-size: 13px; color: #5b6574; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 12px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 8px 8px 2px; }
.title { font-size: 14px; font-weight: 600; margin: 6px 8px; }
.plot { height: 280px; }
.wrap { max-width: 1800px; margin: 0 auto; padding: 14px; }
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.meta { font-size: 13px; color: var(--muted); }
.grid { display: flex; flex-direction: column; gap: 10px; }
.card { width: 100%; background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 8px 8px 8px; }
.title-row { display: flex; justify-content: space-between; align-items: center; margin: 4px 8px; }
.title { font-size: 20px; font-weight: 700; }
.ctrl { display: flex; align-items: center; gap: 6px; }
.ctrl label { font-size: 12px; color: var(--muted); }
.ctrl select { border: 1px solid var(--line); border-radius: 6px; padding: 2px 6px; }
.plot { height: 260px; width: 100%; }
.events-title { font-size: 12px; color: var(--muted); margin: 2px 8px 4px; }
.events { max-height: 110px; overflow-y: auto; border-top: 1px dashed var(--line); margin: 0 8px; padding-top: 4px; }
.ev { display: flex; justify-content: space-between; font-size: 12px; line-height: 1.4; color: var(--text); }
.ev-t { color: var(--muted); }
.ev-empty { color: var(--muted); font-size: 12px; }
</style>
</head>
<body>
@ -152,7 +165,7 @@ MONITOR_HTML = """
<div class=\"head\">
<div>
<h2 style=\"margin:0;\">DroneDetector Telemetry Monitor</h2>
<div class=\"meta\">Green: dBFS current, Red: dynamic alarm threshold</div>
<div class=\"meta\">Green: dBFS current, Red: channel threshold, Red dots: alarm points</div>
</div>
<div class=\"meta\" id=\"status\">connecting...</div>
</div>
@ -162,17 +175,77 @@ MONITOR_HTML = """
<script>
const windowSec = 300;
const state = {}; // freq -> points[]
const selectedChannel = {}; // freq -> 'max' | channel index as string
function numericSortFreq(a, b) {
return Number(a) - Number(b);
}
function formatTime(ts) {
return new Date(Number(ts) * 1000).toLocaleTimeString('ru-RU', {hour12: false});
}
function getChannelCount(freq) {
const pts = state[freq] || [];
let maxCount = 1;
for (const p of pts) {
if (Number.isFinite(Number(p.channels_total))) {
maxCount = Math.max(maxCount, Number(p.channels_total));
}
if (Array.isArray(p.channel_values)) {
maxCount = Math.max(maxCount, p.channel_values.length);
}
}
return maxCount;
}
function ensurePlot(freq) {
if (document.getElementById(`plot-${freq}`)) return;
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `<div class=\"title\">${freq} MHz</div><div class=\"plot\" id=\"plot-${freq}\"></div>`;
card.innerHTML = `
<div class=\"title-row\">
<div class=\"title\">${freq} MHz</div>
<div class=\"ctrl\">
<label for=\"chan-${freq}\">channel</label>
<select id=\"chan-${freq}\"></select>
</div>
</div>
<div class=\"plot\" id=\"plot-${freq}\"></div>
<div class=\"events-title\">Alarms (time -> channel)</div>
<div class=\"events\" id=\"events-${freq}\"></div>
`;
document.getElementById('plots').appendChild(card);
selectedChannel[freq] = 'max';
const sel = document.getElementById(`chan-${freq}`);
sel.addEventListener('change', () => {
selectedChannel[freq] = sel.value;
render(freq);
});
}
function updateChannelSelector(freq) {
const sel = document.getElementById(`chan-${freq}`);
if (!sel) return;
const prev = selectedChannel[freq] ?? 'max';
const count = getChannelCount(freq);
const opts = ['max'];
for (let i = 0; i < count; i += 1) opts.push(String(i));
sel.innerHTML = '';
for (const v of opts) {
const option = document.createElement('option');
option.value = v;
option.textContent = v === 'max' ? 'max' : `ch ${v}`;
sel.appendChild(option);
}
selectedChannel[freq] = opts.includes(prev) ? prev : 'max';
sel.value = selectedChannel[freq];
}
function trimPoints(freq) {
@ -181,47 +254,134 @@ function trimPoints(freq) {
state[freq] = arr.filter(p => Number(p.ts) >= cutoff);
}
function getPointValueForSelection(point, selection) {
if (selection === 'max') {
return {
y: point.dbfs_current ?? null,
threshold: point.dbfs_threshold ?? null,
};
}
const idx = Number(selection);
if (!Number.isInteger(idx)) {
return {y: null, threshold: null};
}
const y = Array.isArray(point.channel_values) && idx < point.channel_values.length
? point.channel_values[idx]
: null;
const threshold = Array.isArray(point.channel_thresholds) && idx < point.channel_thresholds.length
? point.channel_thresholds[idx]
: null;
return {y, threshold};
}
function isAlarmForSelection(point, selection) {
if (point.alarm !== true) return false;
if (selection === 'max') return true;
const idx = Number(selection);
if (!Number.isInteger(idx)) return false;
if (Array.isArray(point.alarm_channels) && point.alarm_channels.length > 0) {
return point.alarm_channels.includes(idx);
}
return Number(point.channel_idx) === idx;
}
function renderAlarmEvents(freq, pts) {
const el = document.getElementById(`events-${freq}`);
if (!el) return;
const alarmPts = pts.filter(p => p.alarm === true);
if (alarmPts.length === 0) {
el.innerHTML = '<div class=\"ev-empty\">no alarms</div>';
return;
}
const rows = alarmPts.slice(-20).reverse().map((p) => {
const channels = Array.isArray(p.alarm_channels) && p.alarm_channels.length > 0
? p.alarm_channels.join(',')
: String(p.channel_idx ?? '-');
return `<div class=\"ev\"><span class=\"ev-t\">${formatTime(p.ts)}</span><span>ch ${channels}</span></div>`;
});
el.innerHTML = rows.join('');
}
function render(freq) {
ensurePlot(freq);
trimPoints(freq);
updateChannelSelector(freq);
const pts = state[freq] || [];
const sel = selectedChannel[freq] ?? 'max';
const x = [];
const y = [];
const thr = [];
const alarmX = [];
const alarmY = [];
for (const p of pts) {
const metric = getPointValueForSelection(p, sel);
if (metric.y === null || metric.y === undefined) {
continue;
}
const x = pts.map(p => new Date(Number(p.ts) * 1000));
const y = pts.map(p => p.dbfs_current);
const thr = pts.map(p => p.dbfs_threshold);
const alarmPts = pts.filter(p => p.alarm === true);
const ts = new Date(Number(p.ts) * 1000);
x.push(ts);
y.push(metric.y);
thr.push(metric.threshold);
if (isAlarmForSelection(p, sel)) {
alarmX.push(ts);
alarmY.push(metric.y);
}
}
const labelSuffix = sel === 'max' ? 'max' : `ch ${sel}`;
const traces = [
{
x,
y,
mode: 'lines',
name: 'dBFS',
line: {color: '#12b76a', width: 2}
name: `dBFS (${labelSuffix})`,
line: {color: '#12b76a', width: 2},
},
{
x,
y: thr,
mode: 'lines',
name: 'Threshold',
line: {color: '#ef4444', width: 2, dash: 'dash'}
name: `Threshold (${labelSuffix})`,
line: {color: '#ef4444', width: 2, dash: 'dash'},
},
{
x: alarmPts.map(p => new Date(Number(p.ts) * 1000)),
y: alarmPts.map(p => p.dbfs_current),
x: alarmX,
y: alarmY,
mode: 'markers',
name: 'Alarm',
marker: {color: '#ef4444', size: 6, symbol: 'circle'}
}
marker: {color: '#ef4444', size: 6, symbol: 'circle'},
},
];
Plotly.react(`plot-${freq}`, traces, {
margin: {l: 40, r: 12, t: 12, b: 32},
showlegend: true,
legend: {orientation: 'h', y: 1.16},
xaxis: {title: 'time'},
yaxis: {title: 'dBFS'}
xaxis: {
title: 'time',
tickformat: '%H:%M:%S',
hoverformat: '%H:%M:%S',
range: [new Date(Date.now() - windowSec * 1000), new Date()],
},
yaxis: {title: 'dBFS'},
}, {displayModeBar: false, responsive: true});
renderAlarmEvents(freq, pts);
}
function renderAll() {
@ -259,7 +419,9 @@ function connectWs() {
renderAll();
return;
}
if (msg.type !== 'point') return;
const p = msg.data;
const freq = String(p.freq);
if (!state[freq]) state[freq] = [];
@ -277,6 +439,10 @@ function connectWs() {
};
}
setInterval(() => {
renderAll();
}, 1000);
loadInitial().then(connectWs).catch((e) => {
document.getElementById('status').textContent = `init error: ${e}`;
connectWs();

Loading…
Cancel
Save