190 lines
6.8 KiB
PHP
190 lines
6.8 KiB
PHP
<?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];
|
|
}
|
|
}
|