diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java index 2388075..764d8b7 100644 --- a/src/main/java/kst4contest/controller/ChatController.java +++ b/src/main/java/kst4contest/controller/ChatController.java @@ -1077,6 +1077,10 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList private DBController dbHandler; + private ReachabilityService reachabilityService; + private final WorkedGrossFieldCache workedGrossFieldCache = new WorkedGrossFieldCache(); + + private Socket socket; private ServerSocket cluster_telnetServerSocket; // socket that accepts telnet client connects (cluster client) // private ServerSocketChannel cluster_telnetServerSocketChannel; @@ -1624,6 +1628,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList }); dbHandler = new DBController(); + reachabilityService = new ReachabilityService(this); + rebuildWorkedGrossFieldCacheFromDatabase(); // chatPreferences = new ChatPreferences(); // chatPreferences.readPreferencesFromXmlFile(); // set the praktikst Prefs by file or default if file is corrupted @@ -2090,6 +2096,103 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList } + + /** + * Returns the background reachability service used by the station table. + * + * @return reachability service + */ + public ReachabilityService getReachabilityService() { + return reachabilityService; + } + + /** + * Returns the runtime gross-field cache used by the new-locator filter. + * + * @return worked gross-field cache + */ + public WorkedGrossFieldCache getWorkedGrossFieldCache() { + return workedGrossFieldCache; + } + + /** + * Rebuilds the gross-field cache from persistent SQLite data and enriches it with + * legacy ChatMember worked/qra rows where possible. + */ + public void rebuildWorkedGrossFieldCacheFromDatabase() { + if (dbHandler == null) { + return; + } + + try { + workedGrossFieldCache.rebuildFromDatabaseSnapshot(dbHandler.fetchWorkedGrossFieldsFromDB()); + workedGrossFieldCache.addWorkedBandsFromStoredChatMembers(dbHandler.fetchChatMemberWkdDataFromDB().values()); + } catch (Exception exception) { + System.out.println("[ChatController, warning]: could not rebuild worked gross-field cache: " + + exception.getMessage()); + } + } + + /** + * Persists and caches one worked gross field after a logger QSO packet. + * + * @param band worked band + * @param locator6 six-character Maidenhead locator + * @param workedCall worked station + * @param source logger/source name + */ + public void registerWorkedGrossField(Band band, String locator6, ChatMember workedCall, String source) { + if (band == null || locator6 == null) { + return; + } + + String normalizedLocator6 = WorkedGrossFieldCache.extractLocator6(locator6); + if (normalizedLocator6 == null) { + return; + } + + String callSignRaw = workedCall == null ? null : workedCall.getCallSignRaw(); + + try { + dbHandler.upsertWorkedGrossField(band, normalizedLocator6, callSignRaw, source); + } catch (Exception exception) { + System.out.println("[ChatController, warning]: could not persist worked gross field: " + + exception.getMessage()); + } + + workedGrossFieldCache.addWorked(band, normalizedLocator6); + fireUserListUpdate("Worked gross field updated"); + } + + /** + * Returns true when the member's gross field is not worked on at least one enabled + * station band. This is the predicate behind the "new locator" filter. + * + * @param member member to inspect + * @return true if the station is still a new locator on any enabled band + */ + public boolean isNewLocatorOnAnyEnabledBand(ChatMember member) { + if (member == null || member.getQra() == null) { + return false; + } + + EnumSet enabledBands = reachabilityService == null + ? getMyEnabledBandsFromPrefs(chatPreferences) + : reachabilityService.getEnabledStationBands(); + + if (enabledBands.isEmpty()) { + return false; + } + + for (Band band : enabledBands) { + if (!workedGrossFieldCache.isGrossFieldWorked(band, member.getQra())) { + return true; + } + } + + return false; + } + public long getCurrentEpochTime() { OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC); diff --git a/src/main/java/kst4contest/controller/DBController.java b/src/main/java/kst4contest/controller/DBController.java index b7184dc..3ec64e4 100644 --- a/src/main/java/kst4contest/controller/DBController.java +++ b/src/main/java/kst4contest/controller/DBController.java @@ -14,6 +14,12 @@ import kst4contest.ApplicationConstants; import kst4contest.model.ChatMember; import kst4contest.utils.ApplicationFileUtils; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.Set; + +import kst4contest.model.Band; + public class DBController { /** @@ -37,7 +43,7 @@ public class DBController { * marker. The marker is stored in SQLite PRAGMA user_version so the expensive * normalization rebuild is executed only once per database file. */ - private static final int CURRENT_DATABASE_SCHEMA_VERSION = 13; + private static final int CURRENT_DATABASE_SCHEMA_VERSION = 14; /** * Minimum interval between two expiration cleanup runs. This avoids repeated full @@ -136,6 +142,7 @@ public class DBController { */ private synchronized void ensureChatMemberTableCompatibility() { createChatMemberTableIfRequired(); + createWorkedGrossFieldTableIfRequired(); versionUpdateOfDBCheckAndChangeV11ToV12(); versionUpdateOfDBCheckAndChangeV12ToV13(); @@ -240,6 +247,30 @@ public class DBController { } } + /** + * Creates the persistent gross-field table used by the new-locator filter. + * The primary key is band + gross field because the filter only needs to know + * whether a large locator square has already been worked on a band. + */ + private synchronized void createWorkedGrossFieldTableIfRequired() { + + String createTableSql = + "CREATE TABLE IF NOT EXISTS WorkedGrossField (" + + "band TEXT NOT NULL, " + + "grossField TEXT NOT NULL, " + + "locator TEXT, " + + "callsign TEXT, " + + "source TEXT, " + + "lastWorkedEpochMs INTEGER NOT NULL, " + + "PRIMARY KEY (band, grossField)" + + ");"; + + try (Statement statement = connection.createStatement()) { + statement.executeUpdate(createTableSql); + } catch (SQLException e) { + throw new RuntimeException("[DBH, ERROR:] Could not create WorkedGrossField table", e); + } + } /** * Updates old v1.1 databases to the v1.2 schema by adding the not-QRV fields if * they are missing. @@ -477,7 +508,15 @@ public class DBController { } catch (SQLException e) { throw new RuntimeException("[DBH, ERROR:] Could not reset expired worked data", e); } - } + + try (PreparedStatement deleteGrossFieldsStatement = connection.prepareStatement( + "DELETE FROM WorkedGrossField WHERE lastWorkedEpochMs > 0 AND lastWorkedEpochMs < ?;")) { + deleteGrossFieldsStatement.setLong(1, expirationThresholdEpochMs); + deleteGrossFieldsStatement.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } /** * Stores a chatmember with its metadata in the database. The unique key is always @@ -612,8 +651,14 @@ public class DBController { + "lastFlagsChangeEpochMs = 0;"; try (Statement statement = connection.createStatement()) { - return statement.executeUpdate(resetAllWorkedDataSql); +// return statement.executeUpdate(resetAllWorkedDataSql); + int affectedRows = statement.executeUpdate(resetAllWorkedDataSql); + statement.executeUpdate("DELETE FROM WorkedGrossField;"); + return affectedRows; + + } catch (SQLException e) { + System.err.println("[DBH, ERROR:] Couldn't reset the worked data"); e.printStackTrace(); return -1; @@ -641,12 +686,27 @@ public class DBController { return false; } +// String updateWorkedSql = +// "UPDATE ChatMember SET worked = 1, " + workedBandColumnName + " = 1, lastFlagsChangeEpochMs = ? WHERE callsign = ?;"; + String updateWorkedSql = - "UPDATE ChatMember SET worked = 1, " + workedBandColumnName + " = 1, lastFlagsChangeEpochMs = ? WHERE callsign = ?;"; + "UPDATE ChatMember SET worked = 1, " + + workedBandColumnName + + " = 1, " + + "qra = CASE WHEN ? IS NOT NULL AND TRIM(?) <> '' AND LOWER(TRIM(?)) <> 'unknown' THEN ? ELSE qra END, " + + "lastFlagsChangeEpochMs = ? WHERE callsign = ?;"; try (PreparedStatement preparedStatement = connection.prepareStatement(updateWorkedSql)) { - preparedStatement.setLong(1, System.currentTimeMillis()); - preparedStatement.setString(2, chatMemberToStore.getCallSignRaw()); +// preparedStatement.setLong(1, System.currentTimeMillis()); +// preparedStatement.setString(2, chatMemberToStore.getCallSignRaw()); + + String qra = chatMemberToStore.getQra(); + preparedStatement.setString(1, qra); + preparedStatement.setString(2, qra); + preparedStatement.setString(3, qra); + preparedStatement.setString(4, qra); + preparedStatement.setLong(5, System.currentTimeMillis()); + preparedStatement.setString(6, chatMemberToStore.getCallSignRaw()); int affectedRows = preparedStatement.executeUpdate(); return affectedRows > 0; @@ -955,4 +1015,79 @@ public class DBController { // dbc.storeChatMember(dummy); // dbc.updateWkdInfoOnChatMember(dummy); } + + /** + * Inserts or updates a worked gross field for the new-locator filter. + * + * @param band worked band + * @param locator6 six-character Maidenhead locator + * @param callsignRaw normalized raw callsign or null + * @param source source identifier such as UCXLOG or WINTEST + */ + public synchronized void upsertWorkedGrossField(Band band, String locator6, String callsignRaw, String source) { + + String grossField = WorkedGrossFieldCache.extractGrossField(locator6); + String normalizedLocator6 = WorkedGrossFieldCache.extractLocator6(locator6); + + if (band == null || grossField == null) { + return; + } + + String upsertSql = + "INSERT INTO WorkedGrossField (band, grossField, locator, callsign, source, lastWorkedEpochMs) " + + "VALUES (?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(band, grossField) DO UPDATE SET " + + "locator = COALESCE(excluded.locator, WorkedGrossField.locator), " + + "callsign = COALESCE(excluded.callsign, WorkedGrossField.callsign), " + + "source = excluded.source, " + + "lastWorkedEpochMs = excluded.lastWorkedEpochMs;"; + + try (PreparedStatement preparedStatement = connection.prepareStatement(upsertSql)) { + preparedStatement.setString(1, band.name()); + preparedStatement.setString(2, grossField); + preparedStatement.setString(3, normalizedLocator6); + preparedStatement.setString(4, callsignRaw); + preparedStatement.setString(5, source == null ? "UNKNOWN" : source); + preparedStatement.setLong(6, System.currentTimeMillis()); + preparedStatement.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException("[DBH, ERROR:] Could not upsert worked gross field", e); + } + } + + /** + * Loads all non-expired worked gross fields from the database. + * + * @return map of band to worked gross fields + */ + public synchronized Map> fetchWorkedGrossFieldsFromDB() { + + resetExpiredWorkedDataIfRequired(); + + Map> result = new EnumMap<>(Band.class); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT band, grossField FROM WorkedGrossField ORDER BY band, grossField;")) { + + while (resultSet.next()) { + Band band; + try { + band = Band.valueOf(resultSet.getString("band")); + } catch (Exception ignored) { + continue; + } + + String grossField = WorkedGrossFieldCache.extractGrossField(resultSet.getString("grossField")); + if (grossField == null) { + continue; + } + + result.computeIfAbsent(band, ignored -> new HashSet<>()).add(grossField); + } + } catch (SQLException e) { + throw new RuntimeException("[DBH, ERROR:] Could not fetch worked gross fields", e); + } + + return result; + } } diff --git a/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/src/main/java/kst4contest/controller/MessageBusManagementThread.java index eba1e07..c9c6195 100644 --- a/src/main/java/kst4contest/controller/MessageBusManagementThread.java +++ b/src/main/java/kst4contest/controller/MessageBusManagementThread.java @@ -327,6 +327,7 @@ public class MessageBusManagementThread extends Thread { // 1. Store in the new Map (for future context/history) sender.addKnownFrequency(finalDetectedBand, finalDetectedFrequency); + client.getReachabilityService().ensureTropoMarginCalculated(sender, finalDetectedBand); //propagate known frequency to all instances of the same callsign (callRaw may exist multiple times) try { @@ -335,7 +336,7 @@ public class MessageBusManagementThread extends Thread { ChatMember cm = client.getLst_chatMemberList().get(idx); if (cm != null && cm != sender) { cm.addKnownFrequency(finalDetectedBand, finalDetectedFrequency); - } + client.getReachabilityService().ensureTropoMarginCalculated(cm, finalDetectedBand); } } } catch (Exception e) { System.out.println("[SmartParser, warning]: failed to propagate known frequency across duplicates: " + e.getMessage()); @@ -587,6 +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); } @@ -632,6 +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.getDbHandler().storeChatMember(newMember); } @@ -1754,190 +1757,6 @@ public class MessageBusManagementThread extends Thread { break;// TODO Change at may24, avoid uncloadability. Check if this could lead to further errors on instable link! // client.getMessageRXBus().clear(); } - { -// System.out.println("MessagebusmgtThread: Readthread is interrupted! Queue will be resetted"); -// this.interrupt(); -// client.getMessageRXBus().clear(); - } - -// if (client.getMessageRXBus().peek() == null) { -// -// Timer doNothingTimer = new Timer(); -// doNothingTimer.schedule(new TimerTask() { -// -// @Override -// public void run() { -// -// //do nothing -// -// } -// }, 100);// TODO: Temporary -// } -// -// -// if (client.getMessageRXBus().peek() == null && client.getMessageTXBus().peek() == null) { -// -// if (this.client.isDisconnectionPerformedByUser()) { -// break;//TODO: what if it´s not the finally closage but a band channel change? -// } -// // do nothing -//// try { -//// this.sleep(20); -//// } catch (InterruptedException e) { -//// // TODO Auto-generated catch block -//// e.printStackTrace(); -//// } catch (Exception e2) { -//// // TODO Auto-generated catch block -//// e2.printStackTrace(); -//// } -// } -// else - { - -// messageLine = messageTextRaw.getMessageText(); -// -// /*********************************************** -// * CASE RX -// ***********************************************/ -// -//// if (client.getMessageRXBus().peek() != null) { -// -//// try { -//// messageTextRaw = client.getMessageRXBus().take(); -//// -////// System.out.println("MSBGBUS: rxed: " + messageTextRaw); -//// } catch (InterruptedException e) { -//// // TODO Auto-generated catch block -//// e.printStackTrace(); -//// } -// -// if (messageTextRaw.getMessageText() == null) { -// System.out.println("[MSGBUSMGT:] ERROR! got NULL message! BYE!"); -//// this.interrupt(); -//// break; -// } -// -// messageLine = messageTextRaw.getMessageText(); -// -//// try { -//// bufwrtrRawMSGOut.write(messageLine + "\n"); -//// bufwrtrRawMSGOut.flush(); -//// -//// } catch (IOException e) { -//// // TODO Auto-generated catch block -//// e.printStackTrace(); -//// } -// -// System.out.println(messageTextRaw.getMessageText() + " <- RXed"); // Stdout at -// // Console#######################################################TODO:Wichtig -// -// try { -// processRXMessage23001(messageTextRaw); -// } catch (IOException e) { -// // TODO Auto-generated catch block -// e.printStackTrace(); -// } catch (SQLException e) { -// // TODO Auto-generated catch block -// e.printStackTrace(); -// } - -// } //end peek != null - - /************************************************************** - * End of case RX - **************************************************************/ - - /************************************************************** - * Start of case TX - **************************************************************/ - -// if (client.getMessageTXBus().peek() != null) { -// /*********************************************** -// * CASE TX -// ***********************************************/ -// -// if (this.isServerready()) { -// // then send the line -// -// try { -// messageTextRaw = client.getMessageTXBus().take(); -//// this.setServerready(false); // after tx always wait for an answer prompt //23000 -// this.setServerready(true); -// -// } catch (InterruptedException e) { -// // TODO Auto-generated catch block -// e.printStackTrace(); -// } -// -// messageLine = messageTextRaw.getMessageText(); -// -// if (messageTextRaw.isMessageDirectedToServer()) { -// /** -// * We have to check if we only commands the server (keepalive) or want do talk -// * to the community -// */ -// -// try { -// client.getWriteThread().tx(messageTextRaw); -// System.out.println("BUS: tx: " + messageTextRaw.getMessageText()); -// -// } catch (InterruptedException e) { -// // TODO Auto-generated catch block -// e.printStackTrace(); -// } -// -// //////////////////////// bgfx ab here////////////////////////////// -//// try { -//// bufwrtrRawMSGOut.write(messageLine + "\r"); -////// bw.write(messageLine + "\n");//kst4contest.test 4 23001 -//// bufwrtrRawMSGOut.flush(); -//// -//// } catch (IOException e) { -//// // TODO Auto-generated catch block -//// e.printStackTrace(); -//// } -// /////////////////////////////////////////////////////////////////// -// } else { -// -// ChatMessage ownMSG = new ChatMessage(); -// -//// ownMSG.setMessageText( -//// "MSG|" + this.client.getCategory().getCategoryNumber() + "|0|" + messageLine + "|0|"); -// -// ownMSG.setMessageText( -// "MSG|" + this.client.getChatPreferences().getLoginChatCategory().getCategoryNumber() -// + "|0|" + messageLine + "|0|"); -// -// try { -// client.getWriteThread().tx(ownMSG); -// System.out.println("BUS: tx: " + ownMSG.getMessageText()); -// -// } catch (InterruptedException e) { -// // TODO Auto-generated catch block -// e.printStackTrace(); -// } -// } -// -// if (messageTextRaw.equals("/QUIT")) { -// try { -// this.client.getReadThread().terminateConnection(); -// this.client.getReadThread().interrupt(); -// this.client.getWriteThread().terminateConnection(); -// this.client.getWriteThread().interrupt(); -// this.interrupt(); -// -// } catch (IOException e) { -// // TODO Auto-generated catch block -// e.printStackTrace(); -// } -// } -// -// } else { -//// System.out.println("msgbus no elements yet"); -// } -// } //end tx.peek != null - } - } // while true end System.out.println("Msgbusmgt: interrupt"); diff --git a/src/main/java/kst4contest/controller/ReachabilityService.java b/src/main/java/kst4contest/controller/ReachabilityService.java new file mode 100644 index 0000000..8fc178a --- /dev/null +++ b/src/main/java/kst4contest/controller/ReachabilityService.java @@ -0,0 +1,231 @@ +package kst4contest.controller; + +import javafx.application.Platform; +import kst4contest.locatorUtils.Location; +import kst4contest.model.Band; +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; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; + +/** + * Background service for station reachability values used by the station table. + * + *

The service performs the expensive full path analysis off the JavaFX thread, + * stores the bidirectional SSB margin in the ChatMember, and leaves the UI with a + * simple numeric value for sorting/filtering.

+ */ +public final class ReachabilityService { + + private final ChatController chatController; + private final PathAnalysisService pathAnalysisService; + private final ExecutorService executor; + + /** + * Prevents duplicate jobs for the same logical station/band while a calculation + * is already queued or running. + */ + private final Set queuedCalculationKeys = ConcurrentHashMap.newKeySet(); + + public ReachabilityService(ChatController chatController) { + this.chatController = Objects.requireNonNull(chatController, "chatController"); + this.pathAnalysisService = new GeometryOnlyPathAnalysisService(new OpenMeteoTerrainProfileProvider()); + this.executor = Executors.newSingleThreadExecutor(new ReachabilityThreadFactory()); + } + + /** + * Ensures that the automatically selected reachability band is calculated. + * + * @param member chatmember to evaluate + */ + public void ensureAutoTropoMarginCalculated(ChatMember member) { + ensureTropoMarginCalculated(member, resolveAutoBand(member)); + } + + /** + * 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 + */ + public void ensureTropoMarginCalculated(ChatMember member, Band band) { + if (member == null || band == null) { + return; + } + + if (member.hasTropoSsbMarginDb(band)) { + return; + } + + String calculationKey = buildCalculationKey(member, band); + if (!queuedCalculationKeys.add(calculationKey)) { + return; + } + + 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"); + }; + + if (Platform.isFxApplicationThread()) { + storeResult.run(); + } else { + Platform.runLater(storeResult); + } + }); + } + + /** + * Resolves the auto reachability band. + * + *
    + *
  1. Use the lowest band detected in this session.
  2. + *
  3. If no session band exists and the station is in the microwave category, use 1296 MHz.
  4. + *
  5. Otherwise use 144 MHz.
  6. + *
+ * + * @param member member to inspect + * @return resolved band + */ + public Band resolveAutoBand(ChatMember member) { + if (member != null && member.getKnownActiveBands() != null && !member.getKnownActiveBands().isEmpty()) { + return member.getKnownActiveBands().keySet().stream() + .filter(Objects::nonNull) + .min(Comparator.comparingDouble(Band::getDefaultAnalysisFrequencyMHz)) + .orElse(Band.B_144); + } + + if (member != null + && member.getChatCategory() != null + && member.getChatCategory().getCategoryNumber() == ChatCategory.MICROWAVE) { + return Band.B_1296; + } + + return Band.B_144; + } + + /** + * Returns the active own bands configured in the station preferences. High bands + * above 10 GHz are intentionally ignored for the first version. + * + * @return set of enabled bands + */ + public EnumSet getEnabledStationBands() { + ChatPreferences preferences = chatController.getChatPreferences(); + EnumSet enabledBands = EnumSet.noneOf(Band.class); + + if (preferences == null) { + return enabledBands; + } + + if (preferences.isStn_bandActive144()) enabledBands.add(Band.B_144); + if (preferences.isStn_bandActive432()) enabledBands.add(Band.B_432); + if (preferences.isStn_bandActive1240()) enabledBands.add(Band.B_1296); + if (preferences.isStn_bandActive2300()) enabledBands.add(Band.B_2320); + if (preferences.isStn_bandActive3400()) enabledBands.add(Band.B_3400); + if (preferences.isStn_bandActive5600()) enabledBands.add(Band.B_5760); + if (preferences.isStn_bandActive10G()) enabledBands.add(Band.B_10G); + + return enabledBands; + } + + /** + * Stops the background executor. + */ + public void shutdown() { + executor.shutdownNow(); + } + + 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 Double.NaN; + } + + Location homeLocation = new Location(ownLocator6); + Location targetLocation = new Location(targetLocator6); + + double analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, band); + + PathAnalysisRequest request = new PathAnalysisRequest( + ownLocator6, + homeLocation.getLatitude().toDegrees(), + homeLocation.getLongitude().toDegrees(), + member.getCallSignRaw(), + targetLocator6, + targetLocation.getLatitude().toDegrees(), + targetLocation.getLongitude().toDegrees(), + analysisFrequencyMHz, + chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(), + chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(), + PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR, + chatController.getChatPreferences().buildPathLinkBudgetSettings() + ); + + PathAnalysisResult result = pathAnalysisService.analyze(request); + if (result == null || result.linkBudgetSummary() == null || !result.linkBudgetSummary().hasUsableBudget()) { + return Double.NaN; + } + + return result.linkBudgetSummary().bidirectionalSsbMarginDb(); + } + + private double resolveAnalysisFrequencyForBand(ChatMember member, Band band) { + if (member != null && member.getKnownActiveBands() != null) { + ChatMember.ActiveFrequencyInfo activeFrequencyInfo = member.getKnownActiveBands().get(band); + if (activeFrequencyInfo != null + && Double.isFinite(activeFrequencyInfo.frequency) + && activeFrequencyInfo.frequency > 0.0) { + return activeFrequencyInfo.frequency; + } + } + + return band.getDefaultAnalysisFrequencyMHz(); + } + + 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(); + } + + private static final class ReachabilityThreadFactory implements ThreadFactory { + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "reachability-service"); + thread.setDaemon(true); + return thread; + } + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java b/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java index 42e690d..842eb84 100644 --- a/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java +++ b/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java @@ -22,6 +22,9 @@ import org.xml.sax.InputSource; import org.xml.sax.SAXException; import kst4contest.model.ChatMember; +import kst4contest.model.Band; + +import javafx.application.Platform; /** * This thread is responsible for reading server's input and printing it to the @@ -63,7 +66,111 @@ public class ReadUDPbyUCXMessageThread extends Thread { System.out.println("UCXUDPRDR: catched error " + e.getMessage()); } } - + + /** + * Strips binary logger framing bytes before the XML payload. Some UCXLog packets + * contain transport bytes before the XML declaration. + * + * @param rawPacket raw UDP payload + * @return cleaned XML string or trimmed original text + */ + private String helper_extractXmlPayload(String rawPacket) { + if (rawPacket == null) { + return ""; + } + + int xmlStart = rawPacket.indexOf("= 0 + ? rawPacket.substring(xmlStart).trim() + : rawPacket.trim(); + } + + /** + * Reads an optional XML child node. + * + * @param element parent element + * @param tagName tag to read + * @return trimmed value or empty string + */ + private String helper_getOptionalElementText(Element element, String tagName) { + if (element == null || tagName == null) { + return ""; + } + + NodeList nodeList = element.getElementsByTagName(tagName); + if (nodeList == null || nodeList.getLength() == 0 || nodeList.item(0) == null) { + return ""; + } + + String textContent = nodeList.item(0).getTextContent(); + return textContent == null ? "" : textContent.trim(); + } + + /** + * Resolves the QSO locator from UCXLog contactinfo. gridsquare is preferred, + * rcvnr is used as fallback for exchanges such as 001JO41HK. + * + * @param element contactinfo XML element + * @return normalized six-character locator or null + */ + private String helper_resolveLocatorFromContactInfo(Element element) { + String gridSquare = WorkedGrossFieldCache.extractLocator6(helper_getOptionalElementText(element, "gridsquare")); + if (gridSquare != null) { + return gridSquare; + } + + return WorkedGrossFieldCache.extractLocator6(helper_getOptionalElementText(element, "rcvnr")); + } + + /** + * Resolves the project Band enum from logger band values. + * + * @param band logger band text + * @return matching Band or null + */ + private Band helper_resolveBandFromLoggerBand(String band) { + if (band == null) { + return null; + } + + switch (band.trim()) { + case "144": + case "2m": + return Band.B_144; + case "432": + case "70cm": + return Band.B_432; + case "1240": + case "1296": + case "23cm": + return Band.B_1296; + case "2300": + case "2320": + case "13cm": + return Band.B_2320; + case "3400": + case "9cm": + return Band.B_3400; + case "5600": + case "5760": + case "6cm": + return Band.B_5760; + case "10G": + case "10368": + case "3cm": + return Band.B_10G; + default: + return null; + } + } + public void run() { System.out.println("ReadUDPByUCXLogThread: started Thread for UCXLog getUDP"); @@ -177,7 +284,7 @@ public class ReadUDPbyUCXMessageThread extends Thread { File logUDPMessageToThisFile; - String udpMsg = udpPacketToProcess; + String udpMsg = helper_extractXmlPayload(udpPacketToProcess); ChatMember modifyThat = null; @@ -221,10 +328,9 @@ public class ReadUDPbyUCXMessageThread extends Thread { Element element = (Element) node; String call = element.getElementsByTagName("call").item(0).getTextContent(); -// call = call.toLowerCase(); - String band = element.getElementsByTagName("band").item(0).getTextContent(); - - String points = element.getElementsByTagName("points").item(0).getTextContent(); + String band = helper_getOptionalElementText(element, "band"); + String gridSquare = helper_resolveLocatorFromContactInfo(element); + String points = helper_getOptionalElementText(element, "points"); System.out.println("[Readudp, info ]: received Current Element :" + node.getNodeName() + "call: " + call + " / " + band + " ----> " + points + " POINTS"); @@ -235,6 +341,12 @@ public class ReadUDPbyUCXMessageThread extends Thread { workedCall.setCallSign(call); workedCall.setWorked(true); + if (gridSquare != null) { + workedCall.setQra(gridSquare); + } + + Band workedBand = helper_resolveBandFromLoggerBand(band); + switch (band) { case "144": { workedCall.setWorked144(true); @@ -304,14 +416,14 @@ public class ReadUDPbyUCXMessageThread extends Thread { break; } - case "3cm": { - workedCall.setWorked10G(true); + case "3cm": { + workedCall.setWorked10G(true); + break; + } - } - - default: - System.out.println("[ReadUDPFromUCX, Error:] unexpected band value: \"" + band + "\""); - break; + default: + System.out.println("[ReadUDPFromUCX, Error:] unexpected band value: \"" + band + "\""); + break; } // if (!client.getMap_ucxLogInfoWorkedCalls().containsKey("call")) { @@ -452,14 +564,22 @@ public class ReadUDPbyUCXMessageThread extends Thread { */ } + if (workedBand != null && gridSquare != null) { + this.client.registerWorkedGrossField(workedBand, gridSquare, workedCall, "UCXLOG"); + } + boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall); // This will update the worked info on a worked chatmember. DBHandler will // check, if an entry at the db had been modified. If not, then the worked // station had not been stored. DBHandler will store the information then. if (!isInChat) { - + workedCall.setName("unknown"); - workedCall.setQra("unknown"); + + if (workedCall.getQra() == null || workedCall.getQra().isBlank()) { + workedCall.setQra("unknown"); + } + workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat()); this.client.getDbHandler().storeChatMember(workedCall); } @@ -550,8 +670,13 @@ public class ReadUDPbyUCXMessageThread extends Thread { // System.out.println("Radio Mode: " + mode); // System.out.println("[ReadUDPFromUCX, Info:] Setted QRG pref to: \"" + qrg + "\"" ); - this.client.getChatPreferences().getMYQRGFirstCat().set(formattedQRG); - +// this.client.getChatPreferences().getMYQRGFirstCat().set(formattedQRG); + + final String finalFormattedQRG = formattedQRG; + helper_runOnFxThread(() -> + this.client.getChatPreferences().getMYQRGFirstCat().set(finalFormattedQRG) + ); + // System.out.println("[ReadUDPbyUCXTh: ] Radioinfo processed: " + formattedQRG); } } @@ -604,6 +729,28 @@ public class ReadUDPbyUCXMessageThread extends Thread { this.socket.close(); return true; + + } + + /** + * Runs UI-bound changes on the JavaFX application thread. + * + *

UCXLog UDP packets are processed in a background thread. Some preference + * properties are bound to JavaFX controls, so setting them directly from this + * thread can crash JavaFX with "Not on FX application thread".

+ * + * @param runnable UI-bound update + */ + private void helper_runOnFxThread(Runnable runnable) { + if (runnable == null) { + return; + } + + if (Platform.isFxApplicationThread()) { + runnable.run(); + } else { + Platform.runLater(runnable); + } } } \ No newline at end of file diff --git a/src/main/java/kst4contest/controller/ScoreService.java b/src/main/java/kst4contest/controller/ScoreService.java index b4b565e..1267788 100644 --- a/src/main/java/kst4contest/controller/ScoreService.java +++ b/src/main/java/kst4contest/controller/ScoreService.java @@ -182,6 +182,7 @@ public final class ScoreService { // 4) Publish to UI in ONE batched runLater Platform.runLater(() -> { + applyScoreSnapshotToChatMembers(snap); topCandidatesFx.setAll(snap.getTopCandidates()); updateSelectedScoreFromSnapshot(snap); uiPulse.set(uiPulse.get() + 1); @@ -237,6 +238,31 @@ public final class ScoreService { return representative; } + /** + * Projects the immutable score snapshot back into ChatMember display fields so + * the normal station table can sort/filter by score without knowing the score + * calculation internals. + * + * @param snap latest score snapshot + */ + private void applyScoreSnapshotToChatMembers(ScoreSnapshot snap) { + if (snap == null) { + return; + } + + Map scoreByCallSignRaw = snap.getScoreByCallSignRaw(); + + for (ChatMember member : controller.snapshotChatMembers()) { + if (member == null || member.getCallSignRaw() == null) { + continue; + } + + Double score = scoreByCallSignRaw.get(normalizeCallRaw(member.getCallSignRaw())); + member.setCurrentPriorityScore(score == null ? 0.0 : score); + } + + controller.fireUserListUpdate("Priority scores projected to ChatMember"); + } private void updateSelectedScoreFromSnapshot(ScoreSnapshot snap) { if (snap == null || selectedCallSignRaw == null) { diff --git a/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java b/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java new file mode 100644 index 0000000..2bfa2ba --- /dev/null +++ b/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java @@ -0,0 +1,175 @@ +package kst4contest.controller; + +import kst4contest.model.Band; +import kst4contest.model.ChatMember; + +import java.util.Collection; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Runtime cache for worked Maidenhead gross fields per band. + * + *

The cache is used by the "new locator" station filter. It is kept in memory + * for fast UI predicates and rebuilt from SQLite on startup/refresh. New QSO log + * packets update both SQLite and this cache immediately.

+ */ +public final class WorkedGrossFieldCache { + + private static final Pattern MAIDENHEAD_6_PATTERN = + Pattern.compile("(?i)([A-R]{2}[0-9]{2}[A-X]{2})"); + + private final Map> workedGrossFieldsByBand = new EnumMap<>(Band.class); + + /** + * Replaces the full cache content with data read from the database. + * + * @param databaseSnapshot map of band to worked gross fields + */ + public synchronized void rebuildFromDatabaseSnapshot(Map> databaseSnapshot) { + workedGrossFieldsByBand.clear(); + + if (databaseSnapshot == null) { + return; + } + + for (Map.Entry> entry : databaseSnapshot.entrySet()) { + Band band = entry.getKey(); + Set grossFields = entry.getValue(); + + if (band == null || grossFields == null) { + continue; + } + + Set normalizedGrossFields = workedGrossFieldsByBand.computeIfAbsent(band, ignored -> new HashSet<>()); + for (String grossField : grossFields) { + String normalizedGrossField = normalizeGrossField(grossField); + if (normalizedGrossField != null) { + normalizedGrossFields.add(normalizedGrossField); + } + } + } + } + + /** + * Adds one worked locator to the cache. + * + * @param band worked band + * @param locatorOrGrossField six-character locator or four-character gross field + */ + public synchronized void addWorked(Band band, String locatorOrGrossField) { + String grossField = extractGrossField(locatorOrGrossField); + if (band == null || grossField == null) { + return; + } + + workedGrossFieldsByBand + .computeIfAbsent(band, ignored -> new HashSet<>()) + .add(grossField); + } + + /** + * Adds all worked-band flags from stored ChatMember rows. This is only a fallback + * for legacy data before WorkedGrossField existed or when logger packets did not + * provide a locator. + * + * @param storedMembers stored ChatMember rows + */ + public synchronized void addWorkedBandsFromStoredChatMembers(Collection storedMembers) { + if (storedMembers == null) { + return; + } + + for (ChatMember member : storedMembers) { + if (member == null) { + continue; + } + + String locator = member.getQra(); + if (member.isWorked144()) addWorked(Band.B_144, locator); + if (member.isWorked432()) addWorked(Band.B_432, locator); + if (member.isWorked1240()) addWorked(Band.B_1296, locator); + if (member.isWorked2300()) addWorked(Band.B_2320, locator); + if (member.isWorked3400()) addWorked(Band.B_3400, locator); + if (member.isWorked5600()) addWorked(Band.B_5760, locator); + if (member.isWorked10G()) addWorked(Band.B_10G, locator); + } + } + + /** + * Checks whether a locator gross field is already worked on a band. + * + * @param band band to check + * @param locatorOrGrossField six-character locator or four-character gross field + * @return true if the gross field is already worked on that band + */ + public synchronized boolean isGrossFieldWorked(Band band, String locatorOrGrossField) { + String grossField = extractGrossField(locatorOrGrossField); + if (band == null || grossField == null) { + return false; + } + + return workedGrossFieldsByBand + .getOrDefault(band, Set.of()) + .contains(grossField); + } + + /** + * Tries to normalize a locator. Accepts a plain six-character locator or extracts + * one from exchange strings such as "001JO41HK". + * + * @param rawLocatorOrExchange raw locator/exchange text + * @return normalized six-character locator, or null if none was found + */ + public static String extractLocator6(String rawLocatorOrExchange) { + if (rawLocatorOrExchange == null || rawLocatorOrExchange.isBlank()) { + return null; + } + + Matcher matcher = MAIDENHEAD_6_PATTERN.matcher(rawLocatorOrExchange.trim()); + if (!matcher.find()) { + return null; + } + + return matcher.group(1).toUpperCase(Locale.ROOT); + } + + /** + * Extracts and normalizes the four-character gross field. + * + * @param rawLocatorOrGrossField six-character locator, four-character gross field or exchange text + * @return normalized gross field, or null if no valid value is available + */ + public static String extractGrossField(String rawLocatorOrGrossField) { + if (rawLocatorOrGrossField == null || rawLocatorOrGrossField.isBlank()) { + return null; + } + + String trimmed = rawLocatorOrGrossField.trim().toUpperCase(Locale.ROOT); + + if (trimmed.matches("[A-R]{2}[0-9]{2}")) { + return trimmed; + } + + String locator6 = extractLocator6(trimmed); + if (locator6 == null || locator6.length() < 4) { + return null; + } + + return locator6.substring(0, 4); + } + + private static String normalizeGrossField(String grossField) { + if (grossField == null) { + return null; + } + + String normalized = grossField.trim().toUpperCase(Locale.ROOT); + return normalized.matches("[A-R]{2}[0-9]{2}") ? normalized : null; + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/model/Band.java b/src/main/java/kst4contest/model/Band.java index 9cfd866..94c3ccf 100644 --- a/src/main/java/kst4contest/model/Band.java +++ b/src/main/java/kst4contest/model/Band.java @@ -29,6 +29,35 @@ public enum Band { return prefix; } + /** + * Returns the lower edge used as practical analysis frequency when only the band + * is known. This keeps the batch reachability calculation deterministic. + * + * @return frequency in MHz + */ + public double getDefaultAnalysisFrequencyMHz() { + return minFreq; + } + + /** + * Returns a compact label for table display and filter controls. + * + * @return human readable band label + */ + public String getDisplayLabel() { + switch (this) { + case B_144: return "144"; + case B_432: return "432"; + case B_1296: return "1296"; + case B_2320: return "2320"; + case B_3400: return "3400"; + case B_5760: return "5760"; + case B_10G: return "10G"; + case B_24G: return "24G"; + default: return prefix; + } + } + /** * Checks if a specific frequency falls within this band's limits. */ diff --git a/src/main/java/kst4contest/model/ChatMember.java b/src/main/java/kst4contest/model/ChatMember.java index 7522506..d7efd17 100644 --- a/src/main/java/kst4contest/model/ChatMember.java +++ b/src/main/java/kst4contest/model/ChatMember.java @@ -10,10 +10,13 @@ import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; +import java.util.OptionalDouble; public class ChatMember { + + long lastFlagsChangeEpochMs; // timestamp of the last worked/not-QRV flag change in the internal DB // private final BooleanProperty workedInfoChangeFireListEventTrigger = new SimpleBooleanProperty(); @@ -74,6 +77,10 @@ public class ChatMember { // Stores the last known frequency per band (Context History) private final Map knownActiveBands = new ConcurrentHashMap<>(); + // Stores the calculated bidirectional SSB tropo margin per band. + // Values are calculated by the reachability backend and used only for UI sorting/filtering. + private final Map tropoSsbMarginDbByBand = new ConcurrentHashMap<>(); + // --- INNER CLASS FOR QRG HISTORY --- public class ActiveFrequencyInfo { @@ -620,6 +627,74 @@ public class ChatMember { return knownActiveBands; } + /** + * Stores the calculated bidirectional SSB tropo margin for one band. + * + *

The value is deliberately stored in ChatMember because the station table + * can then sort and filter directly without triggering a new RF calculation. + * The actual calculation stays outside ChatMember.

+ * + * @param band band for which the margin was calculated + * @param marginDb bidirectional SSB margin in dB; NaN marks an attempted but failed analysis + */ + public void setTropoSsbMarginDb(Band band, double marginDb) { + if (band == null) { + return; + } + + this.tropoSsbMarginDbByBand.put(band, marginDb); + } + + /** + * Returns true if a tropo analysis result already exists for the given band. + * + *

A stored NaN also counts as an existing result because it means the analysis + * was attempted and failed. This prevents endless retry loops when a terrain + * provider is temporarily unavailable.

+ * + * @param band band to check + * @return true if a value or NaN marker is stored + */ + public boolean hasTropoSsbMarginDb(Band band) { + return band != null && this.tropoSsbMarginDbByBand.containsKey(band); + } + + /** + * Reads the calculated tropo SSB margin for one band. + * + * @param band band to read + * @return OptionalDouble with the stored value, or empty if no calculation exists yet + */ + public OptionalDouble getTropoSsbMarginDb(Band band) { + if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) { + return OptionalDouble.empty(); + } + + Double storedValue = this.tropoSsbMarginDbByBand.get(band); + if (storedValue == null) { + return OptionalDouble.empty(); + } + + return OptionalDouble.of(storedValue); + } + + /** + * Formats the calculated tropo margin for direct table display. + * + * @param band selected/auto-resolved reachability band + * @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 (marginDb.isEmpty() || !Double.isFinite(marginDb.getAsDouble())) { + return "- @" + bandLabel; + } + + return String.format(Locale.US, "%+.1f dB @%s", marginDb.getAsDouble(), bandLabel); + } + /** * If a sked fails and the user tells this to the client, this counter will be increased to give the station a diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java index 469848a..5e2a2b2 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -71,6 +71,8 @@ import kst4contest.view.map.OfflineDemImportService; public class Kst4ContestApplication extends Application implements StatusUpdateListener { // private static final Kst4ContestApplication dbcontroller = new DBController(); + // Null means Auto: use the lowest session band, or category fallback. + private Band selectedReachabilityBandOverride = null; private StationMapView stationMapView; //view class for the avl stn map private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view @@ -1387,6 +1389,29 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL */ + TableColumn tropoCol = new TableColumn("Tropo"); + tropoCol.setCellValueFactory(new Callback, ObservableValue>() { + @Override + public ObservableValue call(CellDataFeatures cellDataFeatures) { + ChatMember member = cellDataFeatures.getValue(); + Band selectedBand = resolveReachabilityBandForUi(member); + chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, selectedBand); + return new SimpleStringProperty(member.formatTropoSsbMarginForBand(selectedBand)); + } + }); + tropoCol.setComparator((left, right) -> Double.compare(parseTableDouble(left), parseTableDouble(right))); + tropoCol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(13)); + + TableColumn priorityScoreCol = new TableColumn("Score"); + priorityScoreCol.setCellValueFactory(new Callback, ObservableValue>() { + @Override + public ObservableValue call(CellDataFeatures cellDataFeatures) { + return new SimpleStringProperty(formatPriorityScore(cellDataFeatures.getValue())); + } + }); + priorityScoreCol.setComparator((left, right) -> Double.compare(parseTableDouble(left), parseTableDouble(right))); + priorityScoreCol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(18)); + TableColumn lastActCol = new TableColumn("Act"); lastActCol.setCellValueFactory(new Callback, ObservableValue>() { @@ -1689,7 +1714,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL - tbl_chatMemberTable.getColumns().addAll(callSignCol, nameCol, qraCol, qrBCol, qtfCol, qrgCol, lastActCol, airScoutCol, workedCol, notQRVCol, chatCategoryCol); +// tbl_chatMemberTable.getColumns().addAll(callSignCol, nameCol, qraCol, qrBCol, qtfCol, qrgCol, lastActCol, airScoutCol, workedCol, notQRVCol, chatCategoryCol); + + tbl_chatMemberTable.getColumns().addAll(callSignCol, nameCol, qraCol, qrBCol, qtfCol, qrgCol, tropoCol, priorityScoreCol, lastActCol, airScoutCol, workedCol, notQRVCol, chatCategoryCol); + // tbl_chatMemberTable.setItems(chatcontroller.getLst_chatMemberListFiltered()); @@ -6061,6 +6089,84 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL TextField chatMemberTableFilterMaxQrbTF = new TextField(chatcontroller.getChatPreferences().getStn_maxQRBDefault() + ""); chatMemberTableFilterMaxQrbTF.setFocusTraversable(false); + + + ToggleButton btnTglNewLocator = new ToggleButton("New locator"); + Predicate newLocatorPredicate = new Predicate() { + @Override + public boolean test(ChatMember chatMember) { + return chatcontroller.isNewLocatorOnAnyEnabledBand(chatMember); + } + }; + btnTglNewLocator.setOnAction(new EventHandler() { + @Override + public void handle(ActionEvent actionEvent) { + if (btnTglNewLocator.isSelected()) { + chatcontroller.getLst_chatMemberListFilterPredicates().add(newLocatorPredicate); + } else { + chatcontroller.getLst_chatMemberListFilterPredicates().remove(newLocatorPredicate); + } + } + }); + btnTglNewLocator.setTooltip(new Tooltip("Show only stations whose gross locator is still new on at least one active own band")); + + ToggleButton btnTglReachableTropo = new ToggleButton("Tropo >=0dB"); + Predicate reachableTropoPredicate = new Predicate() { + @Override + public boolean test(ChatMember chatMember) { + return isReachableViaTropoFilterMatch(chatMember); + } + }; + btnTglReachableTropo.setOnAction(new EventHandler() { + @Override + public void handle(ActionEvent actionEvent) { + if (btnTglReachableTropo.isSelected()) { + chatcontroller.getLst_chatMemberListFilterPredicates().add(reachableTropoPredicate); + } else { + chatcontroller.getLst_chatMemberListFilterPredicates().remove(reachableTropoPredicate); + } + } + }); + btnTglReachableTropo.setTooltip(new Tooltip("Show stations with non-negative SSB tropo margin. Pending/failed calculations stay visible.")); + + ToggleButton btnTglNewBands = new ToggleButton("New bands"); + Predicate newBandsPredicate = new Predicate() { + @Override + public boolean test(ChatMember chatMember) { + return isNewBandOpportunity(chatMember); + } + }; + btnTglNewBands.setOnAction(new EventHandler() { + @Override + public void handle(ActionEvent actionEvent) { + if (btnTglNewBands.isSelected()) { + chatcontroller.getLst_chatMemberListFilterPredicates().add(newBandsPredicate); + } else { + chatcontroller.getLst_chatMemberListFilterPredicates().remove(newBandsPredicate); + } + } + }); + btnTglNewBands.setTooltip(new Tooltip("Show stations that are QRV on a known active band that has not been worked yet")); + + ToggleButton btnTglAsNext5Min = new ToggleButton("AS next 5m"); + Predicate asNext5MinPredicate = new Predicate() { + @Override + public boolean test(ChatMember chatMember) { + return hasAsWindowInNextMinutes(chatMember, 5); + } + }; + btnTglAsNext5Min.setOnAction(new EventHandler() { + @Override + public void handle(ActionEvent actionEvent) { + if (btnTglAsNext5Min.isSelected()) { + chatcontroller.getLst_chatMemberListFilterPredicates().add(asNext5MinPredicate); + } else { + chatcontroller.getLst_chatMemberListFilterPredicates().remove(asNext5MinPredicate); + } + } + }); + btnTglAsNext5Min.setTooltip(new Tooltip("Show stations with any AirScout window now or within the next 5 minutes")); + ToggleButton tglBtnQRBEnable = new ToggleButton("Show only QRB [km] <= "); tglBtnQRBEnable.selectedProperty().addListener(new ChangeListener() { Predicate maxQrbPredicate = new Predicate() { @@ -6337,6 +6443,29 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL HBox chatMemberTableFilterWorkedBandFiltersHbx = new HBox(); + + ComboBox cmbReachabilityBand = new ComboBox<>(); + cmbReachabilityBand.getItems().add("Auto"); + for (Band band : chatcontroller.getReachabilityService().getEnabledStationBands()) { + cmbReachabilityBand.getItems().add(band.getDisplayLabel()); + } + cmbReachabilityBand.getSelectionModel().select("Auto"); + cmbReachabilityBand.setTooltip(new Tooltip("Reachability band for Tropo column/filter. Auto uses the station's lowest session band.")); + cmbReachabilityBand.setOnAction(new EventHandler() { + @Override + public void handle(ActionEvent event) { + selectedReachabilityBandOverride = parseReachabilityBandSelection(cmbReachabilityBand.getValue()); + for (ChatMember member : chatcontroller.getLst_chatMemberList()) { + Band bandToCalculate = resolveReachabilityBandForUi(member); + chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, bandToCalculate); + } + chatcontroller.fireUserListUpdate("Reachability band changed"); + } + }); + + chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(new Label("Reachability:")); + chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(cmbReachabilityBand); + ToggleButton btnTglwkd = new ToggleButton("wkd"); Predicate wkdPredicate = new Predicate() { @@ -6602,6 +6731,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL "-fx-border-radius: 1;" + "-fx-border-color: lightgrey;"); + + chatMemberTableFilterWorkedBandFiltersHbx.getChildren().addAll( + btnTglNewLocator, + btnTglReachableTropo, + btnTglNewBands, + btnTglAsNext5Min + ); + // chatMemberTableFilterWorkedBandFilters @@ -7895,8 +8032,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // Unconditionally add listener to manually sync the textfield input to the button // (this listener also fires correctly when the value is updated by the binding) +// txt_ownqrgMainCategory.textProperty().addListener((observable, oldValue, newValue) -> { +// MYQRGButton.textProperty().set(newValue); +// }); txt_ownqrgMainCategory.textProperty().addListener((observable, oldValue, newValue) -> { - MYQRGButton.textProperty().set(newValue); + if (Platform.isFxApplicationThread()) { + MYQRGButton.textProperty().set(newValue); + } else { + Platform.runLater(() -> MYQRGButton.textProperty().set(newValue)); + } }); // That's the default behaviour of the myqrg textfield @@ -9702,6 +9846,164 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL stage.setHeight(correctedHeight); } + /** + * Resolves the currently selected UI reachability band. Null override means Auto. + * + * @param member station row + * @return manually selected band or automatic band + */ + private Band resolveReachabilityBandForUi(ChatMember member) { + if (selectedReachabilityBandOverride != null) { + return selectedReachabilityBandOverride; + } + return chatcontroller.getReachabilityService().resolveAutoBand(member); + } + + /** + * Parses the reachability ComboBox selection. + * + * @param selectedLabel selected UI text + * @return selected band, or null for Auto + */ + private Band parseReachabilityBandSelection(String selectedLabel) { + if (selectedLabel == null || selectedLabel.isBlank() || "Auto".equalsIgnoreCase(selectedLabel)) { + return null; + } + + for (Band band : Band.values()) { + if (selectedLabel.equalsIgnoreCase(band.getDisplayLabel())) { + return band; + } + } + return null; + } + + /** + * Formats the projected priority score for the station table. + * + * @param member station row + * @return score text + */ + private String formatPriorityScore(ChatMember member) { + if (member == null || !Double.isFinite(member.getCurrentPriorityScore())) { + return "-"; + } + return String.format(Locale.US, "%.0f", member.getCurrentPriorityScore()); + } + + /** + * Parses a numeric table value for custom score sorting. + * + * @param value table text + * @return parsed value or negative infinity for missing values + */ + private double parseTableDouble(String value) { + if (value == null || value.isBlank() || value.equals("-") || value.startsWith("- @")) { + return Double.NEGATIVE_INFINITY; + } + + String normalized = value.replace(",", ".").replace("+", "").trim(); + int firstSpace = normalized.indexOf(' '); + if (firstSpace > 0) { + normalized = normalized.substring(0, firstSpace); + } + + try { + return Double.parseDouble(normalized); + } catch (NumberFormatException ignored) { + return Double.NEGATIVE_INFINITY; + } + } + + /** + * Predicate helper for the tropo filter. Pending or failed calculations remain + * visible as requested; only finite negative margins are hidden. + * + * @param member station row + * @return true if station should remain visible under the tropo filter + */ + private boolean isReachableViaTropoFilterMatch(ChatMember member) { + if (member == null || chatcontroller.getReachabilityService() == null) { + return true; + } + + Band selectedBand = resolveReachabilityBandForUi(member); + chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, selectedBand); + + OptionalDouble marginDb = member.getTropoSsbMarginDb(selectedBand); + if (marginDb.isEmpty() || !Double.isFinite(marginDb.getAsDouble())) { + return true; + } + + return marginDb.getAsDouble() >= 0.0; + } + + /** + * Returns true when a station has any AirScout window now or within maxMinutes. + * + * @param member station row + * @param maxMinutes look-ahead window + * @return true if any AS window is available + */ + private boolean hasAsWindowInNextMinutes(ChatMember member, int maxMinutes) { + if (member == null || member.getAirPlaneReflectInfo() == null + || member.getAirPlaneReflectInfo().getRisingAirplanes() == null) { + return false; + } + + return member.getAirPlaneReflectInfo().getRisingAirplanes().stream() + .filter(Objects::nonNull) + .anyMatch(airPlane -> airPlane.getArrivingDurationMinutes() >= 0 + && airPlane.getArrivingDurationMinutes() <= maxMinutes); + } + + /** + * Returns true when the station is known to be QRV on at least one enabled own band + * that has not been worked yet. + * + * @param member station row + * @return true if there is a new-band opportunity + */ + private boolean isNewBandOpportunity(ChatMember member) { + if (member == null || chatcontroller.getReachabilityService() == null + || member.getKnownActiveBands() == null || member.getKnownActiveBands().isEmpty()) { + return false; + } + + EnumSet enabledBands = chatcontroller.getReachabilityService().getEnabledStationBands(); + for (Band band : member.getKnownActiveBands().keySet()) { + if (band != null && enabledBands.contains(band) && !isWorkedOnBand(member, band)) { + return true; + } + } + return false; + } + + /** + * Maps the existing per-band worked flags to the Band enum. + * + * @param member station row + * @param band band to inspect + * @return true if worked on that band + */ + private boolean isWorkedOnBand(ChatMember member, Band band) { + if (member == null || band == null) { + return false; + } + + switch (band) { + case B_144: return member.isWorked144(); + case B_432: return member.isWorked432(); + case B_1296: return member.isWorked1240(); + case B_2320: return member.isWorked2300(); + case B_3400: return member.isWorked3400(); + case B_5760: return member.isWorked5600(); + case B_10G: return member.isWorked10G(); + case B_24G: return member.isWorked24G(); + default: return false; + } + } + } /** diff --git a/udpReaderBackup.txt b/udpReaderBackup.txt index 54efa10..983aeaa 100644 --- a/udpReaderBackup.txt +++ b/udpReaderBackup.txt @@ -2365,4 +2365,5 @@ OZ1DLD/P;Bent;JO45SK;StringProperty [value: 144.285]; wkd true; wkd144 true; wkd OZ1FDH;Claus;JO55QX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz OZ7KJ;Skive Club;JO46ML;StringProperty [value: 144.225]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz SM7VUK;Bengt;JO66LI;StringProperty [value: 144.304]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz -OZ1HDF;Ken;JO55UN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz \ No newline at end of file +OZ1HDF;Ken;JO55UN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DO5SA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null \ No newline at end of file