diff --git a/pom.xml b/pom.xml index ae50061..5cfd89a 100644 --- a/pom.xml +++ b/pom.xml @@ -153,7 +153,7 @@ org.junit.jupiter junit-jupiter-api - 5.12.2 + ${junit.version} compile @@ -266,6 +266,15 @@ ${maven.surfire.plugin} true + + false diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java index 2b3a9c5..b165e4c 100644 --- a/src/main/java/kst4contest/controller/ChatController.java +++ b/src/main/java/kst4contest/controller/ChatController.java @@ -20,6 +20,7 @@ import javafx.collections.transformation.SortedList; import kst4contest.ApplicationConstants; import kst4contest.controller.interfaces.PstRotatorEventListener; import kst4contest.locatorUtils.DirectionUtils; +import kst4contest.logic.BandOpportunityResolver; import kst4contest.logic.PriorityCalculator; import kst4contest.model.*; import kst4contest.test.MockKstServer; @@ -216,18 +217,11 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList } /** - * Called when an external logger (Win-Test or UCXLog interface) reports that a QSO was logged. + * Called when an external logger reports that a QSO was logged. * - * Process goal: - * 1) Detect whether the logged station is *still active (QRV)* on at least one *other* band - * that is enabled for "my station" (stn_bandActive[Band]) AND not worked yet (worked144/432/...). - * 2) If yes: trigger an on-screen hint (blinking status button) and play the existing sked-notification sound. - * 3) Request a score recompute so the station can become visible again (optional boost is applied in PriorityCalculator). - * - * IMPORTANT: - * - We do NOT use ChatMember.worked (UI-only filter flag) for scoring decisions. - * - We only use per-band worked flags (worked144, worked432, ...). - * - "QRV on band" is derived from recent entries in ChatMember.knownActiveBands. + *

The common resolver combines recent QRG evidence and station-name bands + * across every active callsignRaw variant. A manual NOT-QRV tag overrides this + * automatic evidence before the hint and priority boost are evaluated.

*/ public void onExternalLogEntryReceived(String callSignRaw) { @@ -241,26 +235,20 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList System.out.println("[BandUpgradeHint] LOG received for call=" + callRaw); } - // 1) Determine which bands I am active on (configured at startup via stn_bandActive* flags) - EnumSet myEnabledBands = getMyEnabledBandsFromPrefs(chatPreferences); + EnumSet myEnabledBands = + BandOpportunityResolver.getEnabledStationBands(chatPreferences); if (myEnabledBands.isEmpty()) return; - // 2) Determine which bands the station was recently seen active on (from Smart Frequency Extraction history) - final long now = System.currentTimeMillis(); - final long maxAgeMs = TimeUnit.MINUTES.toMillis(30); // keep consistent with "recent activity" semantics - EnumSet stationOfferedBands = collectStationOfferedBandsFromHistory(callRaw, now, maxAgeMs); + List variants = findActiveChatMembersByRawCall(callRaw); + BandOpportunityResolver.Resolution bandResolution = + BandOpportunityResolver.resolve(variants, System.currentTimeMillis()); + + EnumSet stationOfferedBands = bandResolution.getOfferedBands(); if (stationOfferedBands.isEmpty()) return; - // 3) Keep only bands that I can actually work - stationOfferedBands.retainAll(myEnabledBands); - if (stationOfferedBands.isEmpty()) return; - - // 4) Determine already worked bands (per-band flags only) - EnumSet workedBands = collectWorkedBands(callRaw); - - // 5) Remaining bands = offered ∩ enabled - worked - EnumSet remainingBands = EnumSet.copyOf(stationOfferedBands); - remainingBands.removeAll(workedBands); + EnumSet workedBands = bandResolution.getWorkedBands(); + EnumSet remainingBands = + bandResolution.getUnworkedEnabledBands(myEnabledBands); if (remainingBands.isEmpty()) return; if (DEBUG_BAND_UPGRADE_HINT) { @@ -268,34 +256,35 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList + " enabled=" + formatBandsHuman(myEnabledBands) + " offered=" + formatBandsHuman(stationOfferedBands) + " worked=" + (workedBands.isEmpty() ? "-" : formatBandsHuman(workedBands)) + + " NOT-QRV=" + formatBandsHuman(bandResolution.getNotQrvBands()) + " remaining=" + formatBandsHuman(remainingBands)); } - // 6) Build UI text (button + tooltip) String remainingHuman = formatBandsHuman(remainingBands); String shortText = "BAND+ " + callRaw + " " + remainingHuman; - String tooltip = "Logged " + callRaw + ", but station is still QRV on additional band(s): " + String tooltip = "Logged " + callRaw + + ", but the station still offers additional band(s): " + remainingHuman + "\n(Enabled: " + formatBandsHuman(myEnabledBands) - + " | Worked: " + (workedBands.isEmpty() ? "-" : formatBandsHuman(workedBands)) + ")"; + + " | Worked: " + (workedBands.isEmpty() ? "-" : formatBandsHuman(workedBands)) + + " | NOT QRV: " + formatBandsHuman(bandResolution.getNotQrvBands()) + ")"; ThreadStateMessage msg = new ThreadStateMessage("BandUpgradeHint", true, tooltip, false); msg.setRunningInformationTextDescription(shortText); - - // 7) Trigger status update -> View will blink a dedicated indicator button onThreadStatus("BandUpgradeHint", msg); - // 8) Sound (re-use existing sked notification sound) - respects global simple-sound flag if (chatPreferences.isNotify_playSimpleSounds()) { try { - getPlayAudioUtils().playNoiseLauncher('!'); // same as SkedReminderService + getPlayAudioUtils().playNoiseLauncher('!'); } catch (Exception e) { - System.out.println("[ChatController, warning]: failed to play band-upgrade hint sound: " + e.getMessage()); + System.out.println( + "[ChatController, warning]: failed to play band-upgrade hint sound: " + + e.getMessage() + ); } } - // 9) Make sure score reacts quickly (boost is applied in PriorityCalculator if enabled) if (getScoreService() != null) { getScoreService().requestRecompute("BandUpgradeHint"); } @@ -306,77 +295,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList return callRaw.trim().toUpperCase(Locale.ROOT); } - /** Helper: create enabled-band set from preferences. */ - private static EnumSet getMyEnabledBandsFromPrefs(ChatPreferences prefs) { - EnumSet s = EnumSet.noneOf(Band.class); - if (prefs.isStn_bandActive144()) s.add(Band.B_144); - if (prefs.isStn_bandActive432()) s.add(Band.B_432); - if (prefs.isStn_bandActive1240()) s.add(Band.B_1296); - if (prefs.isStn_bandActive2300()) s.add(Band.B_2320); - if (prefs.isStn_bandActive3400()) s.add(Band.B_3400); - if (prefs.isStn_bandActive5600()) s.add(Band.B_5760); - if (prefs.isStn_bandActive10G()) s.add(Band.B_10G); - return s; - } - - /** - * Helper: union of all recently detected "QRV on band" entries across *all* ChatMember instances - * having the same callSignRaw (because a callsign may exist multiple times with different categories). - */ - private EnumSet collectStationOfferedBandsFromHistory(String callRaw, long nowMs, long maxAgeMs) { - - EnumSet offered = EnumSet.noneOf(Band.class); - - for (ChatMember cm : findActiveChatMembersByRawCall(callRaw)) { - if (cm == null || cm.getCallSignRaw() == null) continue; - - Map map = cm.getKnownActiveBands(); - if (map == null || map.isEmpty()) continue; - - for (Map.Entry e : map.entrySet()) { - if (e.getKey() == null || e.getValue() == null) continue; - - long age = nowMs - e.getValue().timestampEpoch; - if (age >= 0 && age <= maxAgeMs) { - offered.add(e.getKey()); - } - - if (DEBUG_BAND_UPGRADE_HINT) { - System.out.println("[BandUpgradeHint] history call=" + callRaw - + " band=" + e.getKey() - + " freq=" + e.getValue().frequency - + " ageMs=" + age); - } - } - } - - return offered; - } - - /** - * Helper: union of per-band worked flags across all ChatMember instances for the same call. - * IMPORTANT: ChatMember.worked is UI-only and NOT used here. - */ - private EnumSet collectWorkedBands(String callRaw) { - - EnumSet worked = EnumSet.noneOf(Band.class); - - for (ChatMember cm : findActiveChatMembersByRawCall(callRaw)) { - if (cm == null || cm.getCallSignRaw() == null) continue; - - if (cm.isWorked144()) worked.add(Band.B_144); - if (cm.isWorked432()) worked.add(Band.B_432); - if (cm.isWorked1240()) worked.add(Band.B_1296); - if (cm.isWorked2300()) worked.add(Band.B_2320); - if (cm.isWorked3400()) worked.add(Band.B_3400); - if (cm.isWorked5600()) worked.add(Band.B_5760); - if (cm.isWorked10G()) worked.add(Band.B_10G); - if (cm.isWorked24G()) worked.add(Band.B_24G); - } - - return worked; - } - private static String formatBandsHuman(EnumSet bands) { if (bands == null || bands.isEmpty()) return "-"; return bands.stream().map(ChatController::bandToHumanLabel).sorted().reduce((a, b) -> a + ", " + b).orElse("-"); @@ -1373,6 +1291,49 @@ private ObservableList return matchingMembers; } + /** + * Copies the band-specific NOT-QRV state to every active category variant of the + * same base callsign. The database already uses callSignRaw as its key; applying + * the state to the runtime model immediately prevents category-dependent B+, + * filter, map and score results before the next database refresh. + * + * @param sourceMember member whose current NOT-QRV checkboxes are authoritative + */ + public void propagateNotQrvStateToActiveMembers(ChatMember sourceMember) { + if (sourceMember == null) { + return; + } + + String rawCall = sourceMember.getCallSignRaw() != null + ? sourceMember.getCallSignRaw() + : sourceMember.getCallSign(); + + List variants = findActiveChatMembersByRawCall(rawCall); + if (variants.isEmpty()) { + variants = List.of(sourceMember); + } + + for (ChatMember target : variants) { + if (target == null) { + continue; + } + + target.setQrv144(sourceMember.isQrv144()); + target.setQrv432(sourceMember.isQrv432()); + target.setQrv1240(sourceMember.isQrv1240()); + target.setQrv2300(sourceMember.isQrv2300()); + target.setQrv3400(sourceMember.isQrv3400()); + target.setQrv5600(sourceMember.isQrv5600()); + target.setQrv10G(sourceMember.isQrv10G()); + } + + if (scoreService != null) { + scoreService.requestRecompute("NOT-QRV state changed"); + } + + fireUserListUpdate("NOT-QRV state propagated to callsign variants"); + } + /** * Updates locator and derived direction values in the active model. The UI list * contains the same member object, but the user-list refresh is still triggered diff --git a/src/main/java/kst4contest/controller/ReachabilityService.java b/src/main/java/kst4contest/controller/ReachabilityService.java index f4ce9f7..721e3ce 100644 --- a/src/main/java/kst4contest/controller/ReachabilityService.java +++ b/src/main/java/kst4contest/controller/ReachabilityService.java @@ -1,4 +1,5 @@ package kst4contest.controller; +import kst4contest.logic.BandOpportunityResolver; import kst4contest.view.map.MapCallsignRawSnapshot; import java.util.ArrayList; @@ -168,14 +169,31 @@ public final class ReachabilityService { 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 && !isUsableAutomaticBand(member, analysisBand)) { + analysisFrequencyMHz = Double.NaN; + analysisBand = null; } - Band analysisBand = Band.fromFrequency(analysisFrequencyMHz); - if (analysisBand == null) { - analysisBand = member == null ? Band.B_144 : resolveAutoBand(member); + if (!Double.isFinite(analysisFrequencyMHz) + || analysisFrequencyMHz <= 0.0 + || analysisBand == null) { + + Band fallbackBand = resolveAutoBand(member); + if (fallbackBand == null) { + dispatchFxCallback( + fxCallback, + PathAnalysisResult.waitingForUsableBand( + ownLocator6, + targetLocator6, + selectedSnapshot.callSignRaw() + ) + ); + return; + } + + analysisBand = fallbackBand; + analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, fallbackBand); } PathAnalysisRequest request = buildRequest( @@ -230,20 +248,49 @@ public final class ReachabilityService { * @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) + EnumSet enabledBands = getEnabledStationBands(); + if (enabledBands.isEmpty()) { + return null; + } + + List variants = resolveCallsignVariants(member); + BandOpportunityResolver.Resolution resolution = + BandOpportunityResolver.resolve(variants, System.currentTimeMillis()); + + EnumSet availableOfferedBands = resolution.getAvailableBands(); + availableOfferedBands.retainAll(enabledBands); + + if (!availableOfferedBands.isEmpty()) { + return availableOfferedBands.stream() .min(Comparator.comparingDouble(Band::getDefaultAnalysisFrequencyMHz)) - .orElse(Band.B_144); + .orElse(null); + } + + // Known evidence exists, but every matching band is disabled or NOT QRV. + if (resolution.hasBandEvidence()) { + return null; + } + + EnumSet fallbackBands = EnumSet.copyOf(enabledBands); + fallbackBands.removeAll(resolution.getNotQrvBands()); + if (fallbackBands.isEmpty()) { + return null; } if (member != null && member.getChatCategory() != null - && member.getChatCategory().getCategoryNumber() == ChatCategory.MICROWAVE) { + && member.getChatCategory().getCategoryNumber() == ChatCategory.MICROWAVE + && fallbackBands.contains(Band.B_1296)) { return Band.B_1296; } - return Band.B_144; + if (fallbackBands.contains(Band.B_144)) { + return Band.B_144; + } + + return fallbackBands.stream() + .min(Comparator.comparingDouble(Band::getDefaultAnalysisFrequencyMHz)) + .orElse(null); } /** @@ -253,22 +300,44 @@ public final class ReachabilityService { * @return set of enabled bands */ public EnumSet getEnabledStationBands() { - ChatPreferences preferences = chatController.getChatPreferences(); - EnumSet enabledBands = EnumSet.noneOf(Band.class); + return BandOpportunityResolver.getEnabledStationBands( + chatController.getChatPreferences() + ); + } - if (preferences == null) { - return enabledBands; + private List resolveCallsignVariants(ChatMember member) { + if (member == null) { + return List.of(); } - 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); + String rawCall = member.getCallSignRaw() != null + ? member.getCallSignRaw() + : member.getCallSign(); - return enabledBands; + List variants = chatController.findActiveChatMembersByRawCall(rawCall); + return variants.isEmpty() ? List.of(member) : variants; + } + + /** + * Verifies that an automatically selected map/snapshot frequency belongs to a + * locally enabled band that is still available after NOT-QRV resolution. + * Manual UI band overrides are handled separately and are not changed here. + */ + private boolean isUsableAutomaticBand(ChatMember member, Band band) { + if (band == null || !getEnabledStationBands().contains(band)) { + return false; + } + + if (member == null) { + return true; + } + + BandOpportunityResolver.Resolution resolution = BandOpportunityResolver.resolve( + resolveCallsignVariants(member), + System.currentTimeMillis() + ); + + return resolution.getAvailableBands().contains(band); } /** diff --git a/src/main/java/kst4contest/controller/ScoreService.java b/src/main/java/kst4contest/controller/ScoreService.java index 1267788..f0a191b 100644 --- a/src/main/java/kst4contest/controller/ScoreService.java +++ b/src/main/java/kst4contest/controller/ScoreService.java @@ -140,7 +140,9 @@ public final class ScoreService { controller.getStationMetricsService().snapshot(nowEpochMs, prefs); // 1) Choose one representative per callsignRaw - Map representativeByCallRaw = chooseRepresentativeMembers(members, lastInbound); + Map> variantsByCallRaw = groupMembersByCallRaw(members); + Map representativeByCallRaw = + chooseRepresentativeMembers(variantsByCallRaw, lastInbound); // 2) Compute score once per callsignRaw Map scoreByCallRaw = new HashMap<>(representativeByCallRaw.size()); @@ -154,6 +156,7 @@ public final class ScoreService { double score = priorityCalculator.calculatePriority( representative, + variantsByCallRaw.getOrDefault(callRaw, List.of(representative)), prefs, activeSkeds, metricsSnapshot, @@ -189,6 +192,21 @@ public final class ScoreService { }); } + private Map> groupMembersByCallRaw(List members) { + Map> byCallRaw = new HashMap<>(); + + for (ChatMember member : members) { + if (member == null) continue; + + String callRaw = normalizeCallRaw(member.getCallSignRaw()); + if (callRaw == null || callRaw.isEmpty()) continue; + + byCallRaw.computeIfAbsent(callRaw, ignored -> new ArrayList<>()).add(member); + } + + return byCallRaw; + } + /** * Picks one ChatMember object per callsignRaw. * Preference order: @@ -196,18 +214,9 @@ public final class ScoreService { * 2) Most recently active variant (fallback) */ private Map chooseRepresentativeMembers( - List members, + Map> byCallRaw, Map lastInboundCategoryByCallRaw ) { - Map> byCallRaw = new HashMap<>(); - - for (ChatMember m : members) { - if (m == null) continue; - String callRaw = normalizeCallRaw(m.getCallSignRaw()); - if (callRaw == null || callRaw.isEmpty()) continue; - byCallRaw.computeIfAbsent(callRaw, k -> new ArrayList<>()).add(m); - } - Map representative = new HashMap<>(byCallRaw.size()); for (Map.Entry> entry : byCallRaw.entrySet()) { diff --git a/src/main/java/kst4contest/logic/BandOpportunityResolver.java b/src/main/java/kst4contest/logic/BandOpportunityResolver.java new file mode 100644 index 0000000..8884709 --- /dev/null +++ b/src/main/java/kst4contest/logic/BandOpportunityResolver.java @@ -0,0 +1,253 @@ +package kst4contest.logic; + +import kst4contest.model.Band; +import kst4contest.model.ChatMember; +import kst4contest.model.ChatPreferences; + +import java.util.Collection; +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Resolves band availability and band-upgrade opportunities from one or more + * active {@link ChatMember} variants of the same base callsign. + * + *

The resolver deliberately separates a band hint from an exact frequency: + * {@code knownActiveBands} remains the source for detected QRGs with timestamps, + * while the station name may add a band without inventing a frequency.

+ * + *

A manual NOT-QRV flag always overrides automatic evidence. Worked flags are + * evaluated separately because an offered band may still be useful for display, + * even when it is no longer a band-upgrade opportunity.

+ */ +public final class BandOpportunityResolver { + + public static final long RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS = 30L * 60L * 1000L; + + private static final Map STATION_NAME_BAND_PATTERNS = createStationNameBandPatterns(); + + private BandOpportunityResolver() { + } + + /** + * Resolves the common band state using the application-wide 30-minute window + * for frequency evidence. Name-derived band hints remain valid while the + * ChatMember is present in the active chat model. + */ + public static Resolution resolve(Collection variants, long nowEpochMs) { + return resolve(variants, nowEpochMs, RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS); + } + + /** + * Resolves offered, worked and manually excluded bands across all supplied + * category/callsign variants. + */ + public static Resolution resolve(Collection variants, + long nowEpochMs, + long dynamicEvidenceMaxAgeMs) { + + EnumSet offeredBands = EnumSet.noneOf(Band.class); + EnumSet workedBands = EnumSet.noneOf(Band.class); + EnumSet notQrvBands = EnumSet.noneOf(Band.class); + + if (variants == null) { + return new Resolution(offeredBands, workedBands, notQrvBands); + } + + for (ChatMember member : variants) { + if (member == null) { + continue; + } + + collectRecentFrequencyBands( + member, + offeredBands, + nowEpochMs, + dynamicEvidenceMaxAgeMs + ); + offeredBands.addAll(detectBandsFromStationName(member.getName())); + collectWorkedBands(member, workedBands); + collectNotQrvBands(member, notQrvBands); + } + + return new Resolution(offeredBands, workedBands, notQrvBands); + } + + /** + * Returns the bands enabled in the local station setup. Bands above 10 GHz + * remain excluded because the current preferences do not provide active-band + * flags for them. + */ + public static EnumSet getEnabledStationBands(ChatPreferences preferences) { + 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; + } + + /** + * Detects explicit amateur-band designators in a station name. + * + *

The boundary rules are intentionally stricter than simple token splitting. + * In particular, the trailing {@code 2} in {@code 1.2 cm} must not be mistaken + * for the 2 m band.

+ */ + public static EnumSet detectBandsFromStationName(String stationName) { + EnumSet detectedBands = EnumSet.noneOf(Band.class); + if (stationName == null || stationName.isBlank()) { + return detectedBands; + } + + for (Map.Entry entry : STATION_NAME_BAND_PATTERNS.entrySet()) { + if (entry.getValue().matcher(stationName).find()) { + detectedBands.add(entry.getKey()); + } + } + + return detectedBands; + } + + private static void collectRecentFrequencyBands(ChatMember member, + EnumSet target, + long nowEpochMs, + long dynamicEvidenceMaxAgeMs) { + if (member.getKnownActiveBands() == null || member.getKnownActiveBands().isEmpty()) { + return; + } + + for (Map.Entry entry + : member.getKnownActiveBands().entrySet()) { + + Band band = entry.getKey(); + ChatMember.ActiveFrequencyInfo info = entry.getValue(); + if (band == null || info == null) { + continue; + } + + long ageMs = nowEpochMs - info.timestampEpoch; + boolean ageAccepted = dynamicEvidenceMaxAgeMs <= 0L + ? ageMs >= 0L + : ageMs >= 0L && ageMs <= dynamicEvidenceMaxAgeMs; + + if (ageAccepted) { + target.add(band); + } + } + } + + private static void collectWorkedBands(ChatMember member, EnumSet target) { + if (member.isWorked144()) target.add(Band.B_144); + if (member.isWorked432()) target.add(Band.B_432); + if (member.isWorked1240()) target.add(Band.B_1296); + if (member.isWorked2300()) target.add(Band.B_2320); + if (member.isWorked3400()) target.add(Band.B_3400); + if (member.isWorked5600()) target.add(Band.B_5760); + if (member.isWorked10G()) target.add(Band.B_10G); + if (member.isWorked24G()) target.add(Band.B_24G); + } + + private static void collectNotQrvBands(ChatMember member, EnumSet target) { + if (!member.isQrv144()) target.add(Band.B_144); + if (!member.isQrv432()) target.add(Band.B_432); + if (!member.isQrv1240()) target.add(Band.B_1296); + if (!member.isQrv2300()) target.add(Band.B_2320); + if (!member.isQrv3400()) target.add(Band.B_3400); + if (!member.isQrv5600()) target.add(Band.B_5760); + if (!member.isQrv10G()) target.add(Band.B_10G); + // There is currently no persisted NOT-QRV flag for 24 GHz. + } + + private static Map createStationNameBandPatterns() { + Map patterns = new EnumMap<>(Band.class); + + patterns.put(Band.B_144, bandPattern("144(?:\\s*MHZ)?|2(?:\\s*M)?")); + patterns.put(Band.B_432, bandPattern("432(?:\\s*MHZ)?|70(?:\\s*CM)?")); + patterns.put(Band.B_1296, bandPattern("1296(?:\\s*MHZ)?|23(?:\\s*CM)?")); + patterns.put(Band.B_2320, bandPattern("(?:2300|2320)(?:\\s*MHZ)?|13(?:\\s*CM)?")); + patterns.put(Band.B_3400, bandPattern("3400(?:\\s*MHZ)?|9(?:\\s*CM)?")); + patterns.put(Band.B_5760, bandPattern("(?:5600|5760)(?:\\s*MHZ)?|6(?:\\s*CM)?")); + patterns.put(Band.B_10G, bandPattern("10368(?:\\s*MHZ)?|10\\s*G(?:HZ)?|3(?:\\s*CM)?")); + patterns.put(Band.B_24G, bandPattern("24048(?:\\s*MHZ)?|24\\s*G(?:HZ)?|1[.,]2(?:\\s*CM)?")); + + return Collections.unmodifiableMap(patterns); + } + + private static Pattern bandPattern(String alternatives) { + return Pattern.compile( + "(? offeredBands; + private final EnumSet workedBands; + private final EnumSet notQrvBands; + + private Resolution(EnumSet offeredBands, + EnumSet workedBands, + EnumSet notQrvBands) { + this.offeredBands = copyOf(offeredBands); + this.workedBands = copyOf(workedBands); + this.notQrvBands = copyOf(notQrvBands); + } + + /** Returns all recent/name-derived bands before NOT-QRV is applied. */ + public EnumSet getOfferedBands() { + return copyOf(offeredBands); + } + + public EnumSet getWorkedBands() { + return copyOf(workedBands); + } + + public EnumSet getNotQrvBands() { + return copyOf(notQrvBands); + } + + /** Returns offered bands after manual NOT-QRV exclusions. */ + public EnumSet getAvailableBands() { + EnumSet availableBands = copyOf(offeredBands); + availableBands.removeAll(notQrvBands); + return availableBands; + } + + /** Returns offered, QRV, enabled and not-yet-worked bands. */ + public EnumSet getUnworkedEnabledBands(EnumSet enabledBands) { + EnumSet opportunities = getAvailableBands(); + if (enabledBands == null || enabledBands.isEmpty()) { + opportunities.clear(); + return opportunities; + } + + opportunities.retainAll(enabledBands); + opportunities.removeAll(workedBands); + return opportunities; + } + + public boolean hasBandEvidence() { + return !offeredBands.isEmpty(); + } + + private static EnumSet copyOf(EnumSet source) { + return source == null || source.isEmpty() + ? EnumSet.noneOf(Band.class) + : EnumSet.copyOf(source); + } + } +} diff --git a/src/main/java/kst4contest/logic/PriorityCalculator.java b/src/main/java/kst4contest/logic/PriorityCalculator.java index fd9d3a7..8fc8436 100644 --- a/src/main/java/kst4contest/logic/PriorityCalculator.java +++ b/src/main/java/kst4contest/logic/PriorityCalculator.java @@ -3,9 +3,9 @@ package kst4contest.logic; import kst4contest.controller.StationMetricsService; import kst4contest.model.*; +import java.util.Collection; import java.util.EnumSet; import java.util.List; -import java.util.Map; /** * Priority score calculation (off FX-thread). @@ -16,10 +16,8 @@ import java.util.Map; */ public class PriorityCalculator { - /** Max age for "known active bands" (derived from chat history). */ - private static final long RX_BANDS_MAX_AGE_MS = 30L * 60L * 1000L; // 30 minutes - public double calculatePriority(ChatMember member, + Collection callsignVariants, ChatPreferences prefs, List activeSkeds, StationMetricsService.Snapshot metricsSnapshot, @@ -33,41 +31,51 @@ public class PriorityCalculator { // -------------------------------------------------------------------- // 1) HARD FILTER: reachable hardware + "already worked on all possible bands" // -------------------------------------------------------------------- - // -------------------------------------------------------------------- -// 1) HARD FILTER: reachable hardware + "already worked on all possible bands" -// -------------------------------------------------------------------- - EnumSet myEnabledBands = getMyEnabledBands(prefs); + Collection variants = callsignVariants == null || callsignVariants.isEmpty() + ? List.of(member) + : callsignVariants; -// "worked" for scoring is derived ONLY from per-band flags (worked144/432/...) -// IMPORTANT: ChatMember.worked is UI-only and NOT used in scoring. - EnumSet workedBandsForScoring = getWorkedBands(member); + BandOpportunityResolver.Resolution bandResolution = + BandOpportunityResolver.resolve(variants, nowEpochMs); + + EnumSet myEnabledBands = + BandOpportunityResolver.getEnabledStationBands(prefs); + + // ChatMember.worked remains UI-only. Scoring uses only per-band worked flags. + EnumSet workedBandsForScoring = bandResolution.getWorkedBands(); -// Remaining bands that are: -// - recently offered by the station (from knownActiveBands history) -// - enabled at our station -// - NOT worked yet (per-band flags) -// If we do not know offered bands (history empty), this remains empty. EnumSet unworkedPossible = EnumSet.noneOf(Band.class); - EnumSet stationOfferedBands = getStationOfferedBandsFromHistory(member, nowEpochMs); + EnumSet stationOfferedBands = bandResolution.getOfferedBands(); + EnumSet stationAvailableBands = bandResolution.getAvailableBands(); EnumSet possibleBands = stationOfferedBands.isEmpty() - ? EnumSet.noneOf(Band.class) // unknown => don't hard-filter - : EnumSet.copyOf(stationOfferedBands); + ? EnumSet.noneOf(Band.class) + : EnumSet.copyOf(stationAvailableBands); - if (!possibleBands.isEmpty()) { + if (!stationOfferedBands.isEmpty()) { possibleBands.retainAll(myEnabledBands); if (possibleBands.isEmpty()) { - // We know their bands, but none of them are enabled at our station. + // Known bands are disabled locally or manually marked NOT QRV. return 0.0; } unworkedPossible = EnumSet.copyOf(possibleBands); unworkedPossible.removeAll(workedBandsForScoring); - // If already worked on all possible bands => no priority on them anymore (contest logic). if (unworkedPossible.isEmpty()) { return 0.0; } + } else { + /* + * Missing band evidence is not automatically negative. A complete manual + * NOT-QRV exclusion is different: if every enabled own band is excluded, + * this station cannot be a current contest candidate. + */ + EnumSet notExplicitlyExcluded = EnumSet.copyOf(myEnabledBands); + notExplicitlyExcluded.removeAll(bandResolution.getNotQrvBands()); + if (!myEnabledBands.isEmpty() && notExplicitlyExcluded.isEmpty()) { + return 0.0; + } } // -------------------------------------------------------------------- @@ -225,46 +233,6 @@ public class PriorityCalculator { return Math.max(0.0, score); } - private static EnumSet getMyEnabledBands(ChatPreferences prefs) { - EnumSet out = EnumSet.noneOf(Band.class); - if (prefs.isStn_bandActive144()) out.add(Band.B_144); - if (prefs.isStn_bandActive432()) out.add(Band.B_432); - if (prefs.isStn_bandActive1240()) out.add(Band.B_1296); - if (prefs.isStn_bandActive2300()) out.add(Band.B_2320); - if (prefs.isStn_bandActive3400()) out.add(Band.B_3400); - if (prefs.isStn_bandActive5600()) out.add(Band.B_5760); - if (prefs.isStn_bandActive10G()) out.add(Band.B_10G); - return out; - } - - private static EnumSet getStationOfferedBandsFromHistory(ChatMember member, long nowEpochMs) { - EnumSet out = EnumSet.noneOf(Band.class); - Map map = member.getKnownActiveBands(); - if (map == null || map.isEmpty()) return out; - - for (Map.Entry e : map.entrySet()) { - if (e == null || e.getKey() == null || e.getValue() == null) continue; - long age = nowEpochMs - e.getValue().timestampEpoch; - if (age <= RX_BANDS_MAX_AGE_MS) { - out.add(e.getKey()); - } - } - return out; - } - - private static EnumSet getWorkedBands(ChatMember member) { - EnumSet out = EnumSet.noneOf(Band.class); - if (member.isWorked144()) out.add(Band.B_144); - if (member.isWorked432()) out.add(Band.B_432); - if (member.isWorked1240()) out.add(Band.B_1296); - if (member.isWorked2300()) out.add(Band.B_2320); - if (member.isWorked3400()) out.add(Band.B_3400); - if (member.isWorked5600()) out.add(Band.B_5760); - if (member.isWorked10G()) out.add(Band.B_10G); - if (member.isWorked24G()) out.add(Band.B_24G); - return out; - } - private static int findNextAirplaneArrivingMinutes(AirPlaneReflectionInfo apInfo) { try { if (apInfo.getRisingAirplanes() == null || apInfo.getRisingAirplanes().isEmpty()) return -1; diff --git a/src/main/java/kst4contest/model/ChatPreferences.java b/src/main/java/kst4contest/model/ChatPreferences.java index 6eb87c8..e29e66e 100644 --- a/src/main/java/kst4contest/model/ChatPreferences.java +++ b/src/main/java/kst4contest/model/ChatPreferences.java @@ -332,6 +332,8 @@ public class ChatPreferences { boolean guiOptions_defaultFilterPmToMe; boolean guiOptions_defaultFilterPmToOther; boolean guiOptions_defaultFilterPublicMsgs; + boolean guiOptions_showGrossFieldWorkedHintInBandColumns = true; // show "o" (grid square already worked on this band) in the band columns + boolean guiOptions_showFreshCallHintInBandColumns = true; // show "a" (band available, call not worked on any band yet) instead of always "B+" in the band columns private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 }; private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN }; @@ -647,6 +649,22 @@ public class ChatPreferences { this.guiOptions_defaultFilterPmToMe = guiOptions_defaultFilterPmToMe; } + public boolean isGuiOptions_showGrossFieldWorkedHintInBandColumns() { + return guiOptions_showGrossFieldWorkedHintInBandColumns; + } + + public void setGuiOptions_showGrossFieldWorkedHintInBandColumns(boolean guiOptions_showGrossFieldWorkedHintInBandColumns) { + this.guiOptions_showGrossFieldWorkedHintInBandColumns = guiOptions_showGrossFieldWorkedHintInBandColumns; + } + + public boolean isGuiOptions_showFreshCallHintInBandColumns() { + return guiOptions_showFreshCallHintInBandColumns; + } + + public void setGuiOptions_showFreshCallHintInBandColumns(boolean guiOptions_showFreshCallHintInBandColumns) { + this.guiOptions_showFreshCallHintInBandColumns = guiOptions_showFreshCallHintInBandColumns; + } + public boolean isGuiOptions_defaultFilterPmToOther() { return guiOptions_defaultFilterPmToOther; } @@ -1935,6 +1953,14 @@ public class ChatPreferences { guiOptions_defaultFilterPublicMsgs.setTextContent(this.isGuiOptions_defaultFilterPublicMsgs()+""); guiSaveableOptions.appendChild(guiOptions_defaultFilterPublicMsgs); + Element guiOptions_showGrossFieldWorkedHintInBandColumns = doc.createElement("guiOptions_showGrossFieldWorkedHintInBandColumns"); + guiOptions_showGrossFieldWorkedHintInBandColumns.setTextContent(this.isGuiOptions_showGrossFieldWorkedHintInBandColumns()+""); + guiSaveableOptions.appendChild(guiOptions_showGrossFieldWorkedHintInBandColumns); + + Element guiOptions_showFreshCallHintInBandColumns = doc.createElement("guiOptions_showFreshCallHintInBandColumns"); + guiOptions_showFreshCallHintInBandColumns.setTextContent(this.isGuiOptions_showFreshCallHintInBandColumns()+""); + guiSaveableOptions.appendChild(guiOptions_showFreshCallHintInBandColumns); + Element guiOptions_darkModeActive = doc.createElement("guiOptions_darkModeActive"); guiOptions_darkModeActive.setTextContent(this.GUI_darkModeActive + ""); guiSaveableOptions.appendChild(guiOptions_darkModeActive); @@ -2815,6 +2841,8 @@ public class ChatPreferences { this.setGuiOptions_defaultFilterPmToMe(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_defaultFilterPmToMe(), "guiOptions_defaultFilterPmToMe")); this.setGuiOptions_defaultFilterPmToOther(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_defaultFilterPmToOther(), "guiOptions_defaultFilterPmToOther")); this.setGuiOptions_defaultFilterPublicMsgs(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_defaultFilterPublicMsgs(), "guiOptions_defaultFilterPublicMsgs")); + this.setGuiOptions_showGrossFieldWorkedHintInBandColumns(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_showGrossFieldWorkedHintInBandColumns(), "guiOptions_showGrossFieldWorkedHintInBandColumns")); + this.setGuiOptions_showFreshCallHintInBandColumns(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_showFreshCallHintInBandColumns(), "guiOptions_showFreshCallHintInBandColumns")); // Added in later versions: dark mode flags this.GUI_darkModeActive = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActive, "guiOptions_darkModeActive"); diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java index bd752db..a2db431 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -66,9 +66,8 @@ import javafx.scene.shape.Line; import javafx.scene.shape.Polygon; import javafx.stage.Screen; +import kst4contest.logic.BandOpportunityResolver; import kst4contest.utils.ApplicationFileUtils; -import kst4contest.view.map.MapCallsignRawSnapshot; -import kst4contest.view.map.MapCallsignRawSnapshotBuilder; import kst4contest.view.map.StationMapBridge; import kst4contest.view.map.StationMapView; import kst4contest.view.map.OfflineDemImportService; @@ -121,9 +120,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL private final Tooltip tipSkedWarnIndicator = new Tooltip(); private Timeline skedWarnBlinkTimeline; - private final MapCallsignRawSnapshotBuilder mainViewBandMarkerSnapshotBuilder = new MapCallsignRawSnapshotBuilder(); - - // Timeline: show at most N priority markers per minute bucket (minute 0/1 often has many planes) private static final int TIMELINE_PRIORITY_MARKERS_PER_MINUTE = 2; @@ -365,61 +361,69 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (callSignRaw == null) return "Bands: -"; - // band -> (freq,timestamp) newest across ALL category-variants - Map newestPerBand = new java.util.EnumMap<>(kst4contest.model.Band.class); + List variants = chatcontroller.findActiveChatMembersByRawCall(callSignRaw); + BandOpportunityResolver.Resolution bandResolution = + BandOpportunityResolver.resolve(variants, System.currentTimeMillis(), maxAgeMs); + EnumSet availableBands = bandResolution.getAvailableBands(); - synchronized (chatcontroller.getLst_chatMemberList()) { - for (ChatMember m : chatcontroller.getLst_chatMemberList()) { - if (m == null) continue; - if (m.getCallSignRaw() == null) continue; - if (!m.getCallSignRaw().equalsIgnoreCase(callSignRaw)) continue; + if (availableBands.isEmpty()) { + return "Bands: -"; + } - Map map = m.getKnownActiveBands(); - if (map == null) continue; + Map newestPerBand = + new EnumMap<>(Band.class); - for (Map.Entry e : map.entrySet()) { - kst4contest.model.Band band = e.getKey(); - ChatMember.ActiveFrequencyInfo info = e.getValue(); - if (band == null || info == null) continue; + for (ChatMember member : variants) { + if (member == null || member.getKnownActiveBands() == null) continue; - // optional age filter (e.g. last 30 minutes) - if (maxAgeMs > 0 && (System.currentTimeMillis() - info.timestampEpoch) > maxAgeMs) { - continue; - } + for (Map.Entry entry + : member.getKnownActiveBands().entrySet()) { - ChatMember.ActiveFrequencyInfo existing = newestPerBand.get(band); - if (existing == null || info.timestampEpoch > existing.timestampEpoch) { - newestPerBand.put(band, info); - } + Band band = entry.getKey(); + ChatMember.ActiveFrequencyInfo info = entry.getValue(); + if (band == null || info == null || !availableBands.contains(band)) continue; + + long ageMs = System.currentTimeMillis() - info.timestampEpoch; + if (ageMs < 0L || (maxAgeMs > 0 && ageMs > maxAgeMs)) continue; + + ChatMember.ActiveFrequencyInfo existing = newestPerBand.get(band); + if (existing == null || info.timestampEpoch > existing.timestampEpoch) { + newestPerBand.put(band, info); } } } + StringBuilder result = new StringBuilder("Bands: ").append( + availableBands.stream() + .sorted() + .map(this::bandToHumanLabel) + .collect(java.util.stream.Collectors.joining(", ")) + ); + if (newestPerBand.isEmpty()) { - return "Bands: -"; + return result.toString(); } - // Render sorted by band enum order - StringBuilder sb = new StringBuilder("Bands: "); + result.append(" | QRGs: "); boolean first = true; - for (kst4contest.model.Band b : kst4contest.model.Band.values()) { - ChatMember.ActiveFrequencyInfo info = newestPerBand.get(b); + for (Band band : Band.values()) { + ChatMember.ActiveFrequencyInfo info = newestPerBand.get(band); if (info == null) continue; - long ageMin = (System.currentTimeMillis() - info.timestampEpoch) / 60000L; - - if (!first) sb.append(" | "); + if (!first) result.append(" | "); first = false; - sb.append(String.format(java.util.Locale.US, "%.3f", info.frequency)) - .append(" MHz") - .append(" (") - .append(ageMin) + long ageMinutes = (System.currentTimeMillis() - info.timestampEpoch) / 60_000L; + result.append(bandToHumanLabel(band)) + .append(" ") + .append(String.format(Locale.US, "%.3f", info.frequency)) + .append(" MHz (") + .append(ageMinutes) .append(" min ago)"); } - return sb.toString(); + return result.toString(); } /** @@ -432,43 +436,135 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * @return true if the band cell should show a star */ private boolean isBandOfferForMainView(ChatMember chatMember, Band band) { - if (chatMember == null - || band == null - || chatcontroller == null - || chatcontroller.getReachabilityService() == null - || chatMember.getCallSignRaw() == null - || chatMember.getCallSignRaw().isBlank()) { + if (chatMember == null || band == null || chatcontroller == null) { return false; } - EnumSet enabledBands = chatcontroller.getReachabilityService().getEnabledStationBands(); - if (enabledBands == null || !enabledBands.contains(band)) { - return false; - } - - List variants = new ArrayList<>(); - synchronized (chatcontroller.getLst_chatMemberList()) { - for (ChatMember variant : chatcontroller.getLst_chatMemberList()) { - if (variant == null || variant.getCallSignRaw() == null) { - continue; - } - if (variant.getCallSignRaw().equalsIgnoreCase(chatMember.getCallSignRaw())) { - variants.add(variant); - } - } - } - + List variants = + chatcontroller.findActiveChatMembersByRawCall(chatMember.getCallSignRaw()); if (variants.isEmpty()) { variants = List.of(chatMember); } - List snapshots = mainViewBandMarkerSnapshotBuilder.buildSnapshots( - variants, - null, - EnumSet.of(band) - ); + BandOpportunityResolver.Resolution resolution = + BandOpportunityResolver.resolve(variants, System.currentTimeMillis()); + EnumSet enabledBands = + BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()); - return snapshots.stream().anyMatch(MapCallsignRawSnapshot::offersSelectedBand); + return resolution.getUnworkedEnabledBands(enabledBands).contains(band); + } + + /** + * Checks whether the station's four-character grid square has already been worked + * specifically on the given band, regardless of which call was worked there. Mirrors + * the "o" semantics of the wkdAny column, just scoped to one band instead of any band. + * Can be turned off via + * {@link ChatPreferences#isGuiOptions_showGrossFieldWorkedHintInBandColumns()}. + * + * @param chatMember station row + * @param band band to check + * @return true if the cell should show the "o" worked-grid-square hint + */ + private boolean isGrossFieldWorkedForBand(ChatMember chatMember, Band band) { + if (chatMember == null || band == null || chatcontroller == null) { + return false; + } + + if (!chatcontroller.getChatPreferences().isGuiOptions_showGrossFieldWorkedHintInBandColumns()) { + return false; + } + + String qra = chatMember.getQra(); + if (qra == null || qra.isBlank()) { + return false; + } + + return chatcontroller.getWorkedGrossFieldCache().isGrossFieldWorked(band, qra); + } + + /** + * Resolves the compact per-band status shown in the 144/432/23/13/9/6/3 table + * columns. + * + *

{@code X}/{@code a}/{@code B+}/(empty) describe the band-opportunity part: + * {@code X} if worked on this exact band, otherwise {@code a}/{@code B+} if the band + * is offered, enabled and unworked ({@code a} when the call has not been worked on + * any band yet, {@code B+} when it has), otherwise empty.

+ * + *

{@code o} is an independent overlay, appended to whichever of the above applies, + * exactly like the {@code x}/{@code o}/{@code xo} combination in the wkdAny column: + * it marks that this band's grid square has already been worked, by any station. + * So e.g. {@code "a"}, {@code "ao"}, {@code "B+"}, {@code "B+o"} or just {@code "o"} + * can all appear.

+ * + * @param chatMember station row + * @param band band this column represents + * @param workedThisBand true if the per-band worked flag for this exact band is set + * @return compact status text for the cell + */ + private String formatBandCellStatus(ChatMember chatMember, Band band, boolean workedThisBand) { + String opportunity; + + boolean showFreshCallHint = chatcontroller != null + && chatcontroller.getChatPreferences().isGuiOptions_showFreshCallHintInBandColumns(); + + if (workedThisBand) { + opportunity = "X"; + } else if (isBandOfferForMainView(chatMember, band)) { + opportunity = showFreshCallHint && chatMember != null && !chatMember.isWorked() ? "a" : "B+"; + } else { + opportunity = ""; + } + + return isGrossFieldWorkedForBand(chatMember, band) ? opportunity + "o" : opportunity; + } + + /** + * Builds the tooltip shown on a band-status cell: a fixed legend plus this row's + * resolved status for the given band. + */ + private Tooltip buildBandCellStatusTooltip(ChatMember chatMember, Band band, String status) { + StringBuilder tooltip = new StringBuilder("Band status:\n") + .append("X = worked on this band\n") + .append("B+ = band available, not worked on this band yet (call already worked on another band)\n") + .append("a = band available, call not worked on any band yet (can be turned off in GUI settings; falls back to B+)\n") + .append("o = grid square already worked on this band, by any station (can be turned off in GUI settings)\n") + .append("(empty) = no information for this band\n") + .append("o can combine with the others, e.g. \"ao\" or \"B+o\""); + + if (chatMember != null && band != null) { + tooltip.append("\n\nThis station (").append(bandToHumanLabel(band)).append("):\n") + .append("Status: ").append(status == null || status.isBlank() ? "-" : status); + } + + return new Tooltip(tooltip.toString()); + } + + /** + * Creates a shared cell factory for one band-status column, attaching the + * {@link #buildBandCellStatusTooltip(ChatMember, Band, String)} tooltip. + */ + private Callback, TableCell> createBandStatusCellFactory(Band band) { + return column -> new TableCell() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + + if (empty) { + setText(null); + setTooltip(null); + setStyle(""); + return; + } + + ChatMember member = getTableRow() == null ? null : getTableRow().getItem(); + + setText(item); + setTooltip(buildBandCellStatusTooltip(member, band, item)); + setAlignment(Pos.CENTER); + setStyle("-fx-font-weight: bold;"); + } + }; } private String bandToHumanLabel(kst4contest.model.Band b) { @@ -529,6 +625,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL formatDetectedRxBandsForCallsignRaw(selectedCallSignInfoStageChatMember.getCallSignRaw(), 30L * 60L * 1000L) ); lblDetectedRxBands.setWrapText(true); + lblDetectedRxBands.setTooltip(new Tooltip( + "Bands are derived from recent QRG detections and the station name. " + + "Manual NOT-QRV tags override automatic hints." + )); selectedCallSignDownerSiteGridPane.add(lblDetectedRxBands, 0, 4, 1, 1); @@ -684,6 +784,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } try { + chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); @@ -707,6 +808,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL try { + chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); } catch (Exception e) { @@ -728,6 +830,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL try { + chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); } catch (Exception e) { @@ -749,6 +852,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL try { + chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); } catch (Exception e) { @@ -770,6 +874,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL try { + chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); } catch (Exception e) { @@ -792,6 +897,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL try { + chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); } catch (Exception e) { @@ -813,6 +919,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL try { + chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); } catch (Exception e) { @@ -845,6 +952,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL try { + chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); } catch (Exception e) { @@ -1692,20 +1800,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked144()) { - wkd.setValue("X"); - } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_144)) { - wkd.setValue("B+"); - } else { - wkd.setValue(""); - } - - return wkd; + return new SimpleStringProperty(formatBandCellStatus( + cellDataFeatures.getValue(), Band.B_144, cellDataFeatures.getValue().isWorked144() + )); } }); vhfCol_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(28)); + vhfCol_subcol.setCellFactory(createBandStatusCellFactory(Band.B_144)); TableColumn uhfCol_subcol = new TableColumn("432"); uhfCol_subcol @@ -1713,122 +1814,80 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked432()) { - wkd.setValue("X"); - } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_432)) { - wkd.setValue("B+"); - } else { - wkd.setValue(""); - } - - return wkd; + return new SimpleStringProperty(formatBandCellStatus( + cellDataFeatures.getValue(), Band.B_432, cellDataFeatures.getValue().isWorked432() + )); } }); uhfCol_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(28)); + uhfCol_subcol.setCellFactory(createBandStatusCellFactory(Band.B_432)); TableColumn shf23_subcol = new TableColumn("23"); shf23_subcol.setCellValueFactory(new Callback, ObservableValue>() { @Override public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked1240()) { - wkd.setValue("X"); - } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_1296)) { - wkd.setValue("B+"); - } else { - wkd.setValue(""); - } - - return wkd; + return new SimpleStringProperty(formatBandCellStatus( + cellDataFeatures.getValue(), Band.B_1296, cellDataFeatures.getValue().isWorked1240() + )); } }); shf23_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(30)); + shf23_subcol.setCellFactory(createBandStatusCellFactory(Band.B_1296)); TableColumn shf13_subcol = new TableColumn("13"); shf13_subcol.setCellValueFactory(new Callback, ObservableValue>() { @Override public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked2300()) { - wkd.setValue("X"); - } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_2320)) { - wkd.setValue("B+"); - } else { - wkd.setValue(""); - } - - return wkd; + return new SimpleStringProperty(formatBandCellStatus( + cellDataFeatures.getValue(), Band.B_2320, cellDataFeatures.getValue().isWorked2300() + )); } }); shf13_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(30)); + shf13_subcol.setCellFactory(createBandStatusCellFactory(Band.B_2320)); TableColumn shf9_subcol = new TableColumn("9"); shf9_subcol.setCellValueFactory(new Callback, ObservableValue>() { @Override public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked3400()) { - wkd.setValue("X"); - } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_3400)) { - wkd.setValue("B+"); - } else { - wkd.setValue(""); - } - - return wkd; + return new SimpleStringProperty(formatBandCellStatus( + cellDataFeatures.getValue(), Band.B_3400, cellDataFeatures.getValue().isWorked3400() + )); } }); shf9_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(32)); + shf9_subcol.setCellFactory(createBandStatusCellFactory(Band.B_3400)); TableColumn shf6_subcol = new TableColumn("6"); shf6_subcol.setCellValueFactory(new Callback, ObservableValue>() { @Override public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked5600()) { - wkd.setValue("X"); - } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_5760)) { - wkd.setValue("B+"); - } else { - wkd.setValue(""); - } - - return wkd; + return new SimpleStringProperty(formatBandCellStatus( + cellDataFeatures.getValue(), Band.B_5760, cellDataFeatures.getValue().isWorked5600() + )); } }); shf6_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(32)); + shf6_subcol.setCellFactory(createBandStatusCellFactory(Band.B_5760)); TableColumn shf3_subcol = new TableColumn("3"); shf3_subcol.setCellValueFactory(new Callback, ObservableValue>() { @Override public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked10G()) { - wkd.setValue("X"); - } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_10G)) { - wkd.setValue("B+"); - } else { - wkd.setValue(""); - } - - return wkd; + return new SimpleStringProperty(formatBandCellStatus( + cellDataFeatures.getValue(), Band.B_10G, cellDataFeatures.getValue().isWorked10G() + )); } }); shf3_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(32)); + shf3_subcol.setCellFactory(createBandStatusCellFactory(Band.B_10G)); /** * section of NOT-QRV flag in chatmember table @@ -7118,7 +7177,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } } }); - btnTglNewBands.setTooltip(new Tooltip("Show stations that are QRV on a known active band that has not been worked yet")); + btnTglNewBands.setTooltip(new Tooltip( + "Show stations offering at least one enabled, unworked band. " + + "Recent QRG detections and station names are evaluated; " + + "NOT-QRV tags override them." + )); ToggleButton btnTglAsNext5Min = new ToggleButton("AS next 5m"); Predicate asNext5MinPredicate = new Predicate() { @@ -10151,7 +10214,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL grdPnlNotify.add(generateLabeledSeparator(100, "Band-upgrade hint (after log entry)"), 0, 13, 2, 1); - Label lblNotifyBandUpgradeHint = new Label("Blink + sound if logged station is still QRV on other unworked enabled band(s)"); + Label lblNotifyBandUpgradeHint = new Label( + "Blink + sound if a logged station still offers another unworked enabled band" + ); CheckBox chkBxNotifyBandUpgradeHint = new CheckBox(); chkBxNotifyBandUpgradeHint.setSelected(chatcontroller.getChatPreferences().isNotify_bandUpgradeHintOnLogEnabled()); chkBxNotifyBandUpgradeHint.selectedProperty().addListener((obs, o, n) -> @@ -10708,6 +10773,38 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL 0, 2, 2, 1); grdPnlGuiOptions.add(new Label("Coloring mode:"), 0, 3); + grdPnlGuiOptions.add(generateLabeledSeparator(100, "Band table hints"), 0, 4, 2, 1); + + Label lblShowGrossFieldWorkedHint = new Label( + "Show \"o\" in band columns when the grid square is already worked on that band" + ); + CheckBox chkBxShowGrossFieldWorkedHint = new CheckBox(); + chkBxShowGrossFieldWorkedHint.setSelected( + chatcontroller.getChatPreferences().isGuiOptions_showGrossFieldWorkedHintInBandColumns() + ); + chkBxShowGrossFieldWorkedHint.selectedProperty().addListener((obs, o, n) -> { + chatcontroller.getChatPreferences().setGuiOptions_showGrossFieldWorkedHintInBandColumns(n); + GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); + }); + + grdPnlGuiOptions.add(lblShowGrossFieldWorkedHint, 0, 5); + grdPnlGuiOptions.add(chkBxShowGrossFieldWorkedHint, 1, 5); + + Label lblShowFreshCallHint = new Label( + "Show \"a\" in band columns for a call not worked on any band yet (otherwise always \"B+\")" + ); + CheckBox chkBxShowFreshCallHint = new CheckBox(); + chkBxShowFreshCallHint.setSelected( + chatcontroller.getChatPreferences().isGuiOptions_showFreshCallHintInBandColumns() + ); + chkBxShowFreshCallHint.selectedProperty().addListener((obs, o, n) -> { + chatcontroller.getChatPreferences().setGuiOptions_showFreshCallHintInBandColumns(n); + GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); + }); + + grdPnlGuiOptions.add(lblShowFreshCallHint, 0, 6); + grdPnlGuiOptions.add(chkBxShowFreshCallHint, 1, 6); + @@ -12004,43 +12101,22 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * @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()) { + if (member == null || chatcontroller == null) { 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; + List variants = + chatcontroller.findActiveChatMembersByRawCall(member.getCallSignRaw()); + if (variants.isEmpty()) { + variants = List.of(member); } - 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; - } + BandOpportunityResolver.Resolution resolution = + BandOpportunityResolver.resolve(variants, System.currentTimeMillis()); + EnumSet enabledBands = + BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()); + + return !resolution.getUnworkedEnabledBands(enabledBands).isEmpty(); } } diff --git a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java b/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java index 6923124..6091321 100644 --- a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java +++ b/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java @@ -1,6 +1,7 @@ package kst4contest.view.map; import kst4contest.locatorUtils.Location; +import kst4contest.logic.BandOpportunityResolver; import kst4contest.model.AirPlaneReflectionInfo; import kst4contest.model.Band; import kst4contest.model.ChatMember; @@ -14,7 +15,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.regex.Pattern; /** * Builds immutable map snapshots from the currently visible chat members. @@ -24,8 +24,6 @@ import java.util.regex.Pattern; */ public final class MapCallsignRawSnapshotBuilder { - private static final Pattern TOKEN_SPLIT_PATTERN = Pattern.compile("[^A-Z0-9]+"); - public List buildSnapshots(Collection visibleChatMembers, ChatMember selectedChatMember, EnumSet selectedBands) { @@ -73,10 +71,21 @@ public final class MapCallsignRawSnapshotBuilder { Location location = new Location(locator6); - LinkedHashMap frequenciesByBand = collectLastKnownFrequenciesByBand(variants); - EnumSet sureBands = collectSureBands(variants, frequenciesByBand); - String bandSummary = buildBandSummary(sureBands); - boolean offersSelectedBand = hasAnySelectedBand(sureBands, selectedBands); + long nowEpochMs = System.currentTimeMillis(); + BandOpportunityResolver.Resolution bandResolution = + BandOpportunityResolver.resolve(variants, nowEpochMs); + + EnumSet availableBands = bandResolution.getAvailableBands(); + LinkedHashMap frequenciesByBand = collectLastKnownFrequenciesByBand( + variants, + availableBands, + nowEpochMs + ); + + String bandSummary = buildBandSummary(availableBands); + boolean offersSelectedBand = !bandResolution + .getUnworkedEnabledBands(selectedBands) + .isEmpty(); boolean warningToMyDirection = variants.stream().anyMatch(ChatMember::isInAngleAndRange); boolean worked = variants.stream().anyMatch(this::isWorkedAtAnyBand); @@ -152,7 +161,11 @@ public final class MapCallsignRawSnapshotBuilder { .orElse(""); } - private LinkedHashMap collectLastKnownFrequenciesByBand(List variants) { + private LinkedHashMap collectLastKnownFrequenciesByBand( + List variants, + EnumSet availableBands, + long nowEpochMs + ) { Map latestByBand = new EnumMap<>(Band.class); @@ -161,16 +174,29 @@ public final class MapCallsignRawSnapshotBuilder { continue; } - for (Map.Entry bandEntry : variant.getKnownActiveBands().entrySet()) { + for (Map.Entry bandEntry + : variant.getKnownActiveBands().entrySet()) { + Band band = bandEntry.getKey(); ChatMember.ActiveFrequencyInfo activeFrequencyInfo = bandEntry.getValue(); - if (band == null || activeFrequencyInfo == null) { + if (band == null + || activeFrequencyInfo == null + || availableBands == null + || !availableBands.contains(band)) { + continue; + } + + long ageMs = nowEpochMs - activeFrequencyInfo.timestampEpoch; + if (ageMs < 0L + || ageMs > BandOpportunityResolver.RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS) { continue; } FrequencyCandidate previous = latestByBand.get(band); - if (previous == null || activeFrequencyInfo.timestampEpoch > previous.timestampEpochMs()) { + if (previous == null + || activeFrequencyInfo.timestampEpoch > previous.timestampEpochMs()) { + latestByBand.put(band, new FrequencyCandidate( band, formatFrequency(activeFrequencyInfo.frequency), @@ -178,152 +204,31 @@ public final class MapCallsignRawSnapshotBuilder { )); } } - - addFallbackCurrentFrequencyIfUseful(variant, latestByBand); } LinkedHashMap ordered = new LinkedHashMap<>(); latestByBand.entrySet().stream() .sorted(Map.Entry.comparingByKey()) - .forEach(entry -> ordered.put(toBandDisplayLabel(entry.getKey()), entry.getValue().formattedFrequency())); + .forEach(entry -> ordered.put( + toBandDisplayLabel(entry.getKey()), + entry.getValue().formattedFrequency() + )); return ordered; } - private EnumSet collectSureBands(List variants, - LinkedHashMap frequenciesByBand) { - EnumSet sureBands = EnumSet.noneOf(Band.class); - - if (frequenciesByBand != null) { - for (String label : frequenciesByBand.keySet()) { - Band mappedBand = bandFromDisplayLabel(label); - if (mappedBand != null) { - sureBands.add(mappedBand); - } - } - } - - for (ChatMember variant : variants) { - if (variant == null || variant.getName() == null || variant.getName().isBlank()) { - continue; - } - sureBands.addAll(detectBandsFromStationName(variant.getName())); - } - - return sureBands; - } - - private EnumSet detectBandsFromStationName(String stationName) { - EnumSet detectedBands = EnumSet.noneOf(Band.class); - if (stationName == null || stationName.isBlank()) { - return detectedBands; - } - - String normalized = stationName.toUpperCase(Locale.ROOT); - String[] tokens = TOKEN_SPLIT_PATTERN.split(normalized); - for (String token : tokens) { - if (token == null || token.isBlank()) { - continue; - } - - switch (token) { - case "2", "2M", "144", "144MHZ" -> detectedBands.add(Band.B_144); - case "70", "70CM", "432", "432MHZ" -> detectedBands.add(Band.B_432); - case "23", "23CM", "1296", "1296MHZ" -> detectedBands.add(Band.B_1296); - case "13", "13CM", "2300", "2320", "2320MHZ" -> detectedBands.add(Band.B_2320); - case "9", "9CM", "3400", "3400MHZ" -> detectedBands.add(Band.B_3400); - case "6", "6CM", "5600", "5760", "5760MHZ" -> detectedBands.add(Band.B_5760); - case "3", "3CM", "10G", "10GHZ", "10368", "10368MHZ" -> detectedBands.add(Band.B_10G); - case "24G", "24GHZ", "24048", "24048MHZ" -> detectedBands.add(Band.B_24G); - default -> { - } - } - } - - return detectedBands; - } - - private String buildBandSummary(EnumSet sureBands) { - if (sureBands == null || sureBands.isEmpty()) { + private String buildBandSummary(EnumSet availableBands) { + if (availableBands == null || availableBands.isEmpty()) { return ""; } - List labels = sureBands.stream() + List labels = availableBands.stream() .sorted() .map(this::toBandDisplayLabel) .toList(); return String.join(", ", labels); } - private boolean hasAnySelectedBand(EnumSet sureBands, EnumSet selectedBands) { - if (sureBands == null || sureBands.isEmpty() || selectedBands == null || selectedBands.isEmpty()) { - return false; - } - for (Band band : selectedBands) { - if (sureBands.contains(band)) { - return true; - } - } - return false; - } - - private Band bandFromDisplayLabel(String label) { - if (label == null || label.isBlank()) { - return null; - } - - return switch (label) { - case "144" -> Band.B_144; - case "432" -> Band.B_432; - case "1296" -> Band.B_1296; - case "2320" -> Band.B_2320; - case "3400" -> Band.B_3400; - case "5760" -> Band.B_5760; - case "10368" -> Band.B_10G; - case "24048" -> Band.B_24G; - default -> null; - }; - } - - /** - * Fallback for stations where the current displayed QRG exists but the - * knownActiveBands history has not yet been filled. - * - * This parsing is intentionally tolerant so strings like "144.300 MHz" - * can still be used. - */ - private void addFallbackCurrentFrequencyIfUseful(ChatMember variant, Map latestByBand) { - if (variant == null || variant.getFrequency() == null || variant.getFrequency().getValue() == null) { - return; - } - - String rawFrequency = variant.getFrequency().getValue().trim(); - if (rawFrequency.isBlank()) { - return; - } - - double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(rawFrequency); - if (!Double.isFinite(parsedFrequencyMHz) || parsedFrequencyMHz <= 0.0) { - return; - } - - Band detectedBand = Band.fromFrequency(parsedFrequencyMHz); - if (detectedBand == null) { - return; - } - - FrequencyCandidate previous = latestByBand.get(detectedBand); - if (previous != null && previous.timestampEpochMs() >= variant.getActivityTimeLastInEpoch()) { - return; - } - - latestByBand.put(detectedBand, new FrequencyCandidate( - detectedBand, - formatFrequency(parsedFrequencyMHz), - variant.getActivityTimeLastInEpoch() - )); - } - private boolean isWorkedAtAnyBand(ChatMember member) { return member.isWorked() || member.isWorked50() diff --git a/src/main/java/kst4contest/view/map/PathAnalysisResult.java b/src/main/java/kst4contest/view/map/PathAnalysisResult.java index 200b542..d5d172b 100644 --- a/src/main/java/kst4contest/view/map/PathAnalysisResult.java +++ b/src/main/java/kst4contest/view/map/PathAnalysisResult.java @@ -240,6 +240,36 @@ public record PathAnalysisResult( ); } + /** + * Creates a placeholder when automatic band resolution has no usable result. + */ + public static PathAnalysisResult waitingForUsableBand(String fromLocator6, + String toLocator6, + String toCallsignRaw) { + return new PathAnalysisResult( + "Waiting", + fromLocator6, + toLocator6, + toCallsignRaw, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + false, + false, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + -1, + "No usable automatic band is available. " + + "Check own enabled bands and the station's NOT-QRV tags.", + List.of() + ); + } + /** * Creates a finished result without a usable terrain profile. * diff --git a/src/main/java/kst4contest/view/map/StationMapBridge.java b/src/main/java/kst4contest/view/map/StationMapBridge.java index 0ed7881..bb76bae 100644 --- a/src/main/java/kst4contest/view/map/StationMapBridge.java +++ b/src/main/java/kst4contest/view/map/StationMapBridge.java @@ -343,6 +343,8 @@ public final class StationMapBridge { + "|" + String.format(Locale.US, "%.3f", analysisFrequencyMHz) + "|" + + selectedSnapshot.bandSummary() + + "|" + String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnAntennaHeightMeters()) + "|" + String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetAntennaHeightMeters()) diff --git a/src/test/java/kst4contest/test/BandOpportunityResolverTest.java b/src/test/java/kst4contest/test/BandOpportunityResolverTest.java new file mode 100644 index 0000000..b4d0c64 --- /dev/null +++ b/src/test/java/kst4contest/test/BandOpportunityResolverTest.java @@ -0,0 +1,97 @@ +package kst4contest.test; + +import kst4contest.logic.BandOpportunityResolver; +import kst4contest.model.Band; +import kst4contest.model.ChatMember; +import org.junit.jupiter.api.Test; + +import java.util.EnumSet; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BandOpportunityResolverTest { + + @Test + void resolvesCommonShorthandBandsFromStationName() { + EnumSet bands = BandOpportunityResolver.detectBandsFromStationName( + "Peter QRV 2-70-23/13/9/6/3" + ); + + assertEquals( + EnumSet.of( + Band.B_144, + Band.B_432, + Band.B_1296, + Band.B_2320, + Band.B_3400, + Band.B_5760, + Band.B_10G + ), + bands + ); + } + + @Test + void doesNotMistakeOnePointTwoCentimetersForTwoMeters() { + EnumSet bands = BandOpportunityResolver.detectBandsFromStationName( + "David 23/3/1.2" + ); + + assertEquals(EnumSet.of(Band.B_1296, Band.B_10G, Band.B_24G), bands); + assertFalse(bands.contains(Band.B_144)); + } + + @Test + void keepsOnlyRecentDynamicBandEvidence() { + long now = 1_000_000L; + ChatMember station = new ChatMember(); + station.addKnownFrequency(Band.B_144, 144.210); + station.addKnownFrequency(Band.B_432, 432.210); + station.getKnownActiveBands().get(Band.B_144).timestampEpoch = now - 5_000L; + station.getKnownActiveBands().get(Band.B_432).timestampEpoch = + now - BandOpportunityResolver.RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS - 1L; + + BandOpportunityResolver.Resolution resolution = + BandOpportunityResolver.resolve(List.of(station), now); + + assertEquals(EnumSet.of(Band.B_144), resolution.getOfferedBands()); + } + + @Test + void notQrvOverridesNameAndFrequencyEvidenceAcrossVariants() { + long now = 1_000_000L; + + ChatMember categoryTwo = new ChatMember(); + categoryTwo.setName("QRV 2m 70cm"); + categoryTwo.addKnownFrequency(Band.B_432, 432.210); + categoryTwo.getKnownActiveBands().get(Band.B_432).timestampEpoch = now - 1_000L; + + ChatMember categoryThree = new ChatMember(); + categoryThree.setQrv432(false); + + BandOpportunityResolver.Resolution resolution = + BandOpportunityResolver.resolve(List.of(categoryTwo, categoryThree), now); + + assertTrue(resolution.getOfferedBands().contains(Band.B_432)); + assertFalse(resolution.getAvailableBands().contains(Band.B_432)); + assertTrue(resolution.getAvailableBands().contains(Band.B_144)); + } + + @Test + void opportunityRequiresAvailableEnabledAndUnworkedBand() { + ChatMember station = new ChatMember(); + station.setName("2m 70cm"); + station.setWorked144(true); + + BandOpportunityResolver.Resolution resolution = + BandOpportunityResolver.resolve(List.of(station), System.currentTimeMillis()); + + assertEquals( + EnumSet.of(Band.B_432), + resolution.getUnworkedEnabledBands(EnumSet.of(Band.B_144, Band.B_432)) + ); + } +} diff --git a/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java b/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java index 0bc1bd5..ef81478 100644 --- a/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java +++ b/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java @@ -45,6 +45,38 @@ class MapCallsignRawSnapshotBuilderTest { assertFalse(snapshots.get(0).offersSelectedBand()); } + @Test + void notQrvOverridesNameDerivedMapOpportunity() { + ChatMember station = buildStation("DL1ABC", "QRV 2m 70cm", "JN58TD", 1_000L); + station.setQrv432(false); + + MapCallsignRawSnapshotBuilder builder = new MapCallsignRawSnapshotBuilder(); + MapCallsignRawSnapshot snapshot = builder.buildSnapshots( + List.of(station), + null, + EnumSet.of(Band.B_432) + ).get(0); + + assertFalse(snapshot.offersSelectedBand()); + assertFalse(snapshot.bandSummary().contains("432")); + } + + @Test + void workedBandIsShownAsInformationButNotAsUpgradeOpportunity() { + ChatMember station = buildStation("DL1ABC", "QRV 2m", "JN58TD", 1_000L); + station.setWorked144(true); + + MapCallsignRawSnapshotBuilder builder = new MapCallsignRawSnapshotBuilder(); + MapCallsignRawSnapshot snapshot = builder.buildSnapshots( + List.of(station), + null, + EnumSet.of(Band.B_144) + ).get(0); + + assertTrue(snapshot.bandSummary().contains("144")); + assertFalse(snapshot.offersSelectedBand()); + } + private ChatMember buildStation(String callSign, String name, String locator, long activityEpoch) { ChatMember chatMember = new ChatMember(); chatMember.setCallSign(callSign);