4 Commits
Author SHA1 Message Date
Marc Froehlich 6092ff882a added project Band enum from Win-Test band IDs 2026-08-05 01:18:41 +02:00
Rsclub2_2 e37cda8ff2 Add 50/70 MHz band support (table, NOT-QRV, station setup, loggers)
Issue #68

Adds Band.B_50/B_70 and wires them through the same band-opportunity
machinery as the other bands: X/a/B+/o table columns and filter button
in the User table and the Workedstn database table, NOT-QRV checkboxes
and propagation across callsign variants, "My station uses 6m/4m band"
toggles, station-name detection ("50", "6M", "70MHZ", "4M" - without
stealing the existing bare "70"/"6" cm-band shorthand), Win-Test sked
band IDs (10/50MHz, 11/70MHz), and UCX-logger worked-band recognition.

Persists worked50/70 and notQRV50/70 via an additive SQLite migration
(same ensureColumnExists pattern as the earlier v1.1->v1.2 migration),
verified against a real database file.

ReachabilityService.resolveAutoBand() now also falls back to 50 MHz
(then 70 MHz) for stations in the "50/70 MHz" chat category, mirroring
the existing Microwave-category fallback to 23cm.

Assisted by Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 21:10:15 +02:00
Rsclub2_2 fdec5220d1 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>
2026-08-04 21:00:23 +02:00
Rsclub2_2 35f790f06b Fix stale Beta tab showing a superseded prerelease
GitHub never clears the prerelease flag once a beta ships as stable,
so the latest prerelease can be older than the latest stable release.
Compare publish dates and fall back to the empty state when stable is
newer.

Assisted by Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 20:49:06 +02:00
22 changed files with 1528 additions and 594 deletions
+10 -1
View File
@@ -153,7 +153,7 @@
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId> <artifactId>junit-jupiter-api</artifactId>
<version>5.12.2</version> <version>${junit.version}</version>
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
<dependency> <dependency>
@@ -266,6 +266,15 @@
<version>${maven.surfire.plugin}</version> <version>${maven.surfire.plugin}</version>
<configuration> <configuration>
<testFailureIgnore>true</testFailureIgnore> <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> </configuration>
</plugin> </plugin>
@@ -20,6 +20,7 @@ import javafx.collections.transformation.SortedList;
import kst4contest.ApplicationConstants; import kst4contest.ApplicationConstants;
import kst4contest.controller.interfaces.PstRotatorEventListener; import kst4contest.controller.interfaces.PstRotatorEventListener;
import kst4contest.locatorUtils.DirectionUtils; import kst4contest.locatorUtils.DirectionUtils;
import kst4contest.logic.BandOpportunityResolver;
import kst4contest.logic.PriorityCalculator; import kst4contest.logic.PriorityCalculator;
import kst4contest.model.*; import kst4contest.model.*;
import kst4contest.test.MockKstServer; 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: * <p>The common resolver combines recent QRG evidence and station-name bands
* 1) Detect whether the logged station is *still active (QRV)* on at least one *other* band * across every active callsignRaw variant. A manual NOT-QRV tag overrides this
* that is enabled for "my station" (stn_bandActive[Band]) AND not worked yet (worked144/432/...). * automatic evidence before the hint and priority boost are evaluated.</p>
* 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.
*/ */
public void onExternalLogEntryReceived(String callSignRaw) { public void onExternalLogEntryReceived(String callSignRaw) {
@@ -241,26 +235,20 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
System.out.println("[BandUpgradeHint] LOG received for call=" + callRaw); 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 =
EnumSet<Band> myEnabledBands = getMyEnabledBandsFromPrefs(chatPreferences); BandOpportunityResolver.getEnabledStationBands(chatPreferences);
if (myEnabledBands.isEmpty()) return; if (myEnabledBands.isEmpty()) return;
// 2) Determine which bands the station was recently seen active on (from Smart Frequency Extraction history) List<ChatMember> variants = findActiveChatMembersByRawCall(callRaw);
final long now = System.currentTimeMillis(); BandOpportunityResolver.Resolution bandResolution =
final long maxAgeMs = TimeUnit.MINUTES.toMillis(30); // keep consistent with "recent activity" semantics BandOpportunityResolver.resolve(variants, System.currentTimeMillis());
EnumSet<Band> stationOfferedBands = collectStationOfferedBandsFromHistory(callRaw, now, maxAgeMs);
EnumSet<Band> stationOfferedBands = bandResolution.getOfferedBands();
if (stationOfferedBands.isEmpty()) return; if (stationOfferedBands.isEmpty()) return;
// 3) Keep only bands that I can actually work EnumSet<Band> workedBands = bandResolution.getWorkedBands();
stationOfferedBands.retainAll(myEnabledBands); EnumSet<Band> remainingBands =
if (stationOfferedBands.isEmpty()) return; bandResolution.getUnworkedEnabledBands(myEnabledBands);
// 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);
if (remainingBands.isEmpty()) return; if (remainingBands.isEmpty()) return;
if (DEBUG_BAND_UPGRADE_HINT) { if (DEBUG_BAND_UPGRADE_HINT) {
@@ -268,34 +256,35 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
+ " enabled=" + formatBandsHuman(myEnabledBands) + " enabled=" + formatBandsHuman(myEnabledBands)
+ " offered=" + formatBandsHuman(stationOfferedBands) + " offered=" + formatBandsHuman(stationOfferedBands)
+ " worked=" + (workedBands.isEmpty() ? "-" : formatBandsHuman(workedBands)) + " worked=" + (workedBands.isEmpty() ? "-" : formatBandsHuman(workedBands))
+ " NOT-QRV=" + formatBandsHuman(bandResolution.getNotQrvBands())
+ " remaining=" + formatBandsHuman(remainingBands)); + " remaining=" + formatBandsHuman(remainingBands));
} }
// 6) Build UI text (button + tooltip)
String remainingHuman = formatBandsHuman(remainingBands); String remainingHuman = formatBandsHuman(remainingBands);
String shortText = "BAND+ " + callRaw + " " + remainingHuman; 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 + remainingHuman
+ "\n(Enabled: " + formatBandsHuman(myEnabledBands) + "\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); ThreadStateMessage msg = new ThreadStateMessage("BandUpgradeHint", true, tooltip, false);
msg.setRunningInformationTextDescription(shortText); msg.setRunningInformationTextDescription(shortText);
// 7) Trigger status update -> View will blink a dedicated indicator button
onThreadStatus("BandUpgradeHint", msg); onThreadStatus("BandUpgradeHint", msg);
// 8) Sound (re-use existing sked notification sound) - respects global simple-sound flag
if (chatPreferences.isNotify_playSimpleSounds()) { if (chatPreferences.isNotify_playSimpleSounds()) {
try { try {
getPlayAudioUtils().playNoiseLauncher('!'); // same as SkedReminderService getPlayAudioUtils().playNoiseLauncher('!');
} catch (Exception e) { } 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) { if (getScoreService() != null) {
getScoreService().requestRecompute("BandUpgradeHint"); getScoreService().requestRecompute("BandUpgradeHint");
} }
@@ -306,77 +295,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
return callRaw.trim().toUpperCase(Locale.ROOT); 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) { private static String formatBandsHuman(EnumSet<Band> bands) {
if (bands == null || bands.isEmpty()) return "-"; if (bands == null || bands.isEmpty()) return "-";
return bands.stream().map(ChatController::bandToHumanLabel).sorted().reduce((a, b) -> a + ", " + b).orElse("-"); return bands.stream().map(ChatController::bandToHumanLabel).sorted().reduce((a, b) -> a + ", " + b).orElse("-");
@@ -385,6 +303,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
private static String bandToHumanLabel(Band b) { private static String bandToHumanLabel(Band b) {
if (b == null) return "?"; if (b == null) return "?";
return switch (b) { return switch (b) {
case B_50 -> "6m";
case B_70 -> "4m";
case B_144 -> "2m"; case B_144 -> "2m";
case B_432 -> "70cm"; case B_432 -> "70cm";
case B_1296 -> "23cm"; case B_1296 -> "23cm";
@@ -1373,6 +1293,51 @@ private ObservableList<String>
return matchingMembers; 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.setQrv50(sourceMember.isQrv50());
target.setQrv70(sourceMember.isQrv70());
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 * 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 * contains the same member object, but the user-list refresh is still triggered
@@ -2961,6 +2926,8 @@ private ObservableList<String>
activeChatMember.setWorked3400(storedChatMemberState.isWorked3400()); activeChatMember.setWorked3400(storedChatMemberState.isWorked3400());
activeChatMember.setWorked5600(storedChatMemberState.isWorked5600()); activeChatMember.setWorked5600(storedChatMemberState.isWorked5600());
activeChatMember.setWorked10G(storedChatMemberState.isWorked10G()); activeChatMember.setWorked10G(storedChatMemberState.isWorked10G());
activeChatMember.setWorked50(storedChatMemberState.isWorked50());
activeChatMember.setWorked70(storedChatMemberState.isWorked70());
activeChatMember.setQrv144(storedChatMemberState.isQrv144()); activeChatMember.setQrv144(storedChatMemberState.isQrv144());
activeChatMember.setQrv432(storedChatMemberState.isQrv432()); activeChatMember.setQrv432(storedChatMemberState.isQrv432());
activeChatMember.setQrv1240(storedChatMemberState.isQrv1240()); activeChatMember.setQrv1240(storedChatMemberState.isQrv1240());
@@ -2968,6 +2935,8 @@ private ObservableList<String>
activeChatMember.setQrv3400(storedChatMemberState.isQrv3400()); activeChatMember.setQrv3400(storedChatMemberState.isQrv3400());
activeChatMember.setQrv5600(storedChatMemberState.isQrv5600()); activeChatMember.setQrv5600(storedChatMemberState.isQrv5600());
activeChatMember.setQrv10G(storedChatMemberState.isQrv10G()); activeChatMember.setQrv10G(storedChatMemberState.isQrv10G());
activeChatMember.setQrv50(storedChatMemberState.isQrv50());
activeChatMember.setQrv70(storedChatMemberState.isQrv70());
} }
} }
@@ -36,8 +36,8 @@ public class DBController {
* Number of milliseconds after which worked/not-QRV data is considered outdated * Number of milliseconds after which worked/not-QRV data is considered outdated
* and therefore automatically reset. * and therefore automatically reset.
*/ */
private static final long WORKED_DATA_EXPIRATION_IN_MILLISECONDS = 65L * 60L * 60L * 1000L; private static final long WORKED_DATA_EXPIRATION_IN_MILLISECONDS =
3L * 24L * 60L * 60L * 1000L;
/** /**
* Database schema version that includes the raw-callsign normalization migration * Database schema version that includes the raw-callsign normalization migration
* marker. The marker is stored in SQLite PRAGMA user_version so the expensive * marker. The marker is stored in SQLite PRAGMA user_version so the expensive
@@ -145,6 +145,7 @@ public class DBController {
createWorkedGrossFieldTableIfRequired(); createWorkedGrossFieldTableIfRequired();
versionUpdateOfDBCheckAndChangeV11ToV12(); versionUpdateOfDBCheckAndChangeV11ToV12();
versionUpdateOfDBCheckAndChangeV12ToV13(); versionUpdateOfDBCheckAndChangeV12ToV13();
versionUpdateOfDBCheckAndChangeV13ToV14();
if (helper_isDatabaseSchemaVersionOlderThanCurrent() || helper_isCallsignNormalizationMigrationRequired()) { if (helper_isDatabaseSchemaVersionOlderThanCurrent() || helper_isCallsignNormalizationMigrationRequired()) {
normalizeStoredCallsignsToRawCallsigns(); normalizeStoredCallsignsToRawCallsigns();
@@ -230,6 +231,8 @@ public class DBController {
+ "worked3400 BOOLEAN DEFAULT 0, " + "worked3400 BOOLEAN DEFAULT 0, "
+ "worked5600 BOOLEAN DEFAULT 0, " + "worked5600 BOOLEAN DEFAULT 0, "
+ "worked10G BOOLEAN DEFAULT 0, " + "worked10G BOOLEAN DEFAULT 0, "
+ "worked50 BOOLEAN DEFAULT 0, "
+ "worked70 BOOLEAN DEFAULT 0, "
+ "notQRV144 BOOLEAN DEFAULT 0, " + "notQRV144 BOOLEAN DEFAULT 0, "
+ "notQRV432 BOOLEAN DEFAULT 0, " + "notQRV432 BOOLEAN DEFAULT 0, "
+ "notQRV1240 BOOLEAN DEFAULT 0, " + "notQRV1240 BOOLEAN DEFAULT 0, "
@@ -237,6 +240,8 @@ public class DBController {
+ "notQRV3400 BOOLEAN DEFAULT 0, " + "notQRV3400 BOOLEAN DEFAULT 0, "
+ "notQRV5600 BOOLEAN DEFAULT 0, " + "notQRV5600 BOOLEAN DEFAULT 0, "
+ "notQRV10G BOOLEAN DEFAULT 0, " + "notQRV10G BOOLEAN DEFAULT 0, "
+ "notQRV50 BOOLEAN DEFAULT 0, "
+ "notQRV70 BOOLEAN DEFAULT 0, "
+ "lastFlagsChangeEpochMs INTEGER DEFAULT 0" + "lastFlagsChangeEpochMs INTEGER DEFAULT 0"
+ ");"; + ");";
@@ -303,6 +308,22 @@ public class DBController {
} }
} }
/**
* Updates old v1.3 databases to the v1.4 schema by adding the worked/not-QRV
* columns for the 50 MHz and 70 MHz bands if they are missing.
*/
public synchronized void versionUpdateOfDBCheckAndChangeV13ToV14() {
try {
ensureColumnExists("ChatMember", "worked50", "BOOLEAN DEFAULT 0");
ensureColumnExists("ChatMember", "worked70", "BOOLEAN DEFAULT 0");
ensureColumnExists("ChatMember", "notQRV50", "BOOLEAN DEFAULT 0");
ensureColumnExists("ChatMember", "notQRV70", "BOOLEAN DEFAULT 0");
} catch (SQLException e) {
throw new RuntimeException("[DBH, ERROR:] Could not migrate database from v1.3 to v1.4", e);
}
}
/** /**
* Adds a missing column to an existing table. This method is used for safe schema * Adds a missing column to an existing table. This method is used for safe schema
* upgrades on customer systems which still contain older database files. * upgrades on customer systems which still contain older database files.
@@ -452,7 +473,11 @@ public class DBController {
targetChatMember.setWorked3400(targetChatMember.isWorked3400() || sourceChatMember.isWorked3400()); targetChatMember.setWorked3400(targetChatMember.isWorked3400() || sourceChatMember.isWorked3400());
targetChatMember.setWorked5600(targetChatMember.isWorked5600() || sourceChatMember.isWorked5600()); targetChatMember.setWorked5600(targetChatMember.isWorked5600() || sourceChatMember.isWorked5600());
targetChatMember.setWorked10G(targetChatMember.isWorked10G() || sourceChatMember.isWorked10G()); targetChatMember.setWorked10G(targetChatMember.isWorked10G() || sourceChatMember.isWorked10G());
targetChatMember.setWorked50(targetChatMember.isWorked50() || sourceChatMember.isWorked50());
targetChatMember.setWorked70(targetChatMember.isWorked70() || sourceChatMember.isWorked70());
targetChatMember.setQrv50(targetChatMember.isQrv50() && sourceChatMember.isQrv50());
targetChatMember.setQrv70(targetChatMember.isQrv70() && sourceChatMember.isQrv70());
targetChatMember.setQrv144(targetChatMember.isQrv144() && sourceChatMember.isQrv144()); targetChatMember.setQrv144(targetChatMember.isQrv144() && sourceChatMember.isQrv144());
targetChatMember.setQrv432(targetChatMember.isQrv432() && sourceChatMember.isQrv432()); targetChatMember.setQrv432(targetChatMember.isQrv432() && sourceChatMember.isQrv432());
targetChatMember.setQrv1240(targetChatMember.isQrv1240() && sourceChatMember.isQrv1240()); targetChatMember.setQrv1240(targetChatMember.isQrv1240() && sourceChatMember.isQrv1240());
@@ -491,6 +516,8 @@ public class DBController {
+ "worked3400 = 0, " + "worked3400 = 0, "
+ "worked5600 = 0, " + "worked5600 = 0, "
+ "worked10G = 0, " + "worked10G = 0, "
+ "worked50 = 0, "
+ "worked70 = 0, "
+ "notQRV144 = 0, " + "notQRV144 = 0, "
+ "notQRV432 = 0, " + "notQRV432 = 0, "
+ "notQRV1240 = 0, " + "notQRV1240 = 0, "
@@ -498,6 +525,8 @@ public class DBController {
+ "notQRV3400 = 0, " + "notQRV3400 = 0, "
+ "notQRV5600 = 0, " + "notQRV5600 = 0, "
+ "notQRV10G = 0, " + "notQRV10G = 0, "
+ "notQRV50 = 0, "
+ "notQRV70 = 0, "
+ "lastFlagsChangeEpochMs = 0 " + "lastFlagsChangeEpochMs = 0 "
+ "WHERE lastFlagsChangeEpochMs > 0 AND lastFlagsChangeEpochMs < ?;"; + "WHERE lastFlagsChangeEpochMs > 0 AND lastFlagsChangeEpochMs < ?;";
@@ -534,9 +563,9 @@ public class DBController {
String insertOrUpdateSql = String insertOrUpdateSql =
"INSERT INTO ChatMember (" "INSERT INTO ChatMember ("
+ "callsign, qra, name, lastActivityDateTime, worked, worked144, worked432, worked1240, worked2300, worked3400, worked5600, worked10G, " + "callsign, qra, name, lastActivityDateTime, worked, worked144, worked432, worked1240, worked2300, worked3400, worked5600, worked10G, worked50, worked70, "
+ "notQRV144, notQRV432, notQRV1240, notQRV2300, notQRV3400, notQRV5600, notQRV10G, lastFlagsChangeEpochMs" + "notQRV144, notQRV432, notQRV1240, notQRV2300, notQRV3400, notQRV5600, notQRV10G, notQRV50, notQRV70, lastFlagsChangeEpochMs"
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(callsign) DO UPDATE SET " + "ON CONFLICT(callsign) DO UPDATE SET "
+ "qra = excluded.qra, " + "qra = excluded.qra, "
+ "name = excluded.name, " + "name = excluded.name, "
@@ -557,14 +586,18 @@ public class DBController {
preparedStatement.setInt(10, helper_booleanIntConverter(chatMemberToStore.isWorked3400())); preparedStatement.setInt(10, helper_booleanIntConverter(chatMemberToStore.isWorked3400()));
preparedStatement.setInt(11, helper_booleanIntConverter(chatMemberToStore.isWorked5600())); preparedStatement.setInt(11, helper_booleanIntConverter(chatMemberToStore.isWorked5600()));
preparedStatement.setInt(12, helper_booleanIntConverter(chatMemberToStore.isWorked10G())); preparedStatement.setInt(12, helper_booleanIntConverter(chatMemberToStore.isWorked10G()));
preparedStatement.setInt(13, helper_booleanIntConverter(!chatMemberToStore.isQrv144())); preparedStatement.setInt(13, helper_booleanIntConverter(chatMemberToStore.isWorked50()));
preparedStatement.setInt(14, helper_booleanIntConverter(!chatMemberToStore.isQrv432())); preparedStatement.setInt(14, helper_booleanIntConverter(chatMemberToStore.isWorked70()));
preparedStatement.setInt(15, helper_booleanIntConverter(!chatMemberToStore.isQrv1240())); preparedStatement.setInt(15, helper_booleanIntConverter(!chatMemberToStore.isQrv144()));
preparedStatement.setInt(16, helper_booleanIntConverter(!chatMemberToStore.isQrv2300())); preparedStatement.setInt(16, helper_booleanIntConverter(!chatMemberToStore.isQrv432()));
preparedStatement.setInt(17, helper_booleanIntConverter(!chatMemberToStore.isQrv3400())); preparedStatement.setInt(17, helper_booleanIntConverter(!chatMemberToStore.isQrv1240()));
preparedStatement.setInt(18, helper_booleanIntConverter(!chatMemberToStore.isQrv5600())); preparedStatement.setInt(18, helper_booleanIntConverter(!chatMemberToStore.isQrv2300()));
preparedStatement.setInt(19, helper_booleanIntConverter(!chatMemberToStore.isQrv10G())); preparedStatement.setInt(19, helper_booleanIntConverter(!chatMemberToStore.isQrv3400()));
preparedStatement.setLong(20, resolvedLastFlagsChangeEpochMs); preparedStatement.setInt(20, helper_booleanIntConverter(!chatMemberToStore.isQrv5600()));
preparedStatement.setInt(21, helper_booleanIntConverter(!chatMemberToStore.isQrv10G()));
preparedStatement.setInt(22, helper_booleanIntConverter(!chatMemberToStore.isQrv50()));
preparedStatement.setInt(23, helper_booleanIntConverter(!chatMemberToStore.isQrv70()));
preparedStatement.setLong(24, resolvedLastFlagsChangeEpochMs);
preparedStatement.executeUpdate(); preparedStatement.executeUpdate();
} catch (SQLException e) { } catch (SQLException e) {
System.err.println("[DBH, ERROR:] Chatmember could not been stored."); System.err.println("[DBH, ERROR:] Chatmember could not been stored.");
@@ -646,8 +679,8 @@ public class DBController {
String resetAllWorkedDataSql = String resetAllWorkedDataSql =
"UPDATE ChatMember SET " "UPDATE ChatMember SET "
+ "worked = 0, worked144 = 0, worked432 = 0, worked1240 = 0, worked2300 = 0, worked3400 = 0, worked5600 = 0, worked10G = 0, " + "worked = 0, worked144 = 0, worked432 = 0, worked1240 = 0, worked2300 = 0, worked3400 = 0, worked5600 = 0, worked10G = 0, worked50 = 0, worked70 = 0, "
+ "notQRV144 = 0, notQRV432 = 0, notQRV1240 = 0, notQRV2300 = 0, notQRV3400 = 0, notQRV5600 = 0, notQRV10G = 0, " + "notQRV144 = 0, notQRV432 = 0, notQRV1240 = 0, notQRV2300 = 0, notQRV3400 = 0, notQRV5600 = 0, notQRV10G = 0, notQRV50 = 0, notQRV70 = 0, "
+ "lastFlagsChangeEpochMs = 0;"; + "lastFlagsChangeEpochMs = 0;";
try (Statement statement = connection.createStatement()) { try (Statement statement = connection.createStatement()) {
@@ -781,6 +814,8 @@ public class DBController {
+ "notQRV3400 = ?, " + "notQRV3400 = ?, "
+ "notQRV5600 = ?, " + "notQRV5600 = ?, "
+ "notQRV10G = ?, " + "notQRV10G = ?, "
+ "notQRV50 = ?, "
+ "notQRV70 = ?, "
+ "lastFlagsChangeEpochMs = ? " + "lastFlagsChangeEpochMs = ? "
+ "WHERE callsign = ?;"; + "WHERE callsign = ?;";
@@ -792,8 +827,10 @@ public class DBController {
preparedStatement.setInt(5, helper_booleanIntConverter(!chatMemberToStore.isQrv3400())); preparedStatement.setInt(5, helper_booleanIntConverter(!chatMemberToStore.isQrv3400()));
preparedStatement.setInt(6, helper_booleanIntConverter(!chatMemberToStore.isQrv5600())); preparedStatement.setInt(6, helper_booleanIntConverter(!chatMemberToStore.isQrv5600()));
preparedStatement.setInt(7, helper_booleanIntConverter(!chatMemberToStore.isQrv10G())); preparedStatement.setInt(7, helper_booleanIntConverter(!chatMemberToStore.isQrv10G()));
preparedStatement.setLong(8, System.currentTimeMillis()); preparedStatement.setInt(8, helper_booleanIntConverter(!chatMemberToStore.isQrv50()));
preparedStatement.setString(9, chatMemberToStore.getCallSignRaw()); preparedStatement.setInt(9, helper_booleanIntConverter(!chatMemberToStore.isQrv70()));
preparedStatement.setLong(10, System.currentTimeMillis());
preparedStatement.setString(11, chatMemberToStore.getCallSignRaw());
int affectedRows = preparedStatement.executeUpdate(); int affectedRows = preparedStatement.executeUpdate();
@@ -821,9 +858,9 @@ public class DBController {
String upsertCompleteRowSql = String upsertCompleteRowSql =
"INSERT INTO ChatMember (" "INSERT INTO ChatMember ("
+ "callsign, qra, name, lastActivityDateTime, worked, worked144, worked432, worked1240, worked2300, worked3400, worked5600, worked10G, " + "callsign, qra, name, lastActivityDateTime, worked, worked144, worked432, worked1240, worked2300, worked3400, worked5600, worked10G, worked50, worked70, "
+ "notQRV144, notQRV432, notQRV1240, notQRV2300, notQRV3400, notQRV5600, notQRV10G, lastFlagsChangeEpochMs" + "notQRV144, notQRV432, notQRV1240, notQRV2300, notQRV3400, notQRV5600, notQRV10G, notQRV50, notQRV70, lastFlagsChangeEpochMs"
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(callsign) DO UPDATE SET " + "ON CONFLICT(callsign) DO UPDATE SET "
+ "qra = excluded.qra, " + "qra = excluded.qra, "
+ "name = excluded.name, " + "name = excluded.name, "
@@ -836,6 +873,8 @@ public class DBController {
+ "worked3400 = excluded.worked3400, " + "worked3400 = excluded.worked3400, "
+ "worked5600 = excluded.worked5600, " + "worked5600 = excluded.worked5600, "
+ "worked10G = excluded.worked10G, " + "worked10G = excluded.worked10G, "
+ "worked50 = excluded.worked50, "
+ "worked70 = excluded.worked70, "
+ "notQRV144 = excluded.notQRV144, " + "notQRV144 = excluded.notQRV144, "
+ "notQRV432 = excluded.notQRV432, " + "notQRV432 = excluded.notQRV432, "
+ "notQRV1240 = excluded.notQRV1240, " + "notQRV1240 = excluded.notQRV1240, "
@@ -843,6 +882,8 @@ public class DBController {
+ "notQRV3400 = excluded.notQRV3400, " + "notQRV3400 = excluded.notQRV3400, "
+ "notQRV5600 = excluded.notQRV5600, " + "notQRV5600 = excluded.notQRV5600, "
+ "notQRV10G = excluded.notQRV10G, " + "notQRV10G = excluded.notQRV10G, "
+ "notQRV50 = excluded.notQRV50, "
+ "notQRV70 = excluded.notQRV70, "
+ "lastFlagsChangeEpochMs = excluded.lastFlagsChangeEpochMs;"; + "lastFlagsChangeEpochMs = excluded.lastFlagsChangeEpochMs;";
try (PreparedStatement preparedStatement = connection.prepareStatement(upsertCompleteRowSql)) { try (PreparedStatement preparedStatement = connection.prepareStatement(upsertCompleteRowSql)) {
@@ -858,14 +899,18 @@ public class DBController {
preparedStatement.setInt(10, helper_booleanIntConverter(chatMemberToStore.isWorked3400())); preparedStatement.setInt(10, helper_booleanIntConverter(chatMemberToStore.isWorked3400()));
preparedStatement.setInt(11, helper_booleanIntConverter(chatMemberToStore.isWorked5600())); preparedStatement.setInt(11, helper_booleanIntConverter(chatMemberToStore.isWorked5600()));
preparedStatement.setInt(12, helper_booleanIntConverter(chatMemberToStore.isWorked10G())); preparedStatement.setInt(12, helper_booleanIntConverter(chatMemberToStore.isWorked10G()));
preparedStatement.setInt(13, helper_booleanIntConverter(!chatMemberToStore.isQrv144())); preparedStatement.setInt(13, helper_booleanIntConverter(chatMemberToStore.isWorked50()));
preparedStatement.setInt(14, helper_booleanIntConverter(!chatMemberToStore.isQrv432())); preparedStatement.setInt(14, helper_booleanIntConverter(chatMemberToStore.isWorked70()));
preparedStatement.setInt(15, helper_booleanIntConverter(!chatMemberToStore.isQrv1240())); preparedStatement.setInt(15, helper_booleanIntConverter(!chatMemberToStore.isQrv144()));
preparedStatement.setInt(16, helper_booleanIntConverter(!chatMemberToStore.isQrv2300())); preparedStatement.setInt(16, helper_booleanIntConverter(!chatMemberToStore.isQrv432()));
preparedStatement.setInt(17, helper_booleanIntConverter(!chatMemberToStore.isQrv3400())); preparedStatement.setInt(17, helper_booleanIntConverter(!chatMemberToStore.isQrv1240()));
preparedStatement.setInt(18, helper_booleanIntConverter(!chatMemberToStore.isQrv5600())); preparedStatement.setInt(18, helper_booleanIntConverter(!chatMemberToStore.isQrv2300()));
preparedStatement.setInt(19, helper_booleanIntConverter(!chatMemberToStore.isQrv10G())); preparedStatement.setInt(19, helper_booleanIntConverter(!chatMemberToStore.isQrv3400()));
preparedStatement.setLong(20, chatMemberToStore.getLastFlagsChangeEpochMs()); preparedStatement.setInt(20, helper_booleanIntConverter(!chatMemberToStore.isQrv5600()));
preparedStatement.setInt(21, helper_booleanIntConverter(!chatMemberToStore.isQrv10G()));
preparedStatement.setInt(22, helper_booleanIntConverter(!chatMemberToStore.isQrv50()));
preparedStatement.setInt(23, helper_booleanIntConverter(!chatMemberToStore.isQrv70()));
preparedStatement.setLong(24, chatMemberToStore.getLastFlagsChangeEpochMs());
preparedStatement.executeUpdate(); preparedStatement.executeUpdate();
} catch (SQLException e) { } catch (SQLException e) {
throw new RuntimeException("[DBH, ERROR:] Could not rebuild normalized ChatMember row", e); throw new RuntimeException("[DBH, ERROR:] Could not rebuild normalized ChatMember row", e);
@@ -894,6 +939,8 @@ public class DBController {
builtChatMember.setWorked3400(helper_IntToBooleanConverter(resultSet.getInt("worked3400"))); builtChatMember.setWorked3400(helper_IntToBooleanConverter(resultSet.getInt("worked3400")));
builtChatMember.setWorked5600(helper_IntToBooleanConverter(resultSet.getInt("worked5600"))); builtChatMember.setWorked5600(helper_IntToBooleanConverter(resultSet.getInt("worked5600")));
builtChatMember.setWorked10G(helper_IntToBooleanConverter(resultSet.getInt("worked10G"))); builtChatMember.setWorked10G(helper_IntToBooleanConverter(resultSet.getInt("worked10G")));
builtChatMember.setWorked50(helper_IntToBooleanConverter(resultSet.getInt("worked50")));
builtChatMember.setWorked70(helper_IntToBooleanConverter(resultSet.getInt("worked70")));
builtChatMember.setQrv144(!helper_IntToBooleanConverter(resultSet.getInt("notQRV144"))); builtChatMember.setQrv144(!helper_IntToBooleanConverter(resultSet.getInt("notQRV144")));
builtChatMember.setQrv432(!helper_IntToBooleanConverter(resultSet.getInt("notQRV432"))); builtChatMember.setQrv432(!helper_IntToBooleanConverter(resultSet.getInt("notQRV432")));
builtChatMember.setQrv1240(!helper_IntToBooleanConverter(resultSet.getInt("notQRV1240"))); builtChatMember.setQrv1240(!helper_IntToBooleanConverter(resultSet.getInt("notQRV1240")));
@@ -901,6 +948,8 @@ public class DBController {
builtChatMember.setQrv3400(!helper_IntToBooleanConverter(resultSet.getInt("notQRV3400"))); builtChatMember.setQrv3400(!helper_IntToBooleanConverter(resultSet.getInt("notQRV3400")));
builtChatMember.setQrv5600(!helper_IntToBooleanConverter(resultSet.getInt("notQRV5600"))); builtChatMember.setQrv5600(!helper_IntToBooleanConverter(resultSet.getInt("notQRV5600")));
builtChatMember.setQrv10G(!helper_IntToBooleanConverter(resultSet.getInt("notQRV10G"))); builtChatMember.setQrv10G(!helper_IntToBooleanConverter(resultSet.getInt("notQRV10G")));
builtChatMember.setQrv50(!helper_IntToBooleanConverter(resultSet.getInt("notQRV50")));
builtChatMember.setQrv70(!helper_IntToBooleanConverter(resultSet.getInt("notQRV70")));
builtChatMember.setLastFlagsChangeEpochMs(resultSet.getLong("lastFlagsChangeEpochMs")); builtChatMember.setLastFlagsChangeEpochMs(resultSet.getLong("lastFlagsChangeEpochMs"));
return builtChatMember; return builtChatMember;
@@ -922,6 +971,8 @@ public class DBController {
targetChatMember.setWorked3400(sourceChatMember.isWorked3400()); targetChatMember.setWorked3400(sourceChatMember.isWorked3400());
targetChatMember.setWorked5600(sourceChatMember.isWorked5600()); targetChatMember.setWorked5600(sourceChatMember.isWorked5600());
targetChatMember.setWorked10G(sourceChatMember.isWorked10G()); targetChatMember.setWorked10G(sourceChatMember.isWorked10G());
targetChatMember.setWorked50(sourceChatMember.isWorked50());
targetChatMember.setWorked70(sourceChatMember.isWorked70());
targetChatMember.setQrv144(sourceChatMember.isQrv144()); targetChatMember.setQrv144(sourceChatMember.isQrv144());
targetChatMember.setQrv432(sourceChatMember.isQrv432()); targetChatMember.setQrv432(sourceChatMember.isQrv432());
targetChatMember.setQrv1240(sourceChatMember.isQrv1240()); targetChatMember.setQrv1240(sourceChatMember.isQrv1240());
@@ -929,6 +980,8 @@ public class DBController {
targetChatMember.setQrv3400(sourceChatMember.isQrv3400()); targetChatMember.setQrv3400(sourceChatMember.isQrv3400());
targetChatMember.setQrv5600(sourceChatMember.isQrv5600()); targetChatMember.setQrv5600(sourceChatMember.isQrv5600());
targetChatMember.setQrv10G(sourceChatMember.isQrv10G()); targetChatMember.setQrv10G(sourceChatMember.isQrv10G());
targetChatMember.setQrv50(sourceChatMember.isQrv50());
targetChatMember.setQrv70(sourceChatMember.isQrv70());
targetChatMember.setLastFlagsChangeEpochMs(sourceChatMember.getLastFlagsChangeEpochMs()); targetChatMember.setLastFlagsChangeEpochMs(sourceChatMember.getLastFlagsChangeEpochMs());
} }
@@ -954,6 +1007,10 @@ public class DBController {
return "worked5600"; return "worked5600";
} else if (chatMemberToStore.isWorked10G()) { } else if (chatMemberToStore.isWorked10G()) {
return "worked10G"; return "worked10G";
} else if (chatMemberToStore.isWorked50()) {
return "worked50";
} else if (chatMemberToStore.isWorked70()) {
return "worked70";
} }
return null; return null;
@@ -996,13 +1053,17 @@ public class DBController {
|| chatMemberToStore.isWorked3400() || chatMemberToStore.isWorked3400()
|| chatMemberToStore.isWorked5600() || chatMemberToStore.isWorked5600()
|| chatMemberToStore.isWorked10G() || chatMemberToStore.isWorked10G()
|| chatMemberToStore.isWorked50()
|| chatMemberToStore.isWorked70()
|| !chatMemberToStore.isQrv144() || !chatMemberToStore.isQrv144()
|| !chatMemberToStore.isQrv432() || !chatMemberToStore.isQrv432()
|| !chatMemberToStore.isQrv1240() || !chatMemberToStore.isQrv1240()
|| !chatMemberToStore.isQrv2300() || !chatMemberToStore.isQrv2300()
|| !chatMemberToStore.isQrv3400() || !chatMemberToStore.isQrv3400()
|| !chatMemberToStore.isQrv5600() || !chatMemberToStore.isQrv5600()
|| !chatMemberToStore.isQrv10G(); || !chatMemberToStore.isQrv10G()
|| !chatMemberToStore.isQrv50()
|| !chatMemberToStore.isQrv70();
} }
/** /**
@@ -1,4 +1,5 @@
package kst4contest.controller; package kst4contest.controller;
import kst4contest.logic.BandOpportunityResolver;
import kst4contest.view.map.MapCallsignRawSnapshot; import kst4contest.view.map.MapCallsignRawSnapshot;
import java.util.ArrayList; import java.util.ArrayList;
@@ -168,14 +169,31 @@ public final class ReachabilityService {
selectedSnapshot.lastKnownFrequenciesByBand() selectedSnapshot.lastKnownFrequenciesByBand()
); );
if (!Double.isFinite(analysisFrequencyMHz) || analysisFrequencyMHz <= 0.0) { Band analysisBand = Band.fromFrequency(analysisFrequencyMHz);
Band fallbackBand = member == null ? Band.B_144 : resolveAutoBand(member); if (analysisBand != null && !isUsableAutomaticBand(member, analysisBand)) {
analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, fallbackBand); analysisFrequencyMHz = Double.NaN;
analysisBand = null;
} }
Band analysisBand = Band.fromFrequency(analysisFrequencyMHz); if (!Double.isFinite(analysisFrequencyMHz)
if (analysisBand == null) { || analysisFrequencyMHz <= 0.0
analysisBand = member == null ? Band.B_144 : resolveAutoBand(member); || 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( PathAnalysisRequest request = buildRequest(
@@ -230,20 +248,60 @@ public final class ReachabilityService {
* @return resolved band * @return resolved band
*/ */
public Band resolveAutoBand(ChatMember member) { public Band resolveAutoBand(ChatMember member) {
if (member != null && member.getKnownActiveBands() != null && !member.getKnownActiveBands().isEmpty()) { EnumSet<Band> enabledBands = getEnabledStationBands();
return member.getKnownActiveBands().keySet().stream() if (enabledBands.isEmpty()) {
.filter(Objects::nonNull) 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)) .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 if (member != null
&& member.getChatCategory() != 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_1296;
} }
return Band.B_144; if (member != null
&& member.getChatCategory() != null
&& member.getChatCategory().getCategoryNumber() == ChatCategory.FIFTYSEVENTYMHz) {
if (fallbackBands.contains(Band.B_50)) {
return Band.B_50;
}
if (fallbackBands.contains(Band.B_70)) {
return Band.B_70;
}
}
if (fallbackBands.contains(Band.B_144)) {
return Band.B_144;
}
return fallbackBands.stream()
.min(Comparator.comparingDouble(Band::getDefaultAnalysisFrequencyMHz))
.orElse(null);
} }
/** /**
@@ -253,22 +311,44 @@ public final class ReachabilityService {
* @return set of enabled bands * @return set of enabled bands
*/ */
public EnumSet<Band> getEnabledStationBands() { public EnumSet<Band> getEnabledStationBands() {
ChatPreferences preferences = chatController.getChatPreferences(); return BandOpportunityResolver.getEnabledStationBands(
EnumSet<Band> enabledBands = EnumSet.noneOf(Band.class); chatController.getChatPreferences()
);
}
if (preferences == null) { private List<ChatMember> resolveCallsignVariants(ChatMember member) {
return enabledBands; if (member == null) {
return List.of();
} }
if (preferences.isStn_bandActive144()) enabledBands.add(Band.B_144); String rawCall = member.getCallSignRaw() != null
if (preferences.isStn_bandActive432()) enabledBands.add(Band.B_432); ? member.getCallSignRaw()
if (preferences.isStn_bandActive1240()) enabledBands.add(Band.B_1296); : member.getCallSign();
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; 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);
} }
/** /**
@@ -303,17 +303,19 @@ public class ReadUDPByWintestThread extends Thread {
return null; return null;
} }
switch (bandId.trim()) { return switch (bandId.trim()) {
case "12": return Band.B_144; case "10" -> Band.B_50;
case "14": return Band.B_432; case "11" -> Band.B_70;
case "16": return Band.B_1296; case "12" -> Band.B_144;
case "17": return Band.B_2320; case "14" -> Band.B_432;
case "18": return Band.B_3400; case "16" -> Band.B_1296;
case "19": return Band.B_5760; case "17" -> Band.B_2320;
case "20": return Band.B_10G; case "18" -> Band.B_3400;
case "21": return Band.B_24G; case "19" -> Band.B_5760;
default: return null; case "20" -> Band.B_10G;
} case "21" -> Band.B_24G;
default -> null;
};
} }
/** /**
@@ -144,6 +144,12 @@ public class ReadUDPbyUCXMessageThread extends Thread {
} }
switch (band.trim()) { switch (band.trim()) {
case "50":
case "6m":
return Band.B_50;
case "70":
case "4m":
return Band.B_70;
case "144": case "144":
case "2m": case "2m":
return Band.B_144; return Band.B_144;
@@ -357,6 +363,20 @@ public class ReadUDPbyUCXMessageThread extends Thread {
Band workedBand = helper_resolveBandFromLoggerBand(band); Band workedBand = helper_resolveBandFromLoggerBand(band);
switch (band) { switch (band) {
case "50":
case "6m":
{
workedCall.setWorked50(true);
break;
}
case "70":
case "4m":
{
workedCall.setWorked70(true);
break;
}
case "144": case "144":
case "2m": //minos contest logger case "2m": //minos contest logger
{ {
@@ -434,7 +454,13 @@ public class ReadUDPbyUCXMessageThread extends Thread {
modifyThat.setWorked(true); modifyThat.setWorked(true);
if (workedCall.isWorked144()) { if (workedCall.isWorked50()) {
modifyThat.setWorked50(true);
} else if (workedCall.isWorked70()) {
modifyThat.setWorked70(true);
} else if (workedCall.isWorked144()) {
modifyThat.setWorked144(true); modifyThat.setWorked144(true);
} else if (workedCall.isWorked432()) { } else if (workedCall.isWorked432()) {
@@ -140,7 +140,9 @@ public final class ScoreService {
controller.getStationMetricsService().snapshot(nowEpochMs, prefs); controller.getStationMetricsService().snapshot(nowEpochMs, prefs);
// 1) Choose one representative per callsignRaw // 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 // 2) Compute score once per callsignRaw
Map<String, Double> scoreByCallRaw = new HashMap<>(representativeByCallRaw.size()); Map<String, Double> scoreByCallRaw = new HashMap<>(representativeByCallRaw.size());
@@ -154,6 +156,7 @@ public final class ScoreService {
double score = priorityCalculator.calculatePriority( double score = priorityCalculator.calculatePriority(
representative, representative,
variantsByCallRaw.getOrDefault(callRaw, List.of(representative)),
prefs, prefs,
activeSkeds, activeSkeds,
metricsSnapshot, 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. * Picks one ChatMember object per callsignRaw.
* Preference order: * Preference order:
@@ -196,18 +214,9 @@ public final class ScoreService {
* 2) Most recently active variant (fallback) * 2) Most recently active variant (fallback)
*/ */
private Map<String, ChatMember> chooseRepresentativeMembers( private Map<String, ChatMember> chooseRepresentativeMembers(
List<ChatMember> members, Map<String, List<ChatMember>> byCallRaw,
Map<String, ChatCategory> lastInboundCategoryByCallRaw 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()); Map<String, ChatMember> representative = new HashMap<>(byCallRaw.size());
for (Map.Entry<String, List<ChatMember>> entry : byCallRaw.entrySet()) { for (Map.Entry<String, List<ChatMember>> entry : byCallRaw.entrySet()) {
@@ -155,6 +155,8 @@ public class WinTestSkedSender {
public static int toWinTestBandId(Band band) { public static int toWinTestBandId(Band band) {
if (band == null) return 12; // default to 144 MHz if (band == null) return 12; // default to 144 MHz
return switch (band) { return switch (band) {
case B_50 -> 10;
case B_70 -> 11;
case B_144 -> 12; case B_144 -> 12;
case B_432 -> 14; case B_432 -> 14;
case B_1296 -> 16; case B_1296 -> 16;
@@ -98,6 +98,8 @@ public final class WorkedGrossFieldCache {
if (member.isWorked3400()) addWorked(Band.B_3400, locator); if (member.isWorked3400()) addWorked(Band.B_3400, locator);
if (member.isWorked5600()) addWorked(Band.B_5760, locator); if (member.isWorked5600()) addWorked(Band.B_5760, locator);
if (member.isWorked10G()) addWorked(Band.B_10G, locator); if (member.isWorked10G()) addWorked(Band.B_10G, locator);
if (member.isWorked50()) addWorked(Band.B_50, locator);
if (member.isWorked70()) addWorked(Band.B_70, locator);
} }
} }
@@ -0,0 +1,264 @@
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_bandActive50()) enabledBands.add(Band.B_50);
if (preferences.isStn_bandActive70()) enabledBands.add(Band.B_70);
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.isWorked50()) target.add(Band.B_50);
if (member.isWorked70()) target.add(Band.B_70);
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.isQrv50()) target.add(Band.B_50);
if (!member.isQrv70()) target.add(Band.B_70);
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);
// Bare "70" and bare "6" are already claimed by the 70cm/6cm shorthand below
// (their "CM" suffix is optional), so 4m/6m must require an explicit MHz/"M"
// suffix here to avoid misreading a cm-band shorthand as 70/50 MHz.
patterns.put(Band.B_50, bandPattern("50(?:\\s*MHZ)?|6\\s*M"));
patterns.put(Band.B_70, bandPattern("70\\s*MHZ|4\\s*M"));
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.controller.StationMetricsService;
import kst4contest.model.*; import kst4contest.model.*;
import java.util.Collection;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* Priority score calculation (off FX-thread). * Priority score calculation (off FX-thread).
@@ -16,10 +16,8 @@ import java.util.Map;
*/ */
public class PriorityCalculator { 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, public double calculatePriority(ChatMember member,
Collection<ChatMember> callsignVariants,
ChatPreferences prefs, ChatPreferences prefs,
List<ContestSked> activeSkeds, List<ContestSked> activeSkeds,
StationMetricsService.Snapshot metricsSnapshot, 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"
// -------------------------------------------------------------------- // --------------------------------------------------------------------
// -------------------------------------------------------------------- Collection<ChatMember> variants = callsignVariants == null || callsignVariants.isEmpty()
// 1) HARD FILTER: reachable hardware + "already worked on all possible bands" ? List.of(member)
// -------------------------------------------------------------------- : callsignVariants;
EnumSet<Band> myEnabledBands = getMyEnabledBands(prefs);
// "worked" for scoring is derived ONLY from per-band flags (worked144/432/...) BandOpportunityResolver.Resolution bandResolution =
// IMPORTANT: ChatMember.worked is UI-only and NOT used in scoring. BandOpportunityResolver.resolve(variants, nowEpochMs);
EnumSet<Band> workedBandsForScoring = getWorkedBands(member);
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> 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<Band> possibleBands = stationOfferedBands.isEmpty()
? EnumSet.noneOf(Band.class) // unknown => don't hard-filter ? EnumSet.noneOf(Band.class)
: EnumSet.copyOf(stationOfferedBands); : EnumSet.copyOf(stationAvailableBands);
if (!possibleBands.isEmpty()) { if (!stationOfferedBands.isEmpty()) {
possibleBands.retainAll(myEnabledBands); possibleBands.retainAll(myEnabledBands);
if (possibleBands.isEmpty()) { 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; return 0.0;
} }
unworkedPossible = EnumSet.copyOf(possibleBands); unworkedPossible = EnumSet.copyOf(possibleBands);
unworkedPossible.removeAll(workedBandsForScoring); unworkedPossible.removeAll(workedBandsForScoring);
// If already worked on all possible bands => no priority on them anymore (contest logic).
if (unworkedPossible.isEmpty()) { if (unworkedPossible.isEmpty()) {
return 0.0; 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); 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) { private static int findNextAirplaneArrivingMinutes(AirPlaneReflectionInfo apInfo) {
try { try {
if (apInfo.getRisingAirplanes() == null || apInfo.getRisingAirplanes().isEmpty()) return -1; if (apInfo.getRisingAirplanes() == null || apInfo.getRisingAirplanes().isEmpty()) return -1;
@@ -5,6 +5,8 @@ package kst4contest.model;
* Used for plausibility checks in the Smart Parser. * Used for plausibility checks in the Smart Parser.
*/ */
public enum Band { public enum Band {
B_50(50.000, 54.000, "50"),
B_70(70.000, 70.500, "70"),
B_144(144.000, 146.000, "144"), B_144(144.000, 146.000, "144"),
B_432(432.000, 434.000, "432"), B_432(432.000, 434.000, "432"),
B_1296(1296.000, 1298.000, "1296"), B_1296(1296.000, 1298.000, "1296"),
@@ -75,6 +77,8 @@ public enum Band {
*/ */
public String getDisplayLabel() { public String getDisplayLabel() {
switch (this) { switch (this) {
case B_50: return "50";
case B_70: return "70";
case B_144: return "144"; case B_144: return "144";
case B_432: return "432"; case B_432: return "432";
case B_1296: return "1296"; case B_1296: return "1296";
@@ -72,6 +72,8 @@ public class ChatMember {
/** /**
* Chatmember is qrv at all band except we initialize anything other, depending to user entry * Chatmember is qrv at all band except we initialize anything other, depending to user entry
*/ */
boolean qrv50 = true;
boolean qrv70 = true;
boolean qrv144 = true; boolean qrv144 = true;
boolean qrv432 = true; boolean qrv432 = true;
boolean qrv1240 = true; boolean qrv1240 = true;
@@ -228,6 +230,22 @@ public class ChatMember {
worked10G = worked10g; worked10G = worked10g;
} }
public boolean isQrv50() {
return qrv50;
}
public void setQrv50(boolean qrv50) {
this.qrv50 = qrv50;
}
public boolean isQrv70() {
return qrv70;
}
public void setQrv70(boolean qrv70) {
this.qrv70 = qrv70;
}
public boolean isQrv144() { public boolean isQrv144() {
return qrv144; return qrv144;
} }
@@ -607,6 +625,8 @@ public class ChatMember {
public void resetQRVInformationAtAllBands() { public void resetQRVInformationAtAllBands() {
this.setQrvAny(true); this.setQrvAny(true);
this.setQrv50(true);
this.setQrv70(true);
this.setQrv144(true); this.setQrv144(true);
this.setQrv432(true); this.setQrv432(true);
this.setQrv1240(true); this.setQrv1240(true);
@@ -197,6 +197,8 @@ public class ChatPreferences {
boolean loginToSecondChatEnabled; boolean loginToSecondChatEnabled;
DoubleProperty actualQTF = new SimpleDoubleProperty(360); // will be updated by user at runtime! DoubleProperty actualQTF = new SimpleDoubleProperty(360); // will be updated by user at runtime!
boolean stn_bandActive50;
boolean stn_bandActive70;
boolean stn_bandActive144; boolean stn_bandActive144;
boolean stn_bandActive432; boolean stn_bandActive432;
boolean stn_bandActive1240; boolean stn_bandActive1240;
@@ -332,6 +334,8 @@ public class ChatPreferences {
boolean guiOptions_defaultFilterPmToMe; boolean guiOptions_defaultFilterPmToMe;
boolean guiOptions_defaultFilterPmToOther; boolean guiOptions_defaultFilterPmToOther;
boolean guiOptions_defaultFilterPublicMsgs; 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[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 };
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN }; private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
@@ -647,6 +651,22 @@ public class ChatPreferences {
this.guiOptions_defaultFilterPmToMe = guiOptions_defaultFilterPmToMe; 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() { public boolean isGuiOptions_defaultFilterPmToOther() {
return guiOptions_defaultFilterPmToOther; return guiOptions_defaultFilterPmToOther;
} }
@@ -1486,6 +1506,14 @@ public class ChatPreferences {
Element stn_bandActive50 = doc.createElement("stn_bandActive50");
stn_bandActive50.setTextContent(this.stn_bandActive50+"");
station.appendChild(stn_bandActive50);
Element stn_bandActive70 = doc.createElement("stn_bandActive70");
stn_bandActive70.setTextContent(this.stn_bandActive70+"");
station.appendChild(stn_bandActive70);
Element stn_bandActive144 = doc.createElement("stn_bandActive144"); Element stn_bandActive144 = doc.createElement("stn_bandActive144");
stn_bandActive144.setTextContent(this.stn_bandActive144+""); stn_bandActive144.setTextContent(this.stn_bandActive144+"");
station.appendChild(stn_bandActive144); station.appendChild(stn_bandActive144);
@@ -1935,6 +1963,14 @@ public class ChatPreferences {
guiOptions_defaultFilterPublicMsgs.setTextContent(this.isGuiOptions_defaultFilterPublicMsgs()+""); guiOptions_defaultFilterPublicMsgs.setTextContent(this.isGuiOptions_defaultFilterPublicMsgs()+"");
guiSaveableOptions.appendChild(guiOptions_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"); Element guiOptions_darkModeActive = doc.createElement("guiOptions_darkModeActive");
guiOptions_darkModeActive.setTextContent(this.GUI_darkModeActive + ""); guiOptions_darkModeActive.setTextContent(this.GUI_darkModeActive + "");
guiSaveableOptions.appendChild(guiOptions_darkModeActive); guiSaveableOptions.appendChild(guiOptions_darkModeActive);
@@ -2213,6 +2249,8 @@ public class ChatPreferences {
); );
// Band activity flags (introduced later; if missing -> keep defaults) // Band activity flags (introduced later; if missing -> keep defaults)
stn_bandActive50 = getBoolean(stationEl, stn_bandActive50, "stn_bandActive50");
stn_bandActive70 = getBoolean(stationEl, stn_bandActive70, "stn_bandActive70");
stn_bandActive144 = getBoolean(stationEl, stn_bandActive144, "stn_bandActive144"); stn_bandActive144 = getBoolean(stationEl, stn_bandActive144, "stn_bandActive144");
stn_bandActive432 = getBoolean(stationEl, stn_bandActive432, "stn_bandActive432"); stn_bandActive432 = getBoolean(stationEl, stn_bandActive432, "stn_bandActive432");
stn_bandActive1240 = getBoolean(stationEl, stn_bandActive1240, "stn_bandActive1240"); stn_bandActive1240 = getBoolean(stationEl, stn_bandActive1240, "stn_bandActive1240");
@@ -2815,6 +2853,8 @@ public class ChatPreferences {
this.setGuiOptions_defaultFilterPmToMe(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_defaultFilterPmToMe(), "guiOptions_defaultFilterPmToMe")); this.setGuiOptions_defaultFilterPmToMe(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_defaultFilterPmToMe(), "guiOptions_defaultFilterPmToMe"));
this.setGuiOptions_defaultFilterPmToOther(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_defaultFilterPmToOther(), "guiOptions_defaultFilterPmToOther")); this.setGuiOptions_defaultFilterPmToOther(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_defaultFilterPmToOther(), "guiOptions_defaultFilterPmToOther"));
this.setGuiOptions_defaultFilterPublicMsgs(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_defaultFilterPublicMsgs(), "guiOptions_defaultFilterPublicMsgs")); 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 // Added in later versions: dark mode flags
this.GUI_darkModeActive = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActive, "guiOptions_darkModeActive"); this.GUI_darkModeActive = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActive, "guiOptions_darkModeActive");
@@ -2868,6 +2908,22 @@ public class ChatPreferences {
return result; return result;
} }
public boolean isStn_bandActive50() {
return stn_bandActive50;
}
public void setStn_bandActive50(boolean stn_bandActive50) {
this.stn_bandActive50 = stn_bandActive50;
}
public boolean isStn_bandActive70() {
return stn_bandActive70;
}
public void setStn_bandActive70(boolean stn_bandActive70) {
this.stn_bandActive70 = stn_bandActive70;
}
public boolean isStn_bandActive144() { public boolean isStn_bandActive144() {
return stn_bandActive144; return stn_bandActive144;
} }
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
package kst4contest.view.map; package kst4contest.view.map;
import kst4contest.locatorUtils.Location; import kst4contest.locatorUtils.Location;
import kst4contest.logic.BandOpportunityResolver;
import kst4contest.model.AirPlaneReflectionInfo; import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.Band; import kst4contest.model.Band;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
@@ -14,7 +15,6 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
/** /**
* Builds immutable map snapshots from the currently visible chat members. * Builds immutable map snapshots from the currently visible chat members.
@@ -24,8 +24,6 @@ import java.util.regex.Pattern;
*/ */
public final class MapCallsignRawSnapshotBuilder { public final class MapCallsignRawSnapshotBuilder {
private static final Pattern TOKEN_SPLIT_PATTERN = Pattern.compile("[^A-Z0-9]+");
public List<MapCallsignRawSnapshot> buildSnapshots(Collection<ChatMember> visibleChatMembers, public List<MapCallsignRawSnapshot> buildSnapshots(Collection<ChatMember> visibleChatMembers,
ChatMember selectedChatMember, ChatMember selectedChatMember,
EnumSet<Band> selectedBands) { EnumSet<Band> selectedBands) {
@@ -73,10 +71,21 @@ public final class MapCallsignRawSnapshotBuilder {
Location location = new Location(locator6); Location location = new Location(locator6);
LinkedHashMap<String, String> frequenciesByBand = collectLastKnownFrequenciesByBand(variants); long nowEpochMs = System.currentTimeMillis();
EnumSet<Band> sureBands = collectSureBands(variants, frequenciesByBand); BandOpportunityResolver.Resolution bandResolution =
String bandSummary = buildBandSummary(sureBands); BandOpportunityResolver.resolve(variants, nowEpochMs);
boolean offersSelectedBand = hasAnySelectedBand(sureBands, selectedBands);
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 warningToMyDirection = variants.stream().anyMatch(ChatMember::isInAngleAndRange);
boolean worked = variants.stream().anyMatch(this::isWorkedAtAnyBand); boolean worked = variants.stream().anyMatch(this::isWorkedAtAnyBand);
@@ -152,7 +161,11 @@ public final class MapCallsignRawSnapshotBuilder {
.orElse(""); .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); Map<Band, FrequencyCandidate> latestByBand = new EnumMap<>(Band.class);
@@ -161,16 +174,29 @@ public final class MapCallsignRawSnapshotBuilder {
continue; 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(); Band band = bandEntry.getKey();
ChatMember.ActiveFrequencyInfo activeFrequencyInfo = bandEntry.getValue(); 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; continue;
} }
FrequencyCandidate previous = latestByBand.get(band); FrequencyCandidate previous = latestByBand.get(band);
if (previous == null || activeFrequencyInfo.timestampEpoch > previous.timestampEpochMs()) { if (previous == null
|| activeFrequencyInfo.timestampEpoch > previous.timestampEpochMs()) {
latestByBand.put(band, new FrequencyCandidate( latestByBand.put(band, new FrequencyCandidate(
band, band,
formatFrequency(activeFrequencyInfo.frequency), formatFrequency(activeFrequencyInfo.frequency),
@@ -178,152 +204,31 @@ public final class MapCallsignRawSnapshotBuilder {
)); ));
} }
} }
addFallbackCurrentFrequencyIfUseful(variant, latestByBand);
} }
LinkedHashMap<String, String> ordered = new LinkedHashMap<>(); LinkedHashMap<String, String> ordered = new LinkedHashMap<>();
latestByBand.entrySet().stream() latestByBand.entrySet().stream()
.sorted(Map.Entry.comparingByKey()) .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; return ordered;
} }
private EnumSet<Band> collectSureBands(List<ChatMember> variants, private String buildBandSummary(EnumSet<Band> availableBands) {
LinkedHashMap<String, String> frequenciesByBand) { if (availableBands == null || availableBands.isEmpty()) {
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()) {
return ""; return "";
} }
List<String> labels = sureBands.stream() List<String> labels = availableBands.stream()
.sorted() .sorted()
.map(this::toBandDisplayLabel) .map(this::toBandDisplayLabel)
.toList(); .toList();
return String.join(", ", labels); 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) { private boolean isWorkedAtAnyBand(ChatMember member) {
return member.isWorked() return member.isWorked()
|| member.isWorked50() || member.isWorked50()
@@ -370,6 +275,8 @@ public final class MapCallsignRawSnapshotBuilder {
private String toBandDisplayLabel(Band band) { private String toBandDisplayLabel(Band band) {
return switch (band) { return switch (band) {
case B_50 -> "50";
case B_70 -> "70";
case B_144 -> "144"; case B_144 -> "144";
case B_432 -> "432"; case B_432 -> "432";
case B_1296 -> "1296"; case B_1296 -> "1296";
@@ -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. * Creates a finished result without a usable terrain profile.
* *
@@ -343,6 +343,8 @@ public final class StationMapBridge {
+ "|" + "|"
+ String.format(Locale.US, "%.3f", analysisFrequencyMHz) + String.format(Locale.US, "%.3f", analysisFrequencyMHz)
+ "|" + "|"
+ selectedSnapshot.bandSummary()
+ "|"
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnAntennaHeightMeters()) + String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnAntennaHeightMeters())
+ "|" + "|"
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetAntennaHeightMeters()) + String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetAntennaHeightMeters())
@@ -0,0 +1,120 @@
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 detectsFourAndSixMetersWithoutStealingCmShorthandBands() {
EnumSet<Band> bands = BandOpportunityResolver.detectBandsFromStationName(
"QRV 50 70cm 6M 4M"
);
assertEquals(
EnumSet.of(Band.B_50, Band.B_432, Band.B_70),
bands
);
}
@Test
void bareSeventyAndBareSixStillMeanCentimeterBands() {
EnumSet<Band> bands = BandOpportunityResolver.detectBandsFromStationName(
"QRV 70 6"
);
assertEquals(EnumSet.of(Band.B_432, Band.B_5760), bands);
assertFalse(bands.contains(Band.B_70));
assertFalse(bands.contains(Band.B_50));
}
@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()); 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) { private ChatMember buildStation(String callSign, String name, String locator, long activityEpoch) {
ChatMember chatMember = new ChatMember(); ChatMember chatMember = new ChatMember();
chatMember.setCallSign(callSign); chatMember.setCallSign(callSign);
+10 -1
View File
@@ -2370,4 +2370,13 @@ DO5SA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd43
9A2HM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null 9A2HM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
9A2HM;null;null;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null 9A2HM;null;null;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
9A2HM;null;null;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300true; wkd3400false; wkd5600false; wkd10Gfalse ; null 9A2HM;null;null;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300true; wkd3400false; wkd5600false; wkd10Gfalse ; null
OV3T;Thomas;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz OV3T;Thomas;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
OZ1ALS;144307;JO44XX;StringProperty [value: 144.307]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
OZ1FDH;Claus;JO55QX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
OZ2AR;FRS Club;JO65BT;StringProperty [value: 144.243]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
SM7NCL;Chris;JO66JI;StringProperty [value: 144.255]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
OZ1DLD/P;Bent;JO45SK;StringProperty [value: 144.285]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
SM6VTZ;Chris 2/70/23/3;JO58UJ;StringProperty [value: 144.135]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
OZ7KJ;Skive Club;JO46ML;StringProperty [value: 144.225]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
OV3T;Thomas;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
OZ6TY;Henning;JO55XE;StringProperty [value: 144.196]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
+19 -1
View File
@@ -12,6 +12,13 @@ const RELEASES_URL = `https://github.com/${REPO}/releases`;
* prerelease flag, but that flag is exactly what this workflow sets, and * prerelease flag, but that flag is exactly what this workflow sets, and
* nothing else in this repository publishes prereleases, so it is a safe * nothing else in this repository publishes prereleases, so it is a safe
* signal to use here. * signal to use here.
*
* A prerelease flag alone isn't enough though: GitHub never clears
* `prerelease` once the corresponding Stable version has actually shipped,
* so the latest prerelease can be stale (e.g. beta-1.41-rc04 published
* 2026-06-30, followed by Stable v1.41.0 on 2026-07-01). If the newest
* Stable release is more recent than the newest prerelease, there is no
* current beta and the empty state should be shown instead.
*/ */
async function fetchLatestBetaRelease() { async function fetchLatestBetaRelease() {
const headers = { Accept: "application/vnd.github+json" }; const headers = { Accept: "application/vnd.github+json" };
@@ -29,7 +36,18 @@ async function fetchLatestBetaRelease() {
const releases = await res.json(); const releases = await res.json();
return releases.find((release) => release.prerelease && !release.draft) || null; const latestBeta = releases.find((release) => release.prerelease && !release.draft) || null;
const latestStable = releases.find((release) => !release.prerelease && !release.draft) || null;
if (!latestBeta) {
return null;
}
if (latestStable && latestStable.published_at > latestBeta.published_at) {
return null;
}
return latestBeta;
} catch (err) { } catch (err) {
console.warn( console.warn(
`[beta] Could not load the latest beta release. ` + `[beta] Could not load the latest beta release. ` +