Reduced opentopo api calls // added 4 char locator new-filter // added new band and tropo filter and priority sorter // decreased map load

This commit is contained in:
Marc Froehlich
2026-06-28 09:24:47 +02:00
committed by Philipp Wagner
parent 2693c3191f
commit 2da9ba8152
7 changed files with 3052 additions and 143 deletions
@@ -327,7 +327,8 @@ 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);
client.getReachabilityService().ensureTropoMarginCalculated(sender, finalDetectedBand); // No automatic full terrain analysis here.
// 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 {
@@ -336,7 +337,9 @@ 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);
client.getReachabilityService().ensureTropoMarginCalculated(cm, finalDetectedBand); } // No automatic full terrain analysis here.
// 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());
@@ -588,7 +591,8 @@ 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.
} }
@@ -634,7 +638,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,4 +1,12 @@
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;
@@ -7,12 +15,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;
@@ -21,26 +29,57 @@ 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;
/** /**
* Background service for station reachability values used by the station table. * Central service for tropo/path reachability calculations.
* *
* <p>The service performs the expensive full path analysis off the JavaFX thread, * <p>This service is now the single calculation path for:
* stores the bidirectional SSB margin in the ChatMember, and leaves the UI with a * <ul>
* simple numeric value for sorting/filtering.</p> * <li>the station table Tropo column</li>
* <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 logical station/band while a calculation * Prevents duplicate jobs for the same path while a calculation is already
* is already queued or running. * 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());
@@ -57,8 +96,12 @@ public final class ReachabilityService {
} }
/** /**
* Ensures that a station/band SSB-margin value exists. Existing finite values * Ensures that a station/band SSB-margin value exists if it is already cached.
* 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
@@ -68,36 +111,110 @@ public final class ReachabilityService {
return; return;
} }
if (member.hasTropoSsbMarginDb(band)) { if (member.hasFiniteTropoSsbMarginDb(band)) {
return; return;
} }
String calculationKey = buildCalculationKey(member, band); PathAnalysisRequest request = buildRequestForChatMember(member, band);
if (!queuedCalculationKeys.add(calculationKey)) { if (request == null) {
return; return;
} }
executor.submit(() -> { PathAnalysisResult cachedResult = pathAnalysisResultCache.get(buildCalculationKey(request));
double marginDb = Double.NaN; if (cachedResult != null) {
try { storePathAnalysisResultInChatMembers(member, band, cachedResult);
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(); /**
} else { * Requests a full path analysis for the station map.
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);
} }
/** /**
@@ -161,42 +278,222 @@ 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 Double.NaN; return null;
} }
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);
PathAnalysisRequest request = new PathAnalysisRequest( return buildRequest(
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);
@@ -207,19 +504,148 @@ 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) { /**
String callSignRaw = member.getCallSignRaw() == null ? "" : member.getCallSignRaw(); * Adds a map callback to the pending callback list for one calculation key.
String locator = member.getQra() == null ? "" : member.getQra(); *
return callSignRaw.trim().toUpperCase() + "|" + locator.trim().toUpperCase() + "|" + band.name(); * @param calculationKey path key
* @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(); return locator == null ? "" : locator.trim().toUpperCase(Locale.ROOT);
} }
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) {
@@ -404,10 +404,6 @@ public class ReadUDPbyUCXMessageThread extends Thread {
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,6 +659,27 @@ 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.
* *
@@ -678,21 +699,34 @@ 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" or "- @144" * @return display text such as "+8.4 dB @144", "? @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 (marginDb.isEmpty() || !Double.isFinite(marginDb.getAsDouble())) { if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
return "- @" + bandLabel; return "... @" + bandLabel;
} }
return String.format(Locale.US, "%+.1f dB @%s", marginDb.getAsDouble(), 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);
} }
@@ -72,6 +72,10 @@ 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. // Null means Auto: use the lowest session band, or category fallback.
private PauseTransition userListRefreshCoalescer;
private String pendingUserListUpdateReason = "";
private Band selectedReachabilityBandOverride = null; private Band selectedReachabilityBandOverride = null;
private StationMapView stationMapView; //view class for the avl stn map private StationMapView stationMapView; //view class for the avl stn map
@@ -955,7 +959,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
return true; return true;
} else return false; } else return false;
} catch (NullPointerException SenderNull) { } catch (NullPointerException SenderNull) {
System.out.println("KST4ContestApp, <<<catched error>>>: Sender/receiver of the message is unknown, categorizing is impossible: " + SenderNull.getMessage()); // System.out.println("KST4ContestApp, <<<catched error>>>: Sender/receiver of the message is unknown, categorizing is impossible: " + SenderNull.getMessage());
return false; return false;
} }
@@ -1040,7 +1044,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
} else return false; } else return false;
} catch (Exception exception) { } catch (Exception exception) {
System.out.println("KST4ContestApplication <<<catched ERROR>>>>: cant get sender infos due to sender is not known yet" + exception.getMessage()); // System.out.println("KST4ContestApplication <<<catched ERROR>>>>: cant get sender infos due to sender is not known yet" + exception.getMessage());
return false; return false;
} }
} }
@@ -1056,6 +1060,31 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
} }
/**
* Applies the currently selected station filters to the ChatMember FilteredList.
*
* <p>This replaces the old predicate-property binding. The old binding worked
* for normal filter changes, but it became unstable when we tried to force a
* refresh by adding/removing a dummy predicate. Calling this method simply
* rebuilds the combined predicate and sets it directly on the FilteredList.</p>
*/
private void applyChatMemberFilterPredicates() {
if (chatcontroller == null
|| chatcontroller.getLst_chatMemberListFiltered() == null
|| chatcontroller.getLst_chatMemberListFilterPredicates() == null) {
return;
}
Predicate<ChatMember> combinedPredicate =
chatcontroller.getLst_chatMemberListFilterPredicates()
.stream()
.reduce(chatMember -> true, Predicate::and);
chatcontroller.getLst_chatMemberListFiltered().setPredicate(combinedPredicate);
}
/** /**
* Helper method for furtherinfoPane * Helper method for furtherinfoPane
* @param s * @param s
@@ -6391,8 +6420,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
"-fx-border-color: lightgrey;"); "-fx-border-color: lightgrey;");
chatcontroller.getLst_chatMemberListFiltered().predicateProperty().bind(Bindings.createObjectBinding(() -> chatcontroller.getLst_chatMemberListFilterPredicates().stream().reduce(x -> true, Predicate::and), chatcontroller.getLst_chatMemberListFilterPredicates())); // chatcontroller.getLst_chatMemberListFiltered().predicateProperty().bind(Bindings.createObjectBinding(() -> chatcontroller.getLst_chatMemberListFilterPredicates().stream().reduce(x -> true, Predicate::and), chatcontroller.getLst_chatMemberListFilterPredicates()));
applyChatMemberFilterPredicates();
chatcontroller.getLst_chatMemberListFilterPredicates().addListener(
(ListChangeListener<Predicate<ChatMember>>) change -> applyChatMemberFilterPredicates()
);
TextField chatMemberTableFilterTextField = new TextField("Find..."); TextField chatMemberTableFilterTextField = new TextField("Find...");
chatMemberTableFilterTextField.setFocusTraversable(false); chatMemberTableFilterTextField.setFocusTraversable(false);
@@ -6434,8 +6468,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
chatcontroller.getLst_chatMemberListFilterPredicates().remove(searchTextPredicate); chatcontroller.getLst_chatMemberListFilterPredicates().remove(searchTextPredicate);
} }
else { else {
if (!chatcontroller.getLst_chatMemberListFilterPredicates().contains(searchTextPredicate)) {
chatcontroller.getLst_chatMemberListFilterPredicates().add(searchTextPredicate); chatcontroller.getLst_chatMemberListFilterPredicates().add(searchTextPredicate);
} }
}
System.out.println("KST4CApp " + chatMemberTableFilterTextField.textProperty().getValue().equals("") + " / " + !chatMemberTableFilterTextField.focusedProperty().getValue()); System.out.println("KST4CApp " + chatMemberTableFilterTextField.textProperty().getValue().equals("") + " / " + !chatMemberTableFilterTextField.focusedProperty().getValue());
} }
@@ -6444,6 +6480,33 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
HBox chatMemberTableFilterWorkedBandFiltersHbx = new HBox(); HBox chatMemberTableFilterWorkedBandFiltersHbx = new HBox();
Button btnCalculateSelectedTropo = new Button("Calc selected");
/**
* Calculates full terrain/path reachability only for the currently selected
* station. This is intentionally operator-triggered to avoid API-limit problems.
*/
btnCalculateSelectedTropo.setOnAction(new EventHandler<ActionEvent>() {
@Override
public void handle(ActionEvent event) {
ChatMember selectedMember = tbl_chatMember.getSelectionModel().getSelectedItem();
if (selectedMember == null && chatcontroller.getScoreService() != null) {
selectedMember = chatcontroller.getScoreService().getSelectedChatMember();
}
if (selectedMember == null) {
return;
}
Band selectedBand = resolveReachabilityBandForUi(selectedMember);
chatcontroller.getReachabilityService().calculateSelectedStationOnDemand(selectedMember, selectedBand);
}
});
btnCalculateSelectedTropo.setTooltip(new Tooltip("Calculate full Tropo/path analysis for the selected station only"));
ComboBox<String> cmbReachabilityBand = new ComboBox<>(); ComboBox<String> cmbReachabilityBand = new ComboBox<>();
cmbReachabilityBand.getItems().add("Auto"); cmbReachabilityBand.getItems().add("Auto");
for (Band band : chatcontroller.getReachabilityService().getEnabledStationBands()) { for (Band band : chatcontroller.getReachabilityService().getEnabledStationBands()) {
@@ -6455,16 +6518,26 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override @Override
public void handle(ActionEvent event) { public void handle(ActionEvent event) {
selectedReachabilityBandOverride = parseReachabilityBandSelection(cmbReachabilityBand.getValue()); selectedReachabilityBandOverride = parseReachabilityBandSelection(cmbReachabilityBand.getValue());
for (ChatMember member : chatcontroller.getLst_chatMemberList()) {
Band bandToCalculate = resolveReachabilityBandForUi(member); // Changing the display band must not start batch terrain analysis.
chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, bandToCalculate); // Existing cached values are shown; new values are calculated on map click
} // or via the explicit "Calc selected" button.
chatcontroller.fireUserListUpdate("Reachability band changed"); chatcontroller.fireUserListUpdate("Reachability band changed");
} }
}); });
chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(new Label("Reachability:")); chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(new Label("Reachability:"));
chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(cmbReachabilityBand); chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(cmbReachabilityBand);
chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnCalculateSelectedTropo);
/**
* In order to work the filters needs the proper band settings, which should be worked
*/
if (chatcontroller.getReachabilityService().getEnabledStationBands().isEmpty()) {
Label bandSetupWarning = new Label("Please enable at least one active station band for New Locator/New Band filters.");
bandSetupWarning.setStyle("-fx-text-fill: orange; -fx-font-weight: bold;");
chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(bandSetupWarning);
}
ToggleButton btnTglwkd = new ToggleButton("wkd"); ToggleButton btnTglwkd = new ToggleButton("wkd");
@@ -9497,16 +9570,43 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
//if we receive a threadstatemessage for sked warning, enable the sked warning //if we receive a threadstatemessage for sked warning, enable the sked warning
}
/**
* Forces the station FilteredList to evaluate all active predicates again.
*
* <p>Several filters depend on mutable ChatMember fields, for example worked
* flags, AirScout windows or calculated Tropo values. Updating such fields does
* not necessarily create a JavaFX list-change event. Therefore we explicitly
* re-apply the combined predicate instead of adding/removing a dummy predicate.
* The dummy-predicate trick can corrupt SortedList mapping in JavaFX.</p>
*/
private void forceChatMemberFilterRefresh() {
applyChatMemberFilterPredicates();
} }
@Override @Override
public void onUserListUpdated(String reason) { public void onUserListUpdated(String reason) {
Platform.runLater(() -> { Platform.runLater(() -> {
pendingUserListUpdateReason = reason;
// tbl_chatMember.sort(); if (userListRefreshCoalescer == null) {
userListRefreshCoalescer = new PauseTransition(Duration.millis(300));
userListRefreshCoalescer.setOnFinished(event -> {
forceChatMemberFilterRefresh();
if (tbl_chatMember != null) {
tbl_chatMember.refresh(); tbl_chatMember.refresh();
}
System.out.println("KST4Capp, UI Update Trigger: " + reason); refreshStationMapIfVisible();
System.out.println("KST4Capp, UI Update Trigger: " + pendingUserListUpdateReason);
});
}
userListRefreshCoalescer.playFromStart();
}); });
} }
@@ -6,7 +6,6 @@ import javafx.collections.ListChangeListener;
import javafx.scene.control.TableView; import javafx.scene.control.TableView;
import javafx.util.Duration; import javafx.util.Duration;
import kst4contest.controller.ChatController; import kst4contest.controller.ChatController;
import kst4contest.locatorUtils.Location;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences; import kst4contest.model.ChatPreferences;
@@ -15,12 +14,13 @@ import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer; import java.util.function.Consumer;
import kst4contest.model.Band;
import java.util.function.Predicate;
/** /**
* Synchronizes the application state with the station map window. * Synchronizes the application state with the station map window.
* *
@@ -32,7 +32,7 @@ import java.util.function.Consumer;
*/ */
public final class StationMapBridge { public final class StationMapBridge {
private final ExecutorService pathAnalysisExecutor = Executors.newSingleThreadExecutor(new PathAnalysisThreadFactory());
private final AtomicLong pathAnalysisGeneration = new AtomicLong(0); private final AtomicLong pathAnalysisGeneration = new AtomicLong(0);
private final ChatController chatController; private final ChatController chatController;
@@ -45,8 +45,7 @@ public final class StationMapBridge {
private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder(); private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder();
// private final OfflineDemManager offlineDemManager = new OfflineDemManager();
private final PathAnalysisService pathAnalysisService;
private String lastPathAnalysisRequestSignature = ""; private String lastPathAnalysisRequestSignature = "";
@@ -64,9 +63,7 @@ public final class StationMapBridge {
this.refreshCoalescer.setOnFinished(event -> refreshNow()); this.refreshCoalescer.setOnFinished(event -> refreshNow());
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(
new OpenMeteoTerrainProfileProvider()
);
} }
public void install() { public void install() {
@@ -85,6 +82,10 @@ public final class StationMapBridge {
(obs, oldValue, newValue) -> scheduleRefresh() (obs, oldValue, newValue) -> scheduleRefresh()
); );
chatController.getLst_chatMemberListFilterPredicates().addListener(
(ListChangeListener<Predicate<ChatMember>>) change -> requestImmediateRefresh()
);
requestImmediateRefresh(); requestImmediateRefresh();
} }
@@ -170,6 +171,17 @@ public final class StationMapBridge {
requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot); requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot);
} }
/**
* Requests the selected station path analysis through the central reachability
* service.
*
* <p>The map no longer owns a separate PathAnalysisService. This ensures that
* the map detail panel, the Tropo table column and the station filters all use
* exactly the same path/link-budget calculation.</p>
*
* @param ownLocator6 own locator from preferences
* @param selectedSnapshot selected map marker snapshot
*/
private void requestPathAnalysisAsync(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) { private void requestPathAnalysisAsync(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
String normalizedOwnLocator6 = normalizeLocator6(ownLocator6); String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
@@ -202,20 +214,28 @@ public final class StationMapBridge {
PathAnalysisResult.loading(normalizedOwnLocator6, normalizedTargetLocator6, targetCallsignRaw) PathAnalysisResult.loading(normalizedOwnLocator6, normalizedTargetLocator6, targetCallsignRaw)
); );
pathAnalysisExecutor.submit(() -> { ChatMember selectedMember = resolveBestChatMember(targetCallsignRaw);
PathAnalysisResult result = buildPathAnalysisResult(normalizedOwnLocator6, selectedSnapshot);
Platform.runLater(() -> { chatController.getReachabilityService().requestPathAnalysisForMap(
selectedMember,
selectedSnapshot,
result -> {
if (generation != pathAnalysisGeneration.get()) { if (generation != pathAnalysisGeneration.get()) {
return; return;
} }
stationMapView.setPathAnalysisResult(result); stationMapView.setPathAnalysisResult(result);
}); }
}); );
} }
/**
* Invalidates pending map callbacks.
*
* <p>The actual calculation executor is owned by ReachabilityService now, so
* this bridge no longer shuts down any path-analysis thread directly.</p>
*/
public void dispose() { public void dispose() {
pathAnalysisExecutor.shutdownNow(); pathAnalysisGeneration.incrementAndGet();
} }
private void handleMapCallsignSelection(String callSignRaw) { private void handleMapCallsignSelection(String callSignRaw) {
@@ -286,43 +306,6 @@ public final class StationMapBridge {
return callSignRaw.trim().toUpperCase(Locale.ROOT); return callSignRaw.trim().toUpperCase(Locale.ROOT);
} }
private PathAnalysisResult buildPathAnalysisResult(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
if (selectedSnapshot == null) {
return PathAnalysisResult.waitingForSelection(normalizedOwnLocator6);
}
String normalizedTargetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
if (normalizedOwnLocator6.length() != 6) {
return PathAnalysisResult.waitingForValidHomeLocator(normalizedOwnLocator6, normalizedTargetLocator6);
}
if (!selectedSnapshot.hasUsablePosition()) {
return PathAnalysisResult.waitingForValidTarget(normalizedOwnLocator6, normalizedTargetLocator6);
}
Location homeLocation = new Location(normalizedOwnLocator6);
double analysisFrequencyMHz = resolveAnalysisFrequencyMHz(selectedSnapshot);
PathAnalysisRequest request = new PathAnalysisRequest(
normalizedOwnLocator6,
homeLocation.getLatitude().toDegrees(),
homeLocation.getLongitude().toDegrees(),
selectedSnapshot.callSignRaw(),
normalizedTargetLocator6,
selectedSnapshot.latitudeDeg(),
selectedSnapshot.longitudeDeg(),
analysisFrequencyMHz,
chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
chatController.getChatPreferences().buildPathLinkBudgetSettings()
);
return pathAnalysisService.analyze(request);
}
private double resolveAnalysisFrequencyMHz(MapCallsignRawSnapshot selectedSnapshot) { private double resolveAnalysisFrequencyMHz(MapCallsignRawSnapshot selectedSnapshot) {
if (selectedSnapshot == null) { if (selectedSnapshot == null) {
@@ -376,12 +359,5 @@ public final class StationMapBridge {
return locator.trim().toUpperCase(Locale.ROOT); return locator.trim().toUpperCase(Locale.ROOT);
} }
private static final class PathAnalysisThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable, "station-map-path-analysis");
thread.setDaemon(true);
return thread;
}
}
} }
+2373
View File
File diff suppressed because it is too large Load Diff