Initial commit

This commit is contained in:
Zdeněk Burda
2026-01-09 21:26:40 +01:00
parent e83aec6dca
commit 41e3ce6f25
404 changed files with 61250 additions and 28 deletions

View 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,
]);
}
}

View 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 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;
}
}

View 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}";
}
}

View 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;
}
}

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

View 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á 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];
}
}