Files
vkv/app/Services/Evaluation/OperatingWindowService.php
Zdeněk Burda 41e3ce6f25 Initial commit
2026-01-09 21:26:40 +01:00

398 lines
14 KiB
PHP

<?php
namespace App\Services\Evaluation;
use App\Enums\QsoErrorCode;
use App\Models\EvaluationRuleSet;
use App\Models\QsoResult;
use Carbon\Carbon;
class OperatingWindowService
{
/**
* 6H (operating window) výběr podle IARU:
* - Max. 2 časové segmenty s pauzou >= 2h mezi nimi.
* - Součet délek segmentů (end-start) <= N hodin.
* - Skóre segmentů odpovídá agregaci (body + penalizace + multiplikátory jen z okna).
* - Deterministický výběr: skóre desc, start asc, QSO desc, start log_qso_id asc.
*
* @return array{startUtc: Carbon, endUtc: Carbon, secondStartUtc: ?Carbon, secondEndUtc: ?Carbon, includedLogQsoIds: int[], qsoCount: int}|null
*/
public function pickBestOperatingWindow(
int $evaluationRunId,
int $logId,
int $hours,
EvaluationRuleSet $ruleSet
): ?array {
$rows = QsoResult::query()
->where('qso_results.evaluation_run_id', $evaluationRunId)
->join('log_qsos', 'log_qsos.id', '=', 'qso_results.log_qso_id')
->join('working_qsos', function ($join) use ($evaluationRunId) {
$join->on('working_qsos.log_qso_id', '=', 'qso_results.log_qso_id')
->where('working_qsos.evaluation_run_id', '=', $evaluationRunId);
})
->where('log_qsos.log_id', $logId)
->where('qso_results.is_valid', true)
->whereNotNull('working_qsos.ts_utc')
->orderBy('working_qsos.ts_utc')
->orderBy('qso_results.log_qso_id')
->get([
'qso_results.log_qso_id',
'qso_results.points',
'qso_results.penalty_points',
'qso_results.error_code',
'qso_results.error_side',
'qso_results.is_nil',
'qso_results.is_duplicate',
'qso_results.is_busted_call',
'qso_results.is_busted_rst',
'qso_results.is_busted_exchange',
'qso_results.is_time_out_of_window',
'qso_results.matched_qso_id',
'qso_results.wwl',
'qso_results.dxcc',
'qso_results.country',
'qso_results.section',
'working_qsos.band_id as band_id',
'working_qsos.ts_utc as ts_utc',
]);
if ($rows->isEmpty()) {
return null;
}
$items = [];
foreach ($rows as $row) {
$ts = Carbon::parse($row->ts_utc, 'UTC')->getTimestamp();
$errorCode = $row->error_code;
$errorSide = $row->error_side ?? 'NONE';
$isNil = (bool) $row->is_nil
|| in_array($errorCode, [QsoErrorCode::NOT_IN_COUNTERPART_LOG, QsoErrorCode::NO_COUNTERPART_LOG], true);
$isUnique = $errorCode === QsoErrorCode::UNIQUE;
$isDuplicate = (bool) $row->is_duplicate || $errorCode === QsoErrorCode::DUP;
$isBusted = (bool) $row->is_busted_call
|| (bool) $row->is_busted_rst
|| (bool) $row->is_busted_exchange
|| (in_array($errorCode, [
QsoErrorCode::BUSTED_CALL,
QsoErrorCode::BUSTED_RST,
QsoErrorCode::BUSTED_SERIAL,
QsoErrorCode::BUSTED_LOCATOR,
], true)
&& $errorSide !== 'TX');
$isOutOfWindow = (bool) $row->is_time_out_of_window;
$eligibleForMultiplier = false;
if ($ruleSet->usesMultipliers()) {
if ($ruleSet->multiplier_source === 'VALID_ONLY') {
$eligibleForMultiplier = ! $isNil && ! $isDuplicate && ! $isBusted && ! $isOutOfWindow;
} elseif ($ruleSet->multiplier_source === 'ALL_MATCHED') {
$eligibleForMultiplier = $row->matched_qso_id !== null
&& ! $isNil
&& ! $isDuplicate
&& ! $isBusted
&& ! $isOutOfWindow;
}
}
$multiplierValue = null;
if ($eligibleForMultiplier) {
if ($ruleSet->multiplier_type === 'WWL') {
$multiplierValue = $row->wwl;
} elseif ($ruleSet->multiplier_type === 'DXCC') {
$multiplierValue = $row->dxcc;
} elseif ($ruleSet->multiplier_type === 'COUNTRY') {
$multiplierValue = $row->country;
} elseif ($ruleSet->multiplier_type === 'SECTION') {
$multiplierValue = $row->section;
}
}
$bandKey = $ruleSet->multiplier_scope === 'PER_BAND'
? (int) ($row->band_id ?? 0)
: 0;
$items[] = [
'log_qso_id' => (int) $row->log_qso_id,
'ts' => $ts,
'points' => (int) ($row->points ?? 0),
'penalty_points' => (int) ($row->penalty_points ?? 0),
'multiplier_eligible' => $eligibleForMultiplier && $multiplierValue,
'multiplier_value' => $multiplierValue,
'multiplier_band_key' => $bandKey,
];
}
$windowSeconds = $hours * 3600;
$intervals = $this->buildIntervals($items, $windowSeconds, $ruleSet);
if (! $intervals) {
return null;
}
$bestSingle = null;
foreach ($intervals as $interval) {
$candidate = [
'score' => $interval['score'],
'start_ts' => $interval['start_ts'],
'end_ts' => $interval['end_ts'],
'qso_count' => $interval['qso_count'],
'start_log_qso_id' => $interval['start_log_qso_id'],
'segment1' => $interval,
'segment2' => null,
];
if ($this->isBetterCandidate($candidate, $bestSingle)) {
$bestSingle = $candidate;
}
}
$bestPair = $this->findBestTwoSegments($intervals, $windowSeconds);
$best = $bestSingle;
if ($this->isBetterCandidate($bestPair, $best)) {
$best = $bestPair;
}
if (! $best || ! $best['segment1']) {
return null;
}
$included = [];
foreach ([$best['segment1'], $best['segment2']] as $segment) {
if (! $segment) {
continue;
}
for ($i = $segment['start']; $i <= $segment['end']; $i++) {
$included[] = $items[$i]['log_qso_id'];
}
}
$segment1 = $best['segment1'];
$segment2 = $best['segment2'];
return [
'startUtc' => Carbon::createFromTimestampUTC($segment1['start_ts']),
'endUtc' => Carbon::createFromTimestampUTC($segment1['end_ts']),
'secondStartUtc' => $segment2 ? Carbon::createFromTimestampUTC($segment2['start_ts']) : null,
'secondEndUtc' => $segment2 ? Carbon::createFromTimestampUTC($segment2['end_ts']) : null,
'includedLogQsoIds' => $included,
'qsoCount' => count($included),
];
}
private function buildIntervals(array $items, int $windowSeconds, EvaluationRuleSet $ruleSet): array
{
$intervals = [];
$total = count($items);
for ($start = 0; $start < $total; $start++) {
$baseScore = 0;
$penaltyScore = 0;
$qsoCount = 0;
$multiplierBuckets = [];
$multiplierCount = 0;
for ($end = $start; $end < $total; $end++) {
$duration = $items[$end]['ts'] - $items[$start]['ts'];
if ($duration > $windowSeconds) {
break;
}
$this->addItem($items[$end], $ruleSet, $baseScore, $penaltyScore, $qsoCount, $multiplierBuckets, $multiplierCount);
$scoreBeforeMultiplier = $baseScore + $penaltyScore;
if ($ruleSet->usesMultipliers()) {
$score = $scoreBeforeMultiplier * $multiplierCount;
} else {
$score = $scoreBeforeMultiplier;
}
$score = max(0, $score);
$intervals[] = [
'start' => $start,
'end' => $end,
'start_ts' => $items[$start]['ts'],
'end_ts' => $items[$end]['ts'],
'duration' => $duration,
'score' => $score,
'qso_count' => $qsoCount,
'start_log_qso_id' => $items[$start]['log_qso_id'],
];
}
}
return $intervals;
}
private function findBestTwoSegments(array $intervals, int $windowSeconds): ?array
{
$gapSeconds = 2 * 3600;
$intervalsByEnd = $intervals;
usort($intervalsByEnd, fn ($a, $b) => $a['end_ts'] <=> $b['end_ts']);
$intervalsByStart = $intervals;
usort($intervalsByStart, fn ($a, $b) => $a['start_ts'] <=> $b['start_ts']);
$tree = new IntervalScoreTree($windowSeconds);
$best = null;
$idx = 0;
$count = count($intervalsByEnd);
foreach ($intervalsByStart as $segment2) {
$threshold = $segment2['start_ts'] - $gapSeconds;
while ($idx < $count && $intervalsByEnd[$idx]['end_ts'] <= $threshold) {
$tree->update($intervalsByEnd[$idx]['duration'], $intervalsByEnd[$idx]);
$idx++;
}
$remaining = $windowSeconds - $segment2['duration'];
if ($remaining < 0) {
continue;
}
$segment1 = $tree->query(0, $remaining);
if (! $segment1) {
continue;
}
$candidate = [
'score' => $segment1['score'] + $segment2['score'],
'start_ts' => $segment1['start_ts'],
'end_ts' => $segment2['end_ts'],
'qso_count' => $segment1['qso_count'] + $segment2['qso_count'],
'start_log_qso_id' => $segment1['start_log_qso_id'],
'segment1' => $segment1,
'segment2' => $segment2,
];
if ($this->isBetterCandidate($candidate, $best)) {
$best = $candidate;
}
}
return $best;
}
private function isBetterCandidate(?array $candidate, ?array $best): bool
{
if (! $candidate) {
return false;
}
if (! $best) {
return true;
}
if ($candidate['score'] !== $best['score']) {
return $candidate['score'] > $best['score'];
}
if ($candidate['start_ts'] !== $best['start_ts']) {
return $candidate['start_ts'] < $best['start_ts'];
}
if ($candidate['qso_count'] !== $best['qso_count']) {
return $candidate['qso_count'] > $best['qso_count'];
}
return $candidate['start_log_qso_id'] < $best['start_log_qso_id'];
}
private function addItem(
array $item,
EvaluationRuleSet $ruleSet,
int &$baseScore,
int &$penaltyScore,
int &$qsoCount,
array &$multiplierBuckets,
int &$multiplierCount
): void {
$baseScore += $item['points'];
$penaltyScore -= $item['penalty_points'];
$qsoCount++;
if (! $ruleSet->usesMultipliers()) {
return;
}
if ($item['multiplier_eligible']) {
$bandKey = $item['multiplier_band_key'];
$value = $item['multiplier_value'];
if (! isset($multiplierBuckets[$bandKey])) {
$multiplierBuckets[$bandKey] = [];
}
$current = $multiplierBuckets[$bandKey][$value] ?? 0;
$multiplierBuckets[$bandKey][$value] = $current + 1;
if ($current === 0) {
$multiplierCount++;
}
}
}
}
final class IntervalScoreTree
{
private int $size;
private array $tree;
public function __construct(int $maxDuration)
{
$size = 1;
while ($size < $maxDuration + 1) {
$size *= 2;
}
$this->size = $size;
$this->tree = array_fill(0, $size * 2, null);
}
public function update(int $duration, array $interval): void
{
$pos = $this->size + $duration;
if ($this->isBetter($interval, $this->tree[$pos])) {
$this->tree[$pos] = $interval;
}
$pos = intdiv($pos, 2);
while ($pos >= 1) {
$left = $this->tree[$pos * 2];
$right = $this->tree[$pos * 2 + 1];
$this->tree[$pos] = $this->isBetter($left, $right) ? $left : $right;
if ($pos === 1) {
break;
}
$pos = intdiv($pos, 2);
}
}
public function query(int $left, int $right): ?array
{
$left += $this->size;
$right += $this->size;
$best = null;
while ($left <= $right) {
if ($left % 2 === 1) {
$best = $this->isBetter($this->tree[$left], $best) ? $this->tree[$left] : $best;
$left++;
}
if ($right % 2 === 0) {
$best = $this->isBetter($this->tree[$right], $best) ? $this->tree[$right] : $best;
$right--;
}
$left = intdiv($left, 2);
$right = intdiv($right, 2);
}
return $best;
}
private function isBetter(?array $candidate, ?array $best): bool
{
if (! $candidate) {
return false;
}
if (! $best) {
return true;
}
if ($candidate['score'] !== $best['score']) {
return $candidate['score'] > $best['score'];
}
if ($candidate['start_ts'] !== $best['start_ts']) {
return $candidate['start_ts'] < $best['start_ts'];
}
if ($candidate['qso_count'] !== $best['qso_count']) {
return $candidate['qso_count'] > $best['qso_count'];
}
return $candidate['start_log_qso_id'] < $best['start_log_qso_id'];
}
}