mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-08-05 01:45:27 +02:00
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:
@@ -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,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<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;
|
||||
}
|
||||
|
||||
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<Band> getEnabledStationBands() {
|
||||
ChatPreferences preferences = chatController.getChatPreferences();
|
||||
EnumSet<Band> enabledBands = EnumSet.noneOf(Band.class);
|
||||
return BandOpportunityResolver.getEnabledStationBands(
|
||||
chatController.getChatPreferences()
|
||||
);
|
||||
}
|
||||
|
||||
if (preferences == null) {
|
||||
return enabledBands;
|
||||
private List<ChatMember> 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<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;
|
||||
if (availableBands.isEmpty()) {
|
||||
return "Bands: -";
|
||||
}
|
||||
|
||||
Map<kst4contest.model.Band, ChatMember.ActiveFrequencyInfo> map = m.getKnownActiveBands();
|
||||
if (map == null) continue;
|
||||
Map<Band, ChatMember.ActiveFrequencyInfo> newestPerBand =
|
||||
new EnumMap<>(Band.class);
|
||||
|
||||
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;
|
||||
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<Band, ChatMember.ActiveFrequencyInfo> 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<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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ChatMember> 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<Band> enabledBands =
|
||||
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user