= 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']; } }