Files
vkv/database/seeders/EvaluationPipelineRegressionSeeder.php
Zdeněk Burda 41e3ce6f25 Initial commit
2026-01-09 21:26:40 +01:00

505 lines
18 KiB
PHP

<?php
namespace Database\Seeders;
use App\Jobs\BuildWorkingSetJob;
use App\Jobs\DuplicateResolutionJob;
use App\Jobs\MatchQsoGroupJob;
use App\Jobs\PrepareRunJob;
use App\Jobs\ScoreGroupJob;
use App\Jobs\UnpairedClassificationJob;
use App\Models\Band;
use App\Models\Category;
use App\Models\Contest;
use App\Models\EvaluationRuleSet;
use App\Models\EvaluationRun;
use App\Models\Log;
use App\Models\LogQso;
use App\Models\PowerCategory;
use App\Models\QsoResult;
use App\Models\Round;
use App\Models\WorkingQso;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* Seeder: EvaluationPipelineRegressionSeeder
*
* Vytváří mini dataset a spouští klíčové kroky pipeline synchronně.
* Slouží jako minimální regresní scénáře bez plného test frameworku.
*/
class EvaluationPipelineRegressionSeeder extends Seeder
{
public function run(): void
{
$band = Band::firstOrCreate(
['name' => '145 MHz'],
[
'order' => 1,
'edi_band_begin' => 144.0,
'edi_band_end' => 146.0,
'has_power_category' => true,
]
);
$category = Category::firstOrCreate(
['name' => 'SINGLE'],
[
'order' => 1,
]
);
$power = PowerCategory::firstOrCreate(
['name' => 'LP'],
[
'order' => 1,
'power_level' => 100,
]
);
$ruleSetUnique = EvaluationRuleSet::firstOrCreate(
['code' => 'REG_UNIQUE'],
[
'name' => 'Regression UNIQUE',
'scoring_mode' => 'FIXED_POINTS',
'points_per_qso' => 1,
'use_multipliers' => false,
'dup_qso_policy' => 'ZERO_POINTS',
'nil_qso_policy' => 'ZERO_POINTS',
'busted_call_policy' => 'ZERO_POINTS',
'busted_exchange_policy' => 'ZERO_POINTS',
'time_tolerance_sec' => 60,
'require_unique_qso' => true,
]
);
$ruleSetNoUnique = EvaluationRuleSet::firstOrCreate(
['code' => 'REG_DUP'],
[
'name' => 'Regression DUP',
'scoring_mode' => 'FIXED_POINTS',
'points_per_qso' => 1,
'use_multipliers' => false,
'dup_qso_policy' => 'ZERO_POINTS',
'nil_qso_policy' => 'ZERO_POINTS',
'busted_call_policy' => 'ZERO_POINTS',
'busted_exchange_policy' => 'ZERO_POINTS',
'time_tolerance_sec' => 60,
'require_unique_qso' => false,
]
);
$ruleSetTimeMismatch = EvaluationRuleSet::firstOrCreate(
['code' => 'REG_TIME_MISMATCH'],
[
'name' => 'Regression TIME_MISMATCH',
'scoring_mode' => 'FIXED_POINTS',
'points_per_qso' => 1,
'use_multipliers' => false,
'dup_qso_policy' => 'ZERO_POINTS',
'nil_qso_policy' => 'ZERO_POINTS',
'busted_call_policy' => 'ZERO_POINTS',
'busted_exchange_policy' => 'ZERO_POINTS',
'time_tolerance_sec' => 60,
'allow_time_mismatch_pairing' => true,
'time_mismatch_max_sec' => 600,
'time_mismatch_policy' => 'ZERO_POINTS',
]
);
$ruleSetFlags = EvaluationRuleSet::firstOrCreate(
['code' => 'REG_FLAGS'],
[
'name' => 'Regression FLAGS',
'scoring_mode' => 'FIXED_POINTS',
'points_per_qso' => 1,
'use_multipliers' => false,
'dup_qso_policy' => 'ZERO_POINTS',
'nil_qso_policy' => 'ZERO_POINTS',
'busted_call_policy' => 'ZERO_POINTS',
'busted_exchange_policy' => 'ZERO_POINTS',
'exchange_requires_report' => true,
'exchange_requires_serial' => true,
'rst_ignore_third_char' => true,
'ignore_slash_part' => true,
'callsign_suffix_max_len' => 4,
'callsign_levenshtein_max' => 1,
'discard_qso_rec_diff_rst' => true,
'discard_qso_rec_diff_serial' => true,
'time_tolerance_sec' => 60,
]
);
$contest = Contest::create([
'name' => ['cs' => 'Regression Contest', 'en' => 'Regression Contest'],
'description' => ['cs' => 'Regresní sada', 'en' => 'Regression set'],
'is_active' => false,
'is_test' => true,
'rule_set_id' => $ruleSetUnique->id,
]);
$contest->bands()->sync([$band->id]);
$contest->categories()->sync([$category->id]);
$contest->powerCategories()->sync([$power->id]);
$this->scenarioUniqueGlobal($contest, $band, $category, $power, $ruleSetUnique);
$this->scenarioUniqueNotGlobal($contest, $band, $category, $power, $ruleSetUnique);
$this->scenarioDuplicateResolution($contest, $band, $category, $power, $ruleSetNoUnique);
$this->scenarioTimeMismatchPolicy($contest, $band, $category, $power, $ruleSetTimeMismatch);
$this->scenarioFlagCoverage($contest, $band, $category, $power, $ruleSetFlags);
}
private function scenarioUniqueGlobal(
Contest $contest,
Band $band,
Category $category,
PowerCategory $power,
EvaluationRuleSet $ruleSet
): void {
$round = $this->makeRound($contest, $ruleSet, 'UNIQUE global');
$this->attachRoundScopes($round, $band, $category, $power);
$log = $this->makeLog($round, 'OK1AAA', 'JN79AB', '145');
$qso = LogQso::create([
'log_id' => $log->id,
'qso_index' => 1,
'time_on' => Carbon::parse($round->start_time)->addMinutes(5),
'freq_khz' => 144000,
'my_call' => 'OK1AAA',
'dx_call' => 'UNIQUE1',
'my_locator' => 'JN79AB',
'rx_wwl' => 'JN79AB',
]);
$run = $this->makeRun($round, $ruleSet);
$this->runPipeline($run);
$errorCode = QsoResult::where('evaluation_run_id', $run->id)
->where('log_qso_id', $qso->id)
->value('error_code');
if ($errorCode !== 'UNIQUE') {
throw new RuntimeException("Regression UNIQUE global selhal: očekáván UNIQUE, got {$errorCode}.");
}
}
private function scenarioUniqueNotGlobal(
Contest $contest,
Band $band,
Category $category,
PowerCategory $power,
EvaluationRuleSet $ruleSet
): void {
$round = $this->makeRound($contest, $ruleSet, 'UNIQUE not global');
$this->attachRoundScopes($round, $band, $category, $power);
$logA = $this->makeLog($round, 'OK1AAB', 'JN79AB', '145');
$logB = $this->makeLog($round, 'OK1AAC', 'JN79AB', '145');
$qsoA = LogQso::create([
'log_id' => $logA->id,
'qso_index' => 1,
'time_on' => Carbon::parse($round->start_time)->addMinutes(5),
'freq_khz' => 144000,
'my_call' => 'OK1AAB',
'dx_call' => 'X2AAA',
'my_locator' => 'JN79AB',
'rx_wwl' => 'JN79AB',
]);
LogQso::create([
'log_id' => $logB->id,
'qso_index' => 1,
'time_on' => Carbon::parse($round->start_time)->addMinutes(6),
'freq_khz' => 144000,
'my_call' => 'OK1AAC',
'dx_call' => 'X2AAA',
'my_locator' => 'JN79AB',
'rx_wwl' => 'JN79AB',
]);
$run = $this->makeRun($round, $ruleSet);
$this->runPipeline($run);
$errorCode = QsoResult::where('evaluation_run_id', $run->id)
->where('log_qso_id', $qsoA->id)
->value('error_code');
if ($errorCode === 'UNIQUE') {
throw new RuntimeException('Regression UNIQUE not global selhal: UNIQUE nesmí vzniknout.');
}
}
private function scenarioDuplicateResolution(
Contest $contest,
Band $band,
Category $category,
PowerCategory $power,
EvaluationRuleSet $ruleSet
): void {
$round = $this->makeRound($contest, $ruleSet, 'DUP resolution');
$this->attachRoundScopes($round, $band, $category, $power);
$log = $this->makeLog($round, 'OK1DUP', 'JN79AB', '145');
LogQso::create([
'log_id' => $log->id,
'qso_index' => 1,
'time_on' => Carbon::parse($round->start_time)->addMinutes(5),
'freq_khz' => 144000,
'my_call' => 'OK1DUP',
'dx_call' => 'DUP1',
'my_locator' => 'JN79AB',
'rx_wwl' => 'JN79AB',
]);
LogQso::create([
'log_id' => $log->id,
'qso_index' => 2,
'time_on' => Carbon::parse($round->start_time)->addMinutes(6),
'freq_khz' => 144000,
'my_call' => 'OK1DUP',
'dx_call' => 'DUP1',
'my_locator' => 'JN79AB',
'rx_wwl' => 'JN79AB',
]);
$run = $this->makeRun($round, $ruleSet);
$this->runPipeline($run);
$dupCount = QsoResult::where('evaluation_run_id', $run->id)
->where('error_code', 'DUP')
->count();
if ($dupCount !== 1) {
throw new RuntimeException("Regression DUP selhal: očekáván 1 DUP, got {$dupCount}.");
}
}
private function scenarioTimeMismatchPolicy(
Contest $contest,
Band $band,
Category $category,
PowerCategory $power,
EvaluationRuleSet $ruleSet
): void {
$round = $this->makeRound($contest, $ruleSet, 'TIME_MISMATCH policy');
$this->attachRoundScopes($round, $band, $category, $power);
$logA = $this->makeLog($round, 'OK1TMA', 'JN79AB', '145');
$logB = $this->makeLog($round, 'OK1TMB', 'JN79AB', '145');
$timeA = Carbon::parse($round->start_time)->addMinutes(5);
$timeB = Carbon::parse($round->start_time)->addMinutes(9);
$qsoA = LogQso::create([
'log_id' => $logA->id,
'qso_index' => 1,
'time_on' => $timeA,
'freq_khz' => 144000,
'my_call' => 'OK1TMA',
'dx_call' => 'OK1TMB',
'my_serial' => '001',
'dx_serial' => '002',
'my_locator' => 'JN79AB',
'rx_wwl' => 'JN79AB',
]);
LogQso::create([
'log_id' => $logB->id,
'qso_index' => 1,
'time_on' => $timeB,
'freq_khz' => 144000,
'my_call' => 'OK1TMB',
'dx_call' => 'OK1TMA',
'my_serial' => '002',
'dx_serial' => '001',
'my_locator' => 'JN79AB',
'rx_wwl' => 'JN79AB',
]);
$run = $this->makeRun($round, $ruleSet);
$this->runPipeline($run);
$result = QsoResult::where('evaluation_run_id', $run->id)
->where('log_qso_id', $qsoA->id)
->first();
if (! $result || $result->error_code !== 'TIME_MISMATCH') {
throw new RuntimeException('Regression TIME_MISMATCH selhal: očekáván TIME_MISMATCH.');
}
if (! $result->is_valid || (int) $result->points !== 0) {
throw new RuntimeException('Regression TIME_MISMATCH selhal: očekáván validní QSO s 0 body.');
}
}
private function scenarioFlagCoverage(
Contest $contest,
Band $band,
Category $category,
PowerCategory $power,
EvaluationRuleSet $ruleSet
): void {
$round = $this->makeRound($contest, $ruleSet, 'FLAGS coverage');
$this->attachRoundScopes($round, $band, $category, $power);
$logSuffix = $this->makeLog($round, 'OK1AAA/P', 'JN79AB', '145');
$qsoFuzzySource = LogQso::create([
'log_id' => $logSuffix->id,
'qso_index' => 1,
'time_on' => Carbon::parse($round->start_time)->addMinutes(2),
'freq_khz' => 144000,
'my_call' => 'OK1AAA/P',
'dx_call' => 'OK1AAB',
'my_rst' => '599',
'dx_rst' => '599',
'my_serial' => '001',
'dx_serial' => '002',
'my_locator' => 'JN79AB',
'rx_wwl' => 'JN79AB',
]);
$logFuzzy = $this->makeLog($round, 'OK1AAB', 'JN79AB', '145');
$qsoFuzzy = LogQso::create([
'log_id' => $logFuzzy->id,
'qso_index' => 1,
'time_on' => Carbon::parse($round->start_time)->addMinutes(2),
'freq_khz' => 144000,
'my_call' => 'OK1AAB',
'dx_call' => 'OK1AAX',
'my_rst' => '599',
'dx_rst' => '599',
'my_serial' => '002',
'dx_serial' => '001',
'my_locator' => 'JN79AB',
'rx_wwl' => 'JN79AB',
]);
$logSerial = $this->makeLog($round, 'OK1SER', 'JN79AB', '145');
$qsoSerialMismatch = LogQso::create([
'log_id' => $logSuffix->id,
'qso_index' => 2,
'time_on' => Carbon::parse($round->start_time)->addMinutes(3),
'freq_khz' => 144000,
'my_call' => 'OK1AAA/P',
'dx_call' => 'OK1SER',
'my_rst' => '599',
'dx_rst' => '599',
'my_serial' => '005',
'dx_serial' => '006',
'my_locator' => 'JN79AB',
'rx_wwl' => 'JN79AB',
]);
LogQso::create([
'log_id' => $logSerial->id,
'qso_index' => 1,
'time_on' => Carbon::parse($round->start_time)->addMinutes(3),
'freq_khz' => 144000,
'my_call' => 'OK1SER',
'dx_call' => 'OK1AAA',
'my_rst' => '599',
'dx_rst' => '599',
'my_serial' => '007',
'dx_serial' => '008',
'my_locator' => 'JN79AB',
'rx_wwl' => 'JN79AB',
]);
$run = $this->makeRun($round, $ruleSet);
$this->runPipeline($run);
$suffixNorm = \App\Models\WorkingQso::where('evaluation_run_id', $run->id)
->where('log_id', $logSuffix->id)
->value('call_norm');
if ($suffixNorm !== 'OK1AAA') {
throw new RuntimeException('Regression FLAGS selhal: callsign suffix nebyl normalizován.');
}
$fuzzyResult = QsoResult::where('evaluation_run_id', $run->id)
->where('log_qso_id', $qsoFuzzy->id)
->first();
if (! $fuzzyResult || $fuzzyResult->match_type !== 'MATCH_FUZZY_CALL_1') {
throw new RuntimeException('Regression FLAGS selhal: callsign_levenshtein_max neovlivňuje matching.');
}
if ($fuzzyResult->error_code === 'BUSTED_RST') {
throw new RuntimeException('Regression FLAGS selhal: rst_ignore_third_char nebylo zohledněno.');
}
$serialResult = QsoResult::where('evaluation_run_id', $run->id)
->where('log_qso_id', $qsoSerialMismatch->id)
->where('error_code', 'BUSTED_SERIAL')
->first();
if (! $serialResult) {
throw new RuntimeException('Regression FLAGS selhal: discard_qso_rec_diff_serial nebylo zohledněno.');
}
}
private function runPipeline(EvaluationRun $run): void
{
(new PrepareRunJob($run->id))->handle();
(new BuildWorkingSetJob($run->id))->handle();
$run = EvaluationRun::find($run->id);
$group = $run && isset($run->scope['groups'][0])
? $run->scope['groups'][0]
: [
'key' => 'b0',
'band_id' => null,
'category_id' => null,
'power_category_id' => null,
];
if (! ($group['band_id'] ?? null)) {
$workingBandId = WorkingQso::where('evaluation_run_id', $run->id)
->whereNotNull('band_id')
->value('band_id');
if ($workingBandId) {
$group['band_id'] = (int) $workingBandId;
$group['key'] = 'b' . $workingBandId;
}
}
$groupKey = $group['key'] ?? 'b0';
(new MatchQsoGroupJob($run->id, $groupKey, $group, 1))->handle();
(new MatchQsoGroupJob($run->id, $groupKey, $group, 2))->handle();
(new UnpairedClassificationJob($run->id))->handle();
(new DuplicateResolutionJob($run->id))->handle();
(new ScoreGroupJob($run->id, $groupKey, $group))->handle();
}
private function makeRound(Contest $contest, EvaluationRuleSet $ruleSet, string $label): Round
{
$start = now()->subDays(1);
$end = now()->subDays(1)->addHours(5);
return Round::create([
'contest_id' => $contest->id,
'rule_set_id' => $ruleSet->id,
'name' => ['cs' => "Regrese {$label}", 'en' => "Regression {$label}"],
'description' => ['cs' => 'Regresní běh', 'en' => 'Regression run'],
'is_test' => true,
'is_active' => false,
'start_time' => $start,
'end_time' => $end,
'logs_deadline' => now(),
]);
}
private function attachRoundScopes(Round $round, Band $band, Category $category, PowerCategory $power): void
{
$round->bands()->sync([$band->id]);
$round->categories()->sync([$category->id]);
$round->powerCategories()->sync([$power->id]);
}
private function makeLog(Round $round, string $pcall, string $pwwlo, string $pband): Log
{
return Log::create([
'round_id' => $round->id,
'pcall' => $pcall,
'pwwlo' => $pwwlo,
'pband' => $pband,
'power_category_id' => PowerCategory::first()?->id,
]);
}
private function makeRun(Round $round, EvaluationRuleSet $ruleSet): EvaluationRun
{
return EvaluationRun::create([
'round_id' => $round->id,
'rule_set_id' => $ruleSet->id,
'status' => 'PENDING',
'current_step' => 'seed',
'is_official' => false,
'result_type' => 'TEST',
]);
}
}