fix: make AirScout and map propagation band-aware by resolving realistic per-station QRGs to canonical AirScout bands, using one shared watchlist, honoring the operator-selected band for Calc selected and map path analysis, and replacing the obsolete 430 MHz fallback with 432 MHz (fixes #67) and parts of #74

This commit is contained in:
Marc Froehlich
2026-08-08 01:53:04 +02:00
parent b0cc5ee9ba
commit 2828e7ef80
14 changed files with 1076 additions and 204 deletions
+51
View File
@@ -177,6 +177,56 @@ Herleitung und Grenzen: [Prioritätsscore und Prioritätsliste](de-Funktionen#pr
---
## Stationskarte
Die Stationskarte kann auf zwei Wegen geöffnet werden:
- **Windows → Show / hide station map** öffnet oder schließt das Kartenfenster.
- **Show on map** im **Further Info**-Bereich öffnet die Karte und zentriert sie auf die ausgewählte Station.
Die Karte zeigt die Stationen, die auch nach Anwendung der aktuellen Benutzerlistenfilter noch sichtbar sind. Ein Hinweis in der Kopfzeile zeigt an, wenn eine gefilterte Ansicht aktiv ist.
![Stationskarte mit ausgewählter Station und eingeblendeter Streckenanalyse](station_map_path_analysis.png)
Ein einzelner Stationsmarker kann direkt angeklickt werden. KST4Contest übernimmt die Station daraufhin als aktuelle Auswahl, scrollt die Benutzerliste zum passenden Chatmember und aktualisiert den **Further Info**-Bereich.
Marker, die bei der aktuellen Zoomstufe zu dicht beieinanderliegen, werden als Cluster mit einer Stationsanzahl angezeigt. Ein Klick auf einen Cluster vergrößert den betreffenden Kartenausschnitt. Erst ein anschließend sichtbarer einzelner Marker wählt eine konkrete Station aus.
Für die ausgewählte Station erscheinen rechts unter **Selected station**:
- Rufzeichen,
- Locator,
- QRB und QTF,
- erkannte aktive Bänder,
- gegebenenfalls `B+` für eine offene Bandmöglichkeit und
- die zuletzt bekannten QRGs.
**Trigger cluster spot** sendet für die ausgewählte Station einen einzelnen Spot an die mit dem integrierten DX-Cluster-Server verbundenen Logprogramme. Die Schaltfläche setzt deshalb einen aktivierten Cluster-Server und mindestens einen verbundenen Client voraus.
Unterhalb der Karte befindet sich das Höhenprofil. Rechts werden die dazugehörigen Detailwerte angezeigt, unter anderem:
- verwendete Datenquelle und Anzahl der Höhenpunkte,
- Analysefrequenz,
- Erdkrümmungs- beziehungsweise Refraktionsmodell,
- Radio- und Geländehorizont,
- Fresnel-Freiheit,
- erkannte Hindernisse,
- Link-Budget,
- geschätzter Empfangspegel und
- eine zusammenfassende Pfadbewertung.
Mit **Hide path analysis** werden das Profil unterhalb der Karte und die ausführlichen Analysewerte rechts gemeinsam ausgeblendet. Der Kartenbereich erhält dadurch mehr Platz.
![Stationskarte mit ausgeblendeter Pfadanalyse](station_map_compact.png)
Der Hinweis **Path analysis is hidden** bleibt zusammen mit **Show path analysis** sichtbar. Die Funktion kann daher ohne Umweg wieder eingeschaltet werden. Der Zustand wird gespeichert.
Der Divider zwischen Karte und Detailbereich lässt sich horizontal verschieben. Bei schmalem Detailbereich werden längere Angaben umgebrochen; falls die Höhe nicht ausreicht, erscheint dort eine vertikale Scrollleiste.
Ausführliche Herleitung und Grenzen: [Stationskarte und Streckenanalyse](de-Funktionen#stationskarte-und-streckenanalyse-ab-v141)
---
## Globale Nachrichtentabs und Monitorfenster
Der untere Bereich des Hauptfensters enthält drei globale Nachrichtentabs. Ihr Inhalt ist nicht von der aktuell in der Benutzerliste ausgewählten Station abhängig.
@@ -226,6 +276,7 @@ Herleitung und Grenzen: [Globale Nachrichtenansichten](de-Funktionen#globale-nac
- **hide options** beziehungsweise **show options**: Blendet das Einstellungsfenster aus oder wieder ein.
- **Use dark mode design**: Aktiviert das dunkle Farbschema.
- **Use default mode design**: Aktiviert das normale helle Farbschema.
- **Show / hide station map** öffnet beziehungsweise schließt das separate Fenster mit Stationskarte und Streckenanalyse.
---
+128 -11
View File
@@ -691,21 +691,138 @@ Die Ansichten helfen dabei, Aktivität und Koordination anderer Stationen zu erk
---
## Stationskarte (ab v1.41)
## Stationskarte und Streckenanalyse (ab v1.41)
Eine interaktive OpenStreetMap-Karte zeigt die geografische Position aller aktiven Chatmember.
Eine lange Benutzerliste beantwortet zwei geografische Fragen nur unzureichend: Wo befinden sich die eingeloggten Stationen, und welche davon liegen ungefähr in der aktuellen Antennenrichtung? Die Stationskarte überträgt deshalb die bereits bekannten Locator-, Richtungs-, Band- und Worked-Informationen in eine interaktive Kartenansicht.
**Funktionen:**
Die Karte ist keine zweite, unabhängig verwaltete Stationsliste. Sie verwendet die aktuell durch die Filter der Benutzerliste sichtbaren Chatmember. Wird beispielsweise nach Entfernung, Richtung, Worked-Status oder einem bestimmten Band gefiltert, wirkt sich dies auch auf die dargestellten Stationen aus. In der Kopfzeile der Karte wird angezeigt, wie viele Stationen sichtbar sind und ob eine gefilterte Ansicht aktiv ist.
- Stationsmarker mit Rufzeichen-Labels, farblich nach Aktivität und Sked-Status
- **Antennen-Kegel** für die eigene Station
- **Verbindungslinie** zur aktuell ausgewählten Station
- **Maidenhead-Raster** (QRA-Locator-Gitter als Overlay)
- **Wegprofil-Diagramm**: Geländehöhen-Querschnitt zwischen eigener und ausgewählter Station, inklusive Fresnel-Zonen-Analyse und Horizonterkennung
- Mehrere Terrainquellen: **Copernicus GLO-30** (hochauflösendes DEM), **Open-Meteo API**, synthetischer Fallback und **Offline-DEM-Import** für den Betrieb ohne Internetverbindung
- Aircraft-Scatter-Weganalyse verknüpft mit den Geländedaten
![Stationskarte mit ausgewählter Station und eingeblendeter Streckenanalyse](station_map_path_analysis.png)
Die Karte funktioniert in gepackten Umgebungen (AppImage, Flatpak) ohne Zugriff auf externe CDNs: Die Kartenkacheln werden über einen lokalen Tile-Proxy abgerufen, die Leaflet.js-Bibliothek ist in der Anwendung eingebettet.
### Welche Stationen werden dargestellt?
Für einen Kartenmarker benötigt KST4Contest einen brauchbaren sechsstelligen Locator. Chatmember ohne einen solchen Locator können in der Benutzerliste vorhanden sein, erscheinen aber nicht auf der Karte.
Mehrere aktive Chat-Einträge desselben Basisrufzeichens werden für die Kartenansicht zusammengefasst. Das verhindert, dass beispielsweise getrennte Logins in mehreren Chat-Kategorien mehrere Marker an derselben geografischen Position erzeugen. Als sichtbares Rufzeichen und für die Detailinformationen wird die zuletzt geeignete aktive Variante verwendet.
Die Beschriftung eines Markers kann zusätzlich enthalten:
- die für die Station erkannten aktiven Bänder,
- `B+`, wenn mindestens ein eigenes aktiviertes und noch nicht gearbeitetes Band angeboten wird.
Die Bandangaben verwenden dieselbe Herleitung wie die Bandspalten, der Filter **New bands** und der Priority Score. Aktuelle QRG-Erkennungen, Bandangaben im Namensfeld, Worked-Informationen und manuelle NOT-QRV-Markierungen werden daher auch in der Kartenansicht konsistent berücksichtigt.
### Bedeutung der Markerfarben
| Darstellung | Bedeutung |
|---|---|
| Blauer Rand | Station ohne eine der nachfolgenden besonderen Markierungen |
| Gelber Rand | Das Basisrufzeichen wurde bereits auf mindestens einem Band gearbeitet |
| Grün | Für die Station besteht eine aus gerichteten Chatnachrichten hergeleitete Richtungsgelegenheit |
| Orange | Aktuell ausgewählte Station |
Treffen mehrere Zustände gleichzeitig zu, hat die für den Betrieb wichtigere Markierung Vorrang. Eine ausgewählte Station bleibt deshalb orange; eine Richtungsgelegenheit wird grün dargestellt, auch wenn das Rufzeichen bereits gearbeitet wurde.
Bei niedrigen Zoomstufen werden räumlich dicht beieinanderliegende Stationen zu einem Cluster zusammengefasst. Die Zahl im Cluster gibt die Anzahl der enthaltenen Stationen an. Ein Klick zoomt weiter hinein, wählt aber noch keine einzelne Station aus. Die aktuell ausgewählte Station und grün markierte Richtungsgelegenheiten bleiben auch bei niedriger Zoomstufe als einzelne Marker sichtbar.
### Auswahl und geografische Hilfen
Ein Klick auf einen einzelnen Stationsmarker:
1. wählt den dazugehörigen aktiven Chatmember aus,
2. scrollt die Benutzerliste zu diesem Eintrag,
3. aktualisiert den **Further Info**-Bereich und
4. bereitet das Rufzeichen wie bei einer Auswahl in der Benutzerliste als Nachrichtenziel vor.
Für die ausgewählte Station zeichnet KST4Contest eine Verbindungslinie von der eigenen Station zum Ziel. Der eingezeichnete Antennensektor verwendet:
- den aktuellen eigenen QTF,
- den konfigurierten Antennen-Öffnungswinkel und
- das konfigurierte Standard-Maximum-QRB.
Das Maidenhead-Raster passt seine Genauigkeit an die Zoomstufe und den sichtbaren Kartenausschnitt an. Es dient der räumlichen Orientierung; die Position eines Stationsmarkers wird aus dem sechsstelligen Locator abgeleitet und ist deshalb keine exakte GPS-Position.
### Strecken- und Geländeprofil
Nach Auswahl einer Station fordert KST4Contest ein Höhenprofil zwischen dem eigenen und dem fremden Locator an. Die aktive Online-Datenquelle ist die Open-Meteo Elevation API mit Geländedaten auf Basis von **Copernicus GLO-90**.
Die Online-Abfrage ist auf höchstens 100 gleichmäßig über die Strecke verteilte Höhenpunkte begrenzt. Eine 100 Kilometer lange Strecke wird damit grob im Abstand von etwa einem Kilometer abgetastet. Bei kürzeren Strecken wird der Abstand entsprechend kleiner, schmale Hindernisse können trotzdem zwischen zwei Abfragepunkten liegen.
Aus den Höhenpunkten berechnet KST4Contest unter anderem:
- das Geländeprofil,
- die geometrische Sichtlinie,
- die Erdkrümmung mit einem festen effektiven Erdradiusfaktor von `k = 4/3`,
- den geometrischen Radiohorizont beider Stationen,
- relevante Geländehorizonte,
- die erste Fresnel-Zone,
- die geringste Fresnel-Freiheit,
- den stärksten erkannten Eingriff in die Fresnel-Zone und
- eine grobe Einzelhindernis- beziehungsweise Knife-Edge-Abschätzung.
Die eingestellte eigene Antennenhöhe wird zur lokalen Geländehöhe addiert. Für die Gegenstation wird derzeit eine feste angenommene Antennenhöhe von 10 Metern über Grund verwendet.
Bewegst du die Maus über das Profil, wird der dazugehörige Abfragepunkt zusätzlich auf der Karte markiert. Dadurch lässt sich ein auffälliger Berg oder Geländeeinschnitt leichter einer geografischen Position zuordnen.
### Welche Frequenz wird für die Berechnung verwendet?
Die Frequenz beeinflusst insbesondere die Größe der Fresnel-Zone, die Freiraumdämpfung und das Link-Budget. KST4Contest versucht deshalb, für die ausgewählte Station eine passende Analysefrequenz zu bestimmen.
Vorrangig wird eine aktuell bekannte QRG auf einem eigenen aktivierten und für die Gegenstation nutzbaren Band verwendet. Fehlt eine geeignete QRG, wird aus den vorhandenen Bandinformationen ein automatisches Analyseband hergeleitet und dessen Standardfrequenz verwendet. Manuelle NOT-QRV-Markierungen werden dabei berücksichtigt.
Die tatsächlich verwendete Frequenz steht im Feld **Frequency** der Streckenanalyse. Sie sollte kontrolliert werden, wenn die automatische Zuordnung nicht zur vorgesehenen Funkverbindung passt. Eine Berechnung auf 144 MHz ist für eine geplante Verbindung auf 1296 MHz keine gleichwertige Näherung.
### Link-Budget und Tropo-Spalte
Zusätzlich zur geometrischen Bewertung erstellt KST4Contest eine vereinfachte Link-Budget-Abschätzung. Verwendet werden:
- die konfigurierte eigene Sendeleistung,
- der eigene Antennengewinn,
- die angenommene Sendeleistung der Gegenstation,
- der angenommene Antennengewinn der Gegenstation,
- eine frequenzabhängig geschätzte Speiseleitungsdämpfung,
- die Freiraumdämpfung und
- gegebenenfalls eine grobe zusätzliche Hindernisdämpfung.
Antennengewinne werden in `dBi` eingegeben. Ein in `dBd` bekannter Wert muss deshalb vor der Eingabe um `2,15 dB` erhöht werden.
Die Berechnung betrachtet beide Übertragungsrichtungen. Der daraus abgeleitete ungünstigere SSB-Wert wird als Tropo-Marge für die Station gespeichert und kann anschließend in der **Tropo**-Spalte, beim Sortieren und durch den Filter **Tropo >=0dB** verwendet werden.
Karte und Benutzerliste führen keine voneinander unabhängigen Berechnungen durch. Sie verwenden denselben Reachability-Service und denselben Ergebnisspeicher. Eine über die Karte oder mit **Calc selected** angestoßene Berechnung kann deshalb anschließend auch in der Benutzerliste erscheinen.
Es wird absichtlich keine automatische Online-Geländeabfrage für jeden sichtbaren Chatmember gestartet. Eine Berechnung erfolgt durch eine ausdrückliche Auswahl in der Karte oder über **Calc selected**. Das begrenzt API-Anfragen und verhindert, dass jede Tabellenaktualisierung eine neue Serie von Höhenabfragen auslöst.
### Pfadanalyse ausblenden
Das Geländeprofil und die ausführliche Analyse benötigen einen erheblichen Teil der Fensterhöhe. Werden sie gerade nicht gebraucht, können beide Bereiche gemeinsam mit **Hide path analysis** ausgeblendet werden. Die Karte nutzt den frei werdenden Platz unmittelbar.
![Stationskarte mit ausgeblendeter Pfadanalyse](station_map_compact.png)
Mit **Show path analysis** werden Profil und Detailwerte wieder eingeblendet. Das zuletzt berechnete Ergebnis bleibt erhalten. Die gewählte Sichtbarkeit wird in den Einstellungen gespeichert und beim nächsten Programmstart wiederhergestellt.
Der rechte Detailbereich ist über einen Divider in der Breite verstellbar. Er kann so weit verkleinert werden, dass mehr Platz für die Karte entsteht, ohne ein Rufzeichen mit üblicher Länge vollständig zu verdecken.
### Was sagt die Streckenanalyse nicht aus?
Die Auswertung ist ein geometrisches und rechnerisches Modell. Sie misst weder die tatsächliche Feldstärke noch die momentanen Ausbreitungsbedingungen.
Insbesondere kennt KST4Contest nicht:
- die wirkliche Antennenhöhe und den tatsächlichen Antennengewinn der Gegenstation,
- deren Sendeleistung und Speiseleitungsverluste,
- Gebäude, Bewuchs und andere Hindernisse, die im Höhenmodell nicht enthalten sind,
- lokale Störungen und Empfängereigenschaften,
- den aktuellen atmosphärischen K-Faktor,
- Inversionsschichten oder Ducting und
- die tatsächliche Antennenrichtung der Gegenstation.
Die unter **Mechanisms** genannten Ausbreitungswege sind eine allgemeine Einordnung anhand der Geländegeometrie. Eine Nennung von Aircraft Scatter bedeutet nicht, dass aktuelle Flugzeuge aus AirScout in die Geländeanalyse eingerechnet wurden.
Im Klartext: Ein freier Weg ist ein nützlicher positiver Hinweis. Ein geometrisch versperrter Weg bedeutet im VHF-, UHF- und Mikrowellenbereich aber nicht automatisch „unmöglich“. Ebenso garantiert ein positives Link-Budget kein QSO.
Bedienung: [Stationskarte in der Benutzeroberfläche](de-Benutzeroberflaeche#stationskarte)
Konfiguration: [Streckenanalyse und Link-Budget](de-Konfiguration#streckenanalyse-und-link-budget)
---
+59
View File
@@ -62,6 +62,65 @@ Der Wert sollte zum eigenen Stationsaufbau und zum vorgesehenen Contestbetrieb p
---
### Streckenanalyse und Link-Budget
Die Streckenanalyse der Stationskarte verwendet einige Angaben aus der Stationskonfiguration. Diese Werte beschreiben das eigene Stationssetup und soweit keine individuellen Daten der Gegenstation vorliegen ein angenommenes Setup der Gegenstation.
Folgende Einstellungen werden berücksichtigt:
- **Own antenna height AGL [m]** gibt die Höhe der eigenen Antenne über dem lokalen Gelände an. Die Angabe bezieht sich auf *Above Ground Level* und nicht auf die Höhe über dem Meeresspiegel. KST4Contest addiert diesen Wert zur Geländehöhe am eigenen Standort.
- **Own TX power [W]** gibt die verwendete Sendeleistung der eigenen Station in Watt an.
- **Own ant. gain [dBi]** gibt den Antennengewinn der eigenen Station in dBi an.
- **DX OM TX power [W]** gibt die für die Gegenstation angenommene Sendeleistung in Watt an.
- **DX OM ant. gain [dBi]** gibt den für die Gegenstation angenommenen Antennengewinn in dBi an.
Für die Antennenhöhe der Gegenstation verwendet KST4Contest derzeit einen festen Wert von 10 m über dem lokalen Gelände. Die Leistungs- und Antennendaten der Gegenstation sind globale Annahmen. Sie ersetzen keine individuell bekannten Stationsdaten, ermöglichen aber eine einheitliche Abschätzung, wenn keine genaueren Informationen vorliegen.
Die Antennengewinne müssen in dBi angegeben werden. Falls ein Wert in dBd vorliegt, kann er näherungsweise wie folgt umgerechnet werden:
`dBi = dBd + 2.15`
Auch die aktuelle Antennenrichtung, die konfigurierte Strahlbreite, das maximale QRB und die für die eigene Station aktivierten Bänder beeinflussen die Darstellung oder Auswertung auf der Stationskarte. Die aktuelle QTF und die Strahlbreite bestimmen beispielsweise den eingezeichneten Antennensektor und die Hervorhebung von Stationen innerhalb dieses Bereichs.
Für die Berücksichtigung der Erdkrümmung verwendet die Streckenanalyse einen festen Faktor von `k = 4/3` für den effektiven Erdradius. Das ist eine übliche Näherung für eine durchschnittliche troposphärische Refraktion. Tatsächliche Ausbreitungsbedingungen können davon deutlich abweichen.
Das Link-Budget berücksichtigt unter anderem:
- die Entfernung zwischen beiden Stationen,
- die verwendete Frequenz,
- die konfigurierte Sendeleistung,
- die Antennengewinne,
- geschätzte Speiseleitungsverluste,
- den Freiraumverlust sowie
- eine grobe Zusatzdämpfung durch Hindernisse im Streckenprofil.
Die daraus berechnete Empfangsleistung und SSB- beziehungsweise CW-Marge sind technische Abschätzungen. Sie sollen dabei helfen, mögliche Verbindungen einzuordnen. Sie sind keine vollständige Feldstärkeprognose und können insbesondere aktuelle Wetterbedingungen, lokale Abschattungen, Mehrwegeausbreitung oder andere nicht bekannte Stationsparameter nicht vollständig berücksichtigen.
---
### Streckenanalyse und Link-Budget
Die Stationskarte verwendet mehrere Werte aus dem Reiter **Station**, um das Geländeprofil und das Link-Budget zur ausgewählten Gegenstation zu berechnen.
| Einstellung | Verwendung |
|---|---|
| **Own antenna height AGL** | Höhe der eigenen Antenne über dem lokalen Gelände in Metern |
| **Own TX power W** | Eigene Sendeleistung in Watt |
| **Own ant. gain dBi** | Gewinn der eigenen Antenne in dBi |
| **DX OM TX power W** | Angenommene Sendeleistung der Gegenstation in Watt |
| **DX OM ant. gain dBi** | Angenommener Antennengewinn der Gegenstation in dBi |
**AGL** bedeutet „above ground level“. Trage hier nicht die Höhe über dem Meeresspiegel ein. Die Geländehöhe am eigenen Standort stammt bereits aus dem abgerufenen Höhenprofil; die konfigurierte Antennenhöhe wird zu diesem Wert addiert.
Für die Antennenhöhe der Gegenstation verwendet KST4Contest derzeit einen festen Standardwert von 10 Metern über Grund. Eine stationsbezogene Antennenhöhe wird im ON4KST-Chat nicht übertragen.
Antennengewinne müssen in `dBi` eingetragen werden. Liegt ein Wert in `dBd` vor, gilt:
```text
dBi = dBd + 2,15 dB
## Server-Einstellungen (ab v1.31)
Der Chat-Server-DNS und -Port sind in den Preferences konfigurierbar:
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

@@ -5,13 +5,19 @@ import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TimerTask;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
/**
* Sends periodical path requests and an AirScout watchlist for the currently
* active ON4KST stations.
@@ -24,8 +30,17 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
private static final String BROADCAST_ADDRESS = "255.255.255.255";
private final ChatController client;
/*
* ASWATCHLIST is sent as one common list. Remember one syntactically valid
* AirScout band value so an empty list can still be sent on a later cycle
* to remove stations which are no longer active.
*/
private String lastWatchListBandValue;
public AirScoutPeriodicalAPReflectionInquirerTask(
ChatController client
) {
@@ -50,9 +65,6 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
client.getChatPreferences().getAirScout_asClientNameString();
String serverIdentifier =
client.getChatPreferences().getAirScout_asServerNameString();
String bandValue =
client.getChatPreferences().getAirScout_asBandString();
String ownCallSign = normalizeOwnCallSign(
client.getChatPreferences().getStn_loginCallSign()
);
@@ -79,14 +91,10 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
+ "\" \"" + serverIdentifier + "\" ";
String ownStation = ownCallSign + "," + ownLocator;
StringBuilder watchListMessage = new StringBuilder(
watchListPrefix
+ bandValue
+ ","
+ ownStation
);
List<ChatMember> activeMembers = client.snapshotChatMembers();
List<String> watchListTargets = new ArrayList<>();
Set<String> processedCallsigns = new LinkedHashSet<>();
String watchListBandValue = null;
int port = client.getChatPreferences()
.getAirScout_asCommunicationPort();
@@ -102,11 +110,38 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
continue;
}
if (member.getQrb()
if (member.getQrb() == null
|| member.getQrb()
>= client.getChatPreferences().getStn_maxQRBDefault()) {
continue;
}
String callsignKey = member.getCallSignRaw();
if (callsignKey == null || callsignKey.isBlank()) {
callsignKey = member.getCallSign();
}
if (callsignKey == null
|| !processedCallsigns.add(
callsignKey.trim().toUpperCase(Locale.ROOT)
)) {
continue;
}
/*
* The resolver may deliberately return an exact QRG. AirScout must
* only see a canonical protocol band value such as 4320000.
*/
String bandValue = canonicalizeAirScoutBandValue(
client.resolveAirScoutBandValue(member)
);
if (bandValue == null) {
continue;
}
if (watchListBandValue == null) {
watchListBandValue = bandValue;
}
String targetStation =
member.getCallSign() + "," + member.getQra();
@@ -126,19 +161,47 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
pathQuery
);
watchListMessage
.append(",")
.append(targetStation);
watchListTargets.add(targetStation);
}
watchListMessage.append(" ");
/*
* AirScout keeps one watchlist per client/server pair. Do not send
* separate lists for the individual station bands because a later
* list would replace stations from an earlier one.
*
* If there are no targets in this cycle, reuse the last valid band
* token and send an empty list so AirScout can clear stale entries.
*/
if (watchListBandValue == null) {
watchListBandValue = lastWatchListBandValue;
}
if (watchListBandValue != null) {
StringBuilder watchListMessage = new StringBuilder(
watchListPrefix
+ watchListBandValue
+ ","
+ ownStation
);
for (String targetStation : watchListTargets) {
watchListMessage
.append(",")
.append(targetStation);
}
watchListMessage.append(" ");
sendPacket(
socket,
broadcastAddress,
port,
watchListMessage.toString()
);
lastWatchListBandValue = watchListBandValue;
}
sendPacket(
socket,
broadcastAddress,
port,
watchListMessage.toString()
);
} catch (IOException exception) {
LOGGER.log(
Level.WARNING,
@@ -148,6 +211,60 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
}
}
/**
* Converts a frequency-like value returned by the station resolver into the
* canonical band token expected by the AirScout UDP protocol.
*
* <p>The internal resolver may keep an exact working frequency for path
* analysis. This method removes that precision only at the AirScout protocol
* boundary. For example, {@code 4321740} is sent to AirScout as
* {@code 4320000}.</p>
*
* @param resolvedValue frequency-like AirScout value produced by the resolver
* @return canonical AirScout band value, or {@code null} if unsupported
*/
private String canonicalizeAirScoutBandValue(String resolvedValue) {
if (resolvedValue == null || resolvedValue.isBlank()) {
return null;
}
String normalizedValue = resolvedValue.trim();
if ("off".equalsIgnoreCase(normalizedValue)
|| "auto".equalsIgnoreCase(normalizedValue)) {
return null;
}
final long numericValue;
try {
numericValue = Long.parseLong(normalizedValue);
} catch (NumberFormatException exception) {
LOGGER.log(
Level.WARNING,
"Unsupported AirScout band value: " + resolvedValue,
exception
);
return null;
}
double frequencyMHz = numericValue / 10_000.0;
Band band = Band.fromFrequency(frequencyMHz);
if (band == null) {
LOGGER.warning(
"AirScout query skipped because frequency "
+ frequencyMHz
+ " MHz does not belong to a supported band."
);
return null;
}
return band.getPrefix() + "0000";
}
/**
* Removes the ON4KST login suffix because AirScout expects the actual
* station callsign, for example 9A1W instead of 9A1W-2.
@@ -450,8 +450,15 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
chatPreferences.getAirScout_asClientNameString();
String serverIdentifier =
chatPreferences.getAirScout_asServerNameString();
String bandValue =
chatPreferences.getAirScout_asBandString();
String bandValue = resolveAirScoutBandValue(remoteChatMember);
if (bandValue == null) {
System.out.println(
"[AirScout, info]: Show-path request ignored because no "
+ "usable propagation frequency could be resolved."
);
return;
}
int port =
chatPreferences.getAirScout_asCommunicationPort();
@@ -498,6 +505,30 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
}
}
/**
* Resolves the AirScout protocol frequency for one station. In automatic mode
* this uses the same station-specific resolution as the internal path analysis;
* manual mode keeps the configured forced value for compatibility.
*
* @param remoteChatMember target station
* @return AirScout frequency in 100-Hz units, or {@code null} if auto mode has
* no safe result
*/
public String resolveAirScoutBandValue(ChatMember remoteChatMember) {
if (!chatPreferences.isAirScout_autoBandSelectionEnabled()) {
return chatPreferences.getAirScout_asBandString();
}
if (reachabilityService == null) {
return null;
}
var resolution = reachabilityService
.resolveAutomaticPropagationFrequency(remoteChatMember);
return resolution == null ? null : resolution.getAirScoutBandValue();
}
/**
* starts the calculation scheduler for scores / priorities of skeds to be made
*/
@@ -1,6 +1,7 @@
package kst4contest.controller;
import kst4contest.logic.BandOpportunityResolver;
import kst4contest.view.map.MapCallsignRawSnapshot;
import kst4contest.logic.PropagationFrequencyResolver;
import java.util.ArrayList;
import java.util.Collections;
@@ -12,9 +13,7 @@ import java.util.function.Consumer;
import javafx.application.Platform;
import kst4contest.locatorUtils.Location;
import kst4contest.model.Band;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import kst4contest.view.map.GeometryOnlyPathAnalysisService;
import kst4contest.view.map.OpenMeteoTerrainProfileProvider;
@@ -22,7 +21,6 @@ import kst4contest.view.map.PathAnalysisRequest;
import kst4contest.view.map.PathAnalysisResult;
import kst4contest.view.map.PathAnalysisService;
import kst4contest.view.map.PathGeometryUtils;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;
@@ -139,9 +137,11 @@ public final class ReachabilityService {
* @param member best matching ChatMember, may be null when only a map snapshot exists
* @param selectedSnapshot selected map snapshot
* @param fxCallback callback executed on the JavaFX thread
* @param requestedBandOverride operator-selected band, or null for automatic resolution
*/
public void requestPathAnalysisForMap(ChatMember member,
MapCallsignRawSnapshot selectedSnapshot,
Band requestedBandOverride,
Consumer<PathAnalysisResult> fxCallback) {
String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
@@ -165,22 +165,23 @@ public final class ReachabilityService {
return;
}
double analysisFrequencyMHz = PathGeometryUtils.resolveAnalysisFrequencyMHz(
selectedSnapshot.lastKnownFrequenciesByBand()
);
Band analysisBand;
double analysisFrequencyMHz;
Band analysisBand = Band.fromFrequency(analysisFrequencyMHz);
if (analysisBand != null && !isUsableAutomaticBand(member, analysisBand)) {
analysisFrequencyMHz = Double.NaN;
analysisBand = null;
}
if (requestedBandOverride != null) {
/*
* An explicit operator selection has priority over automatic propagation
* resolution. Exact recent QRG information on that band is still used
* when available; otherwise the band's default analysis frequency is used.
*/
analysisBand = requestedBandOverride;
analysisFrequencyMHz =
resolveAnalysisFrequencyForBand(member, analysisBand);
} else {
PropagationFrequencyResolver.Resolution frequencyResolution =
resolveAutomaticPropagationFrequency(member);
if (!Double.isFinite(analysisFrequencyMHz)
|| analysisFrequencyMHz <= 0.0
|| analysisBand == null) {
Band fallbackBand = resolveAutoBand(member);
if (fallbackBand == null) {
if (frequencyResolution == null) {
dispatchFxCallback(
fxCallback,
PathAnalysisResult.waitingForUsableBand(
@@ -192,8 +193,9 @@ public final class ReachabilityService {
return;
}
analysisBand = fallbackBand;
analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, fallbackBand);
analysisBand = frequencyResolution.getBand();
analysisFrequencyMHz =
frequencyResolution.getAnalysisFrequencyMHz();
}
PathAnalysisRequest request = buildRequest(
@@ -236,72 +238,32 @@ public final class ReachabilityService {
}
/**
* Resolves the auto reachability band.
*
* <ol>
* <li>Use the lowest band detected in this session.</li>
* <li>If no session band exists and the station is in the microwave category, use 1296 MHz.</li>
* <li>Otherwise use 144 MHz.</li>
* </ol>
* Resolves the auto reachability band through the shared propagation
* frequency selection used by AirScout and path analysis.
*
* @param member member to inspect
* @return resolved band
*/
public Band resolveAutoBand(ChatMember member) {
EnumSet<Band> enabledBands = getEnabledStationBands();
if (enabledBands.isEmpty()) {
return null;
}
PropagationFrequencyResolver.Resolution resolution =
resolveAutomaticPropagationFrequency(member);
return resolution == null ? null : resolution.getBand();
}
List<ChatMember> variants = resolveCallsignVariants(member);
BandOpportunityResolver.Resolution resolution =
BandOpportunityResolver.resolve(variants, System.currentTimeMillis());
EnumSet<Band> availableOfferedBands = resolution.getAvailableBands();
availableOfferedBands.retainAll(enabledBands);
if (!availableOfferedBands.isEmpty()) {
return availableOfferedBands.stream()
.min(Comparator.comparingDouble(Band::getDefaultAnalysisFrequencyMHz))
.orElse(null);
}
// Known evidence exists, but every matching band is disabled or NOT QRV.
if (resolution.hasBandEvidence()) {
return null;
}
EnumSet<Band> fallbackBands = EnumSet.copyOf(enabledBands);
fallbackBands.removeAll(resolution.getNotQrvBands());
if (fallbackBands.isEmpty()) {
return null;
}
if (member != null
&& member.getChatCategory() != null
&& member.getChatCategory().getCategoryNumber() == ChatCategory.MICROWAVE
&& fallbackBands.contains(Band.B_1296)) {
return Band.B_1296;
}
if (member != null
&& member.getChatCategory() != null
&& member.getChatCategory().getCategoryNumber() == ChatCategory.FIFTYSEVENTYMHz) {
if (fallbackBands.contains(Band.B_50)) {
return Band.B_50;
}
if (fallbackBands.contains(Band.B_70)) {
return Band.B_70;
}
}
if (fallbackBands.contains(Band.B_144)) {
return Band.B_144;
}
return fallbackBands.stream()
.min(Comparator.comparingDouble(Band::getDefaultAnalysisFrequencyMHz))
.orElse(null);
/**
* Resolves one automatic band and exact analysis frequency for a station.
*
* @param member any active category variant of the target station
* @return shared propagation resolution, or {@code null} for unsupported data
*/
public PropagationFrequencyResolver.Resolution resolveAutomaticPropagationFrequency(
ChatMember member
) {
return PropagationFrequencyResolver.resolve(
resolveCallsignVariants(member),
getEnabledStationBands(),
System.currentTimeMillis()
);
}
/**
@@ -329,27 +291,7 @@ public final class ReachabilityService {
return variants.isEmpty() ? List.of(member) : variants;
}
/**
* Verifies that an automatically selected map/snapshot frequency belongs to a
* locally enabled band that is still available after NOT-QRV resolution.
* Manual UI band overrides are handled separately and are not changed here.
*/
private boolean isUsableAutomaticBand(ChatMember member, Band band) {
if (band == null || !getEnabledStationBands().contains(band)) {
return false;
}
if (member == null) {
return true;
}
BandOpportunityResolver.Resolution resolution = BandOpportunityResolver.resolve(
resolveCallsignVariants(member),
System.currentTimeMillis()
);
return resolution.getAvailableBands().contains(band);
}
/**
* Stops the background executor.
@@ -541,28 +483,7 @@ public final class ReachabilityService {
}
}
/**
* Resolves the analysis frequency from a map snapshot first, because the map
* aggregates all visible ChatMember variants and often knows the best current
* frequency per band.
*
* @param member fallback member
* @param selectedSnapshot selected map snapshot
* @return analysis frequency in MHz
*/
private double resolveAnalysisFrequencyForSnapshot(ChatMember member, MapCallsignRawSnapshot selectedSnapshot) {
if (selectedSnapshot != null) {
double snapshotFrequencyMHz =
PathGeometryUtils.resolveAnalysisFrequencyMHz(selectedSnapshot.lastKnownFrequenciesByBand());
if (Double.isFinite(snapshotFrequencyMHz) && snapshotFrequencyMHz > 0.0) {
return snapshotFrequencyMHz;
}
}
Band fallbackBand = member == null ? Band.B_144 : resolveAutoBand(member);
return resolveAnalysisFrequencyForBand(member, fallbackBand);
}
/**
* Resolves the analysis frequency for one member/band pair.
@@ -575,15 +496,28 @@ public final class ReachabilityService {
* </ol>
*/
private double resolveAnalysisFrequencyForBand(ChatMember member, Band band) {
if (member != null && member.getKnownActiveBands() != null) {
ChatMember.ActiveFrequencyInfo activeFrequencyInfo = member.getKnownActiveBands().get(band);
if (band == null) {
return Double.NaN;
}
ChatMember.ActiveFrequencyInfo latestFrequencyInfo = null;
for (ChatMember variant : resolveCallsignVariants(member)) {
ChatMember.ActiveFrequencyInfo activeFrequencyInfo =
variant.getKnownActiveBands().get(band);
if (activeFrequencyInfo != null
&& Double.isFinite(activeFrequencyInfo.frequency)
&& activeFrequencyInfo.frequency > 0.0) {
return activeFrequencyInfo.frequency;
&& activeFrequencyInfo.frequency > 0.0
&& (latestFrequencyInfo == null
|| activeFrequencyInfo.timestampEpoch > latestFrequencyInfo.timestampEpoch)) {
latestFrequencyInfo = activeFrequencyInfo;
}
}
if (latestFrequencyInfo != null) {
return latestFrequencyInfo.frequency;
}
if (member != null && member.getFrequency() != null && member.getFrequency().getValue() != null) {
double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(member.getFrequency().getValue());
if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) {
@@ -0,0 +1,299 @@
package kst4contest.logic;
import kst4contest.model.Band;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import java.util.Collection;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
/**
* Selects one realistic propagation frequency for a station.
*
* <p>The same resolution is used by AirScout and by the internal path analysis.
* Only the chat categories supported by these features participate. This keeps
* unrelated KST categories from silently falling back to 144 MHz.</p>
*/
public final class PropagationFrequencyResolver {
// private static final double DUAL_VUHF_MICROWAVE_FALLBACK_MHZ = 430.0;
private PropagationFrequencyResolver() {
}
/** Explains why a frequency was selected. */
public enum Source {
CURRENT_QRG,
STATION_NAME,
DUAL_CATEGORY_FALLBACK,
CHAT_CATEGORY
}
/**
* Resolves the frequency from all active category variants of one base
* callsign.
*
* <ol>
* <li>Most recently detected QRG</li>
* <li>Lowest band explicitly named by the station</li>
* <li>432 MHz if the station is present in VUHF and Microwave</li>
* <li>Lowest usable fallback band of the supported chat category</li>
* </ol>
*
* <p>Locally disabled and manually excluded bands are never selected.</p>
*
* @param variants active category variants of one callsign
* @param enabledBands bands enabled for the local station
* @param nowEpochMs current time used for the QRG age check
* @return one resolution, or {@code null} if no safe choice exists
*/
public static Resolution resolve(Collection<ChatMember> variants,
EnumSet<Band> enabledBands,
long nowEpochMs) {
if (variants == null || variants.isEmpty()
|| enabledBands == null || enabledBands.isEmpty()) {
return null;
}
List<ChatMember> supportedVariants = variants.stream()
.filter(PropagationFrequencyResolver::isSupportedVariant)
.toList();
if (supportedVariants.isEmpty()) {
return null;
}
BandOpportunityResolver.Resolution opportunityResolution =
BandOpportunityResolver.resolve(supportedVariants, nowEpochMs);
EnumSet<Band> usableBands = EnumSet.copyOf(enabledBands);
usableBands.removeAll(opportunityResolution.getNotQrvBands());
if (usableBands.isEmpty()) {
return null;
}
FrequencyCandidate latestQrg = findLatestQrg(
supportedVariants,
usableBands,
nowEpochMs
);
if (latestQrg != null) {
return new Resolution(
latestQrg.band,
latestQrg.frequencyMHz,
Source.CURRENT_QRG
);
}
EnumSet<Band> nameBands = EnumSet.noneOf(Band.class);
for (ChatMember variant : supportedVariants) {
nameBands.addAll(
BandOpportunityResolver.detectBandsFromStationName(variant.getName())
);
}
nameBands.retainAll(usableBands);
Band nameBand = lowestBand(nameBands);
if (nameBand != null) {
return new Resolution(
nameBand,
nameBand.getDefaultAnalysisFrequencyMHz(),
Source.STATION_NAME
);
}
EnumSet<SupportedCategory> categories = collectSupportedCategories(supportedVariants);
if (categories.contains(SupportedCategory.VUHF)
&& categories.contains(SupportedCategory.MICROWAVE)
&& usableBands.contains(Band.B_432)) {
return new Resolution(
Band.B_432,
Band.B_432.getDefaultAnalysisFrequencyMHz(),
Source.DUAL_CATEGORY_FALLBACK
);
}
EnumSet<Band> categoryBands = EnumSet.noneOf(Band.class);
for (SupportedCategory category : categories) {
categoryBands.addAll(category.fallbackBands);
}
categoryBands.retainAll(usableBands);
Band categoryBand = lowestBand(categoryBands);
if (categoryBand == null) {
return null;
}
return new Resolution(
categoryBand,
categoryBand.getDefaultAnalysisFrequencyMHz(),
Source.CHAT_CATEGORY
);
}
private static FrequencyCandidate findLatestQrg(List<ChatMember> variants,
EnumSet<Band> usableBands,
long nowEpochMs) {
FrequencyCandidate latest = null;
for (ChatMember variant : variants) {
for (var entry : variant.getKnownActiveBands().entrySet()) {
Band band = entry.getKey();
ChatMember.ActiveFrequencyInfo info = entry.getValue();
if (band == null || info == null || !usableBands.contains(band)) {
continue;
}
long ageMs = nowEpochMs - info.timestampEpoch;
if (ageMs < 0L
|| ageMs > BandOpportunityResolver.RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS
|| !Double.isFinite(info.frequency)
|| !band.isPlausible(info.frequency)) {
continue;
}
if (latest == null || info.timestampEpoch > latest.timestampEpochMs) {
latest = new FrequencyCandidate(
band,
info.frequency,
info.timestampEpoch
);
}
}
}
return latest;
}
private static boolean isSupportedVariant(ChatMember member) {
if (member == null || member.getChatCategory() == null) {
return false;
}
int categoryNumber = member.getChatCategory().getCategoryNumber();
return categoryNumber == ChatCategory.FIFTYSEVENTYMHz
|| categoryNumber == ChatCategory.VUHF
|| categoryNumber == ChatCategory.MICROWAVE
|| categoryNumber == ChatCategory.EMEJT65;
}
private static EnumSet<SupportedCategory> collectSupportedCategories(
List<ChatMember> variants
) {
EnumSet<SupportedCategory> categories = EnumSet.noneOf(SupportedCategory.class);
for (ChatMember variant : variants) {
int categoryNumber = variant.getChatCategory().getCategoryNumber();
if (categoryNumber == ChatCategory.FIFTYSEVENTYMHz) {
categories.add(SupportedCategory.FIFTY_SEVENTY);
} else if (categoryNumber == ChatCategory.VUHF) {
categories.add(SupportedCategory.VUHF);
} else if (categoryNumber == ChatCategory.MICROWAVE) {
categories.add(SupportedCategory.MICROWAVE);
} else if (categoryNumber == ChatCategory.EMEJT65) {
categories.add(SupportedCategory.EME);
}
}
return categories;
}
private static Band lowestBand(Collection<Band> bands) {
if (bands == null || bands.isEmpty()) {
return null;
}
return bands.stream()
.min(Comparator.comparingDouble(Band::getDefaultAnalysisFrequencyMHz))
.orElse(null);
}
private enum SupportedCategory {
FIFTY_SEVENTY(EnumSet.of(Band.B_50, Band.B_70)),
VUHF(EnumSet.of(Band.B_144, Band.B_432)),
MICROWAVE(EnumSet.of(
Band.B_1296,
Band.B_2320,
Band.B_3400,
Band.B_5760,
Band.B_10G,
Band.B_24G
)),
EME(EnumSet.of(
Band.B_144,
Band.B_432,
Band.B_1296,
Band.B_2320,
Band.B_3400,
Band.B_5760,
Band.B_10G,
Band.B_24G
));
private final EnumSet<Band> fallbackBands;
SupportedCategory(EnumSet<Band> fallbackBands) {
this.fallbackBands = fallbackBands;
}
}
private static final class FrequencyCandidate {
private final Band band;
private final double frequencyMHz;
private final long timestampEpochMs;
private FrequencyCandidate(Band band,
double frequencyMHz,
long timestampEpochMs) {
this.band = band;
this.frequencyMHz = frequencyMHz;
this.timestampEpochMs = timestampEpochMs;
}
}
/** Immutable selected band/frequency pair. */
public static final class Resolution {
private final Band band;
private final double analysisFrequencyMHz;
private final Source source;
private Resolution(Band band,
double analysisFrequencyMHz,
Source source) {
this.band = band;
this.analysisFrequencyMHz = analysisFrequencyMHz;
this.source = source;
}
public Band getBand() {
return band;
}
public double getAnalysisFrequencyMHz() {
return analysisFrequencyMHz;
}
public Source getSource() {
return source;
}
/**
* Converts MHz to AirScout's 100-Hz protocol unit.
*
* @return integer protocol value, for example 1442100 for 144.210 MHz
*/
public String getAirScoutBandValue() {
return Long.toString(Math.round(analysisFrequencyMHz * 10_000.0));
}
}
}
@@ -245,6 +245,7 @@ public class ChatPreferences {
boolean AirScout_asUDPListenerEnabled = true;
String AirScout_asServerNameString = "AS";
String AirScout_asClientNameString = "KST";
boolean AirScout_autoBandSelectionEnabled = true;
String AirScout_asBandString = "1440000";
int AirScout_asCommunicationPort = 9872;
@@ -1004,6 +1005,16 @@ public class ChatPreferences {
return AirScout_asBandString;
}
public boolean isAirScout_autoBandSelectionEnabled() {
return AirScout_autoBandSelectionEnabled;
}
public void setAirScout_autoBandSelectionEnabled(
boolean airScoutAutoBandSelectionEnabled
) {
AirScout_autoBandSelectionEnabled = airScoutAutoBandSelectionEnabled;
}
public void setAirScout_asBandString(String airScout_asBandString) {
if (airScout_asBandString == null) {
AirScout_asBandString = "1440000";
@@ -1695,6 +1706,13 @@ public class ChatPreferences {
asQry_airScoutUDPPort.setTextContent(this.getAirScout_asCommunicationPort()+"");
AirScoutQuerier.appendChild(asQry_airScoutUDPPort);
Element asQry_airScoutAutoBandSelectionEnabled =
doc.createElement("asQry_airScoutAutoBandSelectionEnabled");
asQry_airScoutAutoBandSelectionEnabled.setTextContent(
Boolean.toString(this.isAirScout_autoBandSelectionEnabled())
);
AirScoutQuerier.appendChild(asQry_airScoutAutoBandSelectionEnabled);
Element asQry_airScoutBandValue = doc.createElement("asQry_airScoutBandValue");
asQry_airScoutBandValue.setTextContent(this.getAirScout_asBandString());
AirScoutQuerier.appendChild(asQry_airScoutBandValue);
@@ -2508,6 +2526,14 @@ public class ChatPreferences {
)
);
setAirScout_autoBandSelectionEnabled(
getBoolean(
airScoutEl,
AirScout_autoBandSelectionEnabled,
"asQry_airScoutAutoBandSelectionEnabled"
)
);
setAirScout_asBandString(
getText(
airScoutEl,
@@ -2525,6 +2551,8 @@ public class ChatPreferences {
+ AirScout_asClientNameString
+ ", port="
+ AirScout_asCommunicationPort
+ ", automatic band selection="
+ AirScout_autoBandSelectionEnabled
+ ", band="
+ AirScout_asBandString
);
@@ -178,7 +178,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
chatcontroller,
tbl_chatMember,
stationMapView,
this::focusChatMemberAndPrepareCq
this::focusChatMemberAndPrepareCq,
() -> selectedReachabilityBandOverride
);
stationMapBridge.install();
}
@@ -7958,6 +7959,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
Band selectedBand = resolveReachabilityBandForUi(selectedMember);
chatcontroller.getReachabilityService().calculateSelectedStationOnDemand(selectedMember, selectedBand);
/*
* If the map is already initialized, make it request the same operator-selected
* band. ReachabilityService deduplicates the identical calculation key, so this
* attaches the map callback without causing a second terrain API request.
*/
if (stationMapBridge != null) {
stationMapBridge.requestSelectedPathAnalysisRefresh();
}
}
});
@@ -7970,7 +7980,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
cmbReachabilityBand.getItems().add(band.getDisplayLabel());
}
cmbReachabilityBand.getSelectionModel().select("Auto");
cmbReachabilityBand.setTooltip(new Tooltip("Reachability band for Tropo column/filter. Auto uses the station's lowest session band."));
cmbReachabilityBand.setTooltip(new Tooltip(
"Reachability band for Tropo column/filter. Auto uses the current "
+ "QRG, station-name hints and the supported chat category."
));
cmbReachabilityBand.setOnAction(new EventHandler<ActionEvent>() {
@Override
public void handle(ActionEvent event) {
@@ -9038,6 +9051,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
TextField txtFldstn_pathAnalysisDemRootDirectory =
new TextField(this.chatcontroller.getChatPreferences().getStn_pathAnalysisDemRootDirectory());
txtFldstn_pathAnalysisDemRootDirectory.setDisable(true);
txtFldstn_pathAnalysisDemRootDirectory.setFocusTraversable(false);
txtFldstn_pathAnalysisDemRootDirectory.setTooltip(new Tooltip(
"Root directory that contains locally extracted Copernicus GLO-30 DEM tiles.\n" +
@@ -9058,6 +9072,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
OfflineDemImportService offlineDemImportService = new OfflineDemImportService();
Button btnUseDefaultDemDirectory = new Button("Default");
btnUseDefaultDemDirectory.setDisable(true);
btnUseDefaultDemDirectory.setFocusTraversable(false);
btnUseDefaultDemDirectory.setTooltip(new Tooltip(
"Creates and uses the default local Copernicus DEM directory below .praktiKST.\n" +
@@ -9087,6 +9102,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
});
Button btnImportDemTiles = new Button("Import tiles...");
btnImportDemTiles.setDisable(true);
btnImportDemTiles.setFocusTraversable(false);
btnImportDemTiles.setTooltip(new Tooltip(
"Copies manually selected Copernicus *_DEM.tif files into the configured DEM root directory.\n" +
@@ -9938,8 +9954,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
Label lblASUdpPort =
new Label("AirScout UDP port [9872] — reconnect after changing:");
Label lblASAutoBand =
new Label("Select AirScout frequency automatically per station:");
Label lblASBandName =
new Label("AirScout band value [1440000 = 144 MHz]:");
new Label("Forced AirScout band value [1440000 = 144 MHz]:");
CheckBox chkBxEnableUDPMsgbyAS = new CheckBox();
chkBxEnableUDPMsgbyAS.setSelected(
@@ -10092,13 +10111,38 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
chatcontroller.getChatPreferences()
.getAirScout_asBandString()
);
CheckBox chkBxAutoAirScoutBand = new CheckBox("Auto per station");
chkBxAutoAirScoutBand.setSelected(
chatcontroller.getChatPreferences()
.isAirScout_autoBandSelectionEnabled()
);
chkBxAutoAirScoutBand.setTooltip(
new Tooltip(
"Uses the station's current QRG first, then station-name and "
+ "chat-category evidence. Disable this option only to force "
+ "one protocol value for every station."
)
);
txtFld_asQRGInt.setDisable(chkBxAutoAirScoutBand.isSelected());
chkBxAutoAirScoutBand.selectedProperty().addListener(
(observable, oldValue, newValue) -> {
chatcontroller.getChatPreferences()
.setAirScout_autoBandSelectionEnabled(newValue);
txtFld_asQRGInt.setDisable(newValue);
}
);
txtFld_asQRGInt.setFocusTraversable(false);
txtFld_asQRGInt.setTooltip(
new Tooltip(
"AirScout protocol band value, for example 1440000 for "
+ "144 MHz or 4320000 for 432 MHz."
"Fallback used only when automatic per-station selection is "
+ "disabled. Examples: 1440000 for 144 MHz or 4320000 "
+ "for 432 MHz."
)
);
txtFld_asQRGInt.focusedProperty().addListener(
(observable, oldValue, newValue) -> {
if (newValue) {
@@ -10133,7 +10177,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
);
Label lblASChangeNote = new Label(
"Server identifier, client identifier and band are applied "
"Server identifier, client identifier and frequency mode are applied "
+ "immediately. Reconnect after changing the UDP port."
);
lblASChangeNote.setWrapText(true);
@@ -10156,9 +10200,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
grdPnlAirScout.add(txtFld_asClientNameString, 1, 3);
grdPnlAirScout.add(lblASUdpPort, 0, 4);
grdPnlAirScout.add(txtFld_asUDPPortInt, 1, 4);
grdPnlAirScout.add(lblASBandName, 0, 5);
grdPnlAirScout.add(txtFld_asQRGInt, 1, 5);
grdPnlAirScout.add(lblASChangeNote, 0, 6, 2, 1);
grdPnlAirScout.add(lblASAutoBand, 0, 5);
grdPnlAirScout.add(chkBxAutoAirScoutBand, 1, 5);
grdPnlAirScout.add(lblASBandName, 0, 6);
grdPnlAirScout.add(txtFld_asQRGInt, 1, 6);
grdPnlAirScout.add(lblASChangeNote, 0, 7, 2, 1);
VBox vbxAirScout = new VBox();
vbxAirScout.setPadding(new Insets(10, 10, 10, 10));
@@ -1,6 +1,5 @@
package kst4contest.view.map;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -496,37 +495,7 @@ public final class PathGeometryUtils {
}
}
/**
* Resolves one usable analysis frequency from aggregated marker frequency data.
*
* <p>Strategy:
* <ol>
* <li>Prefer 144 MHz data if available</li>
* <li>Otherwise use the first parsable known station frequency</li>
* <li>Otherwise use the central default frequency</li>
* </ol>
*
* @param frequenciesByBand known frequencies grouped by band
* @return resolved analysis frequency in MHz
*/
public static double resolveAnalysisFrequencyMHz(Map<String, String> frequenciesByBand) {
if (frequenciesByBand != null && !frequenciesByBand.isEmpty()) {
String band144Text = frequenciesByBand.get("144");
double parsed144 = tryParseFrequencyMHz(band144Text);
if (Double.isFinite(parsed144) && parsed144 > 0.0) {
return parsed144;
}
for (String value : frequenciesByBand.values()) {
double parsedFrequencyMHz = tryParseFrequencyMHz(value);
if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) {
return parsedFrequencyMHz;
}
}
}
return DEFAULT_ANALYSIS_FREQUENCY_MHZ;
}
/**
* Small immutable geographic point used by great-circle interpolation.
@@ -18,6 +18,7 @@ import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.function.Supplier;
import kst4contest.model.Band;
import java.util.function.Predicate;
@@ -40,6 +41,7 @@ public final class StationMapBridge {
private final TableView<ChatMember> chatMemberTable;
private final StationMapView stationMapView;
private final Consumer<ChatMember> focusChatMemberConsumer;
private final Supplier<Band> reachabilityBandOverrideSupplier;
@@ -55,16 +57,22 @@ public final class StationMapBridge {
public StationMapBridge(ChatController chatController,
TableView<ChatMember> chatMemberTable,
StationMapView stationMapView,
Consumer<ChatMember> focusChatMemberConsumer) {
Consumer<ChatMember> focusChatMemberConsumer,
Supplier<Band> reachabilityBandOverrideSupplier) {
this.chatController = Objects.requireNonNull(chatController, "chatController");
this.chatMemberTable = Objects.requireNonNull(chatMemberTable, "chatMemberTable");
this.stationMapView = Objects.requireNonNull(stationMapView, "stationMapView");
this.focusChatMemberConsumer = Objects.requireNonNull(focusChatMemberConsumer, "focusChatMemberConsumer");
this.focusChatMemberConsumer = Objects.requireNonNull(
focusChatMemberConsumer,
"focusChatMemberConsumer"
);
this.reachabilityBandOverrideSupplier = Objects.requireNonNull(
reachabilityBandOverrideSupplier,
"reachabilityBandOverrideSupplier"
);
this.refreshCoalescer.setOnFinished(event -> refreshNow());
}
public void install() {
@@ -115,6 +123,18 @@ public final class StationMapBridge {
}
}
/**
* Forces the currently selected map path to be requested again.
*
* <p>This is used by the explicit "Calc selected" action after the operator
* changed the reachability band. Merely changing the ComboBox still does not
* trigger terrain analysis.</p>
*/
public void requestSelectedPathAnalysisRefresh() {
lastPathAnalysisRequestSignature = "";
requestImmediateRefresh();
}
public void focusSelectedCallsign() {
showWindow();
@@ -222,9 +242,12 @@ public final class StationMapBridge {
ChatMember selectedMember = resolveBestChatMember(targetCallsignRaw);
Band requestedBandOverride = reachabilityBandOverrideSupplier.get();
chatController.getReachabilityService().requestPathAnalysisForMap(
selectedMember,
selectedSnapshot,
requestedBandOverride,
result -> {
if (generation != pathAnalysisGeneration.get()) {
return;
@@ -232,6 +255,7 @@ public final class StationMapBridge {
stationMapView.setPathAnalysisResult(result);
}
);
}
/**
@@ -259,6 +283,13 @@ public final class StationMapBridge {
chatMemberTable.scrollTo(resolved);
focusChatMemberConsumer.accept(resolved);
/*
* A map click is an explicit operator action. Clear the signature so a
* newly selected reachability band is honored even when the same station
* is clicked again.
*/
lastPathAnalysisRequestSignature = "";
requestImmediateRefresh();
});
}
@@ -315,10 +346,16 @@ public final class StationMapBridge {
private double resolveAnalysisFrequencyMHz(MapCallsignRawSnapshot selectedSnapshot) {
if (selectedSnapshot == null) {
return PathGeometryUtils.DEFAULT_ANALYSIS_FREQUENCY_MHZ;
return Double.NaN;
}
return PathGeometryUtils.resolveAnalysisFrequencyMHz(selectedSnapshot.lastKnownFrequenciesByBand());
ChatMember selectedMember = resolveBestChatMember(selectedSnapshot.callSignRaw());
var resolution = chatController.getReachabilityService()
.resolveAutomaticPropagationFrequency(selectedMember);
return resolution == null
? Double.NaN
: resolution.getAnalysisFrequencyMHz();
}
@@ -0,0 +1,184 @@
package kst4contest.test;
import kst4contest.logic.PropagationFrequencyResolver;
import kst4contest.model.Band;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test;
import java.util.EnumSet;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class PropagationFrequencyResolverTest {
private static final long NOW = 10_000_000L;
@Test
void currentQrgWinsOverNameAndCategoryFallback() {
ChatMember station = station(ChatCategory.MICROWAVE, "QRV 3cm");
addCurrentQrg(station, Band.B_2320, 2320.175, NOW - 1_000L);
PropagationFrequencyResolver.Resolution resolution = resolve(
List.of(station),
EnumSet.of(Band.B_1296, Band.B_2320, Band.B_10G)
);
assertEquals(Band.B_2320, resolution.getBand());
assertEquals(2320.175, resolution.getAnalysisFrequencyMHz(), 0.000_001);
assertEquals(PropagationFrequencyResolver.Source.CURRENT_QRG, resolution.getSource());
assertEquals("23201750", resolution.getAirScoutBandValue());
}
@Test
void mostRecentlyDetectedQrgWinsAcrossCategoryVariants() {
ChatMember vhf = station(ChatCategory.VUHF, "");
ChatMember microwave = station(ChatCategory.MICROWAVE, "");
addCurrentQrg(vhf, Band.B_144, 144.210, NOW - 10_000L);
addCurrentQrg(microwave, Band.B_1296, 1296.210, NOW - 1_000L);
PropagationFrequencyResolver.Resolution resolution = resolve(
List.of(vhf, microwave),
EnumSet.of(Band.B_144, Band.B_432, Band.B_1296)
);
assertEquals(Band.B_1296, resolution.getBand());
assertEquals(1296.210, resolution.getAnalysisFrequencyMHz(), 0.000_001);
}
@Test
void microwaveFallsBackToLowestEnabledMicrowaveBand() {
ChatMember station = station(ChatCategory.MICROWAVE, "");
PropagationFrequencyResolver.Resolution resolution = resolve(
List.of(station),
EnumSet.of(Band.B_2320, Band.B_3400)
);
assertEquals(Band.B_2320, resolution.getBand());
assertEquals(2320.0, resolution.getAnalysisFrequencyMHz(), 0.000_001);
assertEquals(PropagationFrequencyResolver.Source.CHAT_CATEGORY, resolution.getSource());
}
@Test
void supportedCategoriesUseTheirAgreedLowestFallbackBand() {
assertEquals(
Band.B_50,
resolve(
List.of(station(ChatCategory.FIFTYSEVENTYMHz, "")),
EnumSet.of(Band.B_50, Band.B_70)
).getBand()
);
assertEquals(
Band.B_144,
resolve(
List.of(station(ChatCategory.VUHF, "")),
EnumSet.of(Band.B_144, Band.B_432)
).getBand()
);
assertEquals(
Band.B_1296,
resolve(
List.of(station(ChatCategory.MICROWAVE, "")),
EnumSet.of(Band.B_1296, Band.B_2320)
).getBand()
);
assertEquals(
Band.B_144,
resolve(
List.of(station(ChatCategory.EMEJT65, "")),
EnumSet.of(Band.B_144, Band.B_1296)
).getBand()
);
}
@Test
void vhfAndMicrowaveUse432MhzFallback() {
ChatMember vhf = station(ChatCategory.VUHF, "");
ChatMember microwave = station(ChatCategory.MICROWAVE, "");
PropagationFrequencyResolver.Resolution resolution = resolve(
List.of(vhf, microwave),
EnumSet.of(Band.B_144, Band.B_432, Band.B_1296)
);
assertEquals(Band.B_432, resolution.getBand());
assertEquals(432.0, resolution.getAnalysisFrequencyMHz(), 0.000_001);
assertEquals(
PropagationFrequencyResolver.Source.DUAL_CATEGORY_FALLBACK,
resolution.getSource()
);
assertEquals("4320000", resolution.getAirScoutBandValue());
}
@Test
void unsupportedChatCategoriesDoNotFallBackTo144Mhz() {
for (int categoryNumber = ChatCategory.LOWBAND;
categoryNumber <= ChatCategory.TENMeter;
categoryNumber++) {
ChatMember unsupported = station(categoryNumber, "QRV 2m");
addCurrentQrg(unsupported, Band.B_144, 144.300, NOW - 1_000L);
assertNull(
resolve(
List.of(unsupported),
EnumSet.of(Band.B_144, Band.B_432)
),
"Category " + categoryNumber + " must be ignored"
);
}
}
@Test
void manualNotQrvExclusionForcesNextUsableMicrowaveBand() {
ChatMember station = station(ChatCategory.MICROWAVE, "");
station.setQrv1240(false);
PropagationFrequencyResolver.Resolution resolution = resolve(
List.of(station),
EnumSet.of(Band.B_1296, Band.B_2320)
);
assertEquals(Band.B_2320, resolution.getBand());
assertEquals(2320.0, resolution.getAnalysisFrequencyMHz(), 0.000_001);
}
@Test
void stationNameBandHintWinsOverCategoryFallback() {
ChatMember station = station(ChatCategory.MICROWAVE, "QRV 3cm");
PropagationFrequencyResolver.Resolution resolution = resolve(
List.of(station),
EnumSet.of(Band.B_1296, Band.B_10G)
);
assertEquals(Band.B_10G, resolution.getBand());
assertEquals(10368.0, resolution.getAnalysisFrequencyMHz(), 0.000_001);
assertEquals(PropagationFrequencyResolver.Source.STATION_NAME, resolution.getSource());
}
private PropagationFrequencyResolver.Resolution resolve(
List<ChatMember> variants,
EnumSet<Band> enabledBands
) {
return PropagationFrequencyResolver.resolve(variants, enabledBands, NOW);
}
private ChatMember station(int categoryNumber, String name) {
ChatMember station = new ChatMember();
station.setCallSign("DL1ABC");
station.setChatCategory(new ChatCategory(categoryNumber));
station.setName(name);
return station;
}
private void addCurrentQrg(ChatMember station,
Band band,
double frequencyMHz,
long timestampEpochMs) {
station.addKnownFrequency(band, frequencyMHz);
station.getKnownActiveBands().get(band).timestampEpoch = timestampEpochMs;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB