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

480 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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';
}
}