Initial commit
This commit is contained in:
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