398 lines
14 KiB
PHP
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'];
|
|
}
|
|
}
|