Added filters and sorters for Tropo reachability, AS availability and new big fields / locators. Added reachability band selector for tropo calculation based on a choosen band

This commit is contained in:
Marc Froehlich
2026-06-27 13:35:06 +02:00
committed by Philipp Wagner
parent 29857b8f1b
commit 69c870284d
6 changed files with 416 additions and 551 deletions
@@ -327,8 +327,7 @@ public class MessageBusManagementThread extends Thread {
// 1. Store in the new Map (for future context/history) // 1. Store in the new Map (for future context/history)
sender.addKnownFrequency(finalDetectedBand, finalDetectedFrequency); sender.addKnownFrequency(finalDetectedBand, finalDetectedFrequency);
// No automatic full terrain analysis here. client.getReachabilityService().ensureTropoMarginCalculated(sender, finalDetectedBand);
// The frequency is stored for later map/manual reachability requests.
//propagate known frequency to all instances of the same callsign (callRaw may exist multiple times) //propagate known frequency to all instances of the same callsign (callRaw may exist multiple times)
try { try {
@@ -337,9 +336,7 @@ public class MessageBusManagementThread extends Thread {
ChatMember cm = client.getLst_chatMemberList().get(idx); ChatMember cm = client.getLst_chatMemberList().get(idx);
if (cm != null && cm != sender) { if (cm != null && cm != sender) {
cm.addKnownFrequency(finalDetectedBand, finalDetectedFrequency); cm.addKnownFrequency(finalDetectedBand, finalDetectedFrequency);
// No automatic full terrain analysis here. client.getReachabilityService().ensureTropoMarginCalculated(cm, finalDetectedBand); }
// Avoids exhausting the online terrain API when many stations mention QRGs.
}
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println("[SmartParser, warning]: failed to propagate known frequency across duplicates: " + e.getMessage()); 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())) { 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.getLst_chatMemberList().add(newMember); //the own call will not be in the list
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember); this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
// Reachability is calculated on demand only: map click, selected station, or manual request.
} }
@@ -638,7 +634,7 @@ public class MessageBusManagementThread extends Thread {
newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember); newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember);
this.client.getLst_chatMemberList().add(newMember); this.client.getLst_chatMemberList().add(newMember);
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember); this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
this.client.getDbHandler().storeChatMember(newMember); this.client.getDbHandler().storeChatMember(newMember);
} }
@@ -1,12 +1,4 @@
package kst4contest.controller; 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 javafx.application.Platform;
import kst4contest.locatorUtils.Location; import kst4contest.locatorUtils.Location;
@@ -15,12 +7,12 @@ import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences; import kst4contest.model.ChatPreferences;
import kst4contest.view.map.GeometryOnlyPathAnalysisService; import kst4contest.view.map.GeometryOnlyPathAnalysisService;
import kst4contest.view.map.OpenMeteoTerrainProfileProvider; import kst4contest.view.map.OpenMeteoTerrainProfileProvider;
import kst4contest.view.map.PathAnalysisRequest; import kst4contest.view.map.PathAnalysisRequest;
import kst4contest.view.map.PathAnalysisResult; import kst4contest.view.map.PathAnalysisResult;
import kst4contest.view.map.PathAnalysisService; import kst4contest.view.map.PathAnalysisService;
import kst4contest.view.map.PathGeometryUtils; import kst4contest.view.map.PathGeometryUtils;
import java.util.Comparator; import java.util.Comparator;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.Objects; import java.util.Objects;
@@ -29,57 +21,26 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory; 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.
* *
* <p>This service is now the single calculation path for: * <p>The service performs the expensive full path analysis off the JavaFX thread,
* <ul> * stores the bidirectional SSB margin in the ChatMember, and leaves the UI with a
* <li>the station table Tropo column</li> * simple numeric value for sorting/filtering.</p>
* <li>the Tropo filter/sorter</li>
* <li>the station map path-analysis detail panel</li>
* </ul>
*
* <p>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.</p>
*/ */
public final class ReachabilityService { public final class ReachabilityService {
private static final long FAILED_ANALYSIS_RETRY_DELAY_MS = 5L * 60L * 1000L;
private final ChatController chatController; private final ChatController chatController;
private final PathAnalysisService pathAnalysisService; private final PathAnalysisService pathAnalysisService;
private final ExecutorService executor; private final ExecutorService executor;
/** /**
* Prevents duplicate jobs for the same path while a calculation is already * Prevents duplicate jobs for the same logical station/band while a calculation
* queued or running. * is already queued or running.
*/ */
private final Set<String> queuedCalculationKeys = ConcurrentHashMap.newKeySet(); private final Set<String> 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<String, PathAnalysisResult> pathAnalysisResultCache = new ConcurrentHashMap<>();
/**
* Temporary callback list for map requests waiting for an already running
* background calculation.
*/
private final Map<String, List<Consumer<PathAnalysisResult>>> 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<String, Long> failedCalculationRetryAfterEpochMs = new ConcurrentHashMap<>();
public ReachabilityService(ChatController chatController) { public ReachabilityService(ChatController chatController) {
this.chatController = Objects.requireNonNull(chatController, "chatController"); this.chatController = Objects.requireNonNull(chatController, "chatController");
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(new OpenMeteoTerrainProfileProvider()); 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. * Ensures that a station/band SSB-margin value exists. Existing finite values
* * and existing NaN failure markers are not recalculated automatically.
* <p>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.</p>
* *
* @param member chatmember to evaluate * @param member chatmember to evaluate
* @param band band to evaluate * @param band band to evaluate
@@ -111,110 +68,36 @@ public final class ReachabilityService {
return; return;
} }
if (member.hasFiniteTropoSsbMarginDb(band)) { if (member.hasTropoSsbMarginDb(band)) {
return; return;
} }
PathAnalysisRequest request = buildRequestForChatMember(member, band); String calculationKey = buildCalculationKey(member, band);
if (request == null) { if (!queuedCalculationKeys.add(calculationKey)) {
return; return;
} }
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(buildCalculationKey(request)); executor.submit(() -> {
if (cachedResult != null) { double marginDb = Double.NaN;
storePathAnalysisResultInChatMembers(member, band, cachedResult); 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");
};
if (Platform.isFxApplicationThread()) {
/** storeResult.run();
* Requests a full path analysis for the station map. } else {
* Platform.runLater(storeResult);
* <p>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.</p>
*
* @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<PathAnalysisResult> 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.
*
* <p>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.</p>
*
* @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);
} }
/** /**
@@ -278,222 +161,42 @@ public final class ReachabilityService {
executor.shutdownNow(); executor.shutdownNow();
} }
/** private double calculateSsbMarginDb(ChatMember member, Band band) {
* 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<PathAnalysisResult> 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) {
String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat()); String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
String targetLocator6 = normalizeLocator6(member.getQra()); String targetLocator6 = normalizeLocator6(member.getQra());
if (ownLocator6.length() != 6 || targetLocator6.length() != 6) { if (ownLocator6.length() != 6 || targetLocator6.length() != 6) {
return null; return Double.NaN;
} }
Location homeLocation = new Location(ownLocator6);
Location targetLocation = new Location(targetLocator6); Location targetLocation = new Location(targetLocator6);
double analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, band); double analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, band);
return buildRequest( PathAnalysisRequest request = new PathAnalysisRequest(
ownLocator6, ownLocator6,
homeLocation.getLatitude().toDegrees(),
homeLocation.getLongitude().toDegrees(),
member.getCallSignRaw(), member.getCallSignRaw(),
targetLocator6, targetLocator6,
targetLocation.getLatitude().toDegrees(), targetLocation.getLatitude().toDegrees(),
targetLocation.getLongitude().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, analysisFrequencyMHz,
chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(), chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(), chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR, PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
chatController.getChatPreferences().buildPathLinkBudgetSettings() 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.
*
* <p>Preference order:
* <ol>
* <li>knownActiveBands frequency for the band</li>
* <li>current displayed QRG if it belongs to the same band</li>
* <li>band default frequency</li>
* </ol>
*/
private double resolveAnalysisFrequencyForBand(ChatMember member, Band band) { private double resolveAnalysisFrequencyForBand(ChatMember member, Band band) {
if (member != null && member.getKnownActiveBands() != null) { if (member != null && member.getKnownActiveBands() != null) {
ChatMember.ActiveFrequencyInfo activeFrequencyInfo = member.getKnownActiveBands().get(band); 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(); return band.getDefaultAnalysisFrequencyMHz();
} }
/** private String buildCalculationKey(ChatMember member, Band band) {
* Adds a map callback to the pending callback list for one calculation key. String callSignRaw = member.getCallSignRaw() == null ? "" : member.getCallSignRaw();
* String locator = member.getQra() == null ? "" : member.getQra();
* @param calculationKey path key return callSignRaw.trim().toUpperCase() + "|" + locator.trim().toUpperCase() + "|" + band.name();
* @param fxCallback callback to add
*/
private void addPendingCallback(String calculationKey, Consumer<PathAnalysisResult> 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<Consumer<PathAnalysisResult>> callbacks = pendingMapCallbacksByKey.remove(calculationKey);
if (callbacks == null || callbacks.isEmpty()) {
return;
}
List<Consumer<PathAnalysisResult>> callbackSnapshot;
synchronized (callbacks) {
callbackSnapshot = new ArrayList<>(callbacks);
}
for (Consumer<PathAnalysisResult> 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<PathAnalysisResult> 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.
*
* <p>The key includes station, locators, frequency, antenna heights and link
* budget settings. If any relevant input changes, a new calculation is allowed.</p>
*/
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 normalizeLocator6(String locator) { 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 { private static final class ReachabilityThreadFactory implements ThreadFactory {
@Override @Override
public Thread newThread(Runnable runnable) { public Thread newThread(Runnable runnable) {
@@ -348,62 +348,88 @@ public class ReadUDPbyUCXMessageThread extends Thread {
Band workedBand = helper_resolveBandFromLoggerBand(band); Band workedBand = helper_resolveBandFromLoggerBand(band);
switch (band) { switch (band) {
case "144": case "144": {
case "2m": //minos contest logger
{
workedCall.setWorked144(true); workedCall.setWorked144(true);
break; break;
} }
case "432": case "432": {
case "70cm": workedCall.setWorked432(true);
{ break;
workedCall.setWorked432(true); }
break;
}
case "1240": //ucxlog style case "1240": {
case "1296": //used for n1mm / Dxlog
case "23cm": //minos contest logger
{
workedCall.setWorked1240(true); workedCall.setWorked1240(true);
break; break;
} }
case "2300": case "2300": {
case "13cm":
{
workedCall.setWorked2300(true); workedCall.setWorked2300(true);
break; break;
} }
case "3400": case "3400": {
case "9cm":
{
workedCall.setWorked3400(true); workedCall.setWorked3400(true);
break; break;
} }
case "5600": case "5600": {
case "6cm":
{
workedCall.setWorked5600(true); workedCall.setWorked5600(true);
break; break;
} }
case "10G": case "10G": {
case "3cm":
{
workedCall.setWorked10G(true); workedCall.setWorked10G(true);
break; 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: default:
System.out.println("[ReadUDPFromUCX, Error:] unexpected band value: \"" + band + "\""); System.out.println("[ReadUDPFromUCX, Error:] unexpected band value: \"" + band + "\"");
break; 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 * That means, the station is worked already but maybe at another band. So we
@@ -659,27 +659,6 @@ public class ChatMember {
return band != null && this.tropoSsbMarginDbByBand.containsKey(band); return band != null && this.tropoSsbMarginDbByBand.containsKey(band);
} }
/**
* Returns true only when a finite, usable tropo SSB margin is stored for the band.
*
* <p>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.</p>
*
* @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. * Reads the calculated tropo SSB margin for one band.
* *
@@ -699,34 +678,21 @@ public class ChatMember {
return OptionalDouble.of(storedValue); return OptionalDouble.of(storedValue);
} }
/** /**
* Formats the calculated tropo margin for direct table display. * Formats the calculated tropo margin for direct table display.
* *
* <p>The method distinguishes three states:
* <ul>
* <li>{@code ... @144}: analysis has not run yet</li>
* <li>{@code ? @144}: analysis ran, but no usable link budget was produced</li>
* <li>{@code +8.4 dB @144}: finite SSB margin exists</li>
* </ul>
*
* @param band selected/auto-resolved reachability band * @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) { public String formatTropoSsbMarginForBand(Band band) {
String bandLabel = band == null ? "?" : band.getDisplayLabel(); String bandLabel = band == null ? "?" : band.getDisplayLabel();
OptionalDouble marginDb = getTropoSsbMarginDb(band);
if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) { if (marginDb.isEmpty() || !Double.isFinite(marginDb.getAsDouble())) {
return "... @" + bandLabel; return "- @" + bandLabel;
} }
Double storedValue = this.tropoSsbMarginDbByBand.get(band); return String.format(Locale.US, "%+.1f dB @%s", marginDb.getAsDouble(), bandLabel);
if (storedValue == null || !Double.isFinite(storedValue)) {
return "? @" + bandLabel;
}
return String.format(Locale.US, "%+.1f dB @%s", storedValue, bandLabel);
} }
@@ -71,6 +71,8 @@ import kst4contest.view.map.OfflineDemImportService;
public class Kst4ContestApplication extends Application implements StatusUpdateListener { public class Kst4ContestApplication extends Application implements StatusUpdateListener {
// private static final Kst4ContestApplication dbcontroller = new DBController(); // 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 StationMapView stationMapView; //view class for the avl stn map
private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view
@@ -1387,6 +1389,29 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
*/ */
TableColumn<ChatMember, String> tropoCol = new TableColumn<ChatMember, String>("Tropo");
tropoCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMember, String>, ObservableValue<String>>() {
@Override
public ObservableValue<String> call(CellDataFeatures<ChatMember, String> 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<ChatMember, String> priorityScoreCol = new TableColumn<ChatMember, String>("Score");
priorityScoreCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMember, String>, ObservableValue<String>>() {
@Override
public ObservableValue<String> call(CellDataFeatures<ChatMember, String> 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<ChatMember, String> lastActCol = new TableColumn<ChatMember, String>("Act"); TableColumn<ChatMember, String> lastActCol = new TableColumn<ChatMember, String>("Act");
lastActCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMember, String>, ObservableValue<String>>() { lastActCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMember, String>, ObservableValue<String>>() {
@@ -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()); // 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() + ""); TextField chatMemberTableFilterMaxQrbTF = new TextField(chatcontroller.getChatPreferences().getStn_maxQRBDefault() + "");
chatMemberTableFilterMaxQrbTF.setFocusTraversable(false); chatMemberTableFilterMaxQrbTF.setFocusTraversable(false);
ToggleButton btnTglNewLocator = new ToggleButton("New locator");
Predicate<ChatMember> newLocatorPredicate = new Predicate<ChatMember>() {
@Override
public boolean test(ChatMember chatMember) {
return chatcontroller.isNewLocatorOnAnyEnabledBand(chatMember);
}
};
btnTglNewLocator.setOnAction(new EventHandler<ActionEvent>() {
@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<ChatMember> reachableTropoPredicate = new Predicate<ChatMember>() {
@Override
public boolean test(ChatMember chatMember) {
return isReachableViaTropoFilterMatch(chatMember);
}
};
btnTglReachableTropo.setOnAction(new EventHandler<ActionEvent>() {
@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<ChatMember> newBandsPredicate = new Predicate<ChatMember>() {
@Override
public boolean test(ChatMember chatMember) {
return isNewBandOpportunity(chatMember);
}
};
btnTglNewBands.setOnAction(new EventHandler<ActionEvent>() {
@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<ChatMember> asNext5MinPredicate = new Predicate<ChatMember>() {
@Override
public boolean test(ChatMember chatMember) {
return hasAsWindowInNextMinutes(chatMember, 5);
}
};
btnTglAsNext5Min.setOnAction(new EventHandler<ActionEvent>() {
@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] <= "); ToggleButton tglBtnQRBEnable = new ToggleButton("Show only QRB [km] <= ");
tglBtnQRBEnable.selectedProperty().addListener(new ChangeListener<Boolean>() { tglBtnQRBEnable.selectedProperty().addListener(new ChangeListener<Boolean>() {
Predicate<ChatMember> maxQrbPredicate = new Predicate<ChatMember>() { Predicate<ChatMember> maxQrbPredicate = new Predicate<ChatMember>() {
@@ -6337,6 +6443,29 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
HBox chatMemberTableFilterWorkedBandFiltersHbx = new HBox(); HBox chatMemberTableFilterWorkedBandFiltersHbx = new HBox();
ComboBox<String> 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<ActionEvent>() {
@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"); ToggleButton btnTglwkd = new ToggleButton("wkd");
Predicate<ChatMember> wkdPredicate = new Predicate<ChatMember>() { Predicate<ChatMember> wkdPredicate = new Predicate<ChatMember>() {
@@ -6602,6 +6731,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
"-fx-border-radius: 1;" + "-fx-border-radius: 1;" +
"-fx-border-color: lightgrey;"); "-fx-border-color: lightgrey;");
chatMemberTableFilterWorkedBandFiltersHbx.getChildren().addAll(
btnTglNewLocator,
btnTglReachableTropo,
btnTglNewBands,
btnTglAsNext5Min
);
// chatMemberTableFilterWorkedBandFilters // chatMemberTableFilterWorkedBandFilters
@@ -7895,8 +8032,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
// Unconditionally add listener to manually sync the textfield input to the button // 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) // (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) -> { 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 // That's the default behaviour of the myqrg textfield
@@ -9702,6 +9846,164 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
stage.setHeight(correctedHeight); 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<Band> 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;
}
}
} }
/** /**
+1
View File
@@ -2366,3 +2366,4 @@ OZ1FDH;Claus;JO55QX;StringProperty [value: null]; wkd true; wkd144 true; wkd432f
OZ7KJ;Skive Club;JO46ML;StringProperty [value: 144.225]; 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 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 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