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,479 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class EvaluationRuleSet extends Model
{
use HasFactory;
protected $table = 'evaluation_rule_sets';
/**
* Atributy rulesetu používané při vyhodnocování.
*
* Skupiny:
* - scoring_mode/points_per_*: způsob bodování.
* - multipliers*: multiplikátory a jejich scope.
* - *_policy / penalty_*: penalizace a způsob započtení chyb.
* - exchange_* / discard_*: pravidla výměny a busted detekce.
* - match_* / callsign_*: pravidla párování a normalizace.
* - out_of_window_*: chování mimo časové okno.
*/
protected $fillable = [
'name',
'code',
'description',
'scoring_mode', // DISTANCE / FIXED_POINTS
'points_per_qso', // Fixní body za valid QSO
'points_per_km', // Body za km v režimu DISTANCE
'use_multipliers', // Zapnout multiplikátory ve výsledku
'multiplier_type', // NONE / WWL / DXCC / SECTION / COUNTRY
'dup_qso_policy', // ZERO_POINTS / PENALTY
'nil_qso_policy', // ZERO_POINTS / PENALTY
'no_counterpart_log_policy', // INVALID / ZERO_POINTS / FLAG_ONLY / PENALTY
'not_in_counterpart_log_policy', // INVALID / ZERO_POINTS / FLAG_ONLY / PENALTY
'unique_qso_policy', // INVALID / ZERO_POINTS / FLAG_ONLY
'busted_call_policy', // ZERO_POINTS / PENALTY
'busted_exchange_policy',// ZERO_POINTS / PENALTY
'busted_serial_policy', // ZERO_POINTS / PENALTY
'busted_locator_policy', // ZERO_POINTS / PENALTY
'penalty_dup_points', // Penalizace za DUP
'penalty_nil_points', // Penalizace za NIL
'penalty_busted_call_points', // Penalizace za BUSTED_CALL
'penalty_busted_exchange_points', // Penalizace za BUSTED_EXCHANGE
'penalty_busted_serial_points', // Penalizace za BUSTED_SERIAL
'penalty_busted_locator_points', // Penalizace za BUSTED_LOCATOR
'dupe_scope', // BAND / BAND_MODE
'callsign_normalization',// STRICT / IGNORE_SUFFIX
'distance_rounding', // FLOOR / ROUND / CEIL
'min_distance_km', // Minimum km pro bodované QSO
'require_locators', // Bez lokátorů = nevalidní / bez bodů
'out_of_window_policy', // IGNORE / ZERO_POINTS / PENALTY / INVALID
'penalty_out_of_window_points', // Penalizace za OUT_OF_WINDOW
'exchange_type', // SERIAL / WWL / SERIAL_WWL / CUSTOM
'exchange_requires_wwl', // WWL je povinná část výměny
'exchange_requires_serial', // Serial je povinná část výměny
'exchange_requires_report', // RST je povinná část výměny
'exchange_pattern', // Regex pro CUSTOM výměnu
'ignore_slash_part', // Ignorovat suffix za lomítkem v callsign
'ignore_third_part', // Ignorovat 3. část callsign
'letters_in_rst', // Povolit písmena v RST
'rst_ignore_third_char', // Ignorovat 3. znak v RST
'discard_qso_rec_diff_call', // RX neshoda callsign -> BUSTED_CALL (error_side=RX)
'discard_qso_sent_diff_call', // TX neshoda callsign -> BUSTED_CALL (error_side=TX)
'discard_qso_rec_diff_rst', // RX neshoda RST -> BUSTED_RST (error_side=RX)
'discard_qso_sent_diff_rst', // TX neshoda RST -> BUSTED_RST (error_side=TX)
'discard_qso_rec_diff_code', // RX neshoda exchange -> BUSTED_SERIAL/LOCATOR (error_side=RX)
'discard_qso_sent_diff_code',// TX neshoda exchange -> BUSTED_SERIAL/LOCATOR (error_side=TX)
'discard_qso_rec_diff_serial', // RX neshoda serial -> BUSTED_SERIAL (error_side=RX)
'discard_qso_sent_diff_serial',// TX neshoda serial -> BUSTED_SERIAL (error_side=TX)
'discard_qso_rec_diff_wwl', // RX neshoda WWL -> BUSTED_LOCATOR (error_side=RX)
'discard_qso_sent_diff_wwl', // TX neshoda WWL -> BUSTED_LOCATOR (error_side=TX)
'busted_rst_policy', // ZERO_POINTS / PENALTY
'penalty_busted_rst_points', // Penalizace za BUSTED_RST
'match_tiebreak_order', // Pořadí tiebreak kritérií
'match_require_locator_match', // Matching vyžaduje lokátor
'match_require_exchange_match',// Matching vyžaduje exchange
'multiplier_scope', // PER_BAND / OVERALL
'multiplier_source', // VALID_ONLY / ALL_MATCHED
'wwl_multiplier_level', // LOCATOR_2 / LOCATOR_4 / LOCATOR_6
'checklog_matching', // CHECK logy v matchingu
'out_of_window_dq_threshold', // DQ logu při nadlimitních OOW QSO
'time_diff_dq_threshold_percent', // DQ logu při nadlimitním % časového rozdílu
'time_diff_dq_threshold_sec', // Prah časového rozdílu v sekundách
'bad_qso_dq_threshold_percent', // DQ logu při nadlimitním % špatných QSO
'time_tolerance_sec', // Tolerance času v matchingu (sekundy)
'require_unique_qso', // Zapnout detekci duplicit v logu
'allow_time_shift_one_hour', // Povolit posun o 1 hodinu při matchingu
'time_shift_seconds', // Velikost časového posunu v sekundách
'time_mismatch_policy', // INVALID / ZERO_POINTS / FLAG_ONLY / PENALTY
'callsign_suffix_max_len', // Max délka suffixu za /
'callsign_levenshtein_max', // Maximální Levenshtein vzdálenost pro fuzzy match
'allow_time_mismatch_pairing', // Povolit párování mimo toleranci
'time_mismatch_max_sec', // Max. odchylka mimo toleranci (NULL = bez limitu)
'dup_resolution_strategy', // Pořadí pravidel pro volbu survivor v duplicitách
'operating_window_mode', // NONE / BEST_CONTIGUOUS
'operating_window_hours', // Délka okna v hodinách
'sixhr_ranking_mode', // IARU / CRK
'options', // Volitelné JSON rozšíření pravidel
];
protected $casts = [
'points_per_qso' => 'integer',
'points_per_km' => 'float',
'use_multipliers' => 'boolean',
'time_tolerance_sec' => 'integer',
'require_unique_qso' => 'boolean',
'penalty_dup_points' => 'integer',
'penalty_nil_points' => 'integer',
'penalty_busted_call_points' => 'integer',
'penalty_busted_exchange_points' => 'integer',
'penalty_busted_serial_points' => 'integer',
'penalty_busted_locator_points' => 'integer',
'min_distance_km' => 'integer',
'require_locators' => 'boolean',
'penalty_out_of_window_points' => 'integer',
'exchange_requires_wwl' => 'boolean',
'exchange_requires_serial' => 'boolean',
'exchange_requires_report' => 'boolean',
'ignore_slash_part' => 'boolean',
'ignore_third_part' => 'boolean',
'letters_in_rst' => 'boolean',
'rst_ignore_third_char' => 'boolean',
'discard_qso_rec_diff_call' => 'boolean',
'discard_qso_sent_diff_call' => 'boolean',
'discard_qso_rec_diff_rst' => 'boolean',
'discard_qso_sent_diff_rst' => 'boolean',
'discard_qso_rec_diff_code' => 'boolean',
'discard_qso_sent_diff_code' => 'boolean',
'discard_qso_rec_diff_serial' => 'boolean',
'discard_qso_sent_diff_serial' => 'boolean',
'discard_qso_rec_diff_wwl' => 'boolean',
'discard_qso_sent_diff_wwl' => 'boolean',
'penalty_busted_rst_points' => 'integer',
'match_tiebreak_order' => 'array',
'match_require_locator_match' => 'boolean',
'match_require_exchange_match' => 'boolean',
'checklog_matching' => 'boolean',
'out_of_window_dq_threshold' => 'integer',
'time_diff_dq_threshold_percent' => 'integer',
'time_diff_dq_threshold_sec' => 'integer',
'bad_qso_dq_threshold_percent' => 'integer',
'allow_time_shift_one_hour' => 'boolean',
'time_shift_seconds' => 'integer',
'callsign_suffix_max_len' => 'integer',
'callsign_levenshtein_max' => 'integer',
'allow_time_mismatch_pairing' => 'boolean',
'time_mismatch_max_sec' => 'integer',
'dup_resolution_strategy' => 'array',
'operating_window_mode' => 'string',
'operating_window_hours' => 'integer',
'sixhr_ranking_mode' => 'string',
'options' => 'array',
];
/**
* Mapování: kde je jednotlivý atribut rulesetu použit.
* Pokud je hodnota "unused", musí být explicitně zdůvodněno.
*/
public const FLAG_USAGE = [
'name' => 'metadata (UI, identifikace rulesetu)',
'code' => 'metadata (UI, identifikace rulesetu)',
'description' => 'metadata (UI, identifikace rulesetu)',
'scoring_mode' => 'ScoringService::computeBasePoints',
'points_per_qso' => 'ScoringService::computeBasePoints',
'points_per_km' => 'ScoringService::computeBasePoints',
'use_multipliers' => 'ScoreGroupJob::applyMultipliers + AggregateLogResultsJob',
'multiplier_type' => 'ScoreGroupJob::applyMultipliers + AggregateLogResultsJob',
'dup_qso_policy' => 'ScoringService::penaltyPointsFor',
'nil_qso_policy' => 'ScoringService::penaltyPointsFor',
'no_counterpart_log_policy' => 'ScoreGroupJob (validita/bodování)',
'not_in_counterpart_log_policy' => 'ScoreGroupJob (validita/bodování)',
'unique_qso_policy' => 'ScoreGroupJob (validita/bodování)',
'busted_call_policy' => 'ScoringService::penaltyPointsFor',
'busted_exchange_policy' => 'ScoringService::penaltyPointsFor',
'busted_serial_policy' => 'ScoringService::penaltyPointsFor',
'busted_locator_policy' => 'ScoringService::penaltyPointsFor',
'penalty_dup_points' => 'ScoringService::penaltyPointsFor',
'penalty_nil_points' => 'ScoringService::penaltyPointsFor',
'penalty_busted_call_points' => 'ScoringService::penaltyPointsFor',
'penalty_busted_exchange_points' => 'ScoringService::penaltyPointsFor',
'penalty_busted_serial_points' => 'ScoringService::penaltyPointsFor',
'penalty_busted_locator_points' => 'ScoringService::penaltyPointsFor',
'dupe_scope' => 'BuildWorkingSetLogJob::dupe_key',
'callsign_normalization' => 'MatchingService::normalizeCallsign + MatchQsoGroupJob',
'distance_rounding' => 'ScoringService::calculateDistanceKm',
'min_distance_km' => 'ScoringService::calculateDistanceKm',
'require_locators' => 'ScoreGroupJob (validita/bodování)',
'out_of_window_policy' => 'ScoringService::outOfWindowDecision + ScoreGroupJob',
'penalty_out_of_window_points' => 'ScoringService::penaltyPointsFor',
'exchange_type' => 'MatchQsoGroupJob::resolveExchangeMatch',
'exchange_requires_wwl' => 'MatchQsoGroupJob::resolveExchangeMatch',
'exchange_requires_serial' => 'MatchQsoGroupJob::resolveExchangeMatch',
'exchange_requires_report' => 'MatchQsoGroupJob::resolveExchangeMatch',
'exchange_pattern' => 'MatchQsoGroupJob::resolveExchangeMatch',
'ignore_slash_part' => 'MatchQsoGroupJob::normalizeCallsign',
'ignore_third_part' => 'MatchQsoGroupJob::normalizeCallsign',
'letters_in_rst' => 'MatchQsoGroupJob::normalizeRst',
'rst_ignore_third_char' => 'MatchQsoGroupJob::reportsMatch',
'discard_qso_rec_diff_call' => 'MatchQsoGroupJob (error_side=RX)',
'discard_qso_sent_diff_call' => 'MatchQsoGroupJob (error_side=TX)',
'discard_qso_rec_diff_rst' => 'MatchQsoGroupJob (error_side=RX)',
'discard_qso_sent_diff_rst' => 'MatchQsoGroupJob (error_side=TX)',
'discard_qso_rec_diff_code' => 'MatchQsoGroupJob (RX exchange mismatch)',
'discard_qso_sent_diff_code' => 'MatchQsoGroupJob (TX exchange mismatch)',
'discard_qso_rec_diff_serial' => 'MatchQsoGroupJob (RX serial mismatch)',
'discard_qso_sent_diff_serial' => 'MatchQsoGroupJob (TX serial mismatch)',
'discard_qso_rec_diff_wwl' => 'MatchQsoGroupJob (RX locator mismatch)',
'discard_qso_sent_diff_wwl' => 'MatchQsoGroupJob (TX locator mismatch)',
'busted_rst_policy' => 'ScoringService::penaltyPointsFor',
'penalty_busted_rst_points' => 'ScoringService::penaltyPointsFor',
'match_tiebreak_order' => 'MatchQsoGroupJob::rankDecision',
'match_require_locator_match' => 'MatchQsoGroupJob::resolveExchangeMatch',
'match_require_exchange_match' => 'MatchQsoGroupJob::resolveExchangeMatch',
'multiplier_scope' => 'AggregateLogResultsJob::aggregateMultipliers',
'multiplier_source' => 'AggregateLogResultsJob::aggregateMultipliers',
'wwl_multiplier_level' => 'ScoreGroupJob::applyMultipliers',
'checklog_matching' => 'MatchQsoGroupJob::groupLogsByKey',
'out_of_window_dq_threshold' => 'AggregateLogResultsJob (DQ logu)',
'time_diff_dq_threshold_percent' => 'AggregateLogResultsJob (DQ logu)',
'time_diff_dq_threshold_sec' => 'AggregateLogResultsJob (DQ logu)',
'bad_qso_dq_threshold_percent' => 'AggregateLogResultsJob (DQ logu)',
'time_tolerance_sec' => 'MatchQsoGroupJob::findCandidates',
'require_unique_qso' => 'UnpairedClassificationJob::UNIQUE',
'allow_time_shift_one_hour' => 'MatchQsoGroupJob::matchWithTimeShift',
'time_shift_seconds' => 'MatchQsoGroupJob::matchWithTimeShift',
'time_mismatch_policy' => 'ScoreGroupJob (validita/bodování)',
'callsign_suffix_max_len' => 'MatchingService::normalizeCallsign',
'callsign_levenshtein_max' => 'MatchQsoGroupJob::rankDecision',
'allow_time_mismatch_pairing' => 'MatchQsoGroupJob::findCandidates',
'time_mismatch_max_sec' => 'MatchQsoGroupJob::findCandidates',
'dup_resolution_strategy' => 'DuplicateResolutionJob::sort',
'operating_window_mode' => 'AggregateLogResultsJob (operating window, 6H agregace)',
'operating_window_hours' => 'AggregateLogResultsJob (operating window, 6H agregace)',
'sixhr_ranking_mode' => 'RecalculateOfficialRanksJob (6H ranking mode)',
'options' => 'fallback pro hodnoty bez sloupců (EvaluationRuleSet::getOption)',
];
/**
* Options fallback (používá se jen pokud není vyplněn odpovídající sloupec).
*
* Klíče, typy, defaulty a fáze použití:
* - ignore_slash_part (bool, default=true) matching (normalizace callsign)
* - callsign_suffix_max_len (int, default=4) matching (normalizace callsign)
* - rst_ignore_third_char (bool, default=true) matching (porovnání RST)
* - letters_in_rst (bool, default=false) matching (porovnání RST)
* - callsign_levenshtein_max (int, default=0) matching (fuzzy callsign)
* - allow_time_shift_one_hour (bool, default=true) matching (časový posun)
* - time_shift_seconds (int, default=3600) matching (časový posun)
* - allow_time_mismatch_pairing (bool, default=false) matching (TIME_MISMATCH pairing)
* - time_mismatch_max_sec (int|null, default=null) matching (max odchylka mimo toleranci)
* - match_require_locator_match (bool, default=false) matching (mismatch flagging)
* - match_require_exchange_match (bool, default=false) matching (mismatch flagging)
* - unique_qso_enabled (bool, default=true) unpaired klasifikace (UNIQUE)
* - unique_qso_policy (string, default=FLAG_ONLY) scoring (validita/bodování)
* - no_counterpart_log_policy (string, default=FLAG_ONLY) scoring
* - not_in_counterpart_log_policy (string, default=ZERO_POINTS) scoring
* - duplicate_resolution_strategy (array, default=[paired_first, ok_first, earlier_time, lower_id]) duplicity
* - distance_rounding (string, default=CEIL) scoring (vzdálenost)
* - min_distance_km (int, default=1) scoring (vzdálenost)
*/
// ----- Vztahy -----
public function evaluationRuns(): HasMany
{
return $this->hasMany(EvaluationRun::class, 'rule_set_id');
}
// ----- Pomocné metody pro logiku vyhodnocení -----
public function isDistanceScoring(): bool
{
return $this->scoring_mode === 'DISTANCE';
}
public function isFixedPointsScoring(): bool
{
return $this->scoring_mode === 'FIXED_POINTS';
}
public function usesMultipliers(): bool
{
return $this->use_multipliers && $this->multiplier_type !== 'NONE';
}
public function multiplierIsWwl(): bool
{
return $this->multiplier_type === 'WWL';
}
public function multiplierIsDxcc(): bool
{
return $this->multiplier_type === 'DXCC';
}
public function multiplierIsSection(): bool
{
return $this->multiplier_type === 'SECTION';
}
public function multiplierIsCountry(): bool
{
return $this->multiplier_type === 'COUNTRY';
}
public function dupCountsAsPenalty(): bool
{
return $this->dup_qso_policy === 'PENALTY';
}
// ----- Options fallback & typed accessors -----
protected function getOption(string $key, mixed $default = null): mixed
{
$options = $this->options ?? [];
return array_key_exists($key, $options) ? $options[$key] : $default;
}
public function getString(string $key, ?string $columnValue, ?string $default = null): ?string
{
if ($columnValue !== null) {
return $columnValue;
}
$value = $this->getOption($key, $default);
return is_string($value) ? $value : $default;
}
public function getBool(string $key, ?bool $columnValue, bool $default = false): bool
{
if ($columnValue !== null) {
return (bool) $columnValue;
}
return (bool) $this->getOption($key, $default);
}
public function getInt(string $key, ?int $columnValue, ?int $default = null): ?int
{
if ($columnValue !== null) {
return (int) $columnValue;
}
$value = $this->getOption($key, $default);
return $value === null ? null : (int) $value;
}
public function getArray(string $key, ?array $columnValue, array $default = []): array
{
if ($columnValue !== null) {
return $columnValue;
}
$value = $this->getOption($key, $default);
return is_array($value) ? $value : $default;
}
public function ignoreSlashPart(): bool
{
return $this->getBool('ignore_slash_part', $this->ignore_slash_part);
}
public function ignoreThirdPart(): bool
{
return $this->getBool('ignore_third_part', $this->ignore_third_part);
}
public function lettersInRst(): bool
{
return $this->getBool('letters_in_rst', $this->letters_in_rst);
}
public function callsignLevenshteinMax(): int
{
return (int) ($this->getInt('callsign_levenshtein_max', $this->callsign_levenshtein_max, 0) ?? 0);
}
public function callsignSuffixMaxLen(): int
{
return (int) ($this->getInt('callsign_suffix_max_len', $this->callsign_suffix_max_len, 4) ?? 4);
}
public function rstIgnoreThirdChar(): bool
{
return $this->getBool('rst_ignore_third_char', $this->rst_ignore_third_char, true);
}
public function matchRequireLocatorMatch(): bool
{
return $this->getBool('match_require_locator_match', $this->match_require_locator_match, false);
}
public function matchRequireExchangeMatch(): bool
{
return $this->getBool('match_require_exchange_match', $this->match_require_exchange_match, false);
}
public function allowTimeShiftOneHour(): bool
{
return $this->getBool('allow_time_shift_one_hour', $this->allow_time_shift_one_hour, true);
}
public function allowTimeMismatchPairing(): bool
{
return $this->getBool('allow_time_mismatch_pairing', $this->allow_time_mismatch_pairing, false);
}
public function timeMismatchMaxSec(): ?int
{
return $this->getInt('time_mismatch_max_sec', $this->time_mismatch_max_sec, null);
}
public function uniqueQsoEnabled(): bool
{
return $this->getBool('unique_qso_enabled', $this->require_unique_qso, true);
}
public function dupResolutionStrategy(): array
{
$fallback = $this->getArray('duplicate_resolution_strategy', null, [
'paired_first',
'ok_first',
'earlier_time',
'lower_id',
]);
$strategy = $this->getArray('duplicate_resolution_strategy', $this->dup_resolution_strategy, $fallback);
if ($strategy !== $fallback) {
return $strategy;
}
return $this->getArray('dup_resolution_strategy', $this->dup_resolution_strategy, $fallback);
}
public function distanceRounding(): string
{
return $this->getString('distance_rounding', $this->distance_rounding, 'CEIL') ?? 'CEIL';
}
public function minDistanceKm(): ?int
{
return $this->getInt('min_distance_km', $this->min_distance_km, 1);
}
public function nilCountsAsPenalty(): bool
{
return $this->nil_qso_policy === 'PENALTY';
}
public function bustedCallCountsAsPenalty(): bool
{
return $this->busted_call_policy === 'PENALTY';
}
public function bustedExchangeCountsAsPenalty(): bool
{
return $this->busted_exchange_policy === 'PENALTY';
}
}