Unify band-opportunity logic and add B+/a/o status to the station table

Issue #70, #66, #65

Introduces a shared BandOpportunityResolver so the station table's band
columns, the New bands filter, the band-upgrade hint, the priority
score, the map markers and the automatic reachability band selection
all derive band availability the same way: recent QRG detections (30
min window) plus station-name hints, evaluated across every active
callsignRaw variant, with manual NOT-QRV always taking precedence.

The per-band table cells now distinguish:
- X: worked on this band
- a: band available, call not worked on any band yet
- B+: band available, call already worked on another band
- o: grid square already worked on this band (any station) - combines
  with the others, e.g. "ao" or "B+o"

Both "a" and "o" can be toggled off in the GUI settings tab.

Assisted by Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 21:00:23 +02:00
parent 35f790f06b
commit fdec5220d1
13 changed files with 962 additions and 523 deletions
+10 -1
View File
@@ -153,7 +153,7 @@
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.12.2</version>
<version>${junit.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
@@ -266,6 +266,15 @@
<version>${maven.surfire.plugin}</version>
<configuration>
<testFailureIgnore>true</testFailureIgnore>
<!--
Without this, Surefire's automatic module-path detection splits the
JUnit Platform jars across classpath and module-path inconsistently
(module org.junit.platform.commons ends up loaded while a class from
junit-platform-engine stays in the unnamed module), which fails with
an IllegalAccessError before any test can run. The project itself has
no module-info.java, so plain classpath execution is correct here.
-->
<useModulePath>false</useModulePath>
</configuration>
</plugin>
@@ -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.
* <p>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.</p>
*/
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<Band> myEnabledBands = getMyEnabledBandsFromPrefs(chatPreferences);
EnumSet<Band> 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<Band> stationOfferedBands = collectStationOfferedBandsFromHistory(callRaw, now, maxAgeMs);
List<ChatMember> variants = findActiveChatMembersByRawCall(callRaw);
BandOpportunityResolver.Resolution bandResolution =
BandOpportunityResolver.resolve(variants, System.currentTimeMillis());
EnumSet<Band> 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<Band> workedBands = collectWorkedBands(callRaw);
// 5) Remaining bands = offered ∩ enabled - worked
EnumSet<Band> remainingBands = EnumSet.copyOf(stationOfferedBands);
remainingBands.removeAll(workedBands);
EnumSet<Band> workedBands = bandResolution.getWorkedBands();
EnumSet<Band> 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<Band> getMyEnabledBandsFromPrefs(ChatPreferences prefs) {
EnumSet<Band> 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<Band> collectStationOfferedBandsFromHistory(String callRaw, long nowMs, long maxAgeMs) {
EnumSet<Band> offered = EnumSet.noneOf(Band.class);
for (ChatMember cm : findActiveChatMembersByRawCall(callRaw)) {
if (cm == null || cm.getCallSignRaw() == null) continue;
Map<Band, ChatMember.ActiveFrequencyInfo> map = cm.getKnownActiveBands();
if (map == null || map.isEmpty()) continue;
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> 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<Band> collectWorkedBands(String callRaw) {
EnumSet<Band> 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<Band> 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<String>
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<ChatMember> 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
@@ -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,22 +248,51 @@ 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<Band> enabledBands = getEnabledStationBands();
if (enabledBands.isEmpty()) {
return null;
}
List<ChatMember> variants = resolveCallsignVariants(member);
BandOpportunityResolver.Resolution resolution =
BandOpportunityResolver.resolve(variants, System.currentTimeMillis());
EnumSet<Band> 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<Band> 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;
}
if (fallbackBands.contains(Band.B_144)) {
return Band.B_144;
}
return fallbackBands.stream()
.min(Comparator.comparingDouble(Band::getDefaultAnalysisFrequencyMHz))
.orElse(null);
}
/**
* Returns the active own bands configured in the station preferences. High bands
* above 10 GHz are intentionally ignored for the first version.
@@ -253,22 +300,44 @@ public final class ReachabilityService {
* @return set of enabled bands
*/
public EnumSet<Band> getEnabledStationBands() {
ChatPreferences preferences = chatController.getChatPreferences();
EnumSet<Band> enabledBands = EnumSet.noneOf(Band.class);
if (preferences == null) {
return enabledBands;
return BandOpportunityResolver.getEnabledStationBands(
chatController.getChatPreferences()
);
}
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);
private List<ChatMember> resolveCallsignVariants(ChatMember member) {
if (member == null) {
return List.of();
}
return enabledBands;
String rawCall = member.getCallSignRaw() != null
? member.getCallSignRaw()
: member.getCallSign();
List<ChatMember> 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);
}
/**
@@ -140,7 +140,9 @@ public final class ScoreService {
controller.getStationMetricsService().snapshot(nowEpochMs, prefs);
// 1) Choose one representative per callsignRaw
Map<String, ChatMember> representativeByCallRaw = chooseRepresentativeMembers(members, lastInbound);
Map<String, List<ChatMember>> variantsByCallRaw = groupMembersByCallRaw(members);
Map<String, ChatMember> representativeByCallRaw =
chooseRepresentativeMembers(variantsByCallRaw, lastInbound);
// 2) Compute score once per callsignRaw
Map<String, Double> 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<String, List<ChatMember>> groupMembersByCallRaw(List<ChatMember> members) {
Map<String, List<ChatMember>> 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<String, ChatMember> chooseRepresentativeMembers(
List<ChatMember> members,
Map<String, List<ChatMember>> byCallRaw,
Map<String, ChatCategory> lastInboundCategoryByCallRaw
) {
Map<String, List<ChatMember>> 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<String, ChatMember> representative = new HashMap<>(byCallRaw.size());
for (Map.Entry<String, List<ChatMember>> entry : byCallRaw.entrySet()) {
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*/
public final class BandOpportunityResolver {
public static final long RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS = 30L * 60L * 1000L;
private static final Map<Band, Pattern> 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<ChatMember> 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<ChatMember> variants,
long nowEpochMs,
long dynamicEvidenceMaxAgeMs) {
EnumSet<Band> offeredBands = EnumSet.noneOf(Band.class);
EnumSet<Band> workedBands = EnumSet.noneOf(Band.class);
EnumSet<Band> 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<Band> getEnabledStationBands(ChatPreferences preferences) {
EnumSet<Band> 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.
*
* <p>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.</p>
*/
public static EnumSet<Band> detectBandsFromStationName(String stationName) {
EnumSet<Band> detectedBands = EnumSet.noneOf(Band.class);
if (stationName == null || stationName.isBlank()) {
return detectedBands;
}
for (Map.Entry<Band, Pattern> 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<Band> target,
long nowEpochMs,
long dynamicEvidenceMaxAgeMs) {
if (member.getKnownActiveBands() == null || member.getKnownActiveBands().isEmpty()) {
return;
}
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> 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<Band> 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<Band> 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<Band, Pattern> createStationNameBandPatterns() {
Map<Band, Pattern> 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(
"(?<![A-Z0-9.,])(?:" + alternatives + ")(?![A-Z0-9.,])",
Pattern.CASE_INSENSITIVE
);
}
/** Immutable result of one callsign-wide band resolution. */
public static final class Resolution {
private final EnumSet<Band> offeredBands;
private final EnumSet<Band> workedBands;
private final EnumSet<Band> notQrvBands;
private Resolution(EnumSet<Band> offeredBands,
EnumSet<Band> workedBands,
EnumSet<Band> 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<Band> getOfferedBands() {
return copyOf(offeredBands);
}
public EnumSet<Band> getWorkedBands() {
return copyOf(workedBands);
}
public EnumSet<Band> getNotQrvBands() {
return copyOf(notQrvBands);
}
/** Returns offered bands after manual NOT-QRV exclusions. */
public EnumSet<Band> getAvailableBands() {
EnumSet<Band> availableBands = copyOf(offeredBands);
availableBands.removeAll(notQrvBands);
return availableBands;
}
/** Returns offered, QRV, enabled and not-yet-worked bands. */
public EnumSet<Band> getUnworkedEnabledBands(EnumSet<Band> enabledBands) {
EnumSet<Band> 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<Band> copyOf(EnumSet<Band> source) {
return source == null || source.isEmpty()
? EnumSet.noneOf(Band.class)
: EnumSet.copyOf(source);
}
}
}
@@ -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<ChatMember> callsignVariants,
ChatPreferences prefs,
List<ContestSked> 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<Band> myEnabledBands = getMyEnabledBands(prefs);
Collection<ChatMember> 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<Band> workedBandsForScoring = getWorkedBands(member);
BandOpportunityResolver.Resolution bandResolution =
BandOpportunityResolver.resolve(variants, nowEpochMs);
EnumSet<Band> myEnabledBands =
BandOpportunityResolver.getEnabledStationBands(prefs);
// ChatMember.worked remains UI-only. Scoring uses only per-band worked flags.
EnumSet<Band> 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<Band> unworkedPossible = EnumSet.noneOf(Band.class);
EnumSet<Band> stationOfferedBands = getStationOfferedBandsFromHistory(member, nowEpochMs);
EnumSet<Band> stationOfferedBands = bandResolution.getOfferedBands();
EnumSet<Band> stationAvailableBands = bandResolution.getAvailableBands();
EnumSet<Band> 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<Band> 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<Band> getMyEnabledBands(ChatPreferences prefs) {
EnumSet<Band> 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<Band> getStationOfferedBandsFromHistory(ChatMember member, long nowEpochMs) {
EnumSet<Band> out = EnumSet.noneOf(Band.class);
Map<Band, ChatMember.ActiveFrequencyInfo> map = member.getKnownActiveBands();
if (map == null || map.isEmpty()) return out;
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> 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<Band> getWorkedBands(ChatMember member) {
EnumSet<Band> 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;
@@ -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");
@@ -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<kst4contest.model.Band, ChatMember.ActiveFrequencyInfo> newestPerBand = new java.util.EnumMap<>(kst4contest.model.Band.class);
List<ChatMember> variants = chatcontroller.findActiveChatMembersByRawCall(callSignRaw);
BandOpportunityResolver.Resolution bandResolution =
BandOpportunityResolver.resolve(variants, System.currentTimeMillis(), maxAgeMs);
EnumSet<Band> 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;
Map<kst4contest.model.Band, ChatMember.ActiveFrequencyInfo> map = m.getKnownActiveBands();
if (map == null) continue;
for (Map.Entry<kst4contest.model.Band, ChatMember.ActiveFrequencyInfo> e : map.entrySet()) {
kst4contest.model.Band band = e.getKey();
ChatMember.ActiveFrequencyInfo info = e.getValue();
if (band == null || info == null) continue;
// optional age filter (e.g. last 30 minutes)
if (maxAgeMs > 0 && (System.currentTimeMillis() - info.timestampEpoch) > maxAgeMs) {
continue;
if (availableBands.isEmpty()) {
return "Bands: -";
}
Map<Band, ChatMember.ActiveFrequencyInfo> newestPerBand =
new EnumMap<>(Band.class);
for (ChatMember member : variants) {
if (member == null || member.getKnownActiveBands() == null) continue;
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> entry
: member.getKnownActiveBands().entrySet()) {
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<Band> enabledBands = chatcontroller.getReachabilityService().getEnabledStationBands();
if (enabledBands == null || !enabledBands.contains(band)) {
return false;
}
List<ChatMember> 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<ChatMember> variants =
chatcontroller.findActiveChatMembersByRawCall(chatMember.getCallSignRaw());
if (variants.isEmpty()) {
variants = List.of(chatMember);
}
List<MapCallsignRawSnapshot> snapshots = mainViewBandMarkerSnapshotBuilder.buildSnapshots(
variants,
null,
EnumSet.of(band)
);
BandOpportunityResolver.Resolution resolution =
BandOpportunityResolver.resolve(variants, System.currentTimeMillis());
EnumSet<Band> 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.
*
* <p>{@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.</p>
*
* <p>{@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.</p>
*
* @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<TableColumn<ChatMember, String>, TableCell<ChatMember, String>> createBandStatusCellFactory(Band band) {
return column -> new TableCell<ChatMember, String>() {
@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<String> call(CellDataFeatures<ChatMember, String> 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<ChatMember, String> uhfCol_subcol = new TableColumn<ChatMember, String>("432");
uhfCol_subcol
@@ -1713,122 +1814,80 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override
public ObservableValue<String> call(CellDataFeatures<ChatMember, String> 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<ChatMember, String> shf23_subcol = new TableColumn<ChatMember, String>("23");
shf23_subcol.setCellValueFactory(new Callback<CellDataFeatures<ChatMember, String>, ObservableValue<String>>() {
@Override
public ObservableValue<String> call(CellDataFeatures<ChatMember, String> 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<ChatMember, String> shf13_subcol = new TableColumn<ChatMember, String>("13");
shf13_subcol.setCellValueFactory(new Callback<CellDataFeatures<ChatMember, String>, ObservableValue<String>>() {
@Override
public ObservableValue<String> call(CellDataFeatures<ChatMember, String> 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<ChatMember, String> shf9_subcol = new TableColumn<ChatMember, String>("9");
shf9_subcol.setCellValueFactory(new Callback<CellDataFeatures<ChatMember, String>, ObservableValue<String>>() {
@Override
public ObservableValue<String> call(CellDataFeatures<ChatMember, String> 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<ChatMember, String> shf6_subcol = new TableColumn<ChatMember, String>("6");
shf6_subcol.setCellValueFactory(new Callback<CellDataFeatures<ChatMember, String>, ObservableValue<String>>() {
@Override
public ObservableValue<String> call(CellDataFeatures<ChatMember, String> 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<ChatMember, String> shf3_subcol = new TableColumn<ChatMember, String>("3");
shf3_subcol.setCellValueFactory(new Callback<CellDataFeatures<ChatMember, String>, ObservableValue<String>>() {
@Override
public ObservableValue<String> call(CellDataFeatures<ChatMember, String> 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<ChatMember> asNext5MinPredicate = new Predicate<ChatMember>() {
@@ -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<Band> enabledBands = chatcontroller.getReachabilityService().getEnabledStationBands();
for (Band band : member.getKnownActiveBands().keySet()) {
if (band != null && enabledBands.contains(band) && !isWorkedOnBand(member, band)) {
return true;
}
}
return false;
List<ChatMember> variants =
chatcontroller.findActiveChatMembersByRawCall(member.getCallSignRaw());
if (variants.isEmpty()) {
variants = List.of(member);
}
/**
* 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;
}
BandOpportunityResolver.Resolution resolution =
BandOpportunityResolver.resolve(variants, System.currentTimeMillis());
EnumSet<Band> enabledBands =
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
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;
}
return !resolution.getUnworkedEnabledBands(enabledBands).isEmpty();
}
}
@@ -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<MapCallsignRawSnapshot> buildSnapshots(Collection<ChatMember> visibleChatMembers,
ChatMember selectedChatMember,
EnumSet<Band> selectedBands) {
@@ -73,10 +71,21 @@ public final class MapCallsignRawSnapshotBuilder {
Location location = new Location(locator6);
LinkedHashMap<String, String> frequenciesByBand = collectLastKnownFrequenciesByBand(variants);
EnumSet<Band> 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<Band> availableBands = bandResolution.getAvailableBands();
LinkedHashMap<String, String> 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<String, String> collectLastKnownFrequenciesByBand(List<ChatMember> variants) {
private LinkedHashMap<String, String> collectLastKnownFrequenciesByBand(
List<ChatMember> variants,
EnumSet<Band> availableBands,
long nowEpochMs
) {
Map<Band, FrequencyCandidate> latestByBand = new EnumMap<>(Band.class);
@@ -161,16 +174,29 @@ public final class MapCallsignRawSnapshotBuilder {
continue;
}
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> bandEntry : variant.getKnownActiveBands().entrySet()) {
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> 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<String, String> 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<Band> collectSureBands(List<ChatMember> variants,
LinkedHashMap<String, String> frequenciesByBand) {
EnumSet<Band> 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<Band> detectBandsFromStationName(String stationName) {
EnumSet<Band> 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<Band> sureBands) {
if (sureBands == null || sureBands.isEmpty()) {
private String buildBandSummary(EnumSet<Band> availableBands) {
if (availableBands == null || availableBands.isEmpty()) {
return "";
}
List<String> labels = sureBands.stream()
List<String> labels = availableBands.stream()
.sorted()
.map(this::toBandDisplayLabel)
.toList();
return String.join(", ", labels);
}
private boolean hasAnySelectedBand(EnumSet<Band> sureBands, EnumSet<Band> 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<Band, FrequencyCandidate> 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()
@@ -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.
*
@@ -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())
@@ -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<Band> 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<Band> 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))
);
}
}
@@ -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);