mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-09-11 03:35:28 +02:00
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:
+138
-21
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user