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
@@ -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;
}
}