mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-08-24 02:57:43 +02:00
279 lines
12 KiB
Java
279 lines
12 KiB
Java
package kst4contest.logic;
|
|
|
|
import kst4contest.controller.StationMetricsService;
|
|
import kst4contest.model.*;
|
|
|
|
import java.util.Collection;
|
|
import java.util.EnumSet;
|
|
import java.util.List;
|
|
|
|
/**
|
|
* Priority score calculation (off FX-thread).
|
|
*
|
|
* Notes:
|
|
* - Score is computed once per callsignRaw by ScoreService.
|
|
* - This calculator MUST be pure (no UI calls) and fast.
|
|
*/
|
|
public class PriorityCalculator {
|
|
|
|
public double calculatePriority(ChatMember member,
|
|
Collection<ChatMember> callsignVariants,
|
|
ChatPreferences prefs,
|
|
List<ContestSked> activeSkeds,
|
|
StationMetricsService.Snapshot metricsSnapshot,
|
|
long nowEpochMs) {
|
|
|
|
if (member == null || prefs == null) return 0.0;
|
|
|
|
final String callRaw = normalize(member.getCallSignRaw());
|
|
if (callRaw == null || callRaw.isBlank()) return 0.0;
|
|
|
|
// --------------------------------------------------------------------
|
|
// 1) HARD FILTER: reachable hardware + "already worked on all possible bands"
|
|
// --------------------------------------------------------------------
|
|
Collection<ChatMember> variants = callsignVariants == null || callsignVariants.isEmpty()
|
|
? List.of(member)
|
|
: callsignVariants;
|
|
|
|
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();
|
|
|
|
EnumSet<Band> unworkedPossible = EnumSet.noneOf(Band.class);
|
|
|
|
EnumSet<Band> stationOfferedBands = bandResolution.getOfferedBands();
|
|
EnumSet<Band> stationAvailableBands = bandResolution.getAvailableBands();
|
|
EnumSet<Band> possibleBands = stationOfferedBands.isEmpty()
|
|
? EnumSet.noneOf(Band.class)
|
|
: EnumSet.copyOf(stationAvailableBands);
|
|
|
|
if (!stationOfferedBands.isEmpty()) {
|
|
possibleBands.retainAll(myEnabledBands);
|
|
if (possibleBands.isEmpty()) {
|
|
// Known bands are disabled locally or manually marked NOT QRV.
|
|
return 0.0;
|
|
}
|
|
|
|
unworkedPossible = EnumSet.copyOf(possibleBands);
|
|
unworkedPossible.removeAll(workedBandsForScoring);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// 2) BASE SCORE
|
|
// --------------------------------------------------------------------
|
|
double score = 100.0;
|
|
|
|
/*
|
|
* A manual Sked-fail mark represents an explicit operator decision. The
|
|
* penalty is therefore applied after all positive contributions, including
|
|
* an imminent sked. Applying it earlier would allow the later sked boost to
|
|
* almost completely cancel the failure mark.
|
|
*/
|
|
boolean manualSkedFailed = false;
|
|
|
|
if (workedBandsForScoring.isEmpty()) {
|
|
score += 200.0; // never worked on any supported band -> higher priority
|
|
} else {
|
|
score -= 150.0; // already worked on at least one band -> lower base priority
|
|
}
|
|
|
|
|
|
// Multi-band bonus: if they offer >1 possible band and we worked at least one, prefer them
|
|
if (!possibleBands.isEmpty()) {
|
|
int bandCount = possibleBands.size();
|
|
score += (bandCount - 1) * 80.0;
|
|
}
|
|
|
|
// Optional: band-upgrade visibility boost
|
|
// If the station is already worked on at least one band, but is still QRV on other unworked enabled band(s),
|
|
// we can optionally add a boost so it remains visible in the list.
|
|
if (prefs.isNotify_bandUpgradePriorityBoostEnabled()
|
|
&& !workedBandsForScoring.isEmpty()
|
|
&& !unworkedPossible.isEmpty()) {
|
|
score += 180.0; // tuned visibility boost
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// 3) DISTANCE ("Goldilocks Zone")
|
|
// --------------------------------------------------------------------
|
|
double distKm = member.getQrb() == null ? 0.0 : member.getQrb();
|
|
|
|
if (distKm > 0) {
|
|
if (distKm < 200) {
|
|
score *= 0.7;
|
|
} else if (distKm > prefs.getStn_maxQRBDefault()) {
|
|
score *= 0.3;
|
|
} else {
|
|
score *= 1.15;
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// 4) AIRSCOUT BOOST
|
|
// --------------------------------------------------------------------
|
|
AirPlaneReflectionInfo apInfo = member.getAirPlaneReflectInfo();
|
|
if (apInfo != null && apInfo.getAirPlanesReachableCntr() > 0) {
|
|
score += 200;
|
|
|
|
int nextMinutes = findNextAirplaneArrivingMinutes(apInfo);
|
|
if (nextMinutes == 0) score += 120;
|
|
else if (nextMinutes == 1) score += 60;
|
|
else if (nextMinutes == 2) score += 30;
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// 5) BOOST IDEA #1: Beam direction match (within beamwidth)
|
|
// --------------------------------------------------------------------
|
|
if (member.getQTFdirection() != null) {
|
|
double myAz = prefs.getActualQTF().getValue();
|
|
double targetAz = member.getQTFdirection();
|
|
double diff = minimalAngleDiffDeg(myAz, targetAz);
|
|
|
|
double halfBeam = Math.max(1.0, prefs.getStn_antennaBeamWidthDeg()) / 2.0;
|
|
if (diff <= halfBeam) {
|
|
double centerFactor = 1.0 - (diff / halfBeam); // 1.0 center -> 0.0 edge
|
|
score += 80.0 + (120.0 * centerFactor);
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// 6) BOOST IDEA #3: Conversation momentum (recent inbound burst)
|
|
// --------------------------------------------------------------------
|
|
if (metricsSnapshot != null) {
|
|
StationMetricsService.Snapshot.Metrics mx = metricsSnapshot.get(callRaw);
|
|
if (mx != null) {
|
|
long ageMs = mx.lastInboundEpochMs > 0 ? (nowEpochMs - mx.lastInboundEpochMs) : Long.MAX_VALUE;
|
|
|
|
// "Active now" bonus
|
|
if (ageMs < 60_000) score += 120;
|
|
else if (ageMs < 3 * 60_000) score += 60;
|
|
|
|
// Momentum bonus: multiple lines in the configured window
|
|
int cnt = mx.inboundCountInWindow;
|
|
if (cnt >= 6) score += 160;
|
|
else if (cnt >= 4) score += 110;
|
|
else if (cnt >= 2) score += 60;
|
|
|
|
// Positive signal (configurable)
|
|
if (mx.lastPositiveSignalEpochMs > 0 && (nowEpochMs - mx.lastPositiveSignalEpochMs) < 5 * 60_000) {
|
|
score += 120;
|
|
}
|
|
|
|
// Reply time: prefer fast responders
|
|
if (mx.avgResponseTimeMs > 0) {
|
|
if (mx.avgResponseTimeMs < 60_000) score += 80;
|
|
else if (mx.avgResponseTimeMs < 3 * 60_000) score += 40;
|
|
}
|
|
|
|
// No-reply penalty (automatic failed attempt)
|
|
if (mx.noReplyStrikes > 0) {
|
|
score /= (1.0 + (mx.noReplyStrikes * 0.6));
|
|
}
|
|
|
|
// Manual sked fail (path likely bad) => strong, permanent penalty until reset
|
|
/*
|
|
* Remember the explicit failure state here. The actual penalty is applied
|
|
* after the sked contribution has been calculated.
|
|
*/
|
|
manualSkedFailed = mx.manualSkedFailed;
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// 7) BOOST IDEA #4: Sked commitment ramp-up
|
|
// --------------------------------------------------------------------
|
|
if (activeSkeds != null && !activeSkeds.isEmpty()) {
|
|
for (ContestSked sked : activeSkeds) {
|
|
if (sked == null) continue;
|
|
|
|
if (!callRaw.equals(normalize(sked.getTargetCallsign()))) continue;
|
|
|
|
long seconds = sked.getTimeUntilSkedSeconds();
|
|
|
|
// Imminent sked: absolute priority (T-3min..T+1min)
|
|
if (seconds < 180 && seconds > -60) {
|
|
score += 5000;
|
|
continue;
|
|
}
|
|
|
|
// Ramp: 0..15 minutes before => up to +1200
|
|
if (seconds >= 0 && seconds <= 15 * 60) {
|
|
double t = (15 * 60 - seconds) / (15.0 * 60.0); // 0.0..1.0
|
|
score += 300 + (900 * t);
|
|
} else if (seconds > 15 * 60) {
|
|
score += 40;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// 8) Legacy penalty: failed attempts in ChatMember
|
|
// --------------------------------------------------------------------
|
|
// --------------------------------------------------------------------
|
|
// 8) Legacy penalty: failed attempts in ChatMember
|
|
// --------------------------------------------------------------------
|
|
if (member.getFailedQSOAttempts() > 0) {
|
|
score = score / (member.getFailedQSOAttempts() + 1);
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// 9) Explicit operator override: manual Sked fail
|
|
// --------------------------------------------------------------------
|
|
if (manualSkedFailed) {
|
|
/*
|
|
* Apply the penalty to the complete result. This includes an imminent
|
|
* sked and prevents its strong time-dependent boost from cancelling the
|
|
* operator's explicit failure assessment.
|
|
*/
|
|
score *= 0.15;
|
|
}
|
|
|
|
return Math.max(0.0, score);
|
|
}
|
|
|
|
private static int findNextAirplaneArrivingMinutes(AirPlaneReflectionInfo apInfo) {
|
|
try {
|
|
if (apInfo.getRisingAirplanes() == null || apInfo.getRisingAirplanes().isEmpty()) return -1;
|
|
|
|
int min = Integer.MAX_VALUE;
|
|
for (AirPlane ap : apInfo.getRisingAirplanes()) {
|
|
if (ap == null) continue;
|
|
min = Math.min(min, ap.getArrivingDurationMinutes());
|
|
}
|
|
return min == Integer.MAX_VALUE ? -1 : min;
|
|
} catch (Exception ignore) {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
private static double minimalAngleDiffDeg(double a, double b) {
|
|
double diff = Math.abs((a - b) % 360.0);
|
|
return diff > 180.0 ? 360.0 - diff : diff;
|
|
}
|
|
|
|
private static String normalize(String s) {
|
|
if (s == null) return null;
|
|
return s.trim().toUpperCase();
|
|
}
|
|
}
|