Audit items #17 and #21. #17 — DayOfWeekSignal and StickinessSignal each had their own isSqlite ternary picking between SQLite (strftime/julianday) and MySQL (DAYOFWEEK/DATEDIFF) date expressions. Centralised in App\Services\Prediction\Signals\DbDialect. #21 — ProfileValidationRules was a trait with one consumer (CreateNewUser); inlined the rules into the action and deleted the trait. Also dropped PasswordValidationRules::currentPasswordRules() which was unused. PasswordValidationRules trait stays (two consumers). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
81 lines
3.0 KiB
PHP
81 lines
3.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Prediction\Signals;
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
final class DayOfWeekSignal extends AbstractSignal
|
|
{
|
|
private const int MIN_DAYS = 21;
|
|
|
|
public function compute(SignalContext $context): array
|
|
{
|
|
$dowExpr = DbDialect::dayOfWeekExpr('price_effective_at');
|
|
|
|
$rows = DB::table('station_prices')
|
|
->where('fuel_type', $context->fuelType->value)
|
|
->where('price_effective_at', '>=', now()->subDays(90))
|
|
->selectRaw("{$dowExpr} as dow, DATE(price_effective_at) as day, AVG(price_pence) as avg_price")
|
|
->groupBy('dow', 'day')
|
|
->get();
|
|
|
|
$uniqueDays = $rows->pluck('day')->unique()->count();
|
|
|
|
if ($uniqueDays < self::MIN_DAYS) {
|
|
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need ".self::MIN_DAYS.')');
|
|
}
|
|
|
|
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
|
|
$weekAvg = $dowAverages->avg();
|
|
$todayDow = (int) now()->format('w') + 1; // PHP 0=Sun → MySQL 1=Sun
|
|
$todayAvg = $dowAverages->get($todayDow, $weekAvg);
|
|
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
|
|
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
$todayName = $dayNames[($todayDow - 1) % 7] ?? 'Today';
|
|
$tomorrowName = $dayNames[$todayDow % 7] ?? 'Tomorrow';
|
|
|
|
$todayDeltaPence = round(($todayAvg - $weekAvg) / 100, 1);
|
|
$tomorrowDeltaPence = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
|
|
|
|
$direction = match (true) {
|
|
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
|
|
($weekAvg - $todayAvg) / 100 >= 1.5 => 'down',
|
|
default => 'stable',
|
|
};
|
|
|
|
$score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0);
|
|
|
|
$parts = [];
|
|
$parts[] = abs($todayDeltaPence) < 0.1
|
|
? "Today ({$todayName}) is typically in line with the weekly average."
|
|
: sprintf(
|
|
'Today (%s) is typically %sp %s the weekly average.',
|
|
$todayName,
|
|
number_format(abs($todayDeltaPence), 1),
|
|
$todayDeltaPence > 0 ? 'above' : 'below',
|
|
);
|
|
|
|
$parts[] = abs($tomorrowDeltaPence) < 0.1
|
|
? "Tomorrow ({$tomorrowName}) is typically the same."
|
|
: sprintf(
|
|
'Tomorrow (%s) is typically %sp %s.',
|
|
$tomorrowName,
|
|
number_format(abs($tomorrowDeltaPence), 1),
|
|
$tomorrowDeltaPence < 0 ? 'cheaper' : 'pricier',
|
|
);
|
|
|
|
if ($cheapestDow === $todayDow) {
|
|
$parts[] = 'Today is historically the cheapest day of the week.';
|
|
}
|
|
|
|
return [
|
|
'score' => $score,
|
|
'confidence' => min(1.0, $uniqueDays / 90),
|
|
'direction' => $direction,
|
|
'detail' => implode(' ', $parts),
|
|
'data_points' => $uniqueDays,
|
|
'enabled' => true,
|
|
];
|
|
}
|
|
}
|