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 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: - *

    - *
  1. knownActiveBands frequency for the band
  2. - *
  3. current displayed QRG if it belongs to the same band
  4. - *
  5. band default frequency
  6. - *
- */ 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