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