The 803-line NationalFuelPredictionService had six private compute*Signal methods, a private linearRegression helper, and a private disabledSignal shape factory all crammed together. Each signal is now an independently testable class. - App\Services\Prediction\Signals\Signal — interface - App\Services\Prediction\Signals\SignalContext — input value object (FuelType + optional lat/lng + hasCoordinates() helper) - App\Services\Prediction\Signals\AbstractSignal — shared disabledSignal() and linearRegression() helpers - TrendSignal, DayOfWeekSignal, BrandBehaviourSignal, StickinessSignal, RegionalMomentumSignal, OilSignal — one class each, extending AbstractSignal NationalFuelPredictionService receives the 6 signal classes via constructor injection and orchestrates them. The lat/lng null-guard for regional momentum now lives inside RegionalMomentumSignal::compute() so the coordinator no longer branches on coordinate presence. Aggregation, weekly summary, and reasoning helpers stay in the service for now — they are coupled to the public predict() output shape and are candidates for a follow-up extraction once a stable API is locked in. Service: 803 → 414 lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
84 lines
3.2 KiB
PHP
84 lines
3.2 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
|
|
{
|
|
$isSqlite = DB::connection()->getDriverName() === 'sqlite';
|
|
$dowExpr = $isSqlite
|
|
? "(CAST(strftime('%w', price_effective_at) AS INTEGER) + 1)"
|
|
: 'DAYOFWEEK(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,
|
|
];
|
|
}
|
|
}
|