diff --git a/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/src/main/java/kst4contest/controller/MessageBusManagementThread.java
index 3d74925..c9c6195 100644
--- a/src/main/java/kst4contest/controller/MessageBusManagementThread.java
+++ b/src/main/java/kst4contest/controller/MessageBusManagementThread.java
@@ -327,8 +327,7 @@ public class MessageBusManagementThread extends Thread {
// 1. Store in the new Map (for future context/history)
sender.addKnownFrequency(finalDetectedBand, finalDetectedFrequency);
- // No automatic full terrain analysis here.
- // The frequency is stored for later map/manual reachability requests.
+ client.getReachabilityService().ensureTropoMarginCalculated(sender, finalDetectedBand);
//propagate known frequency to all instances of the same callsign (callRaw may exist multiple times)
try {
@@ -337,9 +336,7 @@ public class MessageBusManagementThread extends Thread {
ChatMember cm = client.getLst_chatMemberList().get(idx);
if (cm != null && cm != sender) {
cm.addKnownFrequency(finalDetectedBand, finalDetectedFrequency);
- // No automatic full terrain analysis here.
- // Avoids exhausting the online terrain API when many stations mention QRGs.
- }
+ client.getReachabilityService().ensureTropoMarginCalculated(cm, finalDetectedBand); }
}
} catch (Exception e) {
System.out.println("[SmartParser, warning]: failed to propagate known frequency across duplicates: " + e.getMessage());
@@ -591,8 +588,7 @@ public class MessageBusManagementThread extends Thread {
if (!client.getChatPreferences().getStn_loginCallSign().equals(newMember.getCallSign())) {
this.client.getLst_chatMemberList().add(newMember); //the own call will not be in the list
-// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
- // Reachability is calculated on demand only: map click, selected station, or manual request.
+ this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
}
@@ -638,7 +634,7 @@ public class MessageBusManagementThread extends Thread {
newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember);
this.client.getLst_chatMemberList().add(newMember);
-// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
+ this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
this.client.getDbHandler().storeChatMember(newMember);
}
diff --git a/src/main/java/kst4contest/controller/ReachabilityService.java b/src/main/java/kst4contest/controller/ReachabilityService.java
index f4ce9f7..8fc178a 100644
--- a/src/main/java/kst4contest/controller/ReachabilityService.java
+++ b/src/main/java/kst4contest/controller/ReachabilityService.java
@@ -1,12 +1,4 @@
package kst4contest.controller;
-import kst4contest.view.map.MapCallsignRawSnapshot;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.function.Consumer;
import javafx.application.Platform;
import kst4contest.locatorUtils.Location;
@@ -15,12 +7,12 @@ import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import kst4contest.view.map.GeometryOnlyPathAnalysisService;
-
import kst4contest.view.map.OpenMeteoTerrainProfileProvider;
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;
@@ -29,57 +21,26 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
-import java.util.function.Consumer;
/**
- * Central service for tropo/path reachability calculations.
+ * Background service for station reachability values used by the station table.
*
- *
This service is now the single calculation path for:
- *
- * - the station table Tropo column
- * - the Tropo filter/sorter
- * - the station map path-analysis detail panel
- *
- *
- * The service always calculates a full {@link PathAnalysisResult}. When the
- * result contains a usable link budget, the bidirectional SSB margin is copied
- * into all matching {@link ChatMember} objects. The UI can then sort/filter by
- * a simple number while the map still receives the full path result.
+ * The service performs the expensive full path analysis off the JavaFX thread,
+ * stores the bidirectional SSB margin in the ChatMember, and leaves the UI with a
+ * simple numeric value for sorting/filtering.
*/
public final class ReachabilityService {
- private static final long FAILED_ANALYSIS_RETRY_DELAY_MS = 5L * 60L * 1000L;
-
private final ChatController chatController;
private final PathAnalysisService pathAnalysisService;
private final ExecutorService executor;
/**
- * Prevents duplicate jobs for the same path while a calculation is already
- * queued or running.
+ * Prevents duplicate jobs for the same logical station/band while a calculation
+ * is already queued or running.
*/
private final Set queuedCalculationKeys = ConcurrentHashMap.newKeySet();
-
-
- /**
- * Stores completed usable path-analysis results. Failed/no-budget results are
- * not permanently cached so transient terrain/network problems can be retried.
- */
- private final Map pathAnalysisResultCache = new ConcurrentHashMap<>();
-
- /**
- * Temporary callback list for map requests waiting for an already running
- * background calculation.
- */
- private final Map>> pendingMapCallbacksByKey = new ConcurrentHashMap<>();
-
- /**
- * Prevents immediate retry loops for failed/no-budget analyses, especially
- * when a TableView cell repeatedly asks for the same value.
- */
- private final Map failedCalculationRetryAfterEpochMs = new ConcurrentHashMap<>();
-
public ReachabilityService(ChatController chatController) {
this.chatController = Objects.requireNonNull(chatController, "chatController");
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(new OpenMeteoTerrainProfileProvider());
@@ -96,12 +57,8 @@ public final class ReachabilityService {
}
/**
- * Ensures that a station/band SSB-margin value exists if it is already cached.
- *
- * This method deliberately does not start online terrain analysis anymore.
- * It is safe to call from table cells and filters because it will not consume
- * API quota. Full calculations are now started only by explicit map/manual
- * requests.
+ * Ensures that a station/band SSB-margin value exists. Existing finite values
+ * and existing NaN failure markers are not recalculated automatically.
*
* @param member chatmember to evaluate
* @param band band to evaluate
@@ -111,110 +68,36 @@ public final class ReachabilityService {
return;
}
- if (member.hasFiniteTropoSsbMarginDb(band)) {
+ if (member.hasTropoSsbMarginDb(band)) {
return;
}
- PathAnalysisRequest request = buildRequestForChatMember(member, band);
- if (request == null) {
+ String calculationKey = buildCalculationKey(member, band);
+ if (!queuedCalculationKeys.add(calculationKey)) {
return;
}
- PathAnalysisResult cachedResult = pathAnalysisResultCache.get(buildCalculationKey(request));
- if (cachedResult != null) {
- storePathAnalysisResultInChatMembers(member, band, cachedResult);
- }
- }
+ executor.submit(() -> {
+ double marginDb = Double.NaN;
+ try {
+ marginDb = calculateSsbMarginDb(member, band);
+ } catch (Exception exception) {
+ System.out.println("[ReachabilityService, warning]: analysis failed for "
+ + member.getCallSignRaw() + " @" + band + ": " + exception.getMessage());
+ }
+ final double finalMarginDb = marginDb;
+ Runnable storeResult = () -> {
+ member.setTropoSsbMarginDb(band, finalMarginDb);
+ chatController.fireUserListUpdate("Reachability calculated");
+ };
-
- /**
- * Requests a full path analysis for the station map.
- *
- * This is the only normal automatic entry point for online/full terrain
- * analysis. The map needs the full PathAnalysisResult, and the station table
- * needs the SSB margin. Both are produced here through the same calculation.
- *
- * @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
- */
- public void requestPathAnalysisForMap(ChatMember member,
- MapCallsignRawSnapshot selectedSnapshot,
- Consumer fxCallback) {
-
- String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
-
- if (selectedSnapshot == null) {
- dispatchFxCallback(fxCallback, PathAnalysisResult.waitingForSelection(ownLocator6));
- return;
- }
-
- String targetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
-
- if (ownLocator6.length() != 6) {
- dispatchFxCallback(fxCallback,
- PathAnalysisResult.waitingForValidHomeLocator(ownLocator6, targetLocator6));
- return;
- }
-
- if (!selectedSnapshot.hasUsablePosition()) {
- dispatchFxCallback(fxCallback,
- PathAnalysisResult.waitingForValidTarget(ownLocator6, targetLocator6));
- return;
- }
-
- double analysisFrequencyMHz = PathGeometryUtils.resolveAnalysisFrequencyMHz(
- selectedSnapshot.lastKnownFrequenciesByBand()
- );
-
- if (!Double.isFinite(analysisFrequencyMHz) || analysisFrequencyMHz <= 0.0) {
- Band fallbackBand = member == null ? Band.B_144 : resolveAutoBand(member);
- analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, fallbackBand);
- }
-
- Band analysisBand = Band.fromFrequency(analysisFrequencyMHz);
- if (analysisBand == null) {
- analysisBand = member == null ? Band.B_144 : resolveAutoBand(member);
- }
-
- PathAnalysisRequest request = buildRequest(
- ownLocator6,
- selectedSnapshot.callSignRaw(),
- targetLocator6,
- selectedSnapshot.latitudeDeg(),
- selectedSnapshot.longitudeDeg(),
- analysisFrequencyMHz
- );
-
- requestPathAnalysisAndStore(member, analysisBand, request, fxCallback);
- }
-
-
-
- /**
- * Starts a full on-demand reachability calculation for one station row.
- *
- * Use this for explicit operator actions only. It may call the online terrain
- * API and therefore must not be used from TableView cell factories or automatic
- * chat-join processing.
- *
- * @param member station to calculate
- * @param band selected reachability band
- */
- public void calculateSelectedStationOnDemand(ChatMember member, Band band) {
- if (member == null || band == null) {
- return;
- }
-
- PathAnalysisRequest request = buildRequestForChatMember(member, band);
- if (request == null) {
- member.setTropoSsbMarginDb(band, Double.NaN);
- chatController.fireUserListUpdate("Reachability unavailable");
- return;
- }
-
- requestPathAnalysisAndStore(member, band, request, null);
+ if (Platform.isFxApplicationThread()) {
+ storeResult.run();
+ } else {
+ Platform.runLater(storeResult);
+ }
+ });
}
/**
@@ -278,222 +161,42 @@ public final class ReachabilityService {
executor.shutdownNow();
}
- /**
- * Starts or reuses one shared path-analysis calculation.
- *
- * @param member matching ChatMember, may be null for map-only snapshots
- * @param band band under which the value is stored in ChatMember
- * @param request full path-analysis request
- * @param fxCallback optional map callback
- */
- private void requestPathAnalysisAndStore(ChatMember member,
- Band band,
- PathAnalysisRequest request,
- Consumer fxCallback) {
-
- if (request == null || band == null) {
- return;
- }
-
- String calculationKey = buildCalculationKey(request);
-
- PathAnalysisResult cachedResult = pathAnalysisResultCache.get(calculationKey);
- if (cachedResult != null) {
- storePathAnalysisResultInChatMembers(member, band, cachedResult);
- dispatchFxCallback(fxCallback, cachedResult);
- return;
- }
-
- Long retryAfterEpochMs = failedCalculationRetryAfterEpochMs.get(calculationKey);
- if (retryAfterEpochMs != null && System.currentTimeMillis() < retryAfterEpochMs) {
- dispatchFxCallback(fxCallback, createNoProfileResult(
- request,
- "Previous path analysis failed or the terrain API limit was reached. Retry is delayed briefly."
- ));
- return;
- }
-
- addPendingCallback(calculationKey, fxCallback);
-
- if (!queuedCalculationKeys.add(calculationKey)) {
- return;
- }
-
- executor.submit(() -> {
- PathAnalysisResult result;
-
- try {
- result = pathAnalysisService.analyze(request);
- if (result == null) {
- result = createNoProfileResult(request, "Path analysis returned no result.");
- }
- } catch (Exception exception) {
- result = createNoProfileResult(
- request,
- "Path analysis failed: " + exception.getMessage()
- );
- }
-
- boolean usableBudget = hasUsableLinkBudget(result);
-
- if (usableBudget) {
- pathAnalysisResultCache.put(calculationKey, result);
- failedCalculationRetryAfterEpochMs.remove(calculationKey);
- } else {
- failedCalculationRetryAfterEpochMs.put(
- calculationKey,
- System.currentTimeMillis() + FAILED_ANALYSIS_RETRY_DELAY_MS
- );
- }
-
- queuedCalculationKeys.remove(calculationKey);
-
- PathAnalysisResult finalResult = result;
- Runnable updateTask = () -> {
- storePathAnalysisResultInChatMembers(member, band, finalResult);
- dispatchAndClearPendingCallbacks(calculationKey, finalResult);
- chatController.fireUserListUpdate("Reachability calculated");
- };
-
- if (Platform.isFxApplicationThread()) {
- updateTask.run();
- } else {
- Platform.runLater(updateTask);
- }
- });
- }
-
- /**
- * Builds a path-analysis request from a ChatMember row.
- *
- * @param member station row
- * @param band selected reachability band
- * @return request or null if locators are not usable
- */
- private PathAnalysisRequest buildRequestForChatMember(ChatMember member, Band band) {
+ private double calculateSsbMarginDb(ChatMember member, Band band) {
String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
String targetLocator6 = normalizeLocator6(member.getQra());
if (ownLocator6.length() != 6 || targetLocator6.length() != 6) {
- return null;
+ return Double.NaN;
}
+ Location homeLocation = new Location(ownLocator6);
Location targetLocation = new Location(targetLocator6);
+
double analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, band);
- return buildRequest(
+ PathAnalysisRequest request = new PathAnalysisRequest(
ownLocator6,
+ homeLocation.getLatitude().toDegrees(),
+ homeLocation.getLongitude().toDegrees(),
member.getCallSignRaw(),
targetLocator6,
targetLocation.getLatitude().toDegrees(),
targetLocation.getLongitude().toDegrees(),
- analysisFrequencyMHz
- );
- }
-
- /**
- * Builds the shared PathAnalysisRequest used by map and table/manual requests.
- */
- private PathAnalysisRequest buildRequest(String ownLocator6,
- String targetCallsignRaw,
- String targetLocator6,
- double targetLatitudeDeg,
- double targetLongitudeDeg,
- double analysisFrequencyMHz) {
-
- Location homeLocation = new Location(ownLocator6);
-
- return new PathAnalysisRequest(
- ownLocator6,
- homeLocation.getLatitude().toDegrees(),
- homeLocation.getLongitude().toDegrees(),
- targetCallsignRaw,
- targetLocator6,
- targetLatitudeDeg,
- targetLongitudeDeg,
analysisFrequencyMHz,
chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
chatController.getChatPreferences().buildPathLinkBudgetSettings()
);
+
+ PathAnalysisResult result = pathAnalysisService.analyze(request);
+ if (result == null || result.linkBudgetSummary() == null || !result.linkBudgetSummary().hasUsableBudget()) {
+ return Double.NaN;
+ }
+
+ return result.linkBudgetSummary().bidirectionalSsbMarginDb();
}
- /**
- * Stores the SSB margin from a completed analysis in all matching ChatMember
- * objects. Matching uses raw callsign and locator.
- */
- private void storePathAnalysisResultInChatMembers(ChatMember primaryMember,
- Band band,
- PathAnalysisResult result) {
-
- if (band == null || result == null) {
- return;
- }
-
- double marginDb = hasUsableLinkBudget(result)
- ? result.linkBudgetSummary().bidirectionalSsbMarginDb()
- : Double.NaN;
-
- if (primaryMember != null) {
- primaryMember.setTropoSsbMarginDb(band, marginDb);
- }
-
- String resultCallSignRaw = normalizeCallsignRaw(result.toCallsignRaw());
- String resultLocator6 = normalizeLocator6(result.toLocator6());
-
- for (ChatMember member : chatController.snapshotChatMembers()) {
- if (member == null) {
- continue;
- }
-
- String memberCallSignRaw = normalizeCallsignRaw(member.getCallSignRaw());
- if (!resultCallSignRaw.isBlank() && !resultCallSignRaw.equals(memberCallSignRaw)) {
- continue;
- }
-
- String memberLocator6 = normalizeLocator6(member.getQra());
- if (!resultLocator6.isBlank() && !resultLocator6.equals(memberLocator6)) {
- continue;
- }
-
- member.setTropoSsbMarginDb(band, marginDb);
- }
- }
-
- /**
- * 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.
- *
- * Preference order:
- *
- * - knownActiveBands frequency for the band
- * - current displayed QRG if it belongs to the same band
- * - band default frequency
- *
- */
private double resolveAnalysisFrequencyForBand(ChatMember member, Band band) {
if (member != null && member.getKnownActiveBands() != null) {
ChatMember.ActiveFrequencyInfo activeFrequencyInfo = member.getKnownActiveBands().get(band);
@@ -504,148 +207,19 @@ public final class ReachabilityService {
}
}
- if (member != null && member.getFrequency() != null && member.getFrequency().getValue() != null) {
- double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(member.getFrequency().getValue());
- if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) {
- Band parsedBand = Band.fromFrequency(parsedFrequencyMHz);
- if (parsedBand == null || parsedBand == band) {
- return parsedFrequencyMHz;
- }
- }
- }
-
return band.getDefaultAnalysisFrequencyMHz();
}
- /**
- * Adds a map callback to the pending callback list for one calculation key.
- *
- * @param calculationKey path key
- * @param fxCallback callback to add
- */
- private void addPendingCallback(String calculationKey, Consumer fxCallback) {
- if (fxCallback == null) {
- return;
- }
-
- pendingMapCallbacksByKey
- .computeIfAbsent(
- calculationKey,
- ignored -> Collections.synchronizedList(new ArrayList<>())
- )
- .add(fxCallback);
- }
-
- /**
- * Dispatches and removes all callbacks waiting for one completed calculation.
- *
- * @param calculationKey path key
- * @param result completed result
- */
- private void dispatchAndClearPendingCallbacks(String calculationKey, PathAnalysisResult result) {
- List> callbacks = pendingMapCallbacksByKey.remove(calculationKey);
- if (callbacks == null || callbacks.isEmpty()) {
- return;
- }
-
- List> callbackSnapshot;
- synchronized (callbacks) {
- callbackSnapshot = new ArrayList<>(callbacks);
- }
-
- for (Consumer callback : callbackSnapshot) {
- dispatchFxCallback(callback, result);
- }
- }
-
- /**
- * Executes a map callback on the JavaFX application thread.
- *
- * @param fxCallback callback to execute
- * @param result result to pass
- */
- private void dispatchFxCallback(Consumer fxCallback, PathAnalysisResult result) {
- if (fxCallback == null) {
- return;
- }
-
- if (Platform.isFxApplicationThread()) {
- fxCallback.accept(result);
- } else {
- Platform.runLater(() -> fxCallback.accept(result));
- }
- }
-
- /**
- * Checks whether the result contains a usable link-budget summary.
- *
- * @param result path-analysis result
- * @return true if SSB margin can be read
- */
- private boolean hasUsableLinkBudget(PathAnalysisResult result) {
- return result != null
- && result.linkBudgetSummary() != null
- && result.linkBudgetSummary().hasUsableBudget();
- }
-
- /**
- * Creates a no-profile/no-budget result for failed service calculations.
- *
- * @param request original request
- * @param statusText status shown in the map detail panel
- * @return placeholder path result
- */
- private PathAnalysisResult createNoProfileResult(PathAnalysisRequest request, String statusText) {
- return PathAnalysisResult.noProfile(
- "Reachability",
- request.fromLocator6(),
- request.toLocator6(),
- request.toCallsignRaw(),
- Double.NaN,
- Double.NaN,
- request.homeAntennaHeightMeters(),
- request.targetAntennaHeightMeters(),
- request.frequencyMHz(),
- statusText
- );
- }
-
- /**
- * Builds a stable calculation key.
- *
- * The key includes station, locators, frequency, antenna heights and link
- * budget settings. If any relevant input changes, a new calculation is allowed.
- */
- private String buildCalculationKey(PathAnalysisRequest request) {
- return normalizeLocator6(request.fromLocator6())
- + "|"
- + normalizeCallsignRaw(request.toCallsignRaw())
- + "|"
- + normalizeLocator6(request.toLocator6())
- + "|"
- + String.format(Locale.US, "%.5f", request.toLatitudeDeg())
- + "|"
- + String.format(Locale.US, "%.5f", request.toLongitudeDeg())
- + "|"
- + String.format(Locale.US, "%.3f", request.frequencyMHz())
- + "|"
- + String.format(Locale.US, "%.1f", request.homeAntennaHeightMeters())
- + "|"
- + String.format(Locale.US, "%.1f", request.targetAntennaHeightMeters())
- + "|"
- + request.linkBudgetSettings();
- }
-
- private String normalizeCallsignRaw(String callSignRaw) {
- return callSignRaw == null ? "" : callSignRaw.trim().toUpperCase(Locale.ROOT);
+ private String buildCalculationKey(ChatMember member, Band band) {
+ String callSignRaw = member.getCallSignRaw() == null ? "" : member.getCallSignRaw();
+ String locator = member.getQra() == null ? "" : member.getQra();
+ return callSignRaw.trim().toUpperCase() + "|" + locator.trim().toUpperCase() + "|" + band.name();
}
private String normalizeLocator6(String locator) {
- return locator == null ? "" : locator.trim().toUpperCase(Locale.ROOT);
+ return locator == null ? "" : locator.trim().toUpperCase();
}
-
-
private static final class ReachabilityThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable runnable) {
diff --git a/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java b/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java
index 46d1cfe..842eb84 100644
--- a/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java
+++ b/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java
@@ -348,62 +348,88 @@ public class ReadUDPbyUCXMessageThread extends Thread {
Band workedBand = helper_resolveBandFromLoggerBand(band);
switch (band) {
- case "144":
- case "2m": //minos contest logger
- {
+ case "144": {
workedCall.setWorked144(true);
break;
}
- case "432":
- case "70cm":
- {
- workedCall.setWorked432(true);
- break;
- }
+ case "432": {
+ workedCall.setWorked432(true);
+ break;
+ }
- case "1240": //ucxlog style
- case "1296": //used for n1mm / Dxlog
- case "23cm": //minos contest logger
- {
+ case "1240": {
workedCall.setWorked1240(true);
break;
}
- case "2300":
- case "13cm":
- {
+ case "2300": {
workedCall.setWorked2300(true);
break;
}
- case "3400":
- case "9cm":
- {
+ case "3400": {
workedCall.setWorked3400(true);
break;
}
- case "5600":
- case "6cm":
- {
+ case "5600": {
workedCall.setWorked5600(true);
break;
}
- case "10G":
- case "3cm":
- {
+ case "10G": {
workedCall.setWorked10G(true);
break;
}
+ /**
+ * cases hotfix for MINOS logger, which tells band like "2m", not "144"
+ */
+ case "2m": {
+ workedCall.setWorked144(true);
+ break;
+ }
+
+ case "70cm": {
+ workedCall.setWorked432(true);
+ break;
+ }
+
+ case "23cm": {
+ workedCall.setWorked1240(true);
+ break;
+ }
+
+ case "13cm": {
+ workedCall.setWorked2300(true);
+ break;
+ }
+
+ case "9cm": {
+ workedCall.setWorked3400(true);
+ break;
+ }
+
+ case "6cm": {
+ workedCall.setWorked5600(true);
+ break;
+ }
+
+ case "3cm": {
+ workedCall.setWorked10G(true);
+ break;
+ }
default:
System.out.println("[ReadUDPFromUCX, Error:] unexpected band value: \"" + band + "\"");
break;
}
+// if (!client.getMap_ucxLogInfoWorkedCalls().containsKey("call")) {
+// client.getMap_ucxLogInfoWorkedCalls().put(call, workedCall);
+
+// } else
{
/**
* That means, the station is worked already but maybe at another band. So we
diff --git a/src/main/java/kst4contest/model/ChatMember.java b/src/main/java/kst4contest/model/ChatMember.java
index fdc223d..d7efd17 100644
--- a/src/main/java/kst4contest/model/ChatMember.java
+++ b/src/main/java/kst4contest/model/ChatMember.java
@@ -659,27 +659,6 @@ public class ChatMember {
return band != null && this.tropoSsbMarginDbByBand.containsKey(band);
}
-
-
- /**
- * Returns true only when a finite, usable tropo SSB margin is stored for the band.
- *
- * This is intentionally different from {@link #hasTropoSsbMarginDb(Band)}:
- * a stored NaN means that an analysis was attempted but did not produce a usable
- * link budget. Such failed values should not permanently block later retries.
- *
- * @param band band to check
- * @return true if a finite SSB margin exists
- */
- public boolean hasFiniteTropoSsbMarginDb(Band band) {
- if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
- return false;
- }
-
- Double storedValue = this.tropoSsbMarginDbByBand.get(band);
- return storedValue != null && Double.isFinite(storedValue);
- }
-
/**
* Reads the calculated tropo SSB margin for one band.
*
@@ -699,34 +678,21 @@ public class ChatMember {
return OptionalDouble.of(storedValue);
}
-
/**
* Formats the calculated tropo margin for direct table display.
*
- * The method distinguishes three states:
- *
- * - {@code ... @144}: analysis has not run yet
- * - {@code ? @144}: analysis ran, but no usable link budget was produced
- * - {@code +8.4 dB @144}: finite SSB margin exists
- *
- *
* @param band selected/auto-resolved reachability band
- * @return display text such as "+8.4 dB @144", "? @144" or "... @144"
+ * @return display text such as "+8.4 dB @144" or "- @144"
*/
public String formatTropoSsbMarginForBand(Band band) {
String bandLabel = band == null ? "?" : band.getDisplayLabel();
+ OptionalDouble marginDb = getTropoSsbMarginDb(band);
- if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
- return "... @" + bandLabel;
+ if (marginDb.isEmpty() || !Double.isFinite(marginDb.getAsDouble())) {
+ return "- @" + bandLabel;
}
- Double storedValue = this.tropoSsbMarginDbByBand.get(band);
-
- if (storedValue == null || !Double.isFinite(storedValue)) {
- return "? @" + bandLabel;
- }
-
- return String.format(Locale.US, "%+.1f dB @%s", storedValue, bandLabel);
+ return String.format(Locale.US, "%+.1f dB @%s", marginDb.getAsDouble(), bandLabel);
}
diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java
index 469848a..5e2a2b2 100644
--- a/src/main/java/kst4contest/view/Kst4ContestApplication.java
+++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java
@@ -71,6 +71,8 @@ import kst4contest.view.map.OfflineDemImportService;
public class Kst4ContestApplication extends Application implements StatusUpdateListener {
// private static final Kst4ContestApplication dbcontroller = new DBController();
+ // Null means Auto: use the lowest session band, or category fallback.
+ private Band selectedReachabilityBandOverride = null;
private StationMapView stationMapView; //view class for the avl stn map
private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view
@@ -1387,6 +1389,29 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
*/
+ TableColumn tropoCol = new TableColumn("Tropo");
+ tropoCol.setCellValueFactory(new Callback, ObservableValue>() {
+ @Override
+ public ObservableValue call(CellDataFeatures cellDataFeatures) {
+ ChatMember member = cellDataFeatures.getValue();
+ Band selectedBand = resolveReachabilityBandForUi(member);
+ chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, selectedBand);
+ return new SimpleStringProperty(member.formatTropoSsbMarginForBand(selectedBand));
+ }
+ });
+ tropoCol.setComparator((left, right) -> Double.compare(parseTableDouble(left), parseTableDouble(right)));
+ tropoCol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(13));
+
+ TableColumn priorityScoreCol = new TableColumn("Score");
+ priorityScoreCol.setCellValueFactory(new Callback, ObservableValue>() {
+ @Override
+ public ObservableValue call(CellDataFeatures cellDataFeatures) {
+ return new SimpleStringProperty(formatPriorityScore(cellDataFeatures.getValue()));
+ }
+ });
+ priorityScoreCol.setComparator((left, right) -> Double.compare(parseTableDouble(left), parseTableDouble(right)));
+ priorityScoreCol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(18));
+
TableColumn lastActCol = new TableColumn("Act");
lastActCol.setCellValueFactory(new Callback, ObservableValue>() {
@@ -1689,7 +1714,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
- tbl_chatMemberTable.getColumns().addAll(callSignCol, nameCol, qraCol, qrBCol, qtfCol, qrgCol, lastActCol, airScoutCol, workedCol, notQRVCol, chatCategoryCol);
+// tbl_chatMemberTable.getColumns().addAll(callSignCol, nameCol, qraCol, qrBCol, qtfCol, qrgCol, lastActCol, airScoutCol, workedCol, notQRVCol, chatCategoryCol);
+
+ tbl_chatMemberTable.getColumns().addAll(callSignCol, nameCol, qraCol, qrBCol, qtfCol, qrgCol, tropoCol, priorityScoreCol, lastActCol, airScoutCol, workedCol, notQRVCol, chatCategoryCol);
+
// tbl_chatMemberTable.setItems(chatcontroller.getLst_chatMemberListFiltered());
@@ -6061,6 +6089,84 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
TextField chatMemberTableFilterMaxQrbTF = new TextField(chatcontroller.getChatPreferences().getStn_maxQRBDefault() + "");
chatMemberTableFilterMaxQrbTF.setFocusTraversable(false);
+
+
+ ToggleButton btnTglNewLocator = new ToggleButton("New locator");
+ Predicate newLocatorPredicate = new Predicate() {
+ @Override
+ public boolean test(ChatMember chatMember) {
+ return chatcontroller.isNewLocatorOnAnyEnabledBand(chatMember);
+ }
+ };
+ btnTglNewLocator.setOnAction(new EventHandler() {
+ @Override
+ public void handle(ActionEvent actionEvent) {
+ if (btnTglNewLocator.isSelected()) {
+ chatcontroller.getLst_chatMemberListFilterPredicates().add(newLocatorPredicate);
+ } else {
+ chatcontroller.getLst_chatMemberListFilterPredicates().remove(newLocatorPredicate);
+ }
+ }
+ });
+ btnTglNewLocator.setTooltip(new Tooltip("Show only stations whose gross locator is still new on at least one active own band"));
+
+ ToggleButton btnTglReachableTropo = new ToggleButton("Tropo >=0dB");
+ Predicate reachableTropoPredicate = new Predicate() {
+ @Override
+ public boolean test(ChatMember chatMember) {
+ return isReachableViaTropoFilterMatch(chatMember);
+ }
+ };
+ btnTglReachableTropo.setOnAction(new EventHandler() {
+ @Override
+ public void handle(ActionEvent actionEvent) {
+ if (btnTglReachableTropo.isSelected()) {
+ chatcontroller.getLst_chatMemberListFilterPredicates().add(reachableTropoPredicate);
+ } else {
+ chatcontroller.getLst_chatMemberListFilterPredicates().remove(reachableTropoPredicate);
+ }
+ }
+ });
+ btnTglReachableTropo.setTooltip(new Tooltip("Show stations with non-negative SSB tropo margin. Pending/failed calculations stay visible."));
+
+ ToggleButton btnTglNewBands = new ToggleButton("New bands");
+ Predicate newBandsPredicate = new Predicate() {
+ @Override
+ public boolean test(ChatMember chatMember) {
+ return isNewBandOpportunity(chatMember);
+ }
+ };
+ btnTglNewBands.setOnAction(new EventHandler() {
+ @Override
+ public void handle(ActionEvent actionEvent) {
+ if (btnTglNewBands.isSelected()) {
+ chatcontroller.getLst_chatMemberListFilterPredicates().add(newBandsPredicate);
+ } else {
+ chatcontroller.getLst_chatMemberListFilterPredicates().remove(newBandsPredicate);
+ }
+ }
+ });
+ btnTglNewBands.setTooltip(new Tooltip("Show stations that are QRV on a known active band that has not been worked yet"));
+
+ ToggleButton btnTglAsNext5Min = new ToggleButton("AS next 5m");
+ Predicate asNext5MinPredicate = new Predicate() {
+ @Override
+ public boolean test(ChatMember chatMember) {
+ return hasAsWindowInNextMinutes(chatMember, 5);
+ }
+ };
+ btnTglAsNext5Min.setOnAction(new EventHandler() {
+ @Override
+ public void handle(ActionEvent actionEvent) {
+ if (btnTglAsNext5Min.isSelected()) {
+ chatcontroller.getLst_chatMemberListFilterPredicates().add(asNext5MinPredicate);
+ } else {
+ chatcontroller.getLst_chatMemberListFilterPredicates().remove(asNext5MinPredicate);
+ }
+ }
+ });
+ btnTglAsNext5Min.setTooltip(new Tooltip("Show stations with any AirScout window now or within the next 5 minutes"));
+
ToggleButton tglBtnQRBEnable = new ToggleButton("Show only QRB [km] <= ");
tglBtnQRBEnable.selectedProperty().addListener(new ChangeListener() {
Predicate maxQrbPredicate = new Predicate() {
@@ -6337,6 +6443,29 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
HBox chatMemberTableFilterWorkedBandFiltersHbx = new HBox();
+
+ ComboBox cmbReachabilityBand = new ComboBox<>();
+ cmbReachabilityBand.getItems().add("Auto");
+ for (Band band : chatcontroller.getReachabilityService().getEnabledStationBands()) {
+ 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.setOnAction(new EventHandler() {
+ @Override
+ public void handle(ActionEvent event) {
+ selectedReachabilityBandOverride = parseReachabilityBandSelection(cmbReachabilityBand.getValue());
+ for (ChatMember member : chatcontroller.getLst_chatMemberList()) {
+ Band bandToCalculate = resolveReachabilityBandForUi(member);
+ chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, bandToCalculate);
+ }
+ chatcontroller.fireUserListUpdate("Reachability band changed");
+ }
+ });
+
+ chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(new Label("Reachability:"));
+ chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(cmbReachabilityBand);
+
ToggleButton btnTglwkd = new ToggleButton("wkd");
Predicate wkdPredicate = new Predicate() {
@@ -6602,6 +6731,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
"-fx-border-radius: 1;" +
"-fx-border-color: lightgrey;");
+
+ chatMemberTableFilterWorkedBandFiltersHbx.getChildren().addAll(
+ btnTglNewLocator,
+ btnTglReachableTropo,
+ btnTglNewBands,
+ btnTglAsNext5Min
+ );
+
// chatMemberTableFilterWorkedBandFilters
@@ -7895,8 +8032,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
// Unconditionally add listener to manually sync the textfield input to the button
// (this listener also fires correctly when the value is updated by the binding)
+// txt_ownqrgMainCategory.textProperty().addListener((observable, oldValue, newValue) -> {
+// MYQRGButton.textProperty().set(newValue);
+// });
txt_ownqrgMainCategory.textProperty().addListener((observable, oldValue, newValue) -> {
- MYQRGButton.textProperty().set(newValue);
+ if (Platform.isFxApplicationThread()) {
+ MYQRGButton.textProperty().set(newValue);
+ } else {
+ Platform.runLater(() -> MYQRGButton.textProperty().set(newValue));
+ }
});
// That's the default behaviour of the myqrg textfield
@@ -9702,6 +9846,164 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
stage.setHeight(correctedHeight);
}
+ /**
+ * Resolves the currently selected UI reachability band. Null override means Auto.
+ *
+ * @param member station row
+ * @return manually selected band or automatic band
+ */
+ private Band resolveReachabilityBandForUi(ChatMember member) {
+ if (selectedReachabilityBandOverride != null) {
+ return selectedReachabilityBandOverride;
+ }
+ return chatcontroller.getReachabilityService().resolveAutoBand(member);
+ }
+
+ /**
+ * Parses the reachability ComboBox selection.
+ *
+ * @param selectedLabel selected UI text
+ * @return selected band, or null for Auto
+ */
+ private Band parseReachabilityBandSelection(String selectedLabel) {
+ if (selectedLabel == null || selectedLabel.isBlank() || "Auto".equalsIgnoreCase(selectedLabel)) {
+ return null;
+ }
+
+ for (Band band : Band.values()) {
+ if (selectedLabel.equalsIgnoreCase(band.getDisplayLabel())) {
+ return band;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Formats the projected priority score for the station table.
+ *
+ * @param member station row
+ * @return score text
+ */
+ private String formatPriorityScore(ChatMember member) {
+ if (member == null || !Double.isFinite(member.getCurrentPriorityScore())) {
+ return "-";
+ }
+ return String.format(Locale.US, "%.0f", member.getCurrentPriorityScore());
+ }
+
+ /**
+ * Parses a numeric table value for custom score sorting.
+ *
+ * @param value table text
+ * @return parsed value or negative infinity for missing values
+ */
+ private double parseTableDouble(String value) {
+ if (value == null || value.isBlank() || value.equals("-") || value.startsWith("- @")) {
+ return Double.NEGATIVE_INFINITY;
+ }
+
+ String normalized = value.replace(",", ".").replace("+", "").trim();
+ int firstSpace = normalized.indexOf(' ');
+ if (firstSpace > 0) {
+ normalized = normalized.substring(0, firstSpace);
+ }
+
+ try {
+ return Double.parseDouble(normalized);
+ } catch (NumberFormatException ignored) {
+ return Double.NEGATIVE_INFINITY;
+ }
+ }
+
+ /**
+ * Predicate helper for the tropo filter. Pending or failed calculations remain
+ * visible as requested; only finite negative margins are hidden.
+ *
+ * @param member station row
+ * @return true if station should remain visible under the tropo filter
+ */
+ private boolean isReachableViaTropoFilterMatch(ChatMember member) {
+ if (member == null || chatcontroller.getReachabilityService() == null) {
+ return true;
+ }
+
+ Band selectedBand = resolveReachabilityBandForUi(member);
+ chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, selectedBand);
+
+ OptionalDouble marginDb = member.getTropoSsbMarginDb(selectedBand);
+ if (marginDb.isEmpty() || !Double.isFinite(marginDb.getAsDouble())) {
+ return true;
+ }
+
+ return marginDb.getAsDouble() >= 0.0;
+ }
+
+ /**
+ * Returns true when a station has any AirScout window now or within maxMinutes.
+ *
+ * @param member station row
+ * @param maxMinutes look-ahead window
+ * @return true if any AS window is available
+ */
+ private boolean hasAsWindowInNextMinutes(ChatMember member, int maxMinutes) {
+ if (member == null || member.getAirPlaneReflectInfo() == null
+ || member.getAirPlaneReflectInfo().getRisingAirplanes() == null) {
+ return false;
+ }
+
+ return member.getAirPlaneReflectInfo().getRisingAirplanes().stream()
+ .filter(Objects::nonNull)
+ .anyMatch(airPlane -> airPlane.getArrivingDurationMinutes() >= 0
+ && airPlane.getArrivingDurationMinutes() <= maxMinutes);
+ }
+
+ /**
+ * Returns true when the station is known to be QRV on at least one enabled own band
+ * that has not been worked yet.
+ *
+ * @param member station row
+ * @return true if there is a new-band opportunity
+ */
+ private boolean isNewBandOpportunity(ChatMember member) {
+ if (member == null || chatcontroller.getReachabilityService() == null
+ || member.getKnownActiveBands() == null || member.getKnownActiveBands().isEmpty()) {
+ return false;
+ }
+
+ EnumSet enabledBands = chatcontroller.getReachabilityService().getEnabledStationBands();
+ for (Band band : member.getKnownActiveBands().keySet()) {
+ if (band != null && enabledBands.contains(band) && !isWorkedOnBand(member, band)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Maps the existing per-band worked flags to the Band enum.
+ *
+ * @param member station row
+ * @param band band to inspect
+ * @return true if worked on that band
+ */
+ private boolean isWorkedOnBand(ChatMember member, Band band) {
+ if (member == null || band == null) {
+ return false;
+ }
+
+ switch (band) {
+ case B_144: return member.isWorked144();
+ case B_432: return member.isWorked432();
+ case B_1296: return member.isWorked1240();
+ case B_2320: return member.isWorked2300();
+ case B_3400: return member.isWorked3400();
+ case B_5760: return member.isWorked5600();
+ case B_10G: return member.isWorked10G();
+ case B_24G: return member.isWorked24G();
+ default: return false;
+ }
+ }
+
}
/**
diff --git a/udpReaderBackup.txt b/udpReaderBackup.txt
index 54efa10..983aeaa 100644
--- a/udpReaderBackup.txt
+++ b/udpReaderBackup.txt
@@ -2365,4 +2365,5 @@ OZ1DLD/P;Bent;JO45SK;StringProperty [value: 144.285]; wkd true; wkd144 true; wkd
OZ1FDH;Claus;JO55QX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
OZ7KJ;Skive Club;JO46ML;StringProperty [value: 144.225]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
SM7VUK;Bengt;JO66LI;StringProperty [value: 144.304]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
-OZ1HDF;Ken;JO55UN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
\ No newline at end of file
+OZ1HDF;Ken;JO55UN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
+DO5SA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
\ No newline at end of file