Initial commit
This commit is contained in:
36
app/Services/Evaluation/ClaimedRunResolver.php
Normal file
36
app/Services/Evaluation/ClaimedRunResolver.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Evaluation;
|
||||
|
||||
use App\Models\EvaluationRun;
|
||||
|
||||
class ClaimedRunResolver
|
||||
{
|
||||
public static function forRound(int $roundId): EvaluationRun
|
||||
{
|
||||
// Preferujeme poslední CLAIMED run, aby projekce zůstala konzistentní.
|
||||
$run = EvaluationRun::where('round_id', $roundId)
|
||||
->where('rules_version', 'CLAIMED')
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
|
||||
if ($run) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
return self::createNewForRound($roundId);
|
||||
}
|
||||
|
||||
public static function createNewForRound(int $roundId, ?int $createdByUserId = null): EvaluationRun
|
||||
{
|
||||
// CLAIMED run je projekce deklarovaných výsledků, ne finální vyhodnocení.
|
||||
return EvaluationRun::create([
|
||||
'round_id' => $roundId,
|
||||
'rules_version' => 'CLAIMED',
|
||||
'name' => 'Deklarované výsledky',
|
||||
'is_official' => false,
|
||||
'status' => 'PENDING',
|
||||
'created_by_user_id' => $createdByUserId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
380
app/Services/Evaluation/EdiParserService.php
Normal file
380
app/Services/Evaluation/EdiParserService.php
Normal file
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Evaluation;
|
||||
|
||||
use App\Models\Log;
|
||||
use App\Models\LogQso;
|
||||
use App\Models\PowerCategory;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Service: EdiParserService
|
||||
*
|
||||
* Účel:
|
||||
* - Zajišťuje parsing a syntaktickou validaci EDI souborů ve formátu REG1TEST.
|
||||
* - Převádí surový textový EDI vstup do strukturovaných datových struktur
|
||||
* použitelných pro další kroky vyhodnocovací pipeline.
|
||||
*
|
||||
* Kontext v architektuře:
|
||||
* - Používá se výhradně v rámci vyhodnocovacího procesu (EvaluationRun).
|
||||
* - Typicky je volán z jobu ParseLogJob nebo nepřímo přes EvaluationCoordinator.
|
||||
* - Neobsahuje žádnou vyhodnocovací logiku (matching, scoring).
|
||||
*
|
||||
* Co služba dělá:
|
||||
* - Načte obsah EDI souboru (již uloženého ve File storage).
|
||||
* - Provede syntaktickou validaci podle specifikace REG1TEST:
|
||||
* - sekce hlavičky
|
||||
* - povinné klíče
|
||||
* - struktura QSORecords
|
||||
* - Parsuje hlavičku EDI souboru:
|
||||
* - identifikace stanice (PCall, PWWLo)
|
||||
* - metadata závodu (TName, TDate, PBand, PSect, SPowe, ...)
|
||||
* - deklarované (claimed) hodnoty
|
||||
* - Parsuje QSORecords do jednotlivých QSO položek se správným typováním:
|
||||
* - datum / čas
|
||||
* - volací znaky
|
||||
* - reporty (RST)
|
||||
* - pořadová čísla QSO
|
||||
* - lokátory, kódy, flagy
|
||||
* - Provádí základní normalizaci vstupních dat:
|
||||
* - trimming, uppercase
|
||||
* - základní kontrolu formátů (datum, čas, lokátor)
|
||||
*
|
||||
* Co služba NEDĚLÁ:
|
||||
* - neřeší správnost QSO (NIL, busted, duplicity)
|
||||
* - nepočítá vzdálenosti ani body
|
||||
* - neaplikuje pravidla soutěže
|
||||
* - neukládá finální výsledky vyhodnocení
|
||||
*
|
||||
* Výstupy služby:
|
||||
* - Strukturovaná data vhodná pro uložení do entit Log a LogQso
|
||||
* (nebo odpovídajících DTO / Value Objects).
|
||||
* - Informace o syntaktických chybách EDI (pro logování a diagnostiku).
|
||||
*
|
||||
* Zásady návrhu:
|
||||
* - Služba musí být deterministická: stejný EDI vstup => stejný výstup.
|
||||
* - Musí být idempotentní z pohledu pipeline (opakované parsování
|
||||
* nesmí vytvářet nekonzistentní data).
|
||||
* - Implementace má striktně vycházet ze specifikace REG1TEST.
|
||||
* - Veškeré heuristiky nebo odchylky od specifikace musí být explicitní
|
||||
* a dobře zdokumentované.
|
||||
*/
|
||||
class EdiParserService
|
||||
{
|
||||
/**
|
||||
* Služba je bezstavová (stateless).
|
||||
*
|
||||
* Všechny závislosti (např. helpery pro validaci, mapování pásma,
|
||||
* případně přístup ke konfiguraci REG1TEST) mají být injektovány
|
||||
* přes DI container.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Naparsuje EDI soubor a zapíše/aktualizuje data v Log a LogQso.
|
||||
* Založeno na původní logice z LogController::parseUploadedFile.
|
||||
*/
|
||||
public function parseLogFile(Log $log, string $path): void
|
||||
{
|
||||
if (! Storage::exists($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parser pracuje se souborem už uloženým ve storage.
|
||||
$content = $this->sanitizeToUtf8(Storage::get($path));
|
||||
$trimmed = ltrim($content);
|
||||
|
||||
// REG1TEST je jediný podporovaný formát v tomto parseru.
|
||||
if (! str_starts_with($trimmed, '[REG1TEST')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = preg_split('/\r\n|\r|\n/', $content);
|
||||
$headerLines = [];
|
||||
$data = [];
|
||||
$remarksLines = [];
|
||||
$section = 'header';
|
||||
|
||||
// Rozdělení do sekcí: header / remarks / QSORecords.
|
||||
foreach ($lines as $line) {
|
||||
if (str_starts_with($line, '[QSORecords')) {
|
||||
$section = 'qso';
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($line, '[Remarks')) {
|
||||
$section = 'remarks';
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($section === 'header') {
|
||||
$headerLines[] = $line;
|
||||
$trimmedLine = trim($line);
|
||||
if (preg_match('/^([A-Za-z0-9]+)=(.*)$/', $trimmedLine, $m)) {
|
||||
$key = mb_strtoupper($m[1]);
|
||||
$val = trim($m[2]);
|
||||
$data[$key] = $val;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($section === 'remarks') {
|
||||
$remarksLines[] = $line;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Mapování header klíčů na sloupce Log.
|
||||
$update = [];
|
||||
$map = [
|
||||
'TNAME' => 'tname',
|
||||
'TDATE' => 'tdate',
|
||||
'PCALL' => 'pcall',
|
||||
'PWWLO' => 'pwwlo',
|
||||
'PEXCH' => 'pexch',
|
||||
'PSECT' => 'psect',
|
||||
'PBAND' => 'pband',
|
||||
'PCLUB' => 'pclub',
|
||||
'PADR1' => 'padr1',
|
||||
'PADR2' => 'padr2',
|
||||
'RNAME' => 'rname',
|
||||
'RCALL' => 'rcall',
|
||||
'RCOUN' => 'rcoun',
|
||||
'LOCATOR' => 'locator',
|
||||
'RADR1' => 'radr1',
|
||||
'RADR2' => 'radr2',
|
||||
'RPOCO' => 'rpoco',
|
||||
'RCITY' => 'rcity',
|
||||
'RPHON' => 'rphon',
|
||||
'RHBBS' => 'rhbbs',
|
||||
'MOPE1' => 'mope1',
|
||||
'MOPE2' => 'mope2',
|
||||
'STXEQ' => 'stxeq',
|
||||
'SRXEQ' => 'srxeq',
|
||||
'SANTE' => 'sante',
|
||||
'SANTH' => 'santh',
|
||||
'CQSOS' => 'cqsos',
|
||||
'CQSOP' => 'cqsop',
|
||||
'CWWLS' => 'cwwls',
|
||||
'CWWLB' => 'cwwlb',
|
||||
'CEXCS' => 'cexcs',
|
||||
'CEXCB' => 'cexcb',
|
||||
'CDXCS' => 'cdxcs',
|
||||
'CDXCB' => 'cdxcb',
|
||||
'CTOSC' => 'ctosc',
|
||||
'CODXC' => 'codxc',
|
||||
];
|
||||
|
||||
foreach ($map as $ediKey => $dbKey) {
|
||||
if (isset($data[$ediKey]) && $data[$ediKey] !== '') {
|
||||
$update[$dbKey] = $data[$ediKey];
|
||||
}
|
||||
}
|
||||
|
||||
// Detekce 6H kategorie z PSect (založeno na tokenu v hlavičce).
|
||||
if (isset($data['PSECT'])
|
||||
&& (stripos($data['PSECT'], '6H') !== false
|
||||
|| stripos($data['PSECT'], '6') !== false)
|
||||
) {
|
||||
$update['sixhr_category'] = true;
|
||||
}
|
||||
|
||||
$rawHeader = implode("\n", $headerLines);
|
||||
if ($rawHeader !== '') {
|
||||
$update['raw_header'] = $rawHeader;
|
||||
}
|
||||
|
||||
if (! empty($update)) {
|
||||
$log->fill($update);
|
||||
$log->save();
|
||||
}
|
||||
|
||||
// Claims / totals jsou deklarované hodnoty účastníka v hlavičce.
|
||||
$claimedQsoRaw = $data['CQSOS'] ?? $log->cqsos ?? null;
|
||||
if ($claimedQsoRaw !== null && $log->claimed_qso_count === null) {
|
||||
$parts = explode(';', (string) $claimedQsoRaw);
|
||||
$log->claimed_qso_count = isset($parts[0]) ? (int) $parts[0] : null;
|
||||
}
|
||||
$claimedScoreRaw = $data['CTOSC'] ?? $log->ctosc ?? null;
|
||||
if ($claimedScoreRaw !== null && $log->claimed_score === null) {
|
||||
$log->claimed_score = is_numeric($claimedScoreRaw) ? (int) $claimedScoreRaw : null;
|
||||
}
|
||||
$claimedWwlRaw = $data['CWWLS'] ?? $log->cwwls ?? null;
|
||||
if ($claimedWwlRaw !== null && $log->claimed_wwl === null) {
|
||||
$parts = explode(';', (string) $claimedWwlRaw);
|
||||
$log->claimed_wwl = isset($parts[0]) ? $parts[0] : null;
|
||||
}
|
||||
$claimedDxccRaw = $data['CDXCS'] ?? $log->cdxcs ?? null;
|
||||
if ($claimedDxccRaw !== null && $log->claimed_dxcc === null) {
|
||||
$parts = explode(';', (string) $claimedDxccRaw);
|
||||
$log->claimed_dxcc = isset($parts[0]) ? $parts[0] : null;
|
||||
}
|
||||
// SPowe se ukládá jako výkon ve wattech, akceptujeme i desetinný formát 0,5 / 0.5.
|
||||
if (isset($data['SPOWE'])) {
|
||||
$parsedPower = $this->parseSpoweValue($data['SPOWE']);
|
||||
if ($parsedPower !== null) {
|
||||
$log->power_watt = $parsedPower;
|
||||
}
|
||||
}
|
||||
if (isset($data['PSECT'])) {
|
||||
// Power kategorie se odvozuje z PSect (fallback z výkonu).
|
||||
$powerName = $this->extractPowerFromPsect($data['PSECT'], $log->power_watt) ?? 'A';
|
||||
$log->power_category = $powerName;
|
||||
$log->power_category_id = PowerCategory::whereRaw('UPPER(name) = ?', [mb_strtoupper($powerName)])
|
||||
->value('id');
|
||||
}
|
||||
$log->save();
|
||||
|
||||
if (! empty($remarksLines)) {
|
||||
$log->remarks = implode("\n", $remarksLines);
|
||||
$log->save();
|
||||
}
|
||||
|
||||
// Smazat staré QSO a znovu naparsovat QSORecords (idempotence).
|
||||
LogQso::where('log_id', $log->id)->delete();
|
||||
|
||||
$qsoIndex = 1;
|
||||
$qsoStarted = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (str_starts_with($line, '[QSORecords')) {
|
||||
$qsoStarted = true;
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($line, '[END')) {
|
||||
break;
|
||||
}
|
||||
if (! $qsoStarted) {
|
||||
continue;
|
||||
}
|
||||
if (trim($line) === '' || str_starts_with($line, '[')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// QSO záznam je oddělený středníky podle REG1TEST.
|
||||
$parts = explode(';', $line);
|
||||
if (count($parts) < 4) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dateRaw = $parts[0] ?? '';
|
||||
$timeRaw = $parts[1] ?? '';
|
||||
|
||||
$timeOn = null;
|
||||
if (strlen($dateRaw) === 6 && strlen($timeRaw) >= 3) {
|
||||
$year = substr($dateRaw, 0, 2);
|
||||
$month = substr($dateRaw, 2, 2);
|
||||
$day = substr($dateRaw, 4, 2);
|
||||
$timeRaw = str_pad($timeRaw, 4, '0', STR_PAD_LEFT);
|
||||
$hour = substr($timeRaw, 0, 2);
|
||||
$minute = substr($timeRaw, 2, 2);
|
||||
$yearFull = 2000 + (int) $year;
|
||||
$validDate = checkdate((int) $month, (int) $day, $yearFull);
|
||||
$validTime = (int) $hour >= 0 && (int) $hour <= 23 && (int) $minute >= 0 && (int) $minute <= 59;
|
||||
if ($validDate && $validTime) {
|
||||
$timeOn = sprintf('20%s-%s-%s %s:%s:00', $year, $month, $day, $hour, $minute);
|
||||
}
|
||||
}
|
||||
|
||||
$qsoData = [
|
||||
'log_id' => $log->id,
|
||||
'qso_index' => $qsoIndex++,
|
||||
'time_on' => $timeOn,
|
||||
'band' => $log->pband,
|
||||
'freq_khz' => null,
|
||||
'my_call' => $log->pcall,
|
||||
'my_locator' => $log->pwwlo,
|
||||
'mode_code' => $parts[3] ?? null,
|
||||
'dx_call' => $parts[2] ?? null,
|
||||
'my_rst' => $parts[4] ?? null,
|
||||
'my_serial' => $parts[5] ?? null,
|
||||
'dx_rst' => $parts[6] ?? null,
|
||||
'dx_serial' => $parts[7] ?? null,
|
||||
'rx_exchange' => $parts[8] ?? null,
|
||||
'rx_wwl' => $parts[9] ?? null,
|
||||
'points' => isset($parts[10]) ? (int) $parts[10] : null,
|
||||
'new_exchange'=> (isset($parts[11]) && strtoupper($parts[11]) === 'N') ? true : null,
|
||||
'new_wwl' => (isset($parts[12]) && strtoupper($parts[12]) === 'N') ? true : null,
|
||||
'new_dxcc' => (isset($parts[13]) && strtoupper($parts[13]) === 'N') ? true : null,
|
||||
'duplicate_qso'=> (isset($parts[14]) && strtoupper($parts[14]) === 'D') ? true : null,
|
||||
'raw_line' => $line,
|
||||
];
|
||||
|
||||
LogQso::create($qsoData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snaží se převést obsah do validního UTF-8, ignoruje nevalidní sekvence.
|
||||
*/
|
||||
protected function sanitizeToUtf8(string $content): string
|
||||
{
|
||||
if (str_starts_with($content, "\xEF\xBB\xBF")) {
|
||||
$content = substr($content, 3);
|
||||
}
|
||||
|
||||
$converted = @iconv('UTF-8', 'UTF-8//IGNORE', $content);
|
||||
if ($converted === false) {
|
||||
$converted = mb_convert_encoding($content, 'UTF-8', 'auto');
|
||||
}
|
||||
|
||||
return $converted ?? '';
|
||||
}
|
||||
|
||||
protected function extractPowerFromPsect(string $psect, ?float $powerWatt): ?string
|
||||
{
|
||||
$value = mb_strtoupper($psect);
|
||||
if (preg_match('/\\bQRP\\b/', $value)) {
|
||||
return 'QRP';
|
||||
}
|
||||
if (preg_match('/\\bLP\\b/', $value)) {
|
||||
return 'LP';
|
||||
}
|
||||
if (preg_match('/\\bN\\b/', $value)) {
|
||||
return 'N';
|
||||
}
|
||||
if (preg_match('/\\bA\\b/', $value)) {
|
||||
return 'A';
|
||||
}
|
||||
if ($powerWatt !== null) {
|
||||
$category = PowerCategory::whereNotNull('power_level')
|
||||
->where('power_level', '>=', $powerWatt)
|
||||
->orderBy('power_level')
|
||||
->first();
|
||||
if ($category && $category->name) {
|
||||
return mb_strtoupper($category->name);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function parseSpoweValue(?string $raw): ?float
|
||||
{
|
||||
$value = trim((string) $raw);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$compact = strtolower(preg_replace('/\s+/', '', $value) ?? '');
|
||||
$factor = 1.0;
|
||||
if (str_ends_with($compact, 'kw')) {
|
||||
$compact = substr($compact, 0, -2);
|
||||
$factor = 1000.0;
|
||||
} elseif (str_ends_with($compact, 'mw')) {
|
||||
$compact = substr($compact, 0, -2);
|
||||
$factor = 0.001;
|
||||
} elseif (str_ends_with($compact, 'w')) {
|
||||
$compact = substr($compact, 0, -1);
|
||||
}
|
||||
|
||||
$compact = str_replace(',', '.', $compact);
|
||||
if (! preg_match('/^[0-9]*\\.?[0-9]+$/', $compact)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$numeric = (float) $compact * $factor;
|
||||
return is_finite($numeric) ? $numeric : null;
|
||||
}
|
||||
}
|
||||
372
app/Services/Evaluation/EvaluationCoordinator.php
Normal file
372
app/Services/Evaluation/EvaluationCoordinator.php
Normal file
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Evaluation;
|
||||
|
||||
use App\Jobs\DispatchAggregateResultsJobsJob;
|
||||
use App\Jobs\DispatchBuildWorkingSetJobsJob;
|
||||
use App\Jobs\DispatchMatchJobsJob;
|
||||
use App\Jobs\DispatchScoreJobsJob;
|
||||
use App\Jobs\DispatchUnpairedJobsJob;
|
||||
use App\Jobs\FinalizeRunJob;
|
||||
use App\Jobs\MatchQsoBucketJob;
|
||||
use App\Jobs\DispatchParseLogsJobsJob;
|
||||
use App\Jobs\PauseEvaluationRunJob;
|
||||
use App\Jobs\PrepareRunJob;
|
||||
use App\Models\EvaluationLock;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\EvaluationRunEvent;
|
||||
use App\Models\WorkingQso;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Service: EvaluationCoordinator
|
||||
*
|
||||
* Účel:
|
||||
* - Koordinátor vyhodnocovací pipeline na úrovni service layer.
|
||||
* - Centralizuje orchestrace vyhodnocení (EvaluationRun) tak, aby joby
|
||||
* zůstaly tenké a obsahovaly pouze řízení toku a minimální glue kód.
|
||||
*
|
||||
* Kontext v architektuře:
|
||||
* - Joby (PrepareRunJob, DispatchParseLogsJobsJob, DispatchBuildWorkingSetJobsJob, MatchQsoBucketJob,
|
||||
* ScoreGroupJob, DispatchAggregateResultsJobsJob, FinalizeRunJob) mají být co nejvíce
|
||||
* bez logiky: pouze načtou kontext běhu, zavolají metodu koordinátoru/služby
|
||||
* a zapíší stav/progress.
|
||||
* - EvaluationCoordinator deleguje konkrétní operace na specializované služby:
|
||||
* - EdiParserService (parsing EDI, validace formátu, mapování hlaviček/QSO)
|
||||
* - MatchingService (matching protistanic, NIL/busted/duplicate/out-of-window)
|
||||
* - ScoringService (výpočet bodů dle rule setu, multiplikátory, policy)
|
||||
* - ResultsAggregationService (součty, ranking, per-log agregace)
|
||||
* - EvaluationFinalizerService (finalizace běhu, publikace, uvolnění locků)
|
||||
*
|
||||
* Co koordinátor řeší:
|
||||
* - Konzistentní práci se "scope" vyhodnocení (round/band/category/power).
|
||||
* - Determinismus a idempotenci kroků (stejné vstupy => stejné výstupy).
|
||||
* - Transakční hranice a bezpečné zápisy (staging vs finální tabulky).
|
||||
* - Aktualizace stavu vyhodnocovacího běhu:
|
||||
* - current_step
|
||||
* - progress_total/progress_done
|
||||
* - run events (EvaluationRunEvent) pro UI monitoring
|
||||
*
|
||||
* Co koordinátor nedělá:
|
||||
* - Není to HTTP vrstva (žádné request/response).
|
||||
* - Není to UI ani prezentace.
|
||||
* - Nemá obsahovat detailní algoritmy parsingu/matchingu/scoringu;
|
||||
* ty patří do dedikovaných služeb.
|
||||
*
|
||||
* Doporučené zásady implementace:
|
||||
* - Všechny metody mají být navrženy tak, aby byly bezpečné pro opakované
|
||||
* spuštění (idempotentní).
|
||||
* - Vstupem je vždy identifikátor nebo instance EvaluationRun + volitelně scope.
|
||||
* - Vracej strukturované výsledky (DTO/Value Objects) a drž zápisy do DB
|
||||
* na jasně definovaných místech.
|
||||
*/
|
||||
class EvaluationCoordinator
|
||||
{
|
||||
/**
|
||||
* Koordinátor je typicky bezstavový (stateless) a jeho závislosti jsou
|
||||
* injektované přes DI container.
|
||||
*
|
||||
* V praxi sem budou patřit služby typu EdiParserService, MatchingService,
|
||||
* ScoringService, ResultsAggregationService a případně repozitáře.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function start(EvaluationRun $run): void
|
||||
{
|
||||
if ($run->isCanceled()) {
|
||||
return;
|
||||
}
|
||||
$lockKey = $this->lockKey($run);
|
||||
$lock = EvaluationLock::acquire(
|
||||
key: $lockKey,
|
||||
run: $run,
|
||||
ttl: 7200
|
||||
);
|
||||
|
||||
if (! $lock) {
|
||||
$run->update([
|
||||
'status' => 'FAILED',
|
||||
'error' => 'Nelze spustit vyhodnocení: probíhá jiný běh pro stejné kolo.',
|
||||
]);
|
||||
$this->event($run, 'error', 'StartEvaluationRunJob selhal: lock je držen jiným během.');
|
||||
return;
|
||||
}
|
||||
|
||||
$run->update(['batch_id' => null]);
|
||||
$this->transition($run, $run->status, 'RUNNING', 'start', [
|
||||
'started_at' => $run->started_at ?? now(),
|
||||
]);
|
||||
$this->event($run, 'info', 'Spuštění vyhodnocení.', [
|
||||
'step' => 'start',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
|
||||
Bus::chain([
|
||||
new PrepareRunJob($run->id),
|
||||
new DispatchParseLogsJobsJob($run->id),
|
||||
])->catch(function (Throwable $e) use ($run, $lockKey) {
|
||||
$this->fail($run, $e, $lockKey);
|
||||
})->onQueue('evaluation')->dispatch();
|
||||
}
|
||||
|
||||
public function resume(EvaluationRun $run, array $options = []): bool
|
||||
{
|
||||
if ($run->isCanceled()) {
|
||||
return false;
|
||||
}
|
||||
$lockKey = $this->lockKey($run);
|
||||
if (! EvaluationLock::isLocked($lockKey)) {
|
||||
$lock = EvaluationLock::acquire(
|
||||
key: $lockKey,
|
||||
run: $run,
|
||||
ttl: 7200
|
||||
);
|
||||
if (! $lock) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($run->status === 'WAITING_REVIEW_INPUT') {
|
||||
$this->transition($run, 'WAITING_REVIEW_INPUT', 'RUNNING', 'resume_input');
|
||||
$this->event($run, 'info', 'Pokračování po kontrole vstupů.', [
|
||||
'step' => 'resume_input',
|
||||
'round_id' => $run->round_id,
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
$jobs = [];
|
||||
if (! empty($options['rebuild_working_set'])) {
|
||||
$jobs[] = new DispatchBuildWorkingSetJobsJob($run->id);
|
||||
}
|
||||
$jobs[] = new DispatchMatchJobsJob($run->id);
|
||||
|
||||
Bus::chain($jobs)->onQueue('evaluation')->dispatch();
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($run->status === 'WAITING_REVIEW_MATCH') {
|
||||
$this->transition($run, 'WAITING_REVIEW_MATCH', 'RUNNING', 'resume_match');
|
||||
$this->event($run, 'info', 'Pokračování po kontrole matchingu.', [
|
||||
'step' => 'resume_match',
|
||||
'round_id' => $run->round_id,
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
DispatchScoreJobsJob::dispatch($run->id)->onQueue('evaluation');
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($run->status === 'WAITING_REVIEW_SCORE') {
|
||||
$this->transition($run, 'WAITING_REVIEW_SCORE', 'RUNNING', 'resume_score');
|
||||
$this->event($run, 'info', 'Pokračování po kontrole skóre.', [
|
||||
'step' => 'resume_score',
|
||||
'round_id' => $run->round_id,
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
Bus::chain([
|
||||
new FinalizeRunJob($run->id, $lockKey),
|
||||
])->onQueue('evaluation')->dispatch();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function fail(EvaluationRun $run, Throwable $e, ?string $lockKey = null): void
|
||||
{
|
||||
$run->update([
|
||||
'status' => 'FAILED',
|
||||
'error' => $e->getMessage(),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
$this->event($run, 'error', "Evaluation run selhal: {$e->getMessage()}", [
|
||||
'step' => 'chain',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
if ($lockKey) {
|
||||
EvaluationLock::release($lockKey, $run);
|
||||
}
|
||||
}
|
||||
|
||||
public function dispatchStep(EvaluationRun $run, string $step): void
|
||||
{
|
||||
if ($step === 'match') {
|
||||
$this->dispatchMatch($run);
|
||||
return;
|
||||
}
|
||||
if ($step === 'score') {
|
||||
$this->dispatchScore($run);
|
||||
}
|
||||
}
|
||||
|
||||
public function transition(EvaluationRun $run, string $from, string $to, ?string $step = null, array $extra = []): bool
|
||||
{
|
||||
if ($from !== '*' && $run->status !== $from) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = array_merge([
|
||||
'status' => $to,
|
||||
], $extra);
|
||||
|
||||
if ($step !== null) {
|
||||
$payload['current_step'] = $step;
|
||||
}
|
||||
|
||||
$run->update($payload);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function event(EvaluationRun $run, string $level, string $message, array $context = []): void
|
||||
{
|
||||
EvaluationRunEvent::create([
|
||||
'evaluation_run_id' => $run->id,
|
||||
'level' => $level,
|
||||
'message' => $message,
|
||||
'context' => $context,
|
||||
]);
|
||||
}
|
||||
|
||||
public function eventInfo(EvaluationRun $run, string $message, array $context = []): void
|
||||
{
|
||||
$this->event($run, 'info', $message, $context);
|
||||
}
|
||||
|
||||
public function eventWarn(EvaluationRun $run, string $message, array $context = []): void
|
||||
{
|
||||
$this->event($run, 'warning', $message, $context);
|
||||
}
|
||||
|
||||
public function eventError(EvaluationRun $run, string $message, array $context = []): void
|
||||
{
|
||||
$this->event($run, 'error', $message, $context);
|
||||
}
|
||||
|
||||
public function progressInit(EvaluationRun $run, int $total, int $done = 0): void
|
||||
{
|
||||
$run->update([
|
||||
'progress_total' => $total,
|
||||
'progress_done' => $done,
|
||||
]);
|
||||
}
|
||||
|
||||
public function progressTick(EvaluationRun $run, int $n = 1): void
|
||||
{
|
||||
EvaluationRun::where('id', $run->id)->increment('progress_done', $n);
|
||||
}
|
||||
|
||||
protected function dispatchMatch(EvaluationRun $run): void
|
||||
{
|
||||
if ($run->isCanceled()) {
|
||||
return;
|
||||
}
|
||||
$bandIds = $run->scope['band_ids'] ?? [];
|
||||
if (! $bandIds) {
|
||||
$bandIds = WorkingQso::where('evaluation_run_id', $run->id)
|
||||
->distinct()
|
||||
->pluck('band_id')
|
||||
->all();
|
||||
}
|
||||
if (! $bandIds) {
|
||||
$bandIds = [null];
|
||||
}
|
||||
|
||||
$this->transition($run, $run->status, 'RUNNING', 'match');
|
||||
$jobs = [];
|
||||
foreach ($bandIds as $bandId) {
|
||||
$callNorms = WorkingQso::where('evaluation_run_id', $run->id)
|
||||
->when($bandId !== null, fn ($q) => $q->where('band_id', $bandId), fn ($q) => $q->whereNull('band_id'))
|
||||
->distinct()
|
||||
->pluck('call_norm')
|
||||
->all();
|
||||
|
||||
if (! $callNorms) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($callNorms as $callNorm) {
|
||||
$jobs[] = new MatchQsoBucketJob($run->id, $bandId, $callNorm, 1);
|
||||
$jobs[] = new MatchQsoBucketJob($run->id, $bandId, $callNorm, 2);
|
||||
}
|
||||
}
|
||||
|
||||
$this->progressInit($run, count($jobs) + 2, 0);
|
||||
$this->event($run, 'info', 'Spuštění matchingu.', [
|
||||
'step' => 'match',
|
||||
'round_id' => $run->round_id,
|
||||
'step_progress_done' => 0,
|
||||
'step_progress_total' => count($jobs),
|
||||
]);
|
||||
|
||||
$next = function () use ($run) {
|
||||
Bus::chain([
|
||||
new DispatchUnpairedJobsJob($run->id),
|
||||
])->onQueue('evaluation')->dispatch();
|
||||
};
|
||||
|
||||
if (! $jobs) {
|
||||
$next();
|
||||
return;
|
||||
}
|
||||
|
||||
$batch = Bus::batch($jobs)
|
||||
->then($next)
|
||||
->onQueue('evaluation')
|
||||
->dispatch();
|
||||
$run->update(['batch_id' => $batch->id]);
|
||||
}
|
||||
|
||||
protected function dispatchScore(EvaluationRun $run): void
|
||||
{
|
||||
if ($run->isCanceled()) {
|
||||
return;
|
||||
}
|
||||
$groups = $run->scope['groups'] ?? [
|
||||
[
|
||||
'key' => 'all',
|
||||
'band_id' => null,
|
||||
'category_id' => null,
|
||||
'power_category_id' => null,
|
||||
],
|
||||
];
|
||||
|
||||
$this->transition($run, $run->status, 'RUNNING', 'score');
|
||||
$this->progressInit($run, count($groups), 0);
|
||||
$this->event($run, 'info', 'Spuštění scoringu.', [
|
||||
'step' => 'score',
|
||||
'round_id' => $run->round_id,
|
||||
'step_progress_done' => 0,
|
||||
'step_progress_total' => count($groups),
|
||||
]);
|
||||
|
||||
$jobs = [];
|
||||
foreach ($groups as $group) {
|
||||
$jobs[] = new \App\Jobs\ScoreGroupJob(
|
||||
$run->id,
|
||||
$group['key'] ?? 'all',
|
||||
$group
|
||||
);
|
||||
}
|
||||
|
||||
$batch = Bus::batch($jobs)
|
||||
->then(function () use ($run) {
|
||||
Bus::chain([
|
||||
new DispatchAggregateResultsJobsJob($run->id),
|
||||
])->onQueue('evaluation')->dispatch();
|
||||
})
|
||||
->onQueue('evaluation')
|
||||
->dispatch();
|
||||
$run->update(['batch_id' => $batch->id]);
|
||||
}
|
||||
|
||||
protected function lockKey(EvaluationRun $run): string
|
||||
{
|
||||
return "evaluation:round:{$run->round_id}";
|
||||
}
|
||||
}
|
||||
153
app/Services/Evaluation/MatchingService.php
Normal file
153
app/Services/Evaluation/MatchingService.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Evaluation;
|
||||
|
||||
use App\Models\EvaluationRuleSet;
|
||||
|
||||
/**
|
||||
* Service: MatchingService
|
||||
*
|
||||
* Účel:
|
||||
* - Implementuje matching (párování) QSO mezi deníky v rámci vyhodnocení.
|
||||
* - Pro každý QSO záznam hledá odpovídající protizáznam v logu protistanice
|
||||
* a rozhoduje o stavu QSO (matched / NIL / duplicate / busted / out-of-window).
|
||||
*
|
||||
* Kontext v architektuře:
|
||||
* - Používá se v rámci vyhodnocovací pipeline (EvaluationRun), typicky
|
||||
* z jobu MatchQsoGroupJob nebo nepřímo přes EvaluationCoordinator.
|
||||
* - Pracuje nad pracovním datasetem (working set) připraveným BuildWorkingSetLogJob
|
||||
* a nad pravidly z EvaluationRuleSet (time tolerance, policy/volby).
|
||||
*
|
||||
* Co služba dělá:
|
||||
* - Provede párování QSO v daném scope (group), např.:
|
||||
* - round_id
|
||||
* - band_id
|
||||
* - category_id
|
||||
* - power_category_id
|
||||
* - Pro každý QSO:
|
||||
* - vyhledá kandidátní protistanici podle volací značky (a případných voleb)
|
||||
* - ověří časovou shodu v rámci tolerance (time_tolerance)
|
||||
* - ověří shodu výměny (RST / pořadové číslo / kód / lokátor dle soutěže)
|
||||
* - vyhodnotí příznaky:
|
||||
* - NIL (nenalezen protizáznam)
|
||||
* - duplicate (duplicitní QSO)
|
||||
* - busted_call (neshoda značky)
|
||||
* - busted_exchange (neshoda výměny / kódu)
|
||||
* - out_of_window (mimo časové okno kola, pokud se řeší v matchingu)
|
||||
* - nastaví vazbu na matched QSO (pokud existuje)
|
||||
*
|
||||
* Co služba NEDĚLÁ:
|
||||
* - neprovádí parsing EDI (to je EdiParserService)
|
||||
* - nepočítá body ani skóre (to je ScoringService)
|
||||
* - neagreguje výsledky a pořadí (to je ResultsAggregationService)
|
||||
* - neřeší publikaci výsledků (to je finalizace běhu)
|
||||
*
|
||||
* Výstupy služby:
|
||||
* - Mezivýsledky matchingu vhodné pro uložení do QsoResult (se svázáním
|
||||
* na evaluation_run_id), případně do pracovních/staging tabulek.
|
||||
* - Strukturované diagnostiky (např. počty NIL/dup/busted) pro monitoring.
|
||||
*
|
||||
* Zásady návrhu:
|
||||
* - Determinismus: stejné vstupy => stejné párování a stejné příznaky.
|
||||
* - Idempotence: opakované spuštění pro stejný run+group musí přepsat
|
||||
* předchozí mezivýsledky bez duplicit.
|
||||
* - Výkon: matching musí být navržen pro dávkové zpracování (chunking),
|
||||
* s využitím indexů a minimalizací N+1 dotazů.
|
||||
* - Pravidla: veškeré odchylky (např. ignorovat část značky za lomítkem,
|
||||
* tolerovat třetí znak reportu) musí být řízené konfigurací (RuleSet/options)
|
||||
* a explicitně testované.
|
||||
*/
|
||||
class MatchingService
|
||||
{
|
||||
/**
|
||||
* Služba je bezstavová (stateless).
|
||||
*
|
||||
* V praxi sem patří injektované závislosti, např.:
|
||||
* - repository / query helpery pro čtení pracovních dat
|
||||
* - helpery pro normalizaci značek/lokátorů
|
||||
* - výpočet vzdálenosti (pokud je potřeba pro rozhodování)
|
||||
*
|
||||
* Matching algoritmus a jeho varianty mají být testované a řízené
|
||||
* konfigurací soutěže (EvaluationRuleSet).
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizuje volaci znak podle pravidel rulesetu.
|
||||
*
|
||||
* - STRICT: pouze trim + uppercase.
|
||||
* - IGNORE_SUFFIX: odstrani suffix za lomitkem (/P, /M, /9...).
|
||||
*/
|
||||
public function normalizeCallsign(string $call, EvaluationRuleSet $ruleSet): string
|
||||
{
|
||||
$value = mb_strtoupper(trim($call));
|
||||
$value = preg_replace('/\s+/', '', $value) ?? '';
|
||||
|
||||
if ($ruleSet->ignoreSlashPart()) {
|
||||
// Ignoruje portable suffixy (/P, /M, /9...), ale ponechá základ prefixu.
|
||||
$value = $this->stripCallsignSuffix($value, $ruleSet->callsignSuffixMaxLen());
|
||||
} elseif ($ruleSet->ignoreThirdPart()) {
|
||||
// Zachová první dvě části (např. OK1ABC/1), zbytek odřízne.
|
||||
$parts = explode('/', $value);
|
||||
if (count($parts) > 2) {
|
||||
$value = $parts[0] . '/' . $parts[1];
|
||||
}
|
||||
} elseif ($ruleSet->callsign_normalization === 'IGNORE_SUFFIX') {
|
||||
$value = $this->stripCallsignSuffix($value, $ruleSet->callsignSuffixMaxLen());
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
protected function stripCallsignSuffix(string $value, int $maxLen): string
|
||||
{
|
||||
$parts = explode('/', $value);
|
||||
if (count($parts) < 2) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$suffix = end($parts) ?: '';
|
||||
if ($suffix !== '' && mb_strlen($suffix) <= $maxLen) {
|
||||
array_pop($parts);
|
||||
return implode('/', $parts);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rozhodne, zda jsou dve QSO ve stejnem dupe scope.
|
||||
* MVP: BAND nebo BAND_MODE.
|
||||
*/
|
||||
public function isSameDupeScope(array $qsoA, array $qsoB, EvaluationRuleSet $ruleSet): bool
|
||||
{
|
||||
// Dupe scope určuje, zda se duplicity řeší jen v pásmu nebo i v rámci módu.
|
||||
$bandMatch = ($qsoA['band_id'] ?? null) === ($qsoB['band_id'] ?? null);
|
||||
if (! $bandMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($ruleSet->dupe_scope === 'BAND_MODE') {
|
||||
return ($qsoA['mode'] ?? null) === ($qsoB['mode'] ?? null);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vyhodnoti, zda QSO spada mimo casove okno kola.
|
||||
* MVP: pouze vraci boolean pro dalsi zpracovani.
|
||||
*/
|
||||
public function isOutOfWindow(?\DateTimeInterface $qsoTime, ?\DateTimeInterface $start, ?\DateTimeInterface $end): bool
|
||||
{
|
||||
if (! $qsoTime || ! $start || ! $end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// True => další kroky rozhodnou, zda QSO zneplatnit, penalizovat nebo DQ log.
|
||||
return $qsoTime < $start || $qsoTime > $end;
|
||||
}
|
||||
}
|
||||
397
app/Services/Evaluation/OperatingWindowService.php
Normal file
397
app/Services/Evaluation/OperatingWindowService.php
Normal file
@@ -0,0 +1,397 @@
|
||||
<?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'];
|
||||
}
|
||||
}
|
||||
189
app/Services/Evaluation/ScoringService.php
Normal file
189
app/Services/Evaluation/ScoringService.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Evaluation;
|
||||
|
||||
use App\Models\EvaluationRuleSet;
|
||||
|
||||
/**
|
||||
* Service: ScoringService
|
||||
*
|
||||
* Účel:
|
||||
* - Implementuje bodování (scoring) QSO a výpočet dílčích součtů v rámci
|
||||
* vyhodnocení (EvaluationRun).
|
||||
* - Převádí mezivýsledky matchingu (např. QsoResult s flagy NIL/busted/duplicate)
|
||||
* na bodové ohodnocení podle EvaluationRuleSet.
|
||||
*
|
||||
* Kontext v architektuře:
|
||||
* - Používá se v rámci vyhodnocovací pipeline, typicky z jobu ScoreGroupJob
|
||||
* nebo nepřímo přes EvaluationCoordinator.
|
||||
* - Vstupem jsou data připravená BuildWorkingSetLogJob a výsledky matchingu
|
||||
* z MatchQsoGroupJob.
|
||||
*
|
||||
* Co služba dělá:
|
||||
* - Vypočítá body pro každé QSO podle režimu bodování:
|
||||
* - DISTANCE: body = vzdálenost_km * points_per_km
|
||||
* - FIXED_POINTS: body = points_per_qso
|
||||
* - Aplikuje policy pro problematická QSO dle EvaluationRuleSet:
|
||||
* - duplicity (dup_qso_policy: COUNT_ONCE / ZERO_POINTS / PENALTY)
|
||||
* - NIL (nil_qso_policy: ZERO_POINTS / PENALTY)
|
||||
* - busted_call (busted_call_policy: ZERO_POINTS / PENALTY)
|
||||
* - busted_exchange (busted_exchange_policy: ZERO_POINTS / PENALTY)
|
||||
* - Připravuje podklady pro multiplikátory (pokud use_multipliers = true):
|
||||
* - WWL / DXCC / SECTION / COUNTRY (multiplier_type)
|
||||
* Pozn.: samotná agregace multiplikátorů se typicky dělá až v agregační vrstvě.
|
||||
* - Vytváří mezivýsledky pro agregaci:
|
||||
* - per-QSO body a penalizace
|
||||
* - per-log součty (pokud se počítají v této vrstvě)
|
||||
*
|
||||
* Co služba NEDĚLÁ:
|
||||
* - neparsuje EDI (to je EdiParserService)
|
||||
* - neprovádí matching QSO (to je MatchingService)
|
||||
* - neřeší finální pořadí a publikaci výsledků (to je agregace/finalizace)
|
||||
*
|
||||
* Výstupy služby:
|
||||
* - Strukturované bodové výsledky připravené k uložení do staging tabulek
|
||||
* svázaných s evaluation_run_id (např. QsoResult/LogResult).
|
||||
* - Souhrnné statistiky (počty penalizovaných QSO apod.) pro monitoring.
|
||||
*
|
||||
* Zásady návrhu:
|
||||
* - Determinismus: stejné vstupy => stejné body, penalizace a příznaky.
|
||||
* - Idempotence: opakovaný výpočet pro stejný run+group musí přepsat
|
||||
* předchozí mezivýsledky bez duplicit.
|
||||
* - Výkon: výpočty musí být dávkové (chunking), bez N+1 dotazů,
|
||||
* s využitím indexů a minimalizací zápisů.
|
||||
* - Přesnost: výpočet vzdálenosti (pokud se používá) musí být jednotný
|
||||
* v celé aplikaci (stejná funkce, stejné zaokrouhlení).
|
||||
*/
|
||||
class ScoringService
|
||||
{
|
||||
/**
|
||||
* Služba je bezstavová (stateless).
|
||||
*
|
||||
* Závislosti (např. kalkulátor vzdálenosti z WWL, mapování multiplikátorů,
|
||||
* repo/query helpery) mají být injektované přes DI container.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Spocte zakladni body za QSO podle scoring modu a pravidel vzdalenosti.
|
||||
*/
|
||||
public function computeBasePoints(?float $distanceKm, EvaluationRuleSet $ruleSet): int
|
||||
{
|
||||
if ($ruleSet->scoring_mode === 'FIXED_POINTS') {
|
||||
return (int) $ruleSet->points_per_qso;
|
||||
}
|
||||
|
||||
if ($distanceKm === null) {
|
||||
// Bez vzdálenosti (např. chybějící lokátory) se body neudělují.
|
||||
return 0;
|
||||
}
|
||||
|
||||
$distance = $this->applyDistanceRounding($distanceKm, $ruleSet);
|
||||
$minDistance = $ruleSet->minDistanceKm();
|
||||
if ($minDistance !== null && $distance < $minDistance) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) round($distance * (float) $ruleSet->points_per_km);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplikuje zaokrouhleni vzdalenosti.
|
||||
*/
|
||||
public function applyDistanceRounding(float $distanceKm, EvaluationRuleSet $ruleSet): float
|
||||
{
|
||||
// Jednotné zaokrouhlení vzdálenosti podle pravidel soutěže.
|
||||
return match ($ruleSet->distanceRounding()) {
|
||||
'CEIL' => (float) ceil($distanceKm),
|
||||
'ROUND' => (float) round($distanceKm),
|
||||
default => (float) floor($distanceKm),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrati penalizaci pro dany typ chyby.
|
||||
*/
|
||||
public function penaltyPointsFor(string $errorCode, EvaluationRuleSet $ruleSet): int
|
||||
{
|
||||
// Penalizace jsou konfigurované přímo v rulesetu, ne v kódu.
|
||||
return match ($errorCode) {
|
||||
'DUP' => (int) $ruleSet->penalty_dup_points,
|
||||
'NIL' => (int) $ruleSet->penalty_nil_points,
|
||||
'BUSTED_CALL' => (int) $ruleSet->penalty_busted_call_points,
|
||||
'BUSTED_RST' => (int) $ruleSet->penalty_busted_rst_points,
|
||||
'BUSTED_EXCHANGE' => (int) $ruleSet->penalty_busted_exchange_points,
|
||||
'BUSTED_SERIAL' => (int) ($ruleSet->penalty_busted_serial_points ?? 0),
|
||||
'BUSTED_LOCATOR' => (int) ($ruleSet->penalty_busted_locator_points ?? 0),
|
||||
'OUT_OF_WINDOW' => (int) $ruleSet->penalty_out_of_window_points,
|
||||
'TIME_MISMATCH' => (int) $ruleSet->penalty_nil_points,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rozhodne, jak nalozit s QSO mimo casove okno kola.
|
||||
*/
|
||||
public function outOfWindowDecision(EvaluationRuleSet $ruleSet): string
|
||||
{
|
||||
// Výsledek rozhoduje o tom, zda QSO zneplatnit, penalizovat nebo jen vynulovat.
|
||||
return $ruleSet->out_of_window_policy ?? 'INVALID';
|
||||
}
|
||||
|
||||
public function calculateDistanceKm(?string $locA, ?string $locB): ?float
|
||||
{
|
||||
if (! $locA || ! $locB) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lokátory musí být validní; jinak se distance nepočítá.
|
||||
$coordA = $this->locatorToLatLon($locA);
|
||||
$coordB = $this->locatorToLatLon($locB);
|
||||
if (! $coordA || ! $coordB) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$lat1, $lon1] = $coordA;
|
||||
[$lat2, $lon2] = $coordB;
|
||||
|
||||
$earthRadius = 6371.0;
|
||||
$dLat = deg2rad($lat2 - $lat1);
|
||||
$dLon = deg2rad($lon2 - $lon1);
|
||||
$lat1Rad = deg2rad($lat1);
|
||||
$lat2Rad = deg2rad($lat2);
|
||||
|
||||
$a = sin($dLat / 2) ** 2
|
||||
+ cos($lat1Rad) * cos($lat2Rad) * sin($dLon / 2) ** 2;
|
||||
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||
|
||||
return $earthRadius * $c;
|
||||
}
|
||||
|
||||
protected function locatorToLatLon(string $locator): ?array
|
||||
{
|
||||
$loc = strtoupper(trim($locator));
|
||||
$loc = preg_replace('/\s+/', '', $loc) ?? '';
|
||||
if (! preg_match('/^[A-R]{2}[0-9]{2}([A-X]{2})?$/', $loc)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lon = (ord($loc[0]) - 65) * 20 - 180;
|
||||
$lat = (ord($loc[1]) - 65) * 10 - 90;
|
||||
$lon += (int) $loc[2] * 2;
|
||||
$lat += (int) $loc[3];
|
||||
|
||||
if (strlen($loc) >= 6) {
|
||||
$lon += (ord($loc[4]) - 65) * (5 / 60);
|
||||
$lat += (ord($loc[5]) - 65) * (2.5 / 60);
|
||||
$lon += 2.5 / 60;
|
||||
$lat += 1.25 / 60;
|
||||
} else {
|
||||
$lon += 1.0;
|
||||
$lat += 0.5;
|
||||
}
|
||||
|
||||
return [$lat, $lon];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user