Refactored QRG recognition (added some band context to increase the quality of the values + changed cluster fallback-band-value to dropdown switch + normalizing qrg values)

This commit is contained in:
Marc Froehlich
2026-08-03 01:09:32 +02:00
parent e9f09b764d
commit c7d1dfbe92
11 changed files with 525 additions and 194 deletions
@@ -46,6 +46,49 @@ public class MessageBusManagementThread extends Thread {
private final String PTRN_QRG_CAT2 = "(([0-9]{3,4}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)|(([a-zA-Z][0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)|((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)";
private final String PTRN_QRG_CAT3 = "(([0-9]{3,5}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)|(([a-zA-Z][0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)|((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)";
/*
* Frequency formats handled by the smart parser:
*
* Group 1: full frequencies, for example 144.210 or 10368.100
* Group 2: relative frequencies with a separator, for example .210 or ,210
* Group 3: bare three-digit values, for example 210
*
* Group 3 is deliberately subjected to an additional context check. Without
* that check, ordinary chat values such as 599 or a bare band name such as 144
* would be converted into plausible but incorrect frequencies.
*/
private static final Pattern SMART_FREQUENCY_PATTERN = Pattern.compile(
"(?<![\\d])(\\d{3,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)(?![\\d])"
+ "|(?<![\\d])([.,]\\d{3}(?:[.,]\\d{1,3})?)(?![\\d])"
+ "|(?<=\\s|^)(\\d{3})(?=\\s|$)"
);
/*
* A bare three-digit value is accepted only when the nearby text makes its
* meaning sufficiently clear. Examples:
*
* qrg 210
* QRG: 210
* freq is 210
* on 210
* pse 210
* at 210
* 210 MHz
* 210 qrg
*/
private static final Pattern BARE_FREQUENCY_PREFIX_CONTEXT_PATTERN =
Pattern.compile(
"(?i)\\b(?:qrg|freq(?:uency)?|on|pse|at)\\b"
+ "\\s*(?:is\\s*)?[:=@-]?\\s*$"
);
private static final Pattern BARE_FREQUENCY_SUFFIX_CONTEXT_PATTERN =
Pattern.compile(
"(?i)^\\s*(?:mhz|qrg|freq(?:uency)?)\\b"
);
private static final int BARE_FREQUENCY_CONTEXT_CHARACTERS = 32;
// ==== Auto-answer flood/ping-pong protection ====
private static final String AUTOANSWER_PREFIX = ApplicationConstants.AUTOANSWER_PREFIX;
@@ -200,145 +243,220 @@ public class MessageBusManagementThread extends Thread {
}
/**
* Smart Frequency Parser (V1.32)
* Replaces the old RegEx logic.
* Features:
* 1. Handles full frequencies (144.210) and short forms (.210, 210).
* 2. Handles extended precision/weird formatting (144.210.10, 144,210,10).
* 3. Prioritizes USER CONTEXT (History) over GLOBAL CONTEXT (Preferences).
* Detects complete and relative frequencies in a chat message and stores the
* result on the sender.
*
* <p>Complete frequencies determine their band directly. Relative frequencies
* first use the sender's most recent band context if that context is not older
* than 30 minutes. Only when no suitable sender context exists does the parser
* use the globally configured fallback band.</p>
*
* <p>A relative value beginning with a dot or comma is sufficiently explicit
* on its own. A bare three-digit value is ambiguous and is therefore accepted
* only with nearby frequency-related text.</p>
*
* @param message message whose text is inspected
* @param prefs preferences containing the global fallback band
*/
private void smartFrequencyExtraction(ChatMessage message, ChatPreferences prefs) {
// Regex Explanation:
// Part 1 (Full): Start (not digit), 3-5 digits, sep, 1-3 digits, OPTIONAL (sep, 1-3 digits)
// Matches: 144.210, 144.210.10, 10368.100
// Part 2 (Short1): Start (not digit), sep, 3 digits, OPTIONAL (sep, 1-3 digits)
// Matches: .210, .210.10, ,210
// Part 3 (Short2): Whitespace/Start, 3 digits, Whitespace/End
// Matches: " 210 ", " 144 "
String smartPattern = "(?<![\\d])(\\d{3,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)(?![\\d])|(?<![\\d])([.,]\\d{3}(?:[.,]\\d{1,3})?)(?![\\d])|(?<=\\s|^)(\\d{3})(?=\\s|$)";
Pattern pattern = Pattern.compile(smartPattern);
Matcher matcher = pattern.matcher(message.getMessageText());
if (message == null || message.getMessageText() == null) {
return;
}
ChatMember sender = message.getSender();
// Safety check, in case sender is null (e.g., server message)
if (sender == null) return;
if (sender == null) {
return;
}
String messageText = message.getMessageText();
Matcher matcher = SMART_FREQUENCY_PATTERN.matcher(messageText);
while (matcher.find()) {
String foundRaw = matcher.group().trim();
boolean dottedShortForm = matcher.group(2) != null;
boolean bareShortForm = matcher.group(3) != null;
boolean shortForm = dottedShortForm || bareShortForm;
// --- PRE-PROCESSING: Normalize separators ---
// 1. Replace all commas with dots to unify format (144,210,10 -> 144.210.10)
foundRaw = foundRaw.replace(",", ".");
if (bareShortForm
&& !hasExplicitBareFrequencyContext(
messageText,
matcher.start(),
matcher.end()
)) {
continue;
}
String foundRaw = matcher.group().trim().replace(',', '.');
double finalDetectedFrequency = 0.0;
Band finalDetectedBand = null;
boolean isShortForm = false;
// --- STEP 1: Type Determination (Short or Full?) ---
if (shortForm) {
if (foundRaw.startsWith(".")) {
foundRaw = foundRaw.substring(1);
}
// Check if it starts with a dot (e.g. ".210") OR is just 3 digits ("210")
if (foundRaw.startsWith(".") || foundRaw.length() == 3) {
/*
* Priority 1: use this sender's most recently observed band if its
* information is not older than 30 minutes and the reconstructed
* frequency lies inside that band.
*/
long bestTimestamp = 0L;
// It is a short form.
// We strip the leading dot for calculation if present -> "210.10" or "210"
if (foundRaw.startsWith(".")) foundRaw = foundRaw.substring(1);
isShortForm = true;
} else {
// It is a full frequency (e.g., 144.210.10 or 144.210)
try {
// Normalize "144.210.10" to "144.21010" for Double.parseDouble
String normalizedFull = normalizeFrequencyString(foundRaw);
finalDetectedFrequency = Double.parseDouble(normalizedFull);
finalDetectedBand = Band.fromFrequency(finalDetectedFrequency);
} catch (NumberFormatException e) { continue; }
}
// --- STEP 2: Context Resolution (Only needed for Short Forms) ---
if (isShortForm) {
// A) HISTORY CHECK (Priority 1: What did THIS USER do recently?)
// We search for the most recent band where this short form makes physical sense.
long bestTimestamp = 0;
// Iterate over all bands where the user is known
// (Assumption: ChatMember has a getter getKnownActiveBands())
if (sender.getKnownActiveBands() != null) {
for (java.util.Map.Entry<Band, ChatMember.ActiveFrequencyInfo> entry : sender.getKnownActiveBands().entrySet()) {
for (java.util.Map.Entry<Band, ChatMember.ActiveFrequencyInfo> entry
: sender.getKnownActiveBands().entrySet()) {
Band candidateBand = entry.getKey();
ChatMember.ActiveFrequencyInfo info = entry.getValue();
// Timeout Check: Info must not be older than 30 mins (1,800,000 ms)
if (System.currentTimeMillis() - info.timestampEpoch > 1800000) continue;
if (candidateBand == null || info == null) {
continue;
}
// Try Reconstruction: Band Prefix + ShortForm
// Example: Band 144 (Prefix "144") + "." + "210.10" -> "144.210.10"
if (System.currentTimeMillis() - info.timestampEpoch > 1_800_000L) {
continue;
}
try {
String reconstructedStr = candidateBand.getPrefix() + "." + foundRaw;
String normalizedReconstruction = normalizeFrequencyString(reconstructedStr);
double attemptFreq = Double.parseDouble(normalizedReconstruction);
String reconstructed =
candidateBand.getPrefix() + "." + foundRaw;
double candidateFrequency = Double.parseDouble(
normalizeFrequencyString(reconstructed)
);
// Does this frequency fit into the candidate band?
if (candidateBand.isPlausible(attemptFreq)) {
// If we have multiple matches, pick the most recent one
if (info.timestampEpoch > bestTimestamp) {
finalDetectedFrequency = attemptFreq;
finalDetectedBand = candidateBand;
bestTimestamp = info.timestampEpoch;
}
if (candidateBand.isPlausible(candidateFrequency)
&& info.timestampEpoch > bestTimestamp) {
finalDetectedFrequency = candidateFrequency;
finalDetectedBand = candidateBand;
bestTimestamp = info.timestampEpoch;
}
} catch (Exception e) { /* Ignore parsing errors */ }
}
}
// B) GLOBAL PREFERENCES CHECK (Priority 2: Fallback if history is empty/old)
if (finalDetectedBand == null) {
// Get standard band from prefs (e.g., "144" or "432")
String defaultPrefix = prefs.getNotify_optionalFrequencyPrefix().get();
try {
String reconstructedStr = defaultPrefix + "." + foundRaw;
String normalizedReconstruction = normalizeFrequencyString(reconstructedStr);
double attemptFreq = Double.parseDouble(normalizedReconstruction);
// Check if this results in a valid amateur radio band
Band defaultBandCandidate = Band.fromFrequency(attemptFreq);
if (defaultBandCandidate != null) {
finalDetectedFrequency = attemptFreq;
finalDetectedBand = defaultBandCandidate;
} catch (NumberFormatException ignored) {
// Try the next known band.
}
} catch (NumberFormatException e) {
// Number was likely not a frequency (e.g., "73" or "599") and didn't fit any band
continue;
}
}
}
// --- STEP 3: Process Result ---
if (finalDetectedBand != null && finalDetectedFrequency > 0) {
/*
* Store the detected QRG in the thread-safe active-member model.
* The UI table is only a JavaFX mirror, so the MessageBus must not scan
* or mutate getLst_chatMemberList() here.
* Priority 2: use the configured fallback band. Invalid values from
* an older hand-edited configuration fall back safely to 144 MHz.
* The current UI itself offers only values from Band.values().
*/
client.applyDetectedFrequencyToActiveMembers(sender, finalDetectedBand, finalDetectedFrequency);
if (finalDetectedBand == null) {
String configuredPrefix = null;
System.out.println("[SmartParser] Detected for " + sender.getCallSign() + ": " +
finalDetectedFrequency + " MHz (" + finalDetectedBand + ") " +
(isShortForm ? "[derived from " + foundRaw + "]" : "[full match]"));
if (prefs != null
&& prefs.getNotify_optionalFrequencyPrefix() != null) {
configuredPrefix =
prefs.getNotify_optionalFrequencyPrefix().get();
}
// Optional: Trigger Cluster-Spot here if enabled
Band fallbackBand = Band.fromPrefix(configuredPrefix);
if (fallbackBand == null) {
fallbackBand = Band.B_144;
}
try {
String reconstructed =
fallbackBand.getPrefix() + "." + foundRaw;
double candidateFrequency = Double.parseDouble(
normalizeFrequencyString(reconstructed)
);
if (fallbackBand.isPlausible(candidateFrequency)) {
finalDetectedFrequency = candidateFrequency;
finalDetectedBand = fallbackBand;
}
} catch (NumberFormatException ignored) {
// The matched value cannot be converted into a frequency.
}
}
} else {
try {
finalDetectedFrequency = Double.parseDouble(
normalizeFrequencyString(foundRaw)
);
finalDetectedBand = Band.fromFrequency(finalDetectedFrequency);
} catch (NumberFormatException ignored) {
// Continue with the next possible match in the message.
}
}
if (finalDetectedBand == null || finalDetectedFrequency <= 0.0) {
continue;
}
/*
* Store the result in the thread-safe active-member model. The existing
* compatibility property used by the TableView and DX-Cluster code is
* updated by applyDetectedFrequencyToActiveMembers(...), too.
*/
client.applyDetectedFrequencyToActiveMembers(
sender,
finalDetectedBand,
finalDetectedFrequency
);
System.out.println(
"[SmartParser] Detected for "
+ sender.getCallSign()
+ ": "
+ finalDetectedFrequency
+ " MHz ("
+ finalDetectedBand
+ ") "
+ (shortForm
? "[derived from " + foundRaw + "]"
: "[full match]")
);
}
}
/**
* Checks whether a bare three-digit value is surrounded by text that identifies
* it as a frequency.
*
* <p>The check intentionally uses only a small area around the value. A remote
* occurrence of the word "QRG" elsewhere in a long message must not turn every
* three-digit number in that message into a frequency.</p>
*
* @param messageText complete chat message
* @param matchStart start index of the three-digit match
* @param matchEnd end index of the three-digit match
* @return {@code true} if the value has explicit frequency context
*/
static boolean hasExplicitBareFrequencyContext(
String messageText,
int matchStart,
int matchEnd
) {
if (messageText == null
|| matchStart < 0
|| matchEnd < matchStart
|| matchEnd > messageText.length()) {
return false;
}
int prefixStart = Math.max(
0,
matchStart - BARE_FREQUENCY_CONTEXT_CHARACTERS
);
int suffixEnd = Math.min(
messageText.length(),
matchEnd + BARE_FREQUENCY_CONTEXT_CHARACTERS
);
String prefixContext = messageText.substring(prefixStart, matchStart);
String suffixContext = messageText.substring(matchEnd, suffixEnd);
return BARE_FREQUENCY_PREFIX_CONTEXT_PATTERN
.matcher(prefixContext)
.find()
|| BARE_FREQUENCY_SUFFIX_CONTEXT_PATTERN
.matcher(suffixContext)
.find();
}
/**
* Helper: Normalizes weird frequency formats to valid Double strings.
* Example: "144.210.10" -> "144.21010"
@@ -1,4 +1,81 @@
package kst4contest.controller;
public class MessageBusManagementThreadFrequencyContextTest {
}
//package kst4contest.controller;
//
//import kst4contest.model.Band;
//import org.junit.jupiter.api.Test;
//import org.junit.jupiter.params.ParameterizedTest;
//import org.junit.jupiter.params.provider.ValueSource;
//
//import java.util.regex.Matcher;
//import java.util.regex.Pattern;
//
//import static org.junit.jupiter.api.Assertions.assertEquals;
//import static org.junit.jupiter.api.Assertions.assertFalse;
//import static org.junit.jupiter.api.Assertions.assertNotNull;
//import static org.junit.jupiter.api.Assertions.assertNull;
//import static org.junit.jupiter.api.Assertions.assertTrue;
//
//class MessageBusManagementThreadFrequencyContextTest {
//
// private static final Pattern THREE_DIGIT_VALUE =
// Pattern.compile("\\b\\d{3}\\b");
//
// @ParameterizedTest
// @ValueSource(strings = {
// "qrg 210",
// "QRG: 210",
// "freq is 210",
// "frequency = 210",
// "on 210",
// "210 MHz",
// "210 qrg"
// })
// void acceptsBareThreeDigitValueWithFrequencyContext(String messageText) {
// Matcher matcher = findThreeDigitValue(messageText);
//
// assertTrue(
// MessageBusManagementThread
// .hasExplicitBareFrequencyContext(
// messageText,
// matcher.start(),
// matcher.end()
// )
// );
// }
//
// @ParameterizedTest
// @ValueSource(strings = {
// "599",
// "144",
// "serial 210",
// "score 210",
// "worked 210 stations"
// })
// void rejectsBareThreeDigitValueWithoutFrequencyContext(String messageText) {
// Matcher matcher = findThreeDigitValue(messageText);
//
// assertFalse(
// MessageBusManagementThread
// .hasExplicitBareFrequencyContext(
// messageText,
// matcher.start(),
// matcher.end()
// )
// );
// }
//
// @Test
// void resolvesOnlySupportedFallbackPrefixes() {
// assertEquals(Band.B_144, Band.fromPrefix("144"));
// assertEquals(Band.B_432, Band.fromPrefix(" 432 "));
// assertEquals(Band.B_10G, Band.fromPrefix("10368"));
// assertNull(Band.fromPrefix("999"));
// assertNull(Band.fromPrefix(null));
// }
//
// private Matcher findThreeDigitValue(String messageText) {
// Matcher matcher = THREE_DIGIT_VALUE.matcher(messageText);
// assertTrue(matcher.find(), "Test message must contain a three-digit value");
// assertNotNull(matcher.group());
// return matcher;
// }
//}
@@ -291,14 +291,6 @@ public class ReadUDPbyUCXMessageThread extends Thread {
ChatMember modifyThat = null;
// System.out.println("ReadUDPByUCX, message catched: " + udpMsg);
// String[] threadStatusMessage = new String[2];
// threadStatusMessage = new String[3];
// threadStatusMessage[0] = "on";
// threadStatusMessage[1] = "received message:";
// threadStatusMessage[2] = udpMsg;
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "received Message\n" + udpMsg, false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
@@ -307,7 +299,7 @@ public class ReadUDPbyUCXMessageThread extends Thread {
try {
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
} catch (ParserConfigurationException e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
}
+29
View File
@@ -29,6 +29,35 @@ public enum Band {
return prefix;
}
/**
* Resolves a configured MHz prefix to one of the bands supported by the
* frequency parser.
*
* <p>The former free-text preference accepted arbitrary numeric values even
* though only prefixes represented by this enum can be used for a plausible
* frequency. Keeping the lookup here gives the UI and the parser one common
* definition of a valid fallback band.</p>
*
* @param prefix configured MHz prefix, for example {@code 144} or {@code 10368}
* @return matching band, or {@code null} if the prefix is not supported
*/
public static Band fromPrefix(String prefix) {
if (prefix == null) {
return null;
}
String normalizedPrefix = prefix.trim();
for (Band band : values()) {
if (band.prefix.equals(normalizedPrefix)) {
return band;
}
}
return null;
}
/**
* Returns the lower edge used as practical analysis frequency when only the band
* is known. This keeps the batch reachability calculation deterministic.
@@ -31,6 +31,7 @@ import javafx.scene.layout.*;
import javafx.scene.media.Media;
import javafx.scene.media.MediaPlayer;
import javafx.util.Duration;
import javafx.util.StringConverter;
import kst4contest.ApplicationConstants;
import kst4contest.controller.ChatController;
import kst4contest.controller.MessageVariableResolver;
@@ -1504,19 +1505,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override
public ObservableValue<String> call(CellDataFeatures<ChatMember, String> cellDataFeatures) {
// StringProperty qrg = new SimpleStringProperty();
// qrg.setValue(cellDataFeatures.getValue().getFrequency());
// qrg = (cellDataFeatures.getValue().getFrequency());
// if (!qrg.getValue().equals("")) {
//
// }
return cellDataFeatures.getValue().getFrequency();
}
});
applyQrgUiFormatting(qrgCol); //insert zero until qrg string looks pretty
TableColumn<ChatMember, String> airScoutCol = new TableColumn<ChatMember, String>("AP [minutes / pot%]");
airScoutCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMember, String>, ObservableValue<String>>() {
@@ -9854,53 +9849,84 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
);
TextField txtFld_notify_DXclusterServerFrequencyPrefix =
new TextField(
this.chatcontroller
.getChatPreferences()
.getNotify_optionalFrequencyPrefix()
.getValue()
ComboBox<Band> cmbBx_notifyFrequencyFallbackBand =
new ComboBox<>(
FXCollections.observableArrayList(Band.values())
);
txtFld_notify_DXclusterServerFrequencyPrefix
.focusedProperty()
cmbBx_notifyFrequencyFallbackBand.setEditable(false);
cmbBx_notifyFrequencyFallbackBand.setMaxWidth(Double.MAX_VALUE);
cmbBx_notifyFrequencyFallbackBand.setTooltip(
new Tooltip(
"Used for relative QRG values such as .210 when no band "
+ "has been recognized for the sender during the "
+ "previous 30 minutes. This setting affects the "
+ "general QRG detection, not only DX-Cluster spots."
)
);
cmbBx_notifyFrequencyFallbackBand.setConverter(
new StringConverter<Band>() {
@Override
public String toString(Band band) {
if (band == null) {
return "";
}
String displayLabel = band.getDisplayLabel();
if (band.getPrefix().equals(displayLabel)) {
return band.getPrefix() + " MHz";
}
return band.getPrefix()
+ " MHz ("
+ displayLabel
+ ")";
}
@Override
public Band fromString(String displayedValue) {
if (displayedValue == null) {
return null;
}
int firstSpace = displayedValue.indexOf(' ');
String prefix = firstSpace >= 0
? displayedValue.substring(0, firstSpace)
: displayedValue;
return Band.fromPrefix(prefix);
}
}
);
Band configuredFallbackBand = Band.fromPrefix(
chatcontroller
.getChatPreferences()
.getNotify_optionalFrequencyPrefix()
.get()
);
if (configuredFallbackBand == null) {
configuredFallbackBand = Band.B_144;
chatcontroller
.getChatPreferences()
.setNotify_optionalFrequencyPrefix(
configuredFallbackBand.getPrefix()
);
}
cmbBx_notifyFrequencyFallbackBand.setValue(configuredFallbackBand);
cmbBx_notifyFrequencyFallbackBand
.valueProperty()
.addListener(
(observable, oldValue, focused) -> {
if (focused) {
return;
}
String bandInMHz =
txtFld_notify_DXclusterServerFrequencyPrefix
.getText()
.trim();
if (bandInMHz.matches(
"[1-9]\\d{1,4}"
)) {
(observable, oldBand, newBand) -> {
if (newBand != null) {
chatcontroller
.getChatPreferences()
.setNotify_optionalFrequencyPrefix(
bandInMHz
);
txtFld_notify_DXclusterServerFrequencyPrefix
.setText(bandInMHz);
} else {
showUserInputErrorWindow(
"\""
+ bandInMHz
+ "\" is not a valid fallback band. "
+ "Enter the MHz part as an integer, "
+ "for example 144, 432 or 1296."
);
txtFld_notify_DXclusterServerFrequencyPrefix
.setText(
chatcontroller
.getChatPreferences()
.getNotify_optionalFrequencyPrefix()
.getValue()
newBand.getPrefix()
);
}
}
@@ -10075,14 +10101,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
grdPnlNotify.add(
new Label(
"Fallback band in MHz for relative "
+ "frequencies [default: 144]:"
"Fallback band for relative QRG detection:"
),
0,
8
);
grdPnlNotify.add(
txtFld_notify_DXclusterServerFrequencyPrefix,
cmbBx_notifyFrequencyFallbackBand,
1,
8
);