mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-08-24 02:57:43 +02:00
Compare commits
4
Commits
c04117ad0a
...
6092ff882a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6092ff882a | ||
|
|
e37cda8ff2
|
||
|
|
fdec5220d1
|
||
|
|
35f790f06b
|
@@ -153,7 +153,7 @@
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<version>5.12.2</version>
|
||||
<version>${junit.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -266,6 +266,15 @@
|
||||
<version>${maven.surfire.plugin}</version>
|
||||
<configuration>
|
||||
<testFailureIgnore>true</testFailureIgnore>
|
||||
<!--
|
||||
Without this, Surefire's automatic module-path detection splits the
|
||||
JUnit Platform jars across classpath and module-path inconsistently
|
||||
(module org.junit.platform.commons ends up loaded while a class from
|
||||
junit-platform-engine stays in the unnamed module), which fails with
|
||||
an IllegalAccessError before any test can run. The project itself has
|
||||
no module-info.java, so plain classpath execution is correct here.
|
||||
-->
|
||||
<useModulePath>false</useModulePath>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import javafx.collections.transformation.SortedList;
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.controller.interfaces.PstRotatorEventListener;
|
||||
import kst4contest.locatorUtils.DirectionUtils;
|
||||
import kst4contest.logic.BandOpportunityResolver;
|
||||
import kst4contest.logic.PriorityCalculator;
|
||||
import kst4contest.model.*;
|
||||
import kst4contest.test.MockKstServer;
|
||||
@@ -216,18 +217,11 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an external logger (Win-Test or UCXLog interface) reports that a QSO was logged.
|
||||
* Called when an external logger reports that a QSO was logged.
|
||||
*
|
||||
* Process goal:
|
||||
* 1) Detect whether the logged station is *still active (QRV)* on at least one *other* band
|
||||
* that is enabled for "my station" (stn_bandActive[Band]) AND not worked yet (worked144/432/...).
|
||||
* 2) If yes: trigger an on-screen hint (blinking status button) and play the existing sked-notification sound.
|
||||
* 3) Request a score recompute so the station can become visible again (optional boost is applied in PriorityCalculator).
|
||||
*
|
||||
* IMPORTANT:
|
||||
* - We do NOT use ChatMember.worked (UI-only filter flag) for scoring decisions.
|
||||
* - We only use per-band worked flags (worked144, worked432, ...).
|
||||
* - "QRV on band" is derived from recent entries in ChatMember.knownActiveBands.
|
||||
* <p>The common resolver combines recent QRG evidence and station-name bands
|
||||
* across every active callsignRaw variant. A manual NOT-QRV tag overrides this
|
||||
* automatic evidence before the hint and priority boost are evaluated.</p>
|
||||
*/
|
||||
public void onExternalLogEntryReceived(String callSignRaw) {
|
||||
|
||||
@@ -241,26 +235,20 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
System.out.println("[BandUpgradeHint] LOG received for call=" + callRaw);
|
||||
}
|
||||
|
||||
// 1) Determine which bands I am active on (configured at startup via stn_bandActive* flags)
|
||||
EnumSet<Band> myEnabledBands = getMyEnabledBandsFromPrefs(chatPreferences);
|
||||
EnumSet<Band> myEnabledBands =
|
||||
BandOpportunityResolver.getEnabledStationBands(chatPreferences);
|
||||
if (myEnabledBands.isEmpty()) return;
|
||||
|
||||
// 2) Determine which bands the station was recently seen active on (from Smart Frequency Extraction history)
|
||||
final long now = System.currentTimeMillis();
|
||||
final long maxAgeMs = TimeUnit.MINUTES.toMillis(30); // keep consistent with "recent activity" semantics
|
||||
EnumSet<Band> stationOfferedBands = collectStationOfferedBandsFromHistory(callRaw, now, maxAgeMs);
|
||||
List<ChatMember> variants = findActiveChatMembersByRawCall(callRaw);
|
||||
BandOpportunityResolver.Resolution bandResolution =
|
||||
BandOpportunityResolver.resolve(variants, System.currentTimeMillis());
|
||||
|
||||
EnumSet<Band> stationOfferedBands = bandResolution.getOfferedBands();
|
||||
if (stationOfferedBands.isEmpty()) return;
|
||||
|
||||
// 3) Keep only bands that I can actually work
|
||||
stationOfferedBands.retainAll(myEnabledBands);
|
||||
if (stationOfferedBands.isEmpty()) return;
|
||||
|
||||
// 4) Determine already worked bands (per-band flags only)
|
||||
EnumSet<Band> workedBands = collectWorkedBands(callRaw);
|
||||
|
||||
// 5) Remaining bands = offered ∩ enabled - worked
|
||||
EnumSet<Band> remainingBands = EnumSet.copyOf(stationOfferedBands);
|
||||
remainingBands.removeAll(workedBands);
|
||||
EnumSet<Band> workedBands = bandResolution.getWorkedBands();
|
||||
EnumSet<Band> remainingBands =
|
||||
bandResolution.getUnworkedEnabledBands(myEnabledBands);
|
||||
if (remainingBands.isEmpty()) return;
|
||||
|
||||
if (DEBUG_BAND_UPGRADE_HINT) {
|
||||
@@ -268,34 +256,35 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
+ " enabled=" + formatBandsHuman(myEnabledBands)
|
||||
+ " offered=" + formatBandsHuman(stationOfferedBands)
|
||||
+ " worked=" + (workedBands.isEmpty() ? "-" : formatBandsHuman(workedBands))
|
||||
+ " NOT-QRV=" + formatBandsHuman(bandResolution.getNotQrvBands())
|
||||
+ " remaining=" + formatBandsHuman(remainingBands));
|
||||
}
|
||||
|
||||
// 6) Build UI text (button + tooltip)
|
||||
String remainingHuman = formatBandsHuman(remainingBands);
|
||||
String shortText = "BAND+ " + callRaw + " " + remainingHuman;
|
||||
|
||||
String tooltip = "Logged " + callRaw + ", but station is still QRV on additional band(s): "
|
||||
String tooltip = "Logged " + callRaw
|
||||
+ ", but the station still offers additional band(s): "
|
||||
+ remainingHuman
|
||||
+ "\n(Enabled: " + formatBandsHuman(myEnabledBands)
|
||||
+ " | Worked: " + (workedBands.isEmpty() ? "-" : formatBandsHuman(workedBands)) + ")";
|
||||
+ " | Worked: " + (workedBands.isEmpty() ? "-" : formatBandsHuman(workedBands))
|
||||
+ " | NOT QRV: " + formatBandsHuman(bandResolution.getNotQrvBands()) + ")";
|
||||
|
||||
ThreadStateMessage msg = new ThreadStateMessage("BandUpgradeHint", true, tooltip, false);
|
||||
msg.setRunningInformationTextDescription(shortText);
|
||||
|
||||
// 7) Trigger status update -> View will blink a dedicated indicator button
|
||||
onThreadStatus("BandUpgradeHint", msg);
|
||||
|
||||
// 8) Sound (re-use existing sked notification sound) - respects global simple-sound flag
|
||||
if (chatPreferences.isNotify_playSimpleSounds()) {
|
||||
try {
|
||||
getPlayAudioUtils().playNoiseLauncher('!'); // same as SkedReminderService
|
||||
getPlayAudioUtils().playNoiseLauncher('!');
|
||||
} catch (Exception e) {
|
||||
System.out.println("[ChatController, warning]: failed to play band-upgrade hint sound: " + e.getMessage());
|
||||
System.out.println(
|
||||
"[ChatController, warning]: failed to play band-upgrade hint sound: "
|
||||
+ e.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 9) Make sure score reacts quickly (boost is applied in PriorityCalculator if enabled)
|
||||
if (getScoreService() != null) {
|
||||
getScoreService().requestRecompute("BandUpgradeHint");
|
||||
}
|
||||
@@ -306,77 +295,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
return callRaw.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/** Helper: create enabled-band set from preferences. */
|
||||
private static EnumSet<Band> getMyEnabledBandsFromPrefs(ChatPreferences prefs) {
|
||||
EnumSet<Band> s = EnumSet.noneOf(Band.class);
|
||||
if (prefs.isStn_bandActive144()) s.add(Band.B_144);
|
||||
if (prefs.isStn_bandActive432()) s.add(Band.B_432);
|
||||
if (prefs.isStn_bandActive1240()) s.add(Band.B_1296);
|
||||
if (prefs.isStn_bandActive2300()) s.add(Band.B_2320);
|
||||
if (prefs.isStn_bandActive3400()) s.add(Band.B_3400);
|
||||
if (prefs.isStn_bandActive5600()) s.add(Band.B_5760);
|
||||
if (prefs.isStn_bandActive10G()) s.add(Band.B_10G);
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: union of all recently detected "QRV on band" entries across *all* ChatMember instances
|
||||
* having the same callSignRaw (because a callsign may exist multiple times with different categories).
|
||||
*/
|
||||
private EnumSet<Band> collectStationOfferedBandsFromHistory(String callRaw, long nowMs, long maxAgeMs) {
|
||||
|
||||
EnumSet<Band> offered = EnumSet.noneOf(Band.class);
|
||||
|
||||
for (ChatMember cm : findActiveChatMembersByRawCall(callRaw)) {
|
||||
if (cm == null || cm.getCallSignRaw() == null) continue;
|
||||
|
||||
Map<Band, ChatMember.ActiveFrequencyInfo> map = cm.getKnownActiveBands();
|
||||
if (map == null || map.isEmpty()) continue;
|
||||
|
||||
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> e : map.entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) continue;
|
||||
|
||||
long age = nowMs - e.getValue().timestampEpoch;
|
||||
if (age >= 0 && age <= maxAgeMs) {
|
||||
offered.add(e.getKey());
|
||||
}
|
||||
|
||||
if (DEBUG_BAND_UPGRADE_HINT) {
|
||||
System.out.println("[BandUpgradeHint] history call=" + callRaw
|
||||
+ " band=" + e.getKey()
|
||||
+ " freq=" + e.getValue().frequency
|
||||
+ " ageMs=" + age);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return offered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: union of per-band worked flags across all ChatMember instances for the same call.
|
||||
* IMPORTANT: ChatMember.worked is UI-only and NOT used here.
|
||||
*/
|
||||
private EnumSet<Band> collectWorkedBands(String callRaw) {
|
||||
|
||||
EnumSet<Band> worked = EnumSet.noneOf(Band.class);
|
||||
|
||||
for (ChatMember cm : findActiveChatMembersByRawCall(callRaw)) {
|
||||
if (cm == null || cm.getCallSignRaw() == null) continue;
|
||||
|
||||
if (cm.isWorked144()) worked.add(Band.B_144);
|
||||
if (cm.isWorked432()) worked.add(Band.B_432);
|
||||
if (cm.isWorked1240()) worked.add(Band.B_1296);
|
||||
if (cm.isWorked2300()) worked.add(Band.B_2320);
|
||||
if (cm.isWorked3400()) worked.add(Band.B_3400);
|
||||
if (cm.isWorked5600()) worked.add(Band.B_5760);
|
||||
if (cm.isWorked10G()) worked.add(Band.B_10G);
|
||||
if (cm.isWorked24G()) worked.add(Band.B_24G);
|
||||
}
|
||||
|
||||
return worked;
|
||||
}
|
||||
|
||||
private static String formatBandsHuman(EnumSet<Band> bands) {
|
||||
if (bands == null || bands.isEmpty()) return "-";
|
||||
return bands.stream().map(ChatController::bandToHumanLabel).sorted().reduce((a, b) -> a + ", " + b).orElse("-");
|
||||
@@ -385,6 +303,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
private static String bandToHumanLabel(Band b) {
|
||||
if (b == null) return "?";
|
||||
return switch (b) {
|
||||
case B_50 -> "6m";
|
||||
case B_70 -> "4m";
|
||||
case B_144 -> "2m";
|
||||
case B_432 -> "70cm";
|
||||
case B_1296 -> "23cm";
|
||||
@@ -1373,6 +1293,51 @@ private ObservableList<String>
|
||||
return matchingMembers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the band-specific NOT-QRV state to every active category variant of the
|
||||
* same base callsign. The database already uses callSignRaw as its key; applying
|
||||
* the state to the runtime model immediately prevents category-dependent B+,
|
||||
* filter, map and score results before the next database refresh.
|
||||
*
|
||||
* @param sourceMember member whose current NOT-QRV checkboxes are authoritative
|
||||
*/
|
||||
public void propagateNotQrvStateToActiveMembers(ChatMember sourceMember) {
|
||||
if (sourceMember == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String rawCall = sourceMember.getCallSignRaw() != null
|
||||
? sourceMember.getCallSignRaw()
|
||||
: sourceMember.getCallSign();
|
||||
|
||||
List<ChatMember> variants = findActiveChatMembersByRawCall(rawCall);
|
||||
if (variants.isEmpty()) {
|
||||
variants = List.of(sourceMember);
|
||||
}
|
||||
|
||||
for (ChatMember target : variants) {
|
||||
if (target == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
target.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
|
||||
* 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.setWorked5600(storedChatMemberState.isWorked5600());
|
||||
activeChatMember.setWorked10G(storedChatMemberState.isWorked10G());
|
||||
activeChatMember.setWorked50(storedChatMemberState.isWorked50());
|
||||
activeChatMember.setWorked70(storedChatMemberState.isWorked70());
|
||||
activeChatMember.setQrv144(storedChatMemberState.isQrv144());
|
||||
activeChatMember.setQrv432(storedChatMemberState.isQrv432());
|
||||
activeChatMember.setQrv1240(storedChatMemberState.isQrv1240());
|
||||
@@ -2968,6 +2935,8 @@ private ObservableList<String>
|
||||
activeChatMember.setQrv3400(storedChatMemberState.isQrv3400());
|
||||
activeChatMember.setQrv5600(storedChatMemberState.isQrv5600());
|
||||
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
|
||||
* 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
|
||||
* marker. The marker is stored in SQLite PRAGMA user_version so the expensive
|
||||
@@ -145,6 +145,7 @@ public class DBController {
|
||||
createWorkedGrossFieldTableIfRequired();
|
||||
versionUpdateOfDBCheckAndChangeV11ToV12();
|
||||
versionUpdateOfDBCheckAndChangeV12ToV13();
|
||||
versionUpdateOfDBCheckAndChangeV13ToV14();
|
||||
|
||||
if (helper_isDatabaseSchemaVersionOlderThanCurrent() || helper_isCallsignNormalizationMigrationRequired()) {
|
||||
normalizeStoredCallsignsToRawCallsigns();
|
||||
@@ -230,6 +231,8 @@ public class DBController {
|
||||
+ "worked3400 BOOLEAN DEFAULT 0, "
|
||||
+ "worked5600 BOOLEAN DEFAULT 0, "
|
||||
+ "worked10G BOOLEAN DEFAULT 0, "
|
||||
+ "worked50 BOOLEAN DEFAULT 0, "
|
||||
+ "worked70 BOOLEAN DEFAULT 0, "
|
||||
+ "notQRV144 BOOLEAN DEFAULT 0, "
|
||||
+ "notQRV432 BOOLEAN DEFAULT 0, "
|
||||
+ "notQRV1240 BOOLEAN DEFAULT 0, "
|
||||
@@ -237,6 +240,8 @@ public class DBController {
|
||||
+ "notQRV3400 BOOLEAN DEFAULT 0, "
|
||||
+ "notQRV5600 BOOLEAN DEFAULT 0, "
|
||||
+ "notQRV10G BOOLEAN DEFAULT 0, "
|
||||
+ "notQRV50 BOOLEAN DEFAULT 0, "
|
||||
+ "notQRV70 BOOLEAN 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
|
||||
* upgrades on customer systems which still contain older database files.
|
||||
@@ -452,7 +473,11 @@ public class DBController {
|
||||
targetChatMember.setWorked3400(targetChatMember.isWorked3400() || sourceChatMember.isWorked3400());
|
||||
targetChatMember.setWorked5600(targetChatMember.isWorked5600() || sourceChatMember.isWorked5600());
|
||||
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.setQrv432(targetChatMember.isQrv432() && sourceChatMember.isQrv432());
|
||||
targetChatMember.setQrv1240(targetChatMember.isQrv1240() && sourceChatMember.isQrv1240());
|
||||
@@ -491,6 +516,8 @@ public class DBController {
|
||||
+ "worked3400 = 0, "
|
||||
+ "worked5600 = 0, "
|
||||
+ "worked10G = 0, "
|
||||
+ "worked50 = 0, "
|
||||
+ "worked70 = 0, "
|
||||
+ "notQRV144 = 0, "
|
||||
+ "notQRV432 = 0, "
|
||||
+ "notQRV1240 = 0, "
|
||||
@@ -498,6 +525,8 @@ public class DBController {
|
||||
+ "notQRV3400 = 0, "
|
||||
+ "notQRV5600 = 0, "
|
||||
+ "notQRV10G = 0, "
|
||||
+ "notQRV50 = 0, "
|
||||
+ "notQRV70 = 0, "
|
||||
+ "lastFlagsChangeEpochMs = 0 "
|
||||
+ "WHERE lastFlagsChangeEpochMs > 0 AND lastFlagsChangeEpochMs < ?;";
|
||||
|
||||
@@ -534,9 +563,9 @@ public class DBController {
|
||||
|
||||
String insertOrUpdateSql =
|
||||
"INSERT INTO ChatMember ("
|
||||
+ "callsign, qra, name, lastActivityDateTime, worked, worked144, worked432, worked1240, worked2300, worked3400, worked5600, worked10G, "
|
||||
+ "notQRV144, notQRV432, notQRV1240, notQRV2300, notQRV3400, notQRV5600, notQRV10G, lastFlagsChangeEpochMs"
|
||||
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
+ "callsign, qra, name, lastActivityDateTime, worked, worked144, worked432, worked1240, worked2300, worked3400, worked5600, worked10G, worked50, worked70, "
|
||||
+ "notQRV144, notQRV432, notQRV1240, notQRV2300, notQRV3400, notQRV5600, notQRV10G, notQRV50, notQRV70, lastFlagsChangeEpochMs"
|
||||
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
+ "ON CONFLICT(callsign) DO UPDATE SET "
|
||||
+ "qra = excluded.qra, "
|
||||
+ "name = excluded.name, "
|
||||
@@ -557,14 +586,18 @@ public class DBController {
|
||||
preparedStatement.setInt(10, helper_booleanIntConverter(chatMemberToStore.isWorked3400()));
|
||||
preparedStatement.setInt(11, helper_booleanIntConverter(chatMemberToStore.isWorked5600()));
|
||||
preparedStatement.setInt(12, helper_booleanIntConverter(chatMemberToStore.isWorked10G()));
|
||||
preparedStatement.setInt(13, helper_booleanIntConverter(!chatMemberToStore.isQrv144()));
|
||||
preparedStatement.setInt(14, helper_booleanIntConverter(!chatMemberToStore.isQrv432()));
|
||||
preparedStatement.setInt(15, helper_booleanIntConverter(!chatMemberToStore.isQrv1240()));
|
||||
preparedStatement.setInt(16, helper_booleanIntConverter(!chatMemberToStore.isQrv2300()));
|
||||
preparedStatement.setInt(17, helper_booleanIntConverter(!chatMemberToStore.isQrv3400()));
|
||||
preparedStatement.setInt(18, helper_booleanIntConverter(!chatMemberToStore.isQrv5600()));
|
||||
preparedStatement.setInt(19, helper_booleanIntConverter(!chatMemberToStore.isQrv10G()));
|
||||
preparedStatement.setLong(20, resolvedLastFlagsChangeEpochMs);
|
||||
preparedStatement.setInt(13, helper_booleanIntConverter(chatMemberToStore.isWorked50()));
|
||||
preparedStatement.setInt(14, helper_booleanIntConverter(chatMemberToStore.isWorked70()));
|
||||
preparedStatement.setInt(15, helper_booleanIntConverter(!chatMemberToStore.isQrv144()));
|
||||
preparedStatement.setInt(16, helper_booleanIntConverter(!chatMemberToStore.isQrv432()));
|
||||
preparedStatement.setInt(17, helper_booleanIntConverter(!chatMemberToStore.isQrv1240()));
|
||||
preparedStatement.setInt(18, helper_booleanIntConverter(!chatMemberToStore.isQrv2300()));
|
||||
preparedStatement.setInt(19, helper_booleanIntConverter(!chatMemberToStore.isQrv3400()));
|
||||
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();
|
||||
} catch (SQLException e) {
|
||||
System.err.println("[DBH, ERROR:] Chatmember could not been stored.");
|
||||
@@ -646,8 +679,8 @@ public class DBController {
|
||||
|
||||
String resetAllWorkedDataSql =
|
||||
"UPDATE ChatMember SET "
|
||||
+ "worked = 0, worked144 = 0, worked432 = 0, worked1240 = 0, worked2300 = 0, worked3400 = 0, worked5600 = 0, worked10G = 0, "
|
||||
+ "notQRV144 = 0, notQRV432 = 0, notQRV1240 = 0, notQRV2300 = 0, notQRV3400 = 0, notQRV5600 = 0, notQRV10G = 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, notQRV50 = 0, notQRV70 = 0, "
|
||||
+ "lastFlagsChangeEpochMs = 0;";
|
||||
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
@@ -781,6 +814,8 @@ public class DBController {
|
||||
+ "notQRV3400 = ?, "
|
||||
+ "notQRV5600 = ?, "
|
||||
+ "notQRV10G = ?, "
|
||||
+ "notQRV50 = ?, "
|
||||
+ "notQRV70 = ?, "
|
||||
+ "lastFlagsChangeEpochMs = ? "
|
||||
+ "WHERE callsign = ?;";
|
||||
|
||||
@@ -792,8 +827,10 @@ public class DBController {
|
||||
preparedStatement.setInt(5, helper_booleanIntConverter(!chatMemberToStore.isQrv3400()));
|
||||
preparedStatement.setInt(6, helper_booleanIntConverter(!chatMemberToStore.isQrv5600()));
|
||||
preparedStatement.setInt(7, helper_booleanIntConverter(!chatMemberToStore.isQrv10G()));
|
||||
preparedStatement.setLong(8, System.currentTimeMillis());
|
||||
preparedStatement.setString(9, chatMemberToStore.getCallSignRaw());
|
||||
preparedStatement.setInt(8, helper_booleanIntConverter(!chatMemberToStore.isQrv50()));
|
||||
preparedStatement.setInt(9, helper_booleanIntConverter(!chatMemberToStore.isQrv70()));
|
||||
preparedStatement.setLong(10, System.currentTimeMillis());
|
||||
preparedStatement.setString(11, chatMemberToStore.getCallSignRaw());
|
||||
|
||||
int affectedRows = preparedStatement.executeUpdate();
|
||||
|
||||
@@ -821,9 +858,9 @@ public class DBController {
|
||||
|
||||
String upsertCompleteRowSql =
|
||||
"INSERT INTO ChatMember ("
|
||||
+ "callsign, qra, name, lastActivityDateTime, worked, worked144, worked432, worked1240, worked2300, worked3400, worked5600, worked10G, "
|
||||
+ "notQRV144, notQRV432, notQRV1240, notQRV2300, notQRV3400, notQRV5600, notQRV10G, lastFlagsChangeEpochMs"
|
||||
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
+ "callsign, qra, name, lastActivityDateTime, worked, worked144, worked432, worked1240, worked2300, worked3400, worked5600, worked10G, worked50, worked70, "
|
||||
+ "notQRV144, notQRV432, notQRV1240, notQRV2300, notQRV3400, notQRV5600, notQRV10G, notQRV50, notQRV70, lastFlagsChangeEpochMs"
|
||||
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
+ "ON CONFLICT(callsign) DO UPDATE SET "
|
||||
+ "qra = excluded.qra, "
|
||||
+ "name = excluded.name, "
|
||||
@@ -836,6 +873,8 @@ public class DBController {
|
||||
+ "worked3400 = excluded.worked3400, "
|
||||
+ "worked5600 = excluded.worked5600, "
|
||||
+ "worked10G = excluded.worked10G, "
|
||||
+ "worked50 = excluded.worked50, "
|
||||
+ "worked70 = excluded.worked70, "
|
||||
+ "notQRV144 = excluded.notQRV144, "
|
||||
+ "notQRV432 = excluded.notQRV432, "
|
||||
+ "notQRV1240 = excluded.notQRV1240, "
|
||||
@@ -843,6 +882,8 @@ public class DBController {
|
||||
+ "notQRV3400 = excluded.notQRV3400, "
|
||||
+ "notQRV5600 = excluded.notQRV5600, "
|
||||
+ "notQRV10G = excluded.notQRV10G, "
|
||||
+ "notQRV50 = excluded.notQRV50, "
|
||||
+ "notQRV70 = excluded.notQRV70, "
|
||||
+ "lastFlagsChangeEpochMs = excluded.lastFlagsChangeEpochMs;";
|
||||
|
||||
try (PreparedStatement preparedStatement = connection.prepareStatement(upsertCompleteRowSql)) {
|
||||
@@ -858,14 +899,18 @@ public class DBController {
|
||||
preparedStatement.setInt(10, helper_booleanIntConverter(chatMemberToStore.isWorked3400()));
|
||||
preparedStatement.setInt(11, helper_booleanIntConverter(chatMemberToStore.isWorked5600()));
|
||||
preparedStatement.setInt(12, helper_booleanIntConverter(chatMemberToStore.isWorked10G()));
|
||||
preparedStatement.setInt(13, helper_booleanIntConverter(!chatMemberToStore.isQrv144()));
|
||||
preparedStatement.setInt(14, helper_booleanIntConverter(!chatMemberToStore.isQrv432()));
|
||||
preparedStatement.setInt(15, helper_booleanIntConverter(!chatMemberToStore.isQrv1240()));
|
||||
preparedStatement.setInt(16, helper_booleanIntConverter(!chatMemberToStore.isQrv2300()));
|
||||
preparedStatement.setInt(17, helper_booleanIntConverter(!chatMemberToStore.isQrv3400()));
|
||||
preparedStatement.setInt(18, helper_booleanIntConverter(!chatMemberToStore.isQrv5600()));
|
||||
preparedStatement.setInt(19, helper_booleanIntConverter(!chatMemberToStore.isQrv10G()));
|
||||
preparedStatement.setLong(20, chatMemberToStore.getLastFlagsChangeEpochMs());
|
||||
preparedStatement.setInt(13, helper_booleanIntConverter(chatMemberToStore.isWorked50()));
|
||||
preparedStatement.setInt(14, helper_booleanIntConverter(chatMemberToStore.isWorked70()));
|
||||
preparedStatement.setInt(15, helper_booleanIntConverter(!chatMemberToStore.isQrv144()));
|
||||
preparedStatement.setInt(16, helper_booleanIntConverter(!chatMemberToStore.isQrv432()));
|
||||
preparedStatement.setInt(17, helper_booleanIntConverter(!chatMemberToStore.isQrv1240()));
|
||||
preparedStatement.setInt(18, helper_booleanIntConverter(!chatMemberToStore.isQrv2300()));
|
||||
preparedStatement.setInt(19, helper_booleanIntConverter(!chatMemberToStore.isQrv3400()));
|
||||
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();
|
||||
} catch (SQLException 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.setWorked5600(helper_IntToBooleanConverter(resultSet.getInt("worked5600")));
|
||||
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.setQrv432(!helper_IntToBooleanConverter(resultSet.getInt("notQRV432")));
|
||||
builtChatMember.setQrv1240(!helper_IntToBooleanConverter(resultSet.getInt("notQRV1240")));
|
||||
@@ -901,6 +948,8 @@ public class DBController {
|
||||
builtChatMember.setQrv3400(!helper_IntToBooleanConverter(resultSet.getInt("notQRV3400")));
|
||||
builtChatMember.setQrv5600(!helper_IntToBooleanConverter(resultSet.getInt("notQRV5600")));
|
||||
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"));
|
||||
|
||||
return builtChatMember;
|
||||
@@ -922,6 +971,8 @@ public class DBController {
|
||||
targetChatMember.setWorked3400(sourceChatMember.isWorked3400());
|
||||
targetChatMember.setWorked5600(sourceChatMember.isWorked5600());
|
||||
targetChatMember.setWorked10G(sourceChatMember.isWorked10G());
|
||||
targetChatMember.setWorked50(sourceChatMember.isWorked50());
|
||||
targetChatMember.setWorked70(sourceChatMember.isWorked70());
|
||||
targetChatMember.setQrv144(sourceChatMember.isQrv144());
|
||||
targetChatMember.setQrv432(sourceChatMember.isQrv432());
|
||||
targetChatMember.setQrv1240(sourceChatMember.isQrv1240());
|
||||
@@ -929,6 +980,8 @@ public class DBController {
|
||||
targetChatMember.setQrv3400(sourceChatMember.isQrv3400());
|
||||
targetChatMember.setQrv5600(sourceChatMember.isQrv5600());
|
||||
targetChatMember.setQrv10G(sourceChatMember.isQrv10G());
|
||||
targetChatMember.setQrv50(sourceChatMember.isQrv50());
|
||||
targetChatMember.setQrv70(sourceChatMember.isQrv70());
|
||||
targetChatMember.setLastFlagsChangeEpochMs(sourceChatMember.getLastFlagsChangeEpochMs());
|
||||
}
|
||||
|
||||
@@ -954,6 +1007,10 @@ public class DBController {
|
||||
return "worked5600";
|
||||
} else if (chatMemberToStore.isWorked10G()) {
|
||||
return "worked10G";
|
||||
} else if (chatMemberToStore.isWorked50()) {
|
||||
return "worked50";
|
||||
} else if (chatMemberToStore.isWorked70()) {
|
||||
return "worked70";
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -996,13 +1053,17 @@ public class DBController {
|
||||
|| chatMemberToStore.isWorked3400()
|
||||
|| chatMemberToStore.isWorked5600()
|
||||
|| chatMemberToStore.isWorked10G()
|
||||
|| chatMemberToStore.isWorked50()
|
||||
|| chatMemberToStore.isWorked70()
|
||||
|| !chatMemberToStore.isQrv144()
|
||||
|| !chatMemberToStore.isQrv432()
|
||||
|| !chatMemberToStore.isQrv1240()
|
||||
|| !chatMemberToStore.isQrv2300()
|
||||
|| !chatMemberToStore.isQrv3400()
|
||||
|| !chatMemberToStore.isQrv5600()
|
||||
|| !chatMemberToStore.isQrv10G();
|
||||
|| !chatMemberToStore.isQrv10G()
|
||||
|| !chatMemberToStore.isQrv50()
|
||||
|| !chatMemberToStore.isQrv70();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
package kst4contest.controller;
|
||||
import kst4contest.logic.BandOpportunityResolver;
|
||||
import kst4contest.view.map.MapCallsignRawSnapshot;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -168,14 +169,31 @@ public final class ReachabilityService {
|
||||
selectedSnapshot.lastKnownFrequenciesByBand()
|
||||
);
|
||||
|
||||
if (!Double.isFinite(analysisFrequencyMHz) || analysisFrequencyMHz <= 0.0) {
|
||||
Band fallbackBand = member == null ? Band.B_144 : resolveAutoBand(member);
|
||||
analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, fallbackBand);
|
||||
Band analysisBand = Band.fromFrequency(analysisFrequencyMHz);
|
||||
if (analysisBand != null && !isUsableAutomaticBand(member, analysisBand)) {
|
||||
analysisFrequencyMHz = Double.NaN;
|
||||
analysisBand = null;
|
||||
}
|
||||
|
||||
Band analysisBand = Band.fromFrequency(analysisFrequencyMHz);
|
||||
if (analysisBand == null) {
|
||||
analysisBand = member == null ? Band.B_144 : resolveAutoBand(member);
|
||||
if (!Double.isFinite(analysisFrequencyMHz)
|
||||
|| analysisFrequencyMHz <= 0.0
|
||||
|| analysisBand == null) {
|
||||
|
||||
Band fallbackBand = resolveAutoBand(member);
|
||||
if (fallbackBand == null) {
|
||||
dispatchFxCallback(
|
||||
fxCallback,
|
||||
PathAnalysisResult.waitingForUsableBand(
|
||||
ownLocator6,
|
||||
targetLocator6,
|
||||
selectedSnapshot.callSignRaw()
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
analysisBand = fallbackBand;
|
||||
analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, fallbackBand);
|
||||
}
|
||||
|
||||
PathAnalysisRequest request = buildRequest(
|
||||
@@ -230,20 +248,60 @@ public final class ReachabilityService {
|
||||
* @return resolved band
|
||||
*/
|
||||
public Band resolveAutoBand(ChatMember member) {
|
||||
if (member != null && member.getKnownActiveBands() != null && !member.getKnownActiveBands().isEmpty()) {
|
||||
return member.getKnownActiveBands().keySet().stream()
|
||||
.filter(Objects::nonNull)
|
||||
EnumSet<Band> enabledBands = getEnabledStationBands();
|
||||
if (enabledBands.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<ChatMember> variants = resolveCallsignVariants(member);
|
||||
BandOpportunityResolver.Resolution resolution =
|
||||
BandOpportunityResolver.resolve(variants, System.currentTimeMillis());
|
||||
|
||||
EnumSet<Band> availableOfferedBands = resolution.getAvailableBands();
|
||||
availableOfferedBands.retainAll(enabledBands);
|
||||
|
||||
if (!availableOfferedBands.isEmpty()) {
|
||||
return availableOfferedBands.stream()
|
||||
.min(Comparator.comparingDouble(Band::getDefaultAnalysisFrequencyMHz))
|
||||
.orElse(Band.B_144);
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
// Known evidence exists, but every matching band is disabled or NOT QRV.
|
||||
if (resolution.hasBandEvidence()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
EnumSet<Band> fallbackBands = EnumSet.copyOf(enabledBands);
|
||||
fallbackBands.removeAll(resolution.getNotQrvBands());
|
||||
if (fallbackBands.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (member != null
|
||||
&& member.getChatCategory() != null
|
||||
&& member.getChatCategory().getCategoryNumber() == ChatCategory.MICROWAVE) {
|
||||
&& member.getChatCategory().getCategoryNumber() == ChatCategory.MICROWAVE
|
||||
&& fallbackBands.contains(Band.B_1296)) {
|
||||
return Band.B_1296;
|
||||
}
|
||||
|
||||
return Band.B_144;
|
||||
if (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
|
||||
*/
|
||||
public EnumSet<Band> getEnabledStationBands() {
|
||||
ChatPreferences preferences = chatController.getChatPreferences();
|
||||
EnumSet<Band> enabledBands = EnumSet.noneOf(Band.class);
|
||||
return BandOpportunityResolver.getEnabledStationBands(
|
||||
chatController.getChatPreferences()
|
||||
);
|
||||
}
|
||||
|
||||
if (preferences == null) {
|
||||
return enabledBands;
|
||||
private List<ChatMember> resolveCallsignVariants(ChatMember member) {
|
||||
if (member == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
if (preferences.isStn_bandActive144()) enabledBands.add(Band.B_144);
|
||||
if (preferences.isStn_bandActive432()) enabledBands.add(Band.B_432);
|
||||
if (preferences.isStn_bandActive1240()) enabledBands.add(Band.B_1296);
|
||||
if (preferences.isStn_bandActive2300()) enabledBands.add(Band.B_2320);
|
||||
if (preferences.isStn_bandActive3400()) enabledBands.add(Band.B_3400);
|
||||
if (preferences.isStn_bandActive5600()) enabledBands.add(Band.B_5760);
|
||||
if (preferences.isStn_bandActive10G()) enabledBands.add(Band.B_10G);
|
||||
String rawCall = member.getCallSignRaw() != null
|
||||
? member.getCallSignRaw()
|
||||
: member.getCallSign();
|
||||
|
||||
return enabledBands;
|
||||
List<ChatMember> variants = chatController.findActiveChatMembersByRawCall(rawCall);
|
||||
return variants.isEmpty() ? List.of(member) : variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that an automatically selected map/snapshot frequency belongs to a
|
||||
* locally enabled band that is still available after NOT-QRV resolution.
|
||||
* Manual UI band overrides are handled separately and are not changed here.
|
||||
*/
|
||||
private boolean isUsableAutomaticBand(ChatMember member, Band band) {
|
||||
if (band == null || !getEnabledStationBands().contains(band)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (member == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
BandOpportunityResolver.Resolution resolution = BandOpportunityResolver.resolve(
|
||||
resolveCallsignVariants(member),
|
||||
System.currentTimeMillis()
|
||||
);
|
||||
|
||||
return resolution.getAvailableBands().contains(band);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -303,17 +303,19 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (bandId.trim()) {
|
||||
case "12": return Band.B_144;
|
||||
case "14": return Band.B_432;
|
||||
case "16": return Band.B_1296;
|
||||
case "17": return Band.B_2320;
|
||||
case "18": return Band.B_3400;
|
||||
case "19": return Band.B_5760;
|
||||
case "20": return Band.B_10G;
|
||||
case "21": return Band.B_24G;
|
||||
default: return null;
|
||||
}
|
||||
return switch (bandId.trim()) {
|
||||
case "10" -> Band.B_50;
|
||||
case "11" -> Band.B_70;
|
||||
case "12" -> Band.B_144;
|
||||
case "14" -> Band.B_432;
|
||||
case "16" -> Band.B_1296;
|
||||
case "17" -> Band.B_2320;
|
||||
case "18" -> Band.B_3400;
|
||||
case "19" -> Band.B_5760;
|
||||
case "20" -> Band.B_10G;
|
||||
case "21" -> Band.B_24G;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -144,6 +144,12 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
}
|
||||
|
||||
switch (band.trim()) {
|
||||
case "50":
|
||||
case "6m":
|
||||
return Band.B_50;
|
||||
case "70":
|
||||
case "4m":
|
||||
return Band.B_70;
|
||||
case "144":
|
||||
case "2m":
|
||||
return Band.B_144;
|
||||
@@ -357,6 +363,20 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
Band workedBand = helper_resolveBandFromLoggerBand(band);
|
||||
|
||||
switch (band) {
|
||||
case "50":
|
||||
case "6m":
|
||||
{
|
||||
workedCall.setWorked50(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "70":
|
||||
case "4m":
|
||||
{
|
||||
workedCall.setWorked70(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "144":
|
||||
case "2m": //minos contest logger
|
||||
{
|
||||
@@ -434,7 +454,13 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
|
||||
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);
|
||||
|
||||
} else if (workedCall.isWorked432()) {
|
||||
|
||||
@@ -140,7 +140,9 @@ public final class ScoreService {
|
||||
controller.getStationMetricsService().snapshot(nowEpochMs, prefs);
|
||||
|
||||
// 1) Choose one representative per callsignRaw
|
||||
Map<String, ChatMember> representativeByCallRaw = chooseRepresentativeMembers(members, lastInbound);
|
||||
Map<String, List<ChatMember>> variantsByCallRaw = groupMembersByCallRaw(members);
|
||||
Map<String, ChatMember> representativeByCallRaw =
|
||||
chooseRepresentativeMembers(variantsByCallRaw, lastInbound);
|
||||
|
||||
// 2) Compute score once per callsignRaw
|
||||
Map<String, Double> scoreByCallRaw = new HashMap<>(representativeByCallRaw.size());
|
||||
@@ -154,6 +156,7 @@ public final class ScoreService {
|
||||
|
||||
double score = priorityCalculator.calculatePriority(
|
||||
representative,
|
||||
variantsByCallRaw.getOrDefault(callRaw, List.of(representative)),
|
||||
prefs,
|
||||
activeSkeds,
|
||||
metricsSnapshot,
|
||||
@@ -189,6 +192,21 @@ public final class ScoreService {
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, List<ChatMember>> groupMembersByCallRaw(List<ChatMember> members) {
|
||||
Map<String, List<ChatMember>> byCallRaw = new HashMap<>();
|
||||
|
||||
for (ChatMember member : members) {
|
||||
if (member == null) continue;
|
||||
|
||||
String callRaw = normalizeCallRaw(member.getCallSignRaw());
|
||||
if (callRaw == null || callRaw.isEmpty()) continue;
|
||||
|
||||
byCallRaw.computeIfAbsent(callRaw, ignored -> new ArrayList<>()).add(member);
|
||||
}
|
||||
|
||||
return byCallRaw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks one ChatMember object per callsignRaw.
|
||||
* Preference order:
|
||||
@@ -196,18 +214,9 @@ public final class ScoreService {
|
||||
* 2) Most recently active variant (fallback)
|
||||
*/
|
||||
private Map<String, ChatMember> chooseRepresentativeMembers(
|
||||
List<ChatMember> members,
|
||||
Map<String, List<ChatMember>> byCallRaw,
|
||||
Map<String, ChatCategory> lastInboundCategoryByCallRaw
|
||||
) {
|
||||
Map<String, List<ChatMember>> byCallRaw = new HashMap<>();
|
||||
|
||||
for (ChatMember m : members) {
|
||||
if (m == null) continue;
|
||||
String callRaw = normalizeCallRaw(m.getCallSignRaw());
|
||||
if (callRaw == null || callRaw.isEmpty()) continue;
|
||||
byCallRaw.computeIfAbsent(callRaw, k -> new ArrayList<>()).add(m);
|
||||
}
|
||||
|
||||
Map<String, ChatMember> representative = new HashMap<>(byCallRaw.size());
|
||||
|
||||
for (Map.Entry<String, List<ChatMember>> entry : byCallRaw.entrySet()) {
|
||||
|
||||
@@ -155,6 +155,8 @@ public class WinTestSkedSender {
|
||||
public static int toWinTestBandId(Band band) {
|
||||
if (band == null) return 12; // default to 144 MHz
|
||||
return switch (band) {
|
||||
case B_50 -> 10;
|
||||
case B_70 -> 11;
|
||||
case B_144 -> 12;
|
||||
case B_432 -> 14;
|
||||
case B_1296 -> 16;
|
||||
|
||||
@@ -98,6 +98,8 @@ public final class WorkedGrossFieldCache {
|
||||
if (member.isWorked3400()) addWorked(Band.B_3400, locator);
|
||||
if (member.isWorked5600()) addWorked(Band.B_5760, 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.model.*;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Priority score calculation (off FX-thread).
|
||||
@@ -16,10 +16,8 @@ import java.util.Map;
|
||||
*/
|
||||
public class PriorityCalculator {
|
||||
|
||||
/** Max age for "known active bands" (derived from chat history). */
|
||||
private static final long RX_BANDS_MAX_AGE_MS = 30L * 60L * 1000L; // 30 minutes
|
||||
|
||||
public double calculatePriority(ChatMember member,
|
||||
Collection<ChatMember> callsignVariants,
|
||||
ChatPreferences prefs,
|
||||
List<ContestSked> activeSkeds,
|
||||
StationMetricsService.Snapshot metricsSnapshot,
|
||||
@@ -33,41 +31,51 @@ public class PriorityCalculator {
|
||||
// --------------------------------------------------------------------
|
||||
// 1) HARD FILTER: reachable hardware + "already worked on all possible bands"
|
||||
// --------------------------------------------------------------------
|
||||
// --------------------------------------------------------------------
|
||||
// 1) HARD FILTER: reachable hardware + "already worked on all possible bands"
|
||||
// --------------------------------------------------------------------
|
||||
EnumSet<Band> myEnabledBands = getMyEnabledBands(prefs);
|
||||
Collection<ChatMember> variants = callsignVariants == null || callsignVariants.isEmpty()
|
||||
? List.of(member)
|
||||
: callsignVariants;
|
||||
|
||||
// "worked" for scoring is derived ONLY from per-band flags (worked144/432/...)
|
||||
// IMPORTANT: ChatMember.worked is UI-only and NOT used in scoring.
|
||||
EnumSet<Band> workedBandsForScoring = getWorkedBands(member);
|
||||
BandOpportunityResolver.Resolution bandResolution =
|
||||
BandOpportunityResolver.resolve(variants, nowEpochMs);
|
||||
|
||||
EnumSet<Band> myEnabledBands =
|
||||
BandOpportunityResolver.getEnabledStationBands(prefs);
|
||||
|
||||
// ChatMember.worked remains UI-only. Scoring uses only per-band worked flags.
|
||||
EnumSet<Band> workedBandsForScoring = bandResolution.getWorkedBands();
|
||||
|
||||
// Remaining bands that are:
|
||||
// - recently offered by the station (from knownActiveBands history)
|
||||
// - enabled at our station
|
||||
// - NOT worked yet (per-band flags)
|
||||
// If we do not know offered bands (history empty), this remains empty.
|
||||
EnumSet<Band> unworkedPossible = EnumSet.noneOf(Band.class);
|
||||
|
||||
EnumSet<Band> stationOfferedBands = getStationOfferedBandsFromHistory(member, nowEpochMs);
|
||||
EnumSet<Band> stationOfferedBands = bandResolution.getOfferedBands();
|
||||
EnumSet<Band> stationAvailableBands = bandResolution.getAvailableBands();
|
||||
EnumSet<Band> possibleBands = stationOfferedBands.isEmpty()
|
||||
? EnumSet.noneOf(Band.class) // unknown => don't hard-filter
|
||||
: EnumSet.copyOf(stationOfferedBands);
|
||||
? EnumSet.noneOf(Band.class)
|
||||
: EnumSet.copyOf(stationAvailableBands);
|
||||
|
||||
if (!possibleBands.isEmpty()) {
|
||||
if (!stationOfferedBands.isEmpty()) {
|
||||
possibleBands.retainAll(myEnabledBands);
|
||||
if (possibleBands.isEmpty()) {
|
||||
// We know their bands, but none of them are enabled at our station.
|
||||
// Known bands are disabled locally or manually marked NOT QRV.
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
unworkedPossible = EnumSet.copyOf(possibleBands);
|
||||
unworkedPossible.removeAll(workedBandsForScoring);
|
||||
|
||||
// If already worked on all possible bands => no priority on them anymore (contest logic).
|
||||
if (unworkedPossible.isEmpty()) {
|
||||
return 0.0;
|
||||
}
|
||||
} else {
|
||||
/*
|
||||
* Missing band evidence is not automatically negative. A complete manual
|
||||
* NOT-QRV exclusion is different: if every enabled own band is excluded,
|
||||
* this station cannot be a current contest candidate.
|
||||
*/
|
||||
EnumSet<Band> notExplicitlyExcluded = EnumSet.copyOf(myEnabledBands);
|
||||
notExplicitlyExcluded.removeAll(bandResolution.getNotQrvBands());
|
||||
if (!myEnabledBands.isEmpty() && notExplicitlyExcluded.isEmpty()) {
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
@@ -225,46 +233,6 @@ public class PriorityCalculator {
|
||||
return Math.max(0.0, score);
|
||||
}
|
||||
|
||||
private static EnumSet<Band> getMyEnabledBands(ChatPreferences prefs) {
|
||||
EnumSet<Band> out = EnumSet.noneOf(Band.class);
|
||||
if (prefs.isStn_bandActive144()) out.add(Band.B_144);
|
||||
if (prefs.isStn_bandActive432()) out.add(Band.B_432);
|
||||
if (prefs.isStn_bandActive1240()) out.add(Band.B_1296);
|
||||
if (prefs.isStn_bandActive2300()) out.add(Band.B_2320);
|
||||
if (prefs.isStn_bandActive3400()) out.add(Band.B_3400);
|
||||
if (prefs.isStn_bandActive5600()) out.add(Band.B_5760);
|
||||
if (prefs.isStn_bandActive10G()) out.add(Band.B_10G);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static EnumSet<Band> getStationOfferedBandsFromHistory(ChatMember member, long nowEpochMs) {
|
||||
EnumSet<Band> out = EnumSet.noneOf(Band.class);
|
||||
Map<Band, ChatMember.ActiveFrequencyInfo> map = member.getKnownActiveBands();
|
||||
if (map == null || map.isEmpty()) return out;
|
||||
|
||||
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> e : map.entrySet()) {
|
||||
if (e == null || e.getKey() == null || e.getValue() == null) continue;
|
||||
long age = nowEpochMs - e.getValue().timestampEpoch;
|
||||
if (age <= RX_BANDS_MAX_AGE_MS) {
|
||||
out.add(e.getKey());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static EnumSet<Band> getWorkedBands(ChatMember member) {
|
||||
EnumSet<Band> out = EnumSet.noneOf(Band.class);
|
||||
if (member.isWorked144()) out.add(Band.B_144);
|
||||
if (member.isWorked432()) out.add(Band.B_432);
|
||||
if (member.isWorked1240()) out.add(Band.B_1296);
|
||||
if (member.isWorked2300()) out.add(Band.B_2320);
|
||||
if (member.isWorked3400()) out.add(Band.B_3400);
|
||||
if (member.isWorked5600()) out.add(Band.B_5760);
|
||||
if (member.isWorked10G()) out.add(Band.B_10G);
|
||||
if (member.isWorked24G()) out.add(Band.B_24G);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int findNextAirplaneArrivingMinutes(AirPlaneReflectionInfo apInfo) {
|
||||
try {
|
||||
if (apInfo.getRisingAirplanes() == null || apInfo.getRisingAirplanes().isEmpty()) return -1;
|
||||
|
||||
@@ -5,6 +5,8 @@ package kst4contest.model;
|
||||
* Used for plausibility checks in the Smart Parser.
|
||||
*/
|
||||
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_432(432.000, 434.000, "432"),
|
||||
B_1296(1296.000, 1298.000, "1296"),
|
||||
@@ -75,6 +77,8 @@ public enum Band {
|
||||
*/
|
||||
public String getDisplayLabel() {
|
||||
switch (this) {
|
||||
case B_50: return "50";
|
||||
case B_70: return "70";
|
||||
case B_144: return "144";
|
||||
case B_432: return "432";
|
||||
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
|
||||
*/
|
||||
boolean qrv50 = true;
|
||||
boolean qrv70 = true;
|
||||
boolean qrv144 = true;
|
||||
boolean qrv432 = true;
|
||||
boolean qrv1240 = true;
|
||||
@@ -228,6 +230,22 @@ public class ChatMember {
|
||||
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() {
|
||||
return qrv144;
|
||||
}
|
||||
@@ -607,6 +625,8 @@ public class ChatMember {
|
||||
public void resetQRVInformationAtAllBands() {
|
||||
|
||||
this.setQrvAny(true);
|
||||
this.setQrv50(true);
|
||||
this.setQrv70(true);
|
||||
this.setQrv144(true);
|
||||
this.setQrv432(true);
|
||||
this.setQrv1240(true);
|
||||
|
||||
@@ -197,6 +197,8 @@ public class ChatPreferences {
|
||||
boolean loginToSecondChatEnabled;
|
||||
DoubleProperty actualQTF = new SimpleDoubleProperty(360); // will be updated by user at runtime!
|
||||
|
||||
boolean stn_bandActive50;
|
||||
boolean stn_bandActive70;
|
||||
boolean stn_bandActive144;
|
||||
boolean stn_bandActive432;
|
||||
boolean stn_bandActive1240;
|
||||
@@ -332,6 +334,8 @@ public class ChatPreferences {
|
||||
boolean guiOptions_defaultFilterPmToMe;
|
||||
boolean guiOptions_defaultFilterPmToOther;
|
||||
boolean guiOptions_defaultFilterPublicMsgs;
|
||||
boolean guiOptions_showGrossFieldWorkedHintInBandColumns = true; // show "o" (grid square already worked on this band) in the band columns
|
||||
boolean guiOptions_showFreshCallHintInBandColumns = true; // show "a" (band available, call not worked on any band yet) instead of always "B+" in the band columns
|
||||
|
||||
private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 };
|
||||
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
|
||||
@@ -647,6 +651,22 @@ public class ChatPreferences {
|
||||
this.guiOptions_defaultFilterPmToMe = guiOptions_defaultFilterPmToMe;
|
||||
}
|
||||
|
||||
public boolean isGuiOptions_showGrossFieldWorkedHintInBandColumns() {
|
||||
return guiOptions_showGrossFieldWorkedHintInBandColumns;
|
||||
}
|
||||
|
||||
public void setGuiOptions_showGrossFieldWorkedHintInBandColumns(boolean guiOptions_showGrossFieldWorkedHintInBandColumns) {
|
||||
this.guiOptions_showGrossFieldWorkedHintInBandColumns = guiOptions_showGrossFieldWorkedHintInBandColumns;
|
||||
}
|
||||
|
||||
public boolean isGuiOptions_showFreshCallHintInBandColumns() {
|
||||
return guiOptions_showFreshCallHintInBandColumns;
|
||||
}
|
||||
|
||||
public void setGuiOptions_showFreshCallHintInBandColumns(boolean guiOptions_showFreshCallHintInBandColumns) {
|
||||
this.guiOptions_showFreshCallHintInBandColumns = guiOptions_showFreshCallHintInBandColumns;
|
||||
}
|
||||
|
||||
public boolean isGuiOptions_defaultFilterPmToOther() {
|
||||
return guiOptions_defaultFilterPmToOther;
|
||||
}
|
||||
@@ -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");
|
||||
stn_bandActive144.setTextContent(this.stn_bandActive144+"");
|
||||
station.appendChild(stn_bandActive144);
|
||||
@@ -1935,6 +1963,14 @@ public class ChatPreferences {
|
||||
guiOptions_defaultFilterPublicMsgs.setTextContent(this.isGuiOptions_defaultFilterPublicMsgs()+"");
|
||||
guiSaveableOptions.appendChild(guiOptions_defaultFilterPublicMsgs);
|
||||
|
||||
Element guiOptions_showGrossFieldWorkedHintInBandColumns = doc.createElement("guiOptions_showGrossFieldWorkedHintInBandColumns");
|
||||
guiOptions_showGrossFieldWorkedHintInBandColumns.setTextContent(this.isGuiOptions_showGrossFieldWorkedHintInBandColumns()+"");
|
||||
guiSaveableOptions.appendChild(guiOptions_showGrossFieldWorkedHintInBandColumns);
|
||||
|
||||
Element guiOptions_showFreshCallHintInBandColumns = doc.createElement("guiOptions_showFreshCallHintInBandColumns");
|
||||
guiOptions_showFreshCallHintInBandColumns.setTextContent(this.isGuiOptions_showFreshCallHintInBandColumns()+"");
|
||||
guiSaveableOptions.appendChild(guiOptions_showFreshCallHintInBandColumns);
|
||||
|
||||
Element guiOptions_darkModeActive = doc.createElement("guiOptions_darkModeActive");
|
||||
guiOptions_darkModeActive.setTextContent(this.GUI_darkModeActive + "");
|
||||
guiSaveableOptions.appendChild(guiOptions_darkModeActive);
|
||||
@@ -2213,6 +2249,8 @@ public class ChatPreferences {
|
||||
);
|
||||
|
||||
// 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_bandActive432 = getBoolean(stationEl, stn_bandActive432, "stn_bandActive432");
|
||||
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_defaultFilterPmToOther(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_defaultFilterPmToOther(), "guiOptions_defaultFilterPmToOther"));
|
||||
this.setGuiOptions_defaultFilterPublicMsgs(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_defaultFilterPublicMsgs(), "guiOptions_defaultFilterPublicMsgs"));
|
||||
this.setGuiOptions_showGrossFieldWorkedHintInBandColumns(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_showGrossFieldWorkedHintInBandColumns(), "guiOptions_showGrossFieldWorkedHintInBandColumns"));
|
||||
this.setGuiOptions_showFreshCallHintInBandColumns(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_showFreshCallHintInBandColumns(), "guiOptions_showFreshCallHintInBandColumns"));
|
||||
|
||||
// Added in later versions: dark mode flags
|
||||
this.GUI_darkModeActive = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActive, "guiOptions_darkModeActive");
|
||||
@@ -2868,6 +2908,22 @@ public class ChatPreferences {
|
||||
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() {
|
||||
return stn_bandActive144;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import kst4contest.locatorUtils.Location;
|
||||
import kst4contest.logic.BandOpportunityResolver;
|
||||
import kst4contest.model.AirPlaneReflectionInfo;
|
||||
import kst4contest.model.Band;
|
||||
import kst4contest.model.ChatMember;
|
||||
@@ -14,7 +15,6 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Builds immutable map snapshots from the currently visible chat members.
|
||||
@@ -24,8 +24,6 @@ import java.util.regex.Pattern;
|
||||
*/
|
||||
public final class MapCallsignRawSnapshotBuilder {
|
||||
|
||||
private static final Pattern TOKEN_SPLIT_PATTERN = Pattern.compile("[^A-Z0-9]+");
|
||||
|
||||
public List<MapCallsignRawSnapshot> buildSnapshots(Collection<ChatMember> visibleChatMembers,
|
||||
ChatMember selectedChatMember,
|
||||
EnumSet<Band> selectedBands) {
|
||||
@@ -73,10 +71,21 @@ public final class MapCallsignRawSnapshotBuilder {
|
||||
|
||||
Location location = new Location(locator6);
|
||||
|
||||
LinkedHashMap<String, String> frequenciesByBand = collectLastKnownFrequenciesByBand(variants);
|
||||
EnumSet<Band> sureBands = collectSureBands(variants, frequenciesByBand);
|
||||
String bandSummary = buildBandSummary(sureBands);
|
||||
boolean offersSelectedBand = hasAnySelectedBand(sureBands, selectedBands);
|
||||
long nowEpochMs = System.currentTimeMillis();
|
||||
BandOpportunityResolver.Resolution bandResolution =
|
||||
BandOpportunityResolver.resolve(variants, nowEpochMs);
|
||||
|
||||
EnumSet<Band> availableBands = bandResolution.getAvailableBands();
|
||||
LinkedHashMap<String, String> frequenciesByBand = collectLastKnownFrequenciesByBand(
|
||||
variants,
|
||||
availableBands,
|
||||
nowEpochMs
|
||||
);
|
||||
|
||||
String bandSummary = buildBandSummary(availableBands);
|
||||
boolean offersSelectedBand = !bandResolution
|
||||
.getUnworkedEnabledBands(selectedBands)
|
||||
.isEmpty();
|
||||
|
||||
boolean warningToMyDirection = variants.stream().anyMatch(ChatMember::isInAngleAndRange);
|
||||
boolean worked = variants.stream().anyMatch(this::isWorkedAtAnyBand);
|
||||
@@ -152,7 +161,11 @@ public final class MapCallsignRawSnapshotBuilder {
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, String> collectLastKnownFrequenciesByBand(List<ChatMember> variants) {
|
||||
private LinkedHashMap<String, String> collectLastKnownFrequenciesByBand(
|
||||
List<ChatMember> variants,
|
||||
EnumSet<Band> availableBands,
|
||||
long nowEpochMs
|
||||
) {
|
||||
|
||||
Map<Band, FrequencyCandidate> latestByBand = new EnumMap<>(Band.class);
|
||||
|
||||
@@ -161,16 +174,29 @@ public final class MapCallsignRawSnapshotBuilder {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> bandEntry : variant.getKnownActiveBands().entrySet()) {
|
||||
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> bandEntry
|
||||
: variant.getKnownActiveBands().entrySet()) {
|
||||
|
||||
Band band = bandEntry.getKey();
|
||||
ChatMember.ActiveFrequencyInfo activeFrequencyInfo = bandEntry.getValue();
|
||||
|
||||
if (band == null || activeFrequencyInfo == null) {
|
||||
if (band == null
|
||||
|| activeFrequencyInfo == null
|
||||
|| availableBands == null
|
||||
|| !availableBands.contains(band)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
long ageMs = nowEpochMs - activeFrequencyInfo.timestampEpoch;
|
||||
if (ageMs < 0L
|
||||
|| ageMs > BandOpportunityResolver.RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
FrequencyCandidate previous = latestByBand.get(band);
|
||||
if (previous == null || activeFrequencyInfo.timestampEpoch > previous.timestampEpochMs()) {
|
||||
if (previous == null
|
||||
|| activeFrequencyInfo.timestampEpoch > previous.timestampEpochMs()) {
|
||||
|
||||
latestByBand.put(band, new FrequencyCandidate(
|
||||
band,
|
||||
formatFrequency(activeFrequencyInfo.frequency),
|
||||
@@ -178,152 +204,31 @@ public final class MapCallsignRawSnapshotBuilder {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
addFallbackCurrentFrequencyIfUseful(variant, latestByBand);
|
||||
}
|
||||
|
||||
LinkedHashMap<String, String> ordered = new LinkedHashMap<>();
|
||||
latestByBand.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.forEach(entry -> ordered.put(toBandDisplayLabel(entry.getKey()), entry.getValue().formattedFrequency()));
|
||||
.forEach(entry -> ordered.put(
|
||||
toBandDisplayLabel(entry.getKey()),
|
||||
entry.getValue().formattedFrequency()
|
||||
));
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private EnumSet<Band> collectSureBands(List<ChatMember> variants,
|
||||
LinkedHashMap<String, String> frequenciesByBand) {
|
||||
EnumSet<Band> sureBands = EnumSet.noneOf(Band.class);
|
||||
|
||||
if (frequenciesByBand != null) {
|
||||
for (String label : frequenciesByBand.keySet()) {
|
||||
Band mappedBand = bandFromDisplayLabel(label);
|
||||
if (mappedBand != null) {
|
||||
sureBands.add(mappedBand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (ChatMember variant : variants) {
|
||||
if (variant == null || variant.getName() == null || variant.getName().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
sureBands.addAll(detectBandsFromStationName(variant.getName()));
|
||||
}
|
||||
|
||||
return sureBands;
|
||||
}
|
||||
|
||||
private EnumSet<Band> detectBandsFromStationName(String stationName) {
|
||||
EnumSet<Band> detectedBands = EnumSet.noneOf(Band.class);
|
||||
if (stationName == null || stationName.isBlank()) {
|
||||
return detectedBands;
|
||||
}
|
||||
|
||||
String normalized = stationName.toUpperCase(Locale.ROOT);
|
||||
String[] tokens = TOKEN_SPLIT_PATTERN.split(normalized);
|
||||
for (String token : tokens) {
|
||||
if (token == null || token.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (token) {
|
||||
case "2", "2M", "144", "144MHZ" -> detectedBands.add(Band.B_144);
|
||||
case "70", "70CM", "432", "432MHZ" -> detectedBands.add(Band.B_432);
|
||||
case "23", "23CM", "1296", "1296MHZ" -> detectedBands.add(Band.B_1296);
|
||||
case "13", "13CM", "2300", "2320", "2320MHZ" -> detectedBands.add(Band.B_2320);
|
||||
case "9", "9CM", "3400", "3400MHZ" -> detectedBands.add(Band.B_3400);
|
||||
case "6", "6CM", "5600", "5760", "5760MHZ" -> detectedBands.add(Band.B_5760);
|
||||
case "3", "3CM", "10G", "10GHZ", "10368", "10368MHZ" -> detectedBands.add(Band.B_10G);
|
||||
case "24G", "24GHZ", "24048", "24048MHZ" -> detectedBands.add(Band.B_24G);
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return detectedBands;
|
||||
}
|
||||
|
||||
private String buildBandSummary(EnumSet<Band> sureBands) {
|
||||
if (sureBands == null || sureBands.isEmpty()) {
|
||||
private String buildBandSummary(EnumSet<Band> availableBands) {
|
||||
if (availableBands == null || availableBands.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
List<String> labels = sureBands.stream()
|
||||
List<String> labels = availableBands.stream()
|
||||
.sorted()
|
||||
.map(this::toBandDisplayLabel)
|
||||
.toList();
|
||||
return String.join(", ", labels);
|
||||
}
|
||||
|
||||
private boolean hasAnySelectedBand(EnumSet<Band> sureBands, EnumSet<Band> selectedBands) {
|
||||
if (sureBands == null || sureBands.isEmpty() || selectedBands == null || selectedBands.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (Band band : selectedBands) {
|
||||
if (sureBands.contains(band)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Band bandFromDisplayLabel(String label) {
|
||||
if (label == null || label.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (label) {
|
||||
case "144" -> Band.B_144;
|
||||
case "432" -> Band.B_432;
|
||||
case "1296" -> Band.B_1296;
|
||||
case "2320" -> Band.B_2320;
|
||||
case "3400" -> Band.B_3400;
|
||||
case "5760" -> Band.B_5760;
|
||||
case "10368" -> Band.B_10G;
|
||||
case "24048" -> Band.B_24G;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for stations where the current displayed QRG exists but the
|
||||
* knownActiveBands history has not yet been filled.
|
||||
*
|
||||
* This parsing is intentionally tolerant so strings like "144.300 MHz"
|
||||
* can still be used.
|
||||
*/
|
||||
private void addFallbackCurrentFrequencyIfUseful(ChatMember variant, Map<Band, FrequencyCandidate> latestByBand) {
|
||||
if (variant == null || variant.getFrequency() == null || variant.getFrequency().getValue() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String rawFrequency = variant.getFrequency().getValue().trim();
|
||||
if (rawFrequency.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(rawFrequency);
|
||||
if (!Double.isFinite(parsedFrequencyMHz) || parsedFrequencyMHz <= 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Band detectedBand = Band.fromFrequency(parsedFrequencyMHz);
|
||||
if (detectedBand == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
FrequencyCandidate previous = latestByBand.get(detectedBand);
|
||||
if (previous != null && previous.timestampEpochMs() >= variant.getActivityTimeLastInEpoch()) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestByBand.put(detectedBand, new FrequencyCandidate(
|
||||
detectedBand,
|
||||
formatFrequency(parsedFrequencyMHz),
|
||||
variant.getActivityTimeLastInEpoch()
|
||||
));
|
||||
}
|
||||
|
||||
private boolean isWorkedAtAnyBand(ChatMember member) {
|
||||
return member.isWorked()
|
||||
|| member.isWorked50()
|
||||
@@ -370,6 +275,8 @@ public final class MapCallsignRawSnapshotBuilder {
|
||||
|
||||
private String toBandDisplayLabel(Band band) {
|
||||
return switch (band) {
|
||||
case B_50 -> "50";
|
||||
case B_70 -> "70";
|
||||
case B_144 -> "144";
|
||||
case B_432 -> "432";
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -343,6 +343,8 @@ public final class StationMapBridge {
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.3f", analysisFrequencyMHz)
|
||||
+ "|"
|
||||
+ selectedSnapshot.bandSummary()
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnAntennaHeightMeters())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetAntennaHeightMeters())
|
||||
|
||||
@@ -0,0 +1,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());
|
||||
}
|
||||
|
||||
@Test
|
||||
void notQrvOverridesNameDerivedMapOpportunity() {
|
||||
ChatMember station = buildStation("DL1ABC", "QRV 2m 70cm", "JN58TD", 1_000L);
|
||||
station.setQrv432(false);
|
||||
|
||||
MapCallsignRawSnapshotBuilder builder = new MapCallsignRawSnapshotBuilder();
|
||||
MapCallsignRawSnapshot snapshot = builder.buildSnapshots(
|
||||
List.of(station),
|
||||
null,
|
||||
EnumSet.of(Band.B_432)
|
||||
).get(0);
|
||||
|
||||
assertFalse(snapshot.offersSelectedBand());
|
||||
assertFalse(snapshot.bandSummary().contains("432"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void workedBandIsShownAsInformationButNotAsUpgradeOpportunity() {
|
||||
ChatMember station = buildStation("DL1ABC", "QRV 2m", "JN58TD", 1_000L);
|
||||
station.setWorked144(true);
|
||||
|
||||
MapCallsignRawSnapshotBuilder builder = new MapCallsignRawSnapshotBuilder();
|
||||
MapCallsignRawSnapshot snapshot = builder.buildSnapshots(
|
||||
List.of(station),
|
||||
null,
|
||||
EnumSet.of(Band.B_144)
|
||||
).get(0);
|
||||
|
||||
assertTrue(snapshot.bandSummary().contains("144"));
|
||||
assertFalse(snapshot.offersSelectedBand());
|
||||
}
|
||||
|
||||
private ChatMember buildStation(String callSign, String name, String locator, long activityEpoch) {
|
||||
ChatMember chatMember = new ChatMember();
|
||||
chatMember.setCallSign(callSign);
|
||||
|
||||
@@ -2371,3 +2371,12 @@ DO5SA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd43
|
||||
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
|
||||
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
|
||||
@@ -12,6 +12,13 @@ const RELEASES_URL = `https://github.com/${REPO}/releases`;
|
||||
* prerelease flag, but that flag is exactly what this workflow sets, and
|
||||
* nothing else in this repository publishes prereleases, so it is a safe
|
||||
* 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() {
|
||||
const headers = { Accept: "application/vnd.github+json" };
|
||||
@@ -29,7 +36,18 @@ async function fetchLatestBetaRelease() {
|
||||
|
||||
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) {
|
||||
console.warn(
|
||||
`[beta] Could not load the latest beta release. ` +
|
||||
|
||||
Reference in New Issue
Block a user