mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-07-13 08:07:11 +02:00
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)
|
||||
|
||||
sender.addKnownFrequency(finalDetectedBand, finalDetectedFrequency);
|
||||
// No automatic full terrain analysis here.
|
||||
// The frequency is stored for later map/manual reachability requests.
|
||||
client.getReachabilityService().ensureTropoMarginCalculated(sender, finalDetectedBand);
|
||||
|
||||
//propagate known frequency to all instances of the same callsign (callRaw may exist multiple times)
|
||||
try {
|
||||
@@ -337,9 +336,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
ChatMember cm = client.getLst_chatMemberList().get(idx);
|
||||
if (cm != null && cm != sender) {
|
||||
cm.addKnownFrequency(finalDetectedBand, finalDetectedFrequency);
|
||||
// No automatic full terrain analysis here.
|
||||
// Avoids exhausting the online terrain API when many stations mention QRGs.
|
||||
}
|
||||
client.getReachabilityService().ensureTropoMarginCalculated(cm, finalDetectedBand); }
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("[SmartParser, warning]: failed to propagate known frequency across duplicates: " + e.getMessage());
|
||||
@@ -591,8 +588,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
|
||||
if (!client.getChatPreferences().getStn_loginCallSign().equals(newMember.getCallSign())) {
|
||||
this.client.getLst_chatMemberList().add(newMember); //the own call will not be in the list
|
||||
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
|
||||
// Reachability is calculated on demand only: map click, selected station, or manual request.
|
||||
this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
|
||||
}
|
||||
|
||||
|
||||
@@ -638,7 +634,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember);
|
||||
|
||||
this.client.getLst_chatMemberList().add(newMember);
|
||||
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
|
||||
this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
|
||||
|
||||
this.client.getDbHandler().storeChatMember(newMember);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
package kst4contest.controller;
|
||||
import kst4contest.view.map.MapCallsignRawSnapshot;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import kst4contest.locatorUtils.Location;
|
||||
@@ -15,12 +7,12 @@ import kst4contest.model.ChatCategory;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.view.map.GeometryOnlyPathAnalysisService;
|
||||
|
||||
import kst4contest.view.map.OpenMeteoTerrainProfileProvider;
|
||||
import kst4contest.view.map.PathAnalysisRequest;
|
||||
import kst4contest.view.map.PathAnalysisResult;
|
||||
import kst4contest.view.map.PathAnalysisService;
|
||||
import kst4contest.view.map.PathGeometryUtils;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Objects;
|
||||
@@ -29,57 +21,26 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Central service for tropo/path reachability calculations.
|
||||
* Background service for station reachability values used by the station table.
|
||||
*
|
||||
* <p>This service is now the single calculation path for:
|
||||
* <ul>
|
||||
* <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>
|
||||
* <p>The service performs the expensive full path analysis off the JavaFX thread,
|
||||
* stores the bidirectional SSB margin in the ChatMember, and leaves the UI with a
|
||||
* simple numeric value for sorting/filtering.</p>
|
||||
*/
|
||||
public final class ReachabilityService {
|
||||
|
||||
private static final long FAILED_ANALYSIS_RETRY_DELAY_MS = 5L * 60L * 1000L;
|
||||
|
||||
private final ChatController chatController;
|
||||
private final PathAnalysisService pathAnalysisService;
|
||||
private final ExecutorService executor;
|
||||
|
||||
/**
|
||||
* Prevents duplicate jobs for the same path while a calculation is already
|
||||
* queued or running.
|
||||
* Prevents duplicate jobs for the same logical station/band while a calculation
|
||||
* is already queued or running.
|
||||
*/
|
||||
private final Set<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) {
|
||||
this.chatController = Objects.requireNonNull(chatController, "chatController");
|
||||
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(new OpenMeteoTerrainProfileProvider());
|
||||
@@ -96,12 +57,8 @@ public final class ReachabilityService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that a station/band SSB-margin value exists if it is already cached.
|
||||
*
|
||||
* <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>
|
||||
* Ensures that a station/band SSB-margin value exists. Existing finite values
|
||||
* and existing NaN failure markers are not recalculated automatically.
|
||||
*
|
||||
* @param member chatmember to evaluate
|
||||
* @param band band to evaluate
|
||||
@@ -111,110 +68,36 @@ public final class ReachabilityService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (member.hasFiniteTropoSsbMarginDb(band)) {
|
||||
if (member.hasTropoSsbMarginDb(band)) {
|
||||
return;
|
||||
}
|
||||
|
||||
PathAnalysisRequest request = buildRequestForChatMember(member, band);
|
||||
if (request == null) {
|
||||
String calculationKey = buildCalculationKey(member, band);
|
||||
if (!queuedCalculationKeys.add(calculationKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(buildCalculationKey(request));
|
||||
if (cachedResult != null) {
|
||||
storePathAnalysisResultInChatMembers(member, band, cachedResult);
|
||||
}
|
||||
executor.submit(() -> {
|
||||
double marginDb = Double.NaN;
|
||||
try {
|
||||
marginDb = calculateSsbMarginDb(member, band);
|
||||
} catch (Exception exception) {
|
||||
System.out.println("[ReachabilityService, warning]: analysis failed for "
|
||||
+ member.getCallSignRaw() + " @" + band + ": " + exception.getMessage());
|
||||
}
|
||||
|
||||
final double finalMarginDb = marginDb;
|
||||
Runnable storeResult = () -> {
|
||||
member.setTropoSsbMarginDb(band, finalMarginDb);
|
||||
chatController.fireUserListUpdate("Reachability calculated");
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Requests a full path analysis for the station map.
|
||||
*
|
||||
* <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;
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
storeResult.run();
|
||||
} else {
|
||||
Platform.runLater(storeResult);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
private double calculateSsbMarginDb(ChatMember member, Band band) {
|
||||
String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
|
||||
String targetLocator6 = normalizeLocator6(member.getQra());
|
||||
|
||||
if (ownLocator6.length() != 6 || targetLocator6.length() != 6) {
|
||||
return null;
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
Location homeLocation = new Location(ownLocator6);
|
||||
Location targetLocation = new Location(targetLocator6);
|
||||
|
||||
double analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, band);
|
||||
|
||||
return buildRequest(
|
||||
PathAnalysisRequest request = new PathAnalysisRequest(
|
||||
ownLocator6,
|
||||
homeLocation.getLatitude().toDegrees(),
|
||||
homeLocation.getLongitude().toDegrees(),
|
||||
member.getCallSignRaw(),
|
||||
targetLocator6,
|
||||
targetLocation.getLatitude().toDegrees(),
|
||||
targetLocation.getLongitude().toDegrees(),
|
||||
analysisFrequencyMHz
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the shared PathAnalysisRequest used by map and table/manual requests.
|
||||
*/
|
||||
private PathAnalysisRequest buildRequest(String ownLocator6,
|
||||
String targetCallsignRaw,
|
||||
String targetLocator6,
|
||||
double targetLatitudeDeg,
|
||||
double targetLongitudeDeg,
|
||||
double analysisFrequencyMHz) {
|
||||
|
||||
Location homeLocation = new Location(ownLocator6);
|
||||
|
||||
return new PathAnalysisRequest(
|
||||
ownLocator6,
|
||||
homeLocation.getLatitude().toDegrees(),
|
||||
homeLocation.getLongitude().toDegrees(),
|
||||
targetCallsignRaw,
|
||||
targetLocator6,
|
||||
targetLatitudeDeg,
|
||||
targetLongitudeDeg,
|
||||
analysisFrequencyMHz,
|
||||
chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
|
||||
chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
|
||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
chatController.getChatPreferences().buildPathLinkBudgetSettings()
|
||||
);
|
||||
|
||||
PathAnalysisResult result = pathAnalysisService.analyze(request);
|
||||
if (result == null || result.linkBudgetSummary() == null || !result.linkBudgetSummary().hasUsableBudget()) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return result.linkBudgetSummary().bidirectionalSsbMarginDb();
|
||||
}
|
||||
|
||||
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) {
|
||||
if (member != null && member.getKnownActiveBands() != null) {
|
||||
ChatMember.ActiveFrequencyInfo activeFrequencyInfo = member.getKnownActiveBands().get(band);
|
||||
@@ -504,148 +207,19 @@ public final class ReachabilityService {
|
||||
}
|
||||
}
|
||||
|
||||
if (member != null && member.getFrequency() != null && member.getFrequency().getValue() != null) {
|
||||
double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(member.getFrequency().getValue());
|
||||
if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) {
|
||||
Band parsedBand = Band.fromFrequency(parsedFrequencyMHz);
|
||||
if (parsedBand == null || parsedBand == band) {
|
||||
return parsedFrequencyMHz;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return band.getDefaultAnalysisFrequencyMHz();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a map callback to the pending callback list for one calculation key.
|
||||
*
|
||||
* @param calculationKey path key
|
||||
* @param fxCallback callback to add
|
||||
*/
|
||||
private void addPendingCallback(String calculationKey, Consumer<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 buildCalculationKey(ChatMember member, Band band) {
|
||||
String callSignRaw = member.getCallSignRaw() == null ? "" : member.getCallSignRaw();
|
||||
String locator = member.getQra() == null ? "" : member.getQra();
|
||||
return callSignRaw.trim().toUpperCase() + "|" + locator.trim().toUpperCase() + "|" + band.name();
|
||||
}
|
||||
|
||||
private String normalizeLocator6(String locator) {
|
||||
return locator == null ? "" : locator.trim().toUpperCase(Locale.ROOT);
|
||||
return locator == null ? "" : locator.trim().toUpperCase();
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static final class ReachabilityThreadFactory implements ThreadFactory {
|
||||
@Override
|
||||
public Thread newThread(Runnable runnable) {
|
||||
|
||||
@@ -348,62 +348,88 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
Band workedBand = helper_resolveBandFromLoggerBand(band);
|
||||
|
||||
switch (band) {
|
||||
case "144":
|
||||
case "2m": //minos contest logger
|
||||
{
|
||||
case "144": {
|
||||
workedCall.setWorked144(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "432":
|
||||
case "70cm":
|
||||
{
|
||||
case "432": {
|
||||
workedCall.setWorked432(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "1240": //ucxlog style
|
||||
case "1296": //used for n1mm / Dxlog
|
||||
case "23cm": //minos contest logger
|
||||
{
|
||||
case "1240": {
|
||||
workedCall.setWorked1240(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "2300":
|
||||
case "13cm":
|
||||
{
|
||||
case "2300": {
|
||||
workedCall.setWorked2300(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "3400":
|
||||
case "9cm":
|
||||
{
|
||||
case "3400": {
|
||||
workedCall.setWorked3400(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "5600":
|
||||
case "6cm":
|
||||
{
|
||||
case "5600": {
|
||||
workedCall.setWorked5600(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "10G":
|
||||
case "3cm":
|
||||
{
|
||||
case "10G": {
|
||||
workedCall.setWorked10G(true);
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* cases hotfix for MINOS logger, which tells band like "2m", not "144"
|
||||
*/
|
||||
case "2m": {
|
||||
workedCall.setWorked144(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "70cm": {
|
||||
workedCall.setWorked432(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "23cm": {
|
||||
workedCall.setWorked1240(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "13cm": {
|
||||
workedCall.setWorked2300(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "9cm": {
|
||||
workedCall.setWorked3400(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "6cm": {
|
||||
workedCall.setWorked5600(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "3cm": {
|
||||
workedCall.setWorked10G(true);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
System.out.println("[ReadUDPFromUCX, Error:] unexpected band value: \"" + band + "\"");
|
||||
break;
|
||||
}
|
||||
|
||||
// if (!client.getMap_ucxLogInfoWorkedCalls().containsKey("call")) {
|
||||
// client.getMap_ucxLogInfoWorkedCalls().put(call, workedCall);
|
||||
|
||||
// } else
|
||||
{
|
||||
/**
|
||||
* That means, the station is worked already but maybe at another band. So we
|
||||
|
||||
@@ -659,27 +659,6 @@ public class ChatMember {
|
||||
return band != null && this.tropoSsbMarginDbByBand.containsKey(band);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Returns true only when a finite, usable tropo SSB margin is stored for the band.
|
||||
*
|
||||
* <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.
|
||||
*
|
||||
@@ -699,34 +678,21 @@ public class ChatMember {
|
||||
return OptionalDouble.of(storedValue);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @return display text such as "+8.4 dB @144", "? @144" or "... @144"
|
||||
* @return display text such as "+8.4 dB @144" or "- @144"
|
||||
*/
|
||||
public String formatTropoSsbMarginForBand(Band band) {
|
||||
String bandLabel = band == null ? "?" : band.getDisplayLabel();
|
||||
OptionalDouble marginDb = getTropoSsbMarginDb(band);
|
||||
|
||||
if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
|
||||
return "... @" + bandLabel;
|
||||
if (marginDb.isEmpty() || !Double.isFinite(marginDb.getAsDouble())) {
|
||||
return "- @" + bandLabel;
|
||||
}
|
||||
|
||||
Double storedValue = this.tropoSsbMarginDbByBand.get(band);
|
||||
|
||||
if (storedValue == null || !Double.isFinite(storedValue)) {
|
||||
return "? @" + bandLabel;
|
||||
}
|
||||
|
||||
return String.format(Locale.US, "%+.1f dB @%s", storedValue, bandLabel);
|
||||
return String.format(Locale.US, "%+.1f dB @%s", marginDb.getAsDouble(), bandLabel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ import kst4contest.view.map.OfflineDemImportService;
|
||||
|
||||
public class Kst4ContestApplication extends Application implements StatusUpdateListener {
|
||||
// private static final Kst4ContestApplication dbcontroller = new DBController();
|
||||
// Null means Auto: use the lowest session band, or category fallback.
|
||||
private Band selectedReachabilityBandOverride = null;
|
||||
|
||||
private StationMapView stationMapView; //view class for the avl stn map
|
||||
private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view
|
||||
@@ -1387,6 +1389,29 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
*/
|
||||
|
||||
|
||||
TableColumn<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");
|
||||
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());
|
||||
|
||||
@@ -6061,6 +6089,84 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
|
||||
TextField chatMemberTableFilterMaxQrbTF = new TextField(chatcontroller.getChatPreferences().getStn_maxQRBDefault() + "");
|
||||
chatMemberTableFilterMaxQrbTF.setFocusTraversable(false);
|
||||
|
||||
|
||||
ToggleButton btnTglNewLocator = new ToggleButton("New locator");
|
||||
Predicate<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] <= ");
|
||||
tglBtnQRBEnable.selectedProperty().addListener(new ChangeListener<Boolean>() {
|
||||
Predicate<ChatMember> maxQrbPredicate = new Predicate<ChatMember>() {
|
||||
@@ -6337,6 +6443,29 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
|
||||
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");
|
||||
|
||||
Predicate<ChatMember> wkdPredicate = new Predicate<ChatMember>() {
|
||||
@@ -6602,6 +6731,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
"-fx-border-radius: 1;" +
|
||||
"-fx-border-color: lightgrey;");
|
||||
|
||||
|
||||
chatMemberTableFilterWorkedBandFiltersHbx.getChildren().addAll(
|
||||
btnTglNewLocator,
|
||||
btnTglReachableTropo,
|
||||
btnTglNewBands,
|
||||
btnTglAsNext5Min
|
||||
);
|
||||
|
||||
// chatMemberTableFilterWorkedBandFilters
|
||||
|
||||
|
||||
@@ -7895,8 +8032,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
|
||||
// Unconditionally add listener to manually sync the textfield input to the button
|
||||
// (this listener also fires correctly when the value is updated by the binding)
|
||||
// txt_ownqrgMainCategory.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
// MYQRGButton.textProperty().set(newValue);
|
||||
// });
|
||||
txt_ownqrgMainCategory.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
MYQRGButton.textProperty().set(newValue);
|
||||
} else {
|
||||
Platform.runLater(() -> MYQRGButton.textProperty().set(newValue));
|
||||
}
|
||||
});
|
||||
|
||||
// That's the default behaviour of the myqrg textfield
|
||||
@@ -9702,6 +9846,164 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
stage.setHeight(correctedHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the currently selected UI reachability band. Null override means Auto.
|
||||
*
|
||||
* @param member station row
|
||||
* @return manually selected band or automatic band
|
||||
*/
|
||||
private Band resolveReachabilityBandForUi(ChatMember member) {
|
||||
if (selectedReachabilityBandOverride != null) {
|
||||
return selectedReachabilityBandOverride;
|
||||
}
|
||||
return chatcontroller.getReachabilityService().resolveAutoBand(member);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the reachability ComboBox selection.
|
||||
*
|
||||
* @param selectedLabel selected UI text
|
||||
* @return selected band, or null for Auto
|
||||
*/
|
||||
private Band parseReachabilityBandSelection(String selectedLabel) {
|
||||
if (selectedLabel == null || selectedLabel.isBlank() || "Auto".equalsIgnoreCase(selectedLabel)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (Band band : Band.values()) {
|
||||
if (selectedLabel.equalsIgnoreCase(band.getDisplayLabel())) {
|
||||
return band;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the projected priority score for the station table.
|
||||
*
|
||||
* @param member station row
|
||||
* @return score text
|
||||
*/
|
||||
private String formatPriorityScore(ChatMember member) {
|
||||
if (member == null || !Double.isFinite(member.getCurrentPriorityScore())) {
|
||||
return "-";
|
||||
}
|
||||
return String.format(Locale.US, "%.0f", member.getCurrentPriorityScore());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a numeric table value for custom score sorting.
|
||||
*
|
||||
* @param value table text
|
||||
* @return parsed value or negative infinity for missing values
|
||||
*/
|
||||
private double parseTableDouble(String value) {
|
||||
if (value == null || value.isBlank() || value.equals("-") || value.startsWith("- @")) {
|
||||
return Double.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
String normalized = value.replace(",", ".").replace("+", "").trim();
|
||||
int firstSpace = normalized.indexOf(' ');
|
||||
if (firstSpace > 0) {
|
||||
normalized = normalized.substring(0, firstSpace);
|
||||
}
|
||||
|
||||
try {
|
||||
return Double.parseDouble(normalized);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return Double.NEGATIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate helper for the tropo filter. Pending or failed calculations remain
|
||||
* visible as requested; only finite negative margins are hidden.
|
||||
*
|
||||
* @param member station row
|
||||
* @return true if station should remain visible under the tropo filter
|
||||
*/
|
||||
private boolean isReachableViaTropoFilterMatch(ChatMember member) {
|
||||
if (member == null || chatcontroller.getReachabilityService() == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Band selectedBand = resolveReachabilityBandForUi(member);
|
||||
chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, selectedBand);
|
||||
|
||||
OptionalDouble marginDb = member.getTropoSsbMarginDb(selectedBand);
|
||||
if (marginDb.isEmpty() || !Double.isFinite(marginDb.getAsDouble())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return marginDb.getAsDouble() >= 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a station has any AirScout window now or within maxMinutes.
|
||||
*
|
||||
* @param member station row
|
||||
* @param maxMinutes look-ahead window
|
||||
* @return true if any AS window is available
|
||||
*/
|
||||
private boolean hasAsWindowInNextMinutes(ChatMember member, int maxMinutes) {
|
||||
if (member == null || member.getAirPlaneReflectInfo() == null
|
||||
|| member.getAirPlaneReflectInfo().getRisingAirplanes() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return member.getAirPlaneReflectInfo().getRisingAirplanes().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.anyMatch(airPlane -> airPlane.getArrivingDurationMinutes() >= 0
|
||||
&& airPlane.getArrivingDurationMinutes() <= maxMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the station is known to be QRV on at least one enabled own band
|
||||
* that has not been worked yet.
|
||||
*
|
||||
* @param member station row
|
||||
* @return true if there is a new-band opportunity
|
||||
*/
|
||||
private boolean isNewBandOpportunity(ChatMember member) {
|
||||
if (member == null || chatcontroller.getReachabilityService() == null
|
||||
|| member.getKnownActiveBands() == null || member.getKnownActiveBands().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
EnumSet<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
|
||||
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
|
||||
DO5SA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
Reference in New Issue
Block a user