evaluationRunId); if (! $run || $run->isCanceled()) { return; } $coordinator = new EvaluationCoordinator(); try { $ruleSet = EvaluationRuleSet::find($run->rule_set_id); if (! $ruleSet) { $coordinator->eventError($run, 'Agregace nelze spustit: chybí ruleset.', [ 'step' => 'aggregate', ]); return; } $timeDiffThresholdSec = $ruleSet->time_diff_dq_threshold_sec; $timeDiffThresholdPercent = $ruleSet->time_diff_dq_threshold_percent; $badQsoThresholdPercent = $ruleSet->bad_qso_dq_threshold_percent; $logResult = LogResult::firstOrNew([ 'evaluation_run_id' => $run->id, 'log_id' => $this->logId, ]); // 6H je omezení agregace, nikoli matchingu; dříve to byl jen flag bez operating-window logiky. $useOperatingWindow = $logResult->sixhr_category && $ruleSet->operating_window_mode === 'BEST_CONTIGUOUS' && (int) $ruleSet->operating_window_hours === 6; if ($useOperatingWindow) { $service = new OperatingWindowService(); $window = $service->pickBestOperatingWindow($run->id, $this->logId, 6, $ruleSet); if ($window) { $this->applyOperatingWindow($run->id, $logResult, $window); } else { $this->resetOperatingWindow($run->id, $logResult); } } else { $this->resetOperatingWindow($run->id, $logResult); } $stats = [ 'base_score' => 0, 'penalty_score' => 0, 'valid_qso_count' => 0, 'dupe_qso_count' => 0, 'busted_qso_count' => 0, 'other_error_qso_count' => 0, 'out_of_window_qso_count' => 0, 'total_qso_count' => 0, 'discarded_qso_count' => 0, 'discarded_points' => 0, 'unique_qso_count' => 0, 'bad_qso_count' => 0, 'matched_qso_count' => 0, 'time_diff_over_threshold_count' => 0, 'multipliers' => [], ]; $query = QsoResult::query() ->where('qso_results.evaluation_run_id', $run->id) ->join('log_qsos', 'log_qsos.id', '=', 'qso_results.log_qso_id') ->leftJoin('working_qsos', function ($join) use ($run) { $join->on('working_qsos.log_qso_id', '=', 'qso_results.log_qso_id') ->where('working_qsos.evaluation_run_id', '=', $run->id); }) ->where('log_qsos.log_id', $this->logId) ->select([ 'qso_results.id', 'qso_results.log_qso_id', 'qso_results.points', 'qso_results.penalty_points', 'qso_results.time_diff_sec', '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.is_valid', 'qso_results.matched_qso_id', 'qso_results.wwl', 'qso_results.dxcc', 'qso_results.country', 'qso_results.section', 'working_qsos.band_id as band_id', ]); if ($useOperatingWindow) { $query->where('qso_results.is_operating_window_excluded', false); } $query->chunkById(1000, function ($rows) use (&$stats, $ruleSet, $timeDiffThresholdSec, $run) { foreach ($rows as $row) { if (EvaluationRun::isCanceledRun($run->id)) { return false; } $stats['total_qso_count']++; if ($row->is_valid) { $stats['base_score'] += (int) $row->points; } $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'); $isTimeMismatch = $errorCode === QsoErrorCode::TIME_MISMATCH; $isOutOfWindow = (bool) $row->is_time_out_of_window; $isValid = (bool) $row->is_valid; if ($isValid) { $stats['valid_qso_count']++; } else { $stats['discarded_qso_count']++; $stats['discarded_points'] += (int) ($row->points ?? 0); } if ($isDuplicate) { $stats['dupe_qso_count']++; } if ($isBusted) { $stats['busted_qso_count']++; } if ($isOutOfWindow) { $stats['out_of_window_qso_count']++; } if ($isNil || $isOutOfWindow || $isUnique) { $stats['other_error_qso_count']++; } if ($isDuplicate || $isBusted || $isOutOfWindow || $isTimeMismatch || $isUnique) { $stats['bad_qso_count']++; } if ($isUnique) { $stats['unique_qso_count']++; } if ($row->matched_qso_id !== null) { $stats['matched_qso_count']++; if ( $timeDiffThresholdSec !== null && $row->time_diff_sec !== null && (int) $row->time_diff_sec > (int) $timeDiffThresholdSec ) { $stats['time_diff_over_threshold_count']++; } } $penalty = (int) ($row->penalty_points ?? 0); if ($row->is_valid && $penalty !== 0) { $stats['penalty_score'] -= $penalty; } if ($ruleSet->usesMultipliers()) { $bandKey = $ruleSet->multiplier_scope === 'PER_BAND' ? (int) ($row->band_id ?? 0) : 0; if (! isset($stats['multipliers'][$bandKey])) { $stats['multipliers'][$bandKey] = []; } $eligible = false; if ($ruleSet->multiplier_source === 'VALID_ONLY') { $eligible = ! $isNil && ! $isDuplicate && ! $isBusted && ! $isOutOfWindow && (bool) $row->is_valid; } elseif ($ruleSet->multiplier_source === 'ALL_MATCHED') { $eligible = $row->matched_qso_id !== null && ! $isNil && ! $isDuplicate && ! $isBusted && ! $isOutOfWindow; } if ($eligible) { $multiplier = null; if ($ruleSet->multiplier_type === 'WWL') { $multiplier = $row->wwl; } elseif ($ruleSet->multiplier_type === 'DXCC') { $multiplier = $row->dxcc; } elseif ($ruleSet->multiplier_type === 'COUNTRY') { $multiplier = $row->country; } elseif ($ruleSet->multiplier_type === 'SECTION') { $multiplier = $row->section; } if ($multiplier) { $stats['multipliers'][$bandKey][$multiplier] = true; } } } } }, 'qso_results.id', 'id'); if ($stats['total_qso_count'] === 0) { $logResult->update([ 'base_score' => 0, 'penalty_score' => 0, 'multiplier_count' => 0, 'multiplier_score' => 0, 'official_score' => 0, 'valid_qso_count' => 0, 'dupe_qso_count' => 0, 'busted_qso_count' => 0, 'other_error_qso_count' => 0, 'total_qso_count' => 0, 'discarded_qso_count' => 0, 'discarded_points' => 0, 'discarded_qso_percent' => 0, 'unique_qso_count' => 0, 'score_per_qso' => null, ]); EvaluationRun::where('id', $run->id)->increment('progress_done'); return; } $multiplierCount = 1; if ($ruleSet->usesMultipliers()) { $multiplierCount = 0; foreach ($stats['multipliers'] as $values) { $multiplierCount += count($values); } } $baseScore = (int) $stats['base_score']; $penaltyScore = (int) $stats['penalty_score']; $scoreBeforeMultiplier = $baseScore + $penaltyScore; if (! $ruleSet->usesMultipliers()) { $multiplierCount = 1; } $multiplierScore = $ruleSet->usesMultipliers() ? $scoreBeforeMultiplier * $multiplierCount : $scoreBeforeMultiplier; $officialScore = max(0, $multiplierScore); $totalQsoCount = (int) ($stats['total_qso_count'] ?? 0); $discardedQsoCount = (int) ($stats['discarded_qso_count'] ?? 0); $discardedPercent = $totalQsoCount > 0 ? round(($discardedQsoCount / $totalQsoCount) * 100, 2) : 0; $validQsoCount = (int) ($stats['valid_qso_count'] ?? 0); $scorePerQso = $validQsoCount > 0 ? round($officialScore / $validQsoCount, 2) : null; $update = [ 'base_score' => $baseScore, 'penalty_score' => $penaltyScore, 'multiplier_count' => $multiplierCount, 'multiplier_score' => $multiplierScore, 'official_score' => $officialScore, 'valid_qso_count' => $stats['valid_qso_count'], 'dupe_qso_count' => $stats['dupe_qso_count'], 'busted_qso_count' => $stats['busted_qso_count'], 'other_error_qso_count' => $stats['other_error_qso_count'], 'total_qso_count' => $totalQsoCount, 'discarded_qso_count' => $discardedQsoCount, 'discarded_points' => (int) ($stats['discarded_points'] ?? 0), 'discarded_qso_percent' => $discardedPercent, 'unique_qso_count' => (int) ($stats['unique_qso_count'] ?? 0), 'score_per_qso' => $scorePerQso, ]; $outOfWindowThreshold = $ruleSet->out_of_window_dq_threshold; if ($outOfWindowThreshold && in_array($logResult->status, ['OK', 'CHECK'], true)) { $outOfWindowCount = (int) ($stats['out_of_window_qso_count'] ?? 0); if ($outOfWindowCount >= (int) $outOfWindowThreshold) { $reason = 'OUT_OF_WINDOW >= ' . (int) $outOfWindowThreshold; $update['status'] = 'DQ'; $update['status_reason'] = $logResult->status_reason ? $logResult->status_reason . '; ' . $reason : $reason; } } if ( $timeDiffThresholdSec !== null && $timeDiffThresholdPercent !== null && in_array($logResult->status, ['OK', 'CHECK'], true) ) { $matchedCount = (int) ($stats['matched_qso_count'] ?? 0); if ($matchedCount > 0) { $overCount = (int) ($stats['time_diff_over_threshold_count'] ?? 0); $percent = ($overCount / $matchedCount) * 100; if ($percent > (float) $timeDiffThresholdPercent) { $percentLabel = rtrim(rtrim(number_format($percent, 1, '.', ''), '0'), '.'); $reason = sprintf( 'TIME_DIFF > %ss (%s/%s = %s%%)', (int) $timeDiffThresholdSec, $overCount, $matchedCount, $percentLabel ); $update['status'] = 'DQ'; $update['status_reason'] = $logResult->status_reason ? $logResult->status_reason . '; ' . $reason : $reason; } } } if ($badQsoThresholdPercent !== null && in_array($logResult->status, ['OK', 'CHECK'], true)) { $totalCount = (int) ($stats['total_qso_count'] ?? 0); if ($totalCount > 0) { $badCount = (int) ($stats['bad_qso_count'] ?? 0); $percent = ($badCount / $totalCount) * 100; if ($percent >= (float) $badQsoThresholdPercent) { $percentLabel = rtrim(rtrim(number_format($percent, 1, '.', ''), '0'), '.'); $reason = sprintf( 'BAD_QSO >= %s%% (%s/%s = %s%%)', (int) $badQsoThresholdPercent, $badCount, $totalCount, $percentLabel ); $update['status'] = 'DQ'; $update['status_reason'] = $logResult->status_reason ? $logResult->status_reason . '; ' . $reason : $reason; } } } $logResult->update($update); EvaluationRun::where('id', $run->id)->increment('progress_done'); } catch (Throwable $e) { $coordinator->eventError($run, 'Aggregate log: krok selhal.', [ 'step' => 'aggregate', 'round_id' => $run->round_id, 'log_id' => $this->logId, 'error' => $e->getMessage(), ]); throw $e; } } protected function applyOperatingWindow(int $runId, LogResult $logResult, array $window): void { $logQsoIds = LogQso::where('log_id', $logResult->log_id)->pluck('id')->all(); if (! $logQsoIds) { $this->resetOperatingWindow($runId, $logResult); return; } $logResult->update([ 'operating_window_start_utc' => $window['startUtc'], 'operating_window_end_utc' => $window['endUtc'], 'operating_window_2_start_utc' => $window['secondStartUtc'] ?? null, 'operating_window_2_end_utc' => $window['secondEndUtc'] ?? null, 'operating_window_hours' => 6, 'operating_window_qso_count' => $window['qsoCount'], ]); QsoResult::where('evaluation_run_id', $runId) ->whereIn('log_qso_id', $logQsoIds) ->update(['is_operating_window_excluded' => true]); if (! empty($window['includedLogQsoIds'])) { QsoResult::where('evaluation_run_id', $runId) ->whereIn('log_qso_id', $window['includedLogQsoIds']) ->update(['is_operating_window_excluded' => false]); } } protected function resetOperatingWindow(int $runId, LogResult $logResult): void { $logResult->update([ 'operating_window_start_utc' => null, 'operating_window_end_utc' => null, 'operating_window_2_start_utc' => null, 'operating_window_2_end_utc' => null, 'operating_window_hours' => null, 'operating_window_qso_count' => null, ]); $logQsoIds = LogQso::where('log_id', $logResult->log_id)->pluck('id')->all(); if (! $logQsoIds) { return; } QsoResult::where('evaluation_run_id', $runId) ->whereIn('log_qso_id', $logQsoIds) ->update(['is_operating_window_excluded' => false]); } }