Files
vkv/app/Services/Evaluation/ScoringService.php
Zdeněk Burda 41e3ce6f25 Initial commit
2026-01-09 21:26:40 +01:00

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