refactor: extract 6 prediction signals into Signal classes
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>
This commit is contained in:
83
app/Services/Prediction/Signals/DayOfWeekSignal.php
Normal file
83
app/Services/Prediction/Signals/DayOfWeekSignal.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user