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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user