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

Issue #70, #66, #65

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

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

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

Assisted by Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 21:00:23 +02:00
parent 35f790f06b
commit fdec5220d1
13 changed files with 962 additions and 523 deletions
@@ -0,0 +1,253 @@
package kst4contest.logic;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Resolves band availability and band-upgrade opportunities from one or more
* active {@link ChatMember} variants of the same base callsign.
*
* <p>The resolver deliberately separates a band hint from an exact frequency:
* {@code knownActiveBands} remains the source for detected QRGs with timestamps,
* while the station name may add a band without inventing a frequency.</p>
*
* <p>A manual NOT-QRV flag always overrides automatic evidence. Worked flags are
* evaluated separately because an offered band may still be useful for display,
* even when it is no longer a band-upgrade opportunity.</p>
*/
public final class BandOpportunityResolver {
public static final long RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS = 30L * 60L * 1000L;
private static final Map<Band, Pattern> STATION_NAME_BAND_PATTERNS = createStationNameBandPatterns();
private BandOpportunityResolver() {
}
/**
* Resolves the common band state using the application-wide 30-minute window
* for frequency evidence. Name-derived band hints remain valid while the
* ChatMember is present in the active chat model.
*/
public static Resolution resolve(Collection<ChatMember> variants, long nowEpochMs) {
return resolve(variants, nowEpochMs, RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS);
}
/**
* Resolves offered, worked and manually excluded bands across all supplied
* category/callsign variants.
*/
public static Resolution resolve(Collection<ChatMember> variants,
long nowEpochMs,
long dynamicEvidenceMaxAgeMs) {
EnumSet<Band> offeredBands = EnumSet.noneOf(Band.class);
EnumSet<Band> workedBands = EnumSet.noneOf(Band.class);
EnumSet<Band> notQrvBands = EnumSet.noneOf(Band.class);
if (variants == null) {
return new Resolution(offeredBands, workedBands, notQrvBands);
}
for (ChatMember member : variants) {
if (member == null) {
continue;
}
collectRecentFrequencyBands(
member,
offeredBands,
nowEpochMs,
dynamicEvidenceMaxAgeMs
);
offeredBands.addAll(detectBandsFromStationName(member.getName()));
collectWorkedBands(member, workedBands);
collectNotQrvBands(member, notQrvBands);
}
return new Resolution(offeredBands, workedBands, notQrvBands);
}
/**
* Returns the bands enabled in the local station setup. Bands above 10 GHz
* remain excluded because the current preferences do not provide active-band
* flags for them.
*/
public static EnumSet<Band> getEnabledStationBands(ChatPreferences preferences) {
EnumSet<Band> enabledBands = EnumSet.noneOf(Band.class);
if (preferences == null) {
return enabledBands;
}
if (preferences.isStn_bandActive144()) enabledBands.add(Band.B_144);
if (preferences.isStn_bandActive432()) enabledBands.add(Band.B_432);
if (preferences.isStn_bandActive1240()) enabledBands.add(Band.B_1296);
if (preferences.isStn_bandActive2300()) enabledBands.add(Band.B_2320);
if (preferences.isStn_bandActive3400()) enabledBands.add(Band.B_3400);
if (preferences.isStn_bandActive5600()) enabledBands.add(Band.B_5760);
if (preferences.isStn_bandActive10G()) enabledBands.add(Band.B_10G);
return enabledBands;
}
/**
* Detects explicit amateur-band designators in a station name.
*
* <p>The boundary rules are intentionally stricter than simple token splitting.
* In particular, the trailing {@code 2} in {@code 1.2 cm} must not be mistaken
* for the 2 m band.</p>
*/
public static EnumSet<Band> detectBandsFromStationName(String stationName) {
EnumSet<Band> detectedBands = EnumSet.noneOf(Band.class);
if (stationName == null || stationName.isBlank()) {
return detectedBands;
}
for (Map.Entry<Band, Pattern> entry : STATION_NAME_BAND_PATTERNS.entrySet()) {
if (entry.getValue().matcher(stationName).find()) {
detectedBands.add(entry.getKey());
}
}
return detectedBands;
}
private static void collectRecentFrequencyBands(ChatMember member,
EnumSet<Band> target,
long nowEpochMs,
long dynamicEvidenceMaxAgeMs) {
if (member.getKnownActiveBands() == null || member.getKnownActiveBands().isEmpty()) {
return;
}
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> entry
: member.getKnownActiveBands().entrySet()) {
Band band = entry.getKey();
ChatMember.ActiveFrequencyInfo info = entry.getValue();
if (band == null || info == null) {
continue;
}
long ageMs = nowEpochMs - info.timestampEpoch;
boolean ageAccepted = dynamicEvidenceMaxAgeMs <= 0L
? ageMs >= 0L
: ageMs >= 0L && ageMs <= dynamicEvidenceMaxAgeMs;
if (ageAccepted) {
target.add(band);
}
}
}
private static void collectWorkedBands(ChatMember member, EnumSet<Band> target) {
if (member.isWorked144()) target.add(Band.B_144);
if (member.isWorked432()) target.add(Band.B_432);
if (member.isWorked1240()) target.add(Band.B_1296);
if (member.isWorked2300()) target.add(Band.B_2320);
if (member.isWorked3400()) target.add(Band.B_3400);
if (member.isWorked5600()) target.add(Band.B_5760);
if (member.isWorked10G()) target.add(Band.B_10G);
if (member.isWorked24G()) target.add(Band.B_24G);
}
private static void collectNotQrvBands(ChatMember member, EnumSet<Band> target) {
if (!member.isQrv144()) target.add(Band.B_144);
if (!member.isQrv432()) target.add(Band.B_432);
if (!member.isQrv1240()) target.add(Band.B_1296);
if (!member.isQrv2300()) target.add(Band.B_2320);
if (!member.isQrv3400()) target.add(Band.B_3400);
if (!member.isQrv5600()) target.add(Band.B_5760);
if (!member.isQrv10G()) target.add(Band.B_10G);
// There is currently no persisted NOT-QRV flag for 24 GHz.
}
private static Map<Band, Pattern> createStationNameBandPatterns() {
Map<Band, Pattern> patterns = new EnumMap<>(Band.class);
patterns.put(Band.B_144, bandPattern("144(?:\\s*MHZ)?|2(?:\\s*M)?"));
patterns.put(Band.B_432, bandPattern("432(?:\\s*MHZ)?|70(?:\\s*CM)?"));
patterns.put(Band.B_1296, bandPattern("1296(?:\\s*MHZ)?|23(?:\\s*CM)?"));
patterns.put(Band.B_2320, bandPattern("(?:2300|2320)(?:\\s*MHZ)?|13(?:\\s*CM)?"));
patterns.put(Band.B_3400, bandPattern("3400(?:\\s*MHZ)?|9(?:\\s*CM)?"));
patterns.put(Band.B_5760, bandPattern("(?:5600|5760)(?:\\s*MHZ)?|6(?:\\s*CM)?"));
patterns.put(Band.B_10G, bandPattern("10368(?:\\s*MHZ)?|10\\s*G(?:HZ)?|3(?:\\s*CM)?"));
patterns.put(Band.B_24G, bandPattern("24048(?:\\s*MHZ)?|24\\s*G(?:HZ)?|1[.,]2(?:\\s*CM)?"));
return Collections.unmodifiableMap(patterns);
}
private static Pattern bandPattern(String alternatives) {
return Pattern.compile(
"(?<![A-Z0-9.,])(?:" + alternatives + ")(?![A-Z0-9.,])",
Pattern.CASE_INSENSITIVE
);
}
/** Immutable result of one callsign-wide band resolution. */
public static final class Resolution {
private final EnumSet<Band> offeredBands;
private final EnumSet<Band> workedBands;
private final EnumSet<Band> notQrvBands;
private Resolution(EnumSet<Band> offeredBands,
EnumSet<Band> workedBands,
EnumSet<Band> notQrvBands) {
this.offeredBands = copyOf(offeredBands);
this.workedBands = copyOf(workedBands);
this.notQrvBands = copyOf(notQrvBands);
}
/** Returns all recent/name-derived bands before NOT-QRV is applied. */
public EnumSet<Band> getOfferedBands() {
return copyOf(offeredBands);
}
public EnumSet<Band> getWorkedBands() {
return copyOf(workedBands);
}
public EnumSet<Band> getNotQrvBands() {
return copyOf(notQrvBands);
}
/** Returns offered bands after manual NOT-QRV exclusions. */
public EnumSet<Band> getAvailableBands() {
EnumSet<Band> availableBands = copyOf(offeredBands);
availableBands.removeAll(notQrvBands);
return availableBands;
}
/** Returns offered, QRV, enabled and not-yet-worked bands. */
public EnumSet<Band> getUnworkedEnabledBands(EnumSet<Band> enabledBands) {
EnumSet<Band> opportunities = getAvailableBands();
if (enabledBands == null || enabledBands.isEmpty()) {
opportunities.clear();
return opportunities;
}
opportunities.retainAll(enabledBands);
opportunities.removeAll(workedBands);
return opportunities;
}
public boolean hasBandEvidence() {
return !offeredBands.isEmpty();
}
private static EnumSet<Band> copyOf(EnumSet<Band> source) {
return source == null || source.isEmpty()
? EnumSet.noneOf(Band.class)
: EnumSet.copyOf(source);
}
}
}