Files
ewsdr/DXSpotStore.pas
T

553 lines
19 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
Copyright (C)
2026 - Uladzimir Karpenka, EW8BAK
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
}
unit DXSpotStore;
{
DXSpotStore.pas — потокобезопасное хранилище DX-спотов.
Единственная точка обмена между сетевым потоком кластера (TDXClusterClient,
пишет) и UI (оверлей спектра + окно списка, читают). Поэтому здесь НЕТ ни
Synchronize, ни очередей в главный поток: поток кластера кладёт спот прямо
сюда под критической секцией, а UI замечает изменение по монотонному
счётчику Version — ровно так же, как оверлеи замечают смену вида по своим
ключам кэша. Никаких LCL-зависимостей (юнит компилируется и в headless).
Дедуп: спот с тем же позывным в пределах DEDUP_HZ считается тем же самым —
обновляем частоту/время/комментарий, а не плодим строки. Тот же позывной на
другом диапазоне — отдельная запись.
TTL: споты старше FTTLMinutes выбрасываются при каждой правке/снимке.
}
{$IFDEF FPC}
{$MODE Delphi}
{$LONGSTRINGS ON}
{$ENDIF}
interface
uses
SysUtils, Classes, SyncObjs, DateUtils, StrUtils; // StrUtils — PosEx
const
DX_DEDUP_HZ = 5000.0; // тот же позывной в этом окне = тот же спот
DX_DEFAULT_TTL = 30; // мин
DX_DEFAULT_MAX = 500; // потолок записей
type
// Мода спота. Определяется по комментарию кластера (FT8/CW/…), при неудаче —
// остаётся dxmUnknown: гадать по частоте не пытаемся, бэндпланы разные.
TDXMode = (dxmUnknown, dxmCW, dxmSSB, dxmDigi, dxmFT8, dxmFT4, dxmRTTY,
dxmPSK, dxmFM, dxmSSTV);
TDXModeSet = set of TDXMode;
TDXSpot = record
FreqHz: Double; // частота DX-станции, Гц (кластер отдаёт кГц)
Call: string; // позывной DX
Spotter: string; // кто заспотил
Comment: string; // комментарий кластера (уже без времени)
TimeUTC: string; // 'HHMM' как пришло от кластера
Stamp: TDateTime; // локальное время приёма — для TTL и затухания
Mode: TDXMode;
ModeGuessed: Boolean; // мода не из комментария, а из бэндплана (см. ниже)
end;
TDXSpotArray = array of TDXSpot;
TDXSpotStore = class
private
FLock: TCriticalSection;
FSpots: TDXSpotArray;
FCount: Integer;
FVersion: Int64;
FTTLMinutes: Integer;
FMaxSpots: Integer;
procedure PurgeLocked;
procedure DropOldestLocked;
function GetTTLMinutes: Integer;
procedure SetTTLMinutes(V: Integer);
function GetMaxSpots: Integer;
procedure SetMaxSpots(V: Integer);
public
constructor Create;
destructor Destroy; override;
// Добавить/обновить спот (вызывается из потока кластера).
procedure Add(const S: TDXSpot);
// Убрать все споты позывного (SPOT_DELETE в TCI; регистр не важен).
procedure RemoveCall(const Call: string);
procedure Clear;
// Выбросить просроченное прямо сейчас. Нужен тому, кто следит за временем
// снаружи: сам стор чистится только на Add и снимках, а когда споты никто
// не берёт и не приходит новых, устаревать им иначе негде.
procedure Purge;
// Снимок всех спотов, отсортированный по частоте.
function Snapshot(out Arr: TDXSpotArray): Integer;
// Снимок окна [LoHz..HiHz] с фильтром по моде (Modes = [] — без фильтра)
// и по возрасту (MaxAgeMin <= 0 — только общий TTL). Для оверлея.
function SnapshotRange(LoHz, HiHz: Double; Modes: TDXModeSet;
MaxAgeMin: Integer; out Arr: TDXSpotArray): Integer;
// Монотонный счётчик правок — ключ инвалидации кэша оверлея/списка.
function Version: Int64;
function Count: Integer;
// Читаются сетевым потоком (PurgeLocked/Add), пишутся из UI — только через
// критическую секцию, как и всё остальное состояние стора.
property TTLMinutes: Integer read GetTTLMinutes write SetTTLMinutes;
property MaxSpots: Integer read GetMaxSpots write SetMaxSpots;
end;
// Мода по комментарию кластера ('FT8 -12 dB' → dxmFT8). Пусто/непонятно —
// dxmUnknown.
function DXModeFromComment(const Comment: string): TDXMode;
// Мода по участку бэндплана — запасной путь, когда комментарий молчит (в CW и
// SSB его сплошь и рядом просто не пишут). dxmUnknown = участок спорный или
// вне таблицы; см. DX_BAND_PLAN о том, где мы намеренно не гадаем.
function DXModeFromFreq(FreqHz: Double): TDXMode;
function DXModeName(M: TDXMode): string;
implementation
const
MODE_NAMES: array[TDXMode] of string =
('', 'CW', 'SSB', 'DIGI', 'FT8', 'FT4', 'RTTY', 'PSK', 'FM', 'SSTV');
function DXModeName(M: TDXMode): string;
begin
Result := MODE_NAMES[M];
end;
function DXModeFromComment(const Comment: string): TDXMode;
// Ищем маркер моды как ОТДЕЛЬНОЕ слово: 'CW' внутри 'CWOPS' или позывного
// модой не является. Порядок проверки — от длинных/специфичных к общим.
var
U: string;
function HasWord(const W: string): Boolean;
var P, L, N: Integer; OkL, OkR: Boolean;
begin
Result := False;
L := Length(W); N := Length(U);
P := Pos(W, U);
while P > 0 do
begin
OkL := (P = 1) or not (U[P-1] in ['A'..'Z', '0'..'9', '/', '-']);
OkR := (P + L > N) or not (U[P+L] in ['A'..'Z', '0'..'9', '/', '-']);
if OkL and OkR then Exit(True);
P := PosEx(W, U, P + 1);
end;
end;
begin
Result := dxmUnknown;
if Comment = '' then Exit;
U := UpperCase(Comment);
if HasWord('FT8') then Result := dxmFT8
else if HasWord('FT4') then Result := dxmFT4
else if HasWord('RTTY') then Result := dxmRTTY
else if HasWord('PSK') or HasWord('PSK31') or HasWord('BPSK') then Result := dxmPSK
else if HasWord('SSTV') then Result := dxmSSTV
else if HasWord('CW') then Result := dxmCW
else if HasWord('SSB') or HasWord('USB') or HasWord('LSB') then Result := dxmSSB
else if HasWord('FM') then Result := dxmFM
else if HasWord('JT65') or HasWord('JT9') or HasWord('JS8') or
HasWord('MSK144') or HasWord('OLIVIA') or HasWord('DIGI') then
Result := dxmDigi;
end;
{ ── Мода по бэндплану ────────────────────────────────────────────────────── }
type
TDXBandSeg = record
LoKHz, HiKHz: Double; // [Lo; Hi)
Mode: TDXMode;
end;
const
// Участки бэндплана для запасного определения моды. Читается СВЕРХУ ВНИЗ,
// первое попадание выигрывает — поэтому узкие «водопои» цифры стоят раньше
// широких сегментов, внутрь которых они попадают (FT8 на 7074 живёт посреди
// телефонного участка R1, FT4 на 21140 — посреди 15-метрового).
//
// ★ Границы — по плану IARU Region 1 (наш регион): у R2/R3 они другие, и
// спорные куски мы НАМЕРЕННО оставляем пустыми, а не гадаем — пропуск честнее
// ошибки. Отсюда дырки: 1843-1850 (R1 SSB против R2 CW), 7053-7125 у R2 —
// данные, а не телефон, и т.п. Мода из комментария всегда важнее этой
// таблицы, сюда попадают только споты, где комментарий промолчал.
DX_BAND_PLAN: array[0..47] of TDXBandSeg = (
// ── узкие окна FT8/FT4 (перекрывают широкие сегменты ниже) ──
(LoKHz: 1840.0; HiKHz: 1843.0; Mode: dxmFT8),
(LoKHz: 3573.0; HiKHz: 3576.0; Mode: dxmFT8),
(LoKHz: 7047.0; HiKHz: 7049.0; Mode: dxmFT4),
(LoKHz: 7074.0; HiKHz: 7078.0; Mode: dxmFT8),
(LoKHz: 10136.0; HiKHz: 10139.0; Mode: dxmFT8),
(LoKHz: 10140.0; HiKHz: 10142.0; Mode: dxmFT4),
(LoKHz: 14074.0; HiKHz: 14078.0; Mode: dxmFT8),
(LoKHz: 14080.0; HiKHz: 14082.0; Mode: dxmFT4),
(LoKHz: 18100.0; HiKHz: 18102.0; Mode: dxmFT8),
(LoKHz: 18104.0; HiKHz: 18106.0; Mode: dxmFT4),
(LoKHz: 21074.0; HiKHz: 21078.0; Mode: dxmFT8),
(LoKHz: 21140.0; HiKHz: 21142.0; Mode: dxmFT4),
(LoKHz: 24915.0; HiKHz: 24917.0; Mode: dxmFT8),
(LoKHz: 24919.0; HiKHz: 24921.0; Mode: dxmFT4),
(LoKHz: 28074.0; HiKHz: 28078.0; Mode: dxmFT8),
(LoKHz: 28180.0; HiKHz: 28182.0; Mode: dxmFT4),
(LoKHz: 50313.0; HiKHz: 50323.0; Mode: dxmFT8),
(LoKHz: 144174.0; HiKHz: 144180.0; Mode: dxmFT8),
(LoKHz: 432174.0; HiKHz: 432180.0; Mode: dxmFT8),
// ── КВ, широкие сегменты ──
(LoKHz: 1800.0; HiKHz: 1838.0; Mode: dxmCW),
(LoKHz: 1838.0; HiKHz: 1843.0; Mode: dxmDigi),
(LoKHz: 1850.0; HiKHz: 2000.0; Mode: dxmSSB),
(LoKHz: 3500.0; HiKHz: 3570.0; Mode: dxmCW),
(LoKHz: 3570.0; HiKHz: 3600.0; Mode: dxmDigi),
(LoKHz: 3600.0; HiKHz: 4000.0; Mode: dxmSSB),
(LoKHz: 7000.0; HiKHz: 7040.0; Mode: dxmCW),
(LoKHz: 7040.0; HiKHz: 7053.0; Mode: dxmDigi),
(LoKHz: 7053.0; HiKHz: 7300.0; Mode: dxmSSB),
(LoKHz: 10100.0; HiKHz: 10130.0; Mode: dxmCW),
(LoKHz: 10130.0; HiKHz: 10150.0; Mode: dxmDigi),
(LoKHz: 14000.0; HiKHz: 14070.0; Mode: dxmCW),
(LoKHz: 14070.0; HiKHz: 14099.0; Mode: dxmDigi),
(LoKHz: 14101.0; HiKHz: 14350.0; Mode: dxmSSB),
(LoKHz: 18068.0; HiKHz: 18095.0; Mode: dxmCW),
(LoKHz: 18095.0; HiKHz: 18109.0; Mode: dxmDigi),
(LoKHz: 18111.0; HiKHz: 18168.0; Mode: dxmSSB),
(LoKHz: 21000.0; HiKHz: 21070.0; Mode: dxmCW),
(LoKHz: 21070.0; HiKHz: 21120.0; Mode: dxmDigi),
(LoKHz: 21151.0; HiKHz: 21450.0; Mode: dxmSSB),
(LoKHz: 24890.0; HiKHz: 24915.0; Mode: dxmCW),
(LoKHz: 24915.0; HiKHz: 24929.0; Mode: dxmDigi),
(LoKHz: 24931.0; HiKHz: 24990.0; Mode: dxmSSB),
(LoKHz: 28000.0; HiKHz: 28070.0; Mode: dxmCW),
(LoKHz: 28070.0; HiKHz: 28190.0; Mode: dxmDigi),
(LoKHz: 28300.0; HiKHz: 29100.0; Mode: dxmSSB),
(LoKHz: 29100.0; HiKHz: 29700.0; Mode: dxmFM),
// ── УКВ (минимум, где регионы согласны) ──
(LoKHz: 50000.0; HiKHz: 50100.0; Mode: dxmCW),
(LoKHz: 50100.0; HiKHz: 50500.0; Mode: dxmSSB));
function DXModeFromFreq(FreqHz: Double): TDXMode;
var
K: Double;
i: Integer;
begin
Result := dxmUnknown;
if FreqHz <= 0 then Exit;
// QO-100 (узкополосный транспондер, DOWNLINK). Отдельно от КВ-таблицы: план
// AMSAT-DL, границы те же, что рисует BandPlanOverlay. Маяки (…500-505 и
// …745-755) и «mixed modes» (…850-990) — не гадаем.
K := FreqHz / 1000.0;
if (K >= 10489500.0) and (K < 10490000.0) then
begin
if (K >= 10489505.0) and (K < 10489540.0) then Result := dxmCW
else if (K >= 10489540.0) and (K < 10489650.0) then Result := dxmDigi
else if (K >= 10489650.0) and (K < 10489745.0) then Result := dxmSSB
else if (K >= 10489755.0) and (K < 10489850.0) then Result := dxmSSB;
Exit;
end;
for i := Low(DX_BAND_PLAN) to High(DX_BAND_PLAN) do
if (K >= DX_BAND_PLAN[i].LoKHz) and (K < DX_BAND_PLAN[i].HiKHz) then
Exit(DX_BAND_PLAN[i].Mode);
end;
{ ── TDXSpotStore ─────────────────────────────────────────────────────────── }
constructor TDXSpotStore.Create;
begin
inherited Create;
FLock := TCriticalSection.Create;
FCount := 0;
FVersion := 0;
FTTLMinutes := DX_DEFAULT_TTL;
FMaxSpots := DX_DEFAULT_MAX;
SetLength(FSpots, DX_DEFAULT_MAX);
end;
destructor TDXSpotStore.Destroy;
begin
FLock.Free;
inherited Destroy;
end;
procedure TDXSpotStore.PurgeLocked;
// Выбрасывает споты старше TTL, уплотняя массив на месте.
var
i, Dst: Integer;
Cutoff: TDateTime;
begin
if FTTLMinutes <= 0 then Exit;
Cutoff := IncMinute(Now, -FTTLMinutes);
Dst := 0;
for i := 0 to FCount - 1 do
if FSpots[i].Stamp >= Cutoff then
begin
if Dst <> i then FSpots[Dst] := FSpots[i];
Inc(Dst);
end;
if Dst <> FCount then
begin
FCount := Dst;
Inc(FVersion);
end;
end;
procedure TDXSpotStore.DropOldestLocked;
var i, Oldest: Integer;
begin
if FCount <= 0 then Exit;
Oldest := 0;
for i := 1 to FCount - 1 do
if FSpots[i].Stamp < FSpots[Oldest].Stamp then Oldest := i;
for i := Oldest to FCount - 2 do FSpots[i] := FSpots[i + 1];
Dec(FCount);
end;
procedure TDXSpotStore.Add(const S: TDXSpot);
var
i: Integer;
UCall: string;
begin
if S.Call = '' then Exit;
FLock.Enter;
try
PurgeLocked;
UCall := UpperCase(S.Call);
for i := 0 to FCount - 1 do
if (UpperCase(FSpots[i].Call) = UCall) and
(Abs(FSpots[i].FreqHz - S.FreqHz) <= DX_DEDUP_HZ) then
begin
FSpots[i] := S; // тот же спот — обновляем целиком
Inc(FVersion);
Exit;
end;
if Length(FSpots) < FMaxSpots then SetLength(FSpots, FMaxSpots);
while FCount >= FMaxSpots do DropOldestLocked;
FSpots[FCount] := S;
Inc(FCount);
Inc(FVersion);
finally
FLock.Leave;
end;
end;
procedure TDXSpotStore.Purge;
begin
FLock.Enter;
try
PurgeLocked; // сам поднимет Version, если что-то выбросил
finally
FLock.Leave;
end;
end;
procedure TDXSpotStore.Clear;
begin
FLock.Enter;
try
if FCount = 0 then Exit;
FCount := 0;
Inc(FVersion);
finally
FLock.Leave;
end;
end;
procedure TDXSpotStore.RemoveCall(const Call: string);
var i, j: Integer; Up: string;
begin
Up := UpperCase(Trim(Call));
if Up = '' then Exit;
FLock.Enter;
try
i := 0;
while i < FCount do
if UpperCase(FSpots[i].Call) = Up then
begin
for j := i to FCount - 2 do FSpots[j] := FSpots[j + 1];
Dec(FCount);
Inc(FVersion);
end
else
Inc(i);
finally
FLock.Leave;
end;
end;
function CompareSpotFreq(const A, B: TDXSpot): Integer;
begin
if A.FreqHz < B.FreqHz then Result := -1
else if A.FreqHz > B.FreqHz then Result := 1
else Result := 0;
end;
procedure SortByFreq(var Arr: TDXSpotArray; N: Integer);
// Вставками: N здесь десятки-сотни и массив почти всегда уже почти
// отсортирован (споты приходят вперемешку, но окно узкое).
var i, j: Integer; T: TDXSpot;
begin
for i := 1 to N - 1 do
begin
T := Arr[i];
j := i - 1;
while (j >= 0) and (CompareSpotFreq(Arr[j], T) > 0) do
begin
Arr[j + 1] := Arr[j];
Dec(j);
end;
Arr[j + 1] := T;
end;
end;
function TDXSpotStore.Snapshot(out Arr: TDXSpotArray): Integer;
var i: Integer;
begin
FLock.Enter;
try
PurgeLocked;
SetLength(Arr, FCount);
for i := 0 to FCount - 1 do Arr[i] := FSpots[i];
Result := FCount;
finally
FLock.Leave;
end;
SortByFreq(Arr, Result);
end;
function TDXSpotStore.SnapshotRange(LoHz, HiHz: Double; Modes: TDXModeSet;
MaxAgeMin: Integer; out Arr: TDXSpotArray): Integer;
var
i, N: Integer;
Cutoff: TDateTime;
UseAge: Boolean;
begin
N := 0;
UseAge := MaxAgeMin > 0;
Cutoff := 0;
if UseAge then Cutoff := IncMinute(Now, -MaxAgeMin);
FLock.Enter;
try
PurgeLocked;
SetLength(Arr, FCount);
for i := 0 to FCount - 1 do
begin
if (FSpots[i].FreqHz < LoHz) or (FSpots[i].FreqHz > HiHz) then Continue;
if (Modes <> []) and not (FSpots[i].Mode in Modes) then Continue;
if UseAge and (FSpots[i].Stamp < Cutoff) then Continue;
Arr[N] := FSpots[i];
Inc(N);
end;
SetLength(Arr, N);
Result := N;
finally
FLock.Leave;
end;
SortByFreq(Arr, Result);
end;
function TDXSpotStore.GetTTLMinutes: Integer;
begin
FLock.Enter;
try
Result := FTTLMinutes;
finally
FLock.Leave;
end;
end;
procedure TDXSpotStore.SetTTLMinutes(V: Integer);
begin
if V < 1 then V := 1;
FLock.Enter;
try
if FTTLMinutes = V then Exit;
FTTLMinutes := V;
PurgeLocked; // укоротили TTL — лишнее выбрасываем сразу
finally
FLock.Leave;
end;
end;
function TDXSpotStore.GetMaxSpots: Integer;
begin
FLock.Enter;
try
Result := FMaxSpots;
finally
FLock.Leave;
end;
end;
procedure TDXSpotStore.SetMaxSpots(V: Integer);
begin
if V < 16 then V := 16;
FLock.Enter;
try
if FMaxSpots = V then Exit;
FMaxSpots := V;
if Length(FSpots) < FMaxSpots then SetLength(FSpots, FMaxSpots);
while FCount > FMaxSpots do
begin
DropOldestLocked;
Inc(FVersion);
end;
finally
FLock.Leave;
end;
end;
function TDXSpotStore.Version: Int64;
begin
FLock.Enter;
try
Result := FVersion;
finally
FLock.Leave;
end;
end;
function TDXSpotStore.Count: Integer;
begin
FLock.Enter;
try
Result := FCount;
finally
FLock.Leave;
end;
end;
end.