mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-08-24 02:57:43 +02:00
285 lines
11 KiB
Java
285 lines
11 KiB
Java
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;
|
|
}
|
|
|
|
/*
|
|
* Explicit band descriptions:
|
|
* 2m, 70cm, 23cm, 432, 1296, ...
|
|
*/
|
|
for (Map.Entry<Band, Pattern> entry : STATION_NAME_BAND_PATTERNS.entrySet()) {
|
|
if (entry.getValue().matcher(stationName).find()) {
|
|
detectedBands.add(entry.getKey());
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Explicit full QRGs in name field:
|
|
* 432.357 -> 432 MHz
|
|
* 1296.210 -> 1296 MHz
|
|
* etc.
|
|
*/
|
|
for (FrequencyTextParser.DetectedFrequency detectedFrequency
|
|
: FrequencyTextParser.findExplicitFrequencies(
|
|
stationName
|
|
)) {
|
|
|
|
detectedBands.add(
|
|
detectedFrequency.getBand()
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|