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>
415 lines
15 KiB
PHP
415 lines
15 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Enums\FuelType;
|
||
use App\Models\StationPriceCurrent;
|
||
use App\Services\Prediction\Signals\BrandBehaviourSignal;
|
||
use App\Services\Prediction\Signals\DayOfWeekSignal;
|
||
use App\Services\Prediction\Signals\OilSignal;
|
||
use App\Services\Prediction\Signals\RegionalMomentumSignal;
|
||
use App\Services\Prediction\Signals\SignalContext;
|
||
use App\Services\Prediction\Signals\StickinessSignal;
|
||
use App\Services\Prediction\Signals\TrendSignal;
|
||
use Carbon\CarbonInterface;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
class NationalFuelPredictionService
|
||
{
|
||
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
||
|
||
private const int PREDICTION_HORIZON_DAYS = 7;
|
||
|
||
public function __construct(
|
||
private readonly TrendSignal $trendSignal,
|
||
private readonly DayOfWeekSignal $dayOfWeekSignal,
|
||
private readonly BrandBehaviourSignal $brandBehaviourSignal,
|
||
private readonly StickinessSignal $stickinessSignal,
|
||
private readonly RegionalMomentumSignal $regionalMomentumSignal,
|
||
private readonly OilSignal $oilSignal,
|
||
) {}
|
||
|
||
/**
|
||
* @return array{
|
||
* fuel_type: string,
|
||
* current_avg: float,
|
||
* predicted_direction: string,
|
||
* predicted_change_pence: float,
|
||
* confidence_score: float,
|
||
* confidence_label: string,
|
||
* action: string,
|
||
* reasoning: string,
|
||
* prediction_horizon_days: int,
|
||
* region_key: string,
|
||
* methodology: string,
|
||
* signals: array
|
||
* }
|
||
*/
|
||
public function predict(?float $lat = null, ?float $lng = null): array
|
||
{
|
||
$fuelType = FuelType::E10;
|
||
$hasCoordinates = $lat !== null && $lng !== null;
|
||
$context = new SignalContext($fuelType, $lat, $lng);
|
||
|
||
$currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng);
|
||
$trend = $this->trendSignal->compute($context);
|
||
$dayOfWeek = $this->dayOfWeekSignal->compute($context);
|
||
$brandBehaviour = $this->brandBehaviourSignal->compute($context);
|
||
$stickiness = $this->stickinessSignal->compute($context);
|
||
$oil = $this->oilSignal->compute($context);
|
||
|
||
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
|
||
$regionalMomentum = $this->regionalMomentumSignal->compute($context);
|
||
|
||
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness', 'oil');
|
||
|
||
[$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates);
|
||
|
||
$slope = $trend['slope'] ?? 0.0;
|
||
$predictedChangePence = round($slope * self::PREDICTION_HORIZON_DAYS, 1);
|
||
|
||
$confidenceLabel = match (true) {
|
||
$confidenceScore >= 70 => 'high',
|
||
$confidenceScore >= 40 => 'medium',
|
||
default => 'low',
|
||
};
|
||
|
||
$action = match ($direction) {
|
||
'up' => 'fill_now',
|
||
'down' => 'wait',
|
||
default => 'no_signal',
|
||
};
|
||
|
||
$weeklySummary = $this->computeWeeklySummary($fuelType, $lat, $lng, $currentAvg, $slope);
|
||
|
||
return [
|
||
'fuel_type' => $fuelType->value,
|
||
'current_avg' => $currentAvg,
|
||
'predicted_direction' => $direction,
|
||
'predicted_change_pence' => $predictedChangePence,
|
||
'confidence_score' => $confidenceScore,
|
||
'confidence_label' => $confidenceLabel,
|
||
'action' => $action,
|
||
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour, $dayOfWeek),
|
||
'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS,
|
||
'region_key' => $hasCoordinates ? 'regional' : 'national',
|
||
'methodology' => 'multi_signal_live_fallback',
|
||
'weekly_summary' => $weeklySummary,
|
||
'signals' => [
|
||
'trend' => $trend,
|
||
'day_of_week' => $dayOfWeek,
|
||
'brand_behaviour' => $brandBehaviour,
|
||
'national_momentum' => $nationalMomentum,
|
||
'regional_momentum' => $regionalMomentum,
|
||
'price_stickiness' => $stickiness,
|
||
'oil' => $oil,
|
||
],
|
||
];
|
||
}
|
||
|
||
private function getCurrentAverage(FuelType $fuelType, ?float $lat, ?float $lng): float
|
||
{
|
||
if ($lat !== null && $lng !== null) {
|
||
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
|
||
|
||
$avg = DB::table('station_prices_current')
|
||
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
|
||
->where('station_prices_current.fuel_type', $fuelType->value)
|
||
->whereRaw($radiusSql, $radiusBindings)
|
||
->avg('station_prices_current.price_pence');
|
||
|
||
if ($avg !== null) {
|
||
return round((float) $avg / 100, 1);
|
||
}
|
||
}
|
||
|
||
$avg = StationPriceCurrent::where('fuel_type', $fuelType->value)->avg('price_pence');
|
||
|
||
return $avg !== null ? round((float) $avg / 100, 1) : 0.0;
|
||
}
|
||
|
||
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */
|
||
private function disabledSignal(string $detail): array
|
||
{
|
||
return [
|
||
'score' => 0.0,
|
||
'confidence' => 0.0,
|
||
'direction' => 'stable',
|
||
'detail' => $detail,
|
||
'data_points' => 0,
|
||
'enabled' => false,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Aggregate enabled signals into a final direction + confidence score.
|
||
*
|
||
* Direction: weighted vote across signals that have a non-stable direction.
|
||
* stable signals do NOT dilute the directional vote.
|
||
*
|
||
* Confidence: weighted average of enabled signals' own confidence values,
|
||
* multiplied by an agreement coefficient (0..1) measuring how the signals
|
||
* line up with the chosen direction.
|
||
*
|
||
* @param array<string, array{score: float, confidence: float, direction: string, enabled: bool}> $signals
|
||
* @return array{0: string, 1: float}
|
||
*/
|
||
private function aggregateSignals(array $signals, bool $hasCoordinates = false): array
|
||
{
|
||
$weights = $hasCoordinates
|
||
? [
|
||
'regionalMomentum' => 0.35,
|
||
'oil' => 0.20,
|
||
'trend' => 0.15,
|
||
'dayOfWeek' => 0.15,
|
||
'brandBehaviour' => 0.10,
|
||
'stickiness' => 0.05,
|
||
]
|
||
: [
|
||
'trend' => 0.30,
|
||
'oil' => 0.25,
|
||
'dayOfWeek' => 0.20,
|
||
'brandBehaviour' => 0.15,
|
||
'stickiness' => 0.10,
|
||
];
|
||
|
||
$directionalScoreSum = 0.0;
|
||
$directionalWeightSum = 0.0;
|
||
$confidenceWeightedSum = 0.0;
|
||
$totalEnabledWeight = 0.0;
|
||
|
||
foreach ($weights as $key => $weight) {
|
||
$signal = $signals[$key] ?? null;
|
||
if (! $signal || ! $signal['enabled']) {
|
||
continue;
|
||
}
|
||
|
||
$totalEnabledWeight += $weight;
|
||
$confidenceWeightedSum += $signal['confidence'] * $weight;
|
||
|
||
if ($signal['direction'] !== 'stable') {
|
||
$directionalScoreSum += $signal['score'] * $signal['confidence'] * $weight;
|
||
$directionalWeightSum += $weight;
|
||
}
|
||
}
|
||
|
||
if ($totalEnabledWeight < 0.01) {
|
||
return ['stable', 0.0];
|
||
}
|
||
|
||
$normalised = $directionalWeightSum > 0.01
|
||
? $directionalScoreSum / $directionalWeightSum
|
||
: 0.0;
|
||
|
||
$direction = match (true) {
|
||
$normalised >= 0.1 => 'up',
|
||
$normalised <= -0.1 => 'down',
|
||
default => 'stable',
|
||
};
|
||
|
||
$avgConfidence = $confidenceWeightedSum / $totalEnabledWeight;
|
||
$agreement = $this->computeAgreement($signals, $weights, $direction);
|
||
|
||
$confidenceScore = round(min(100.0, $avgConfidence * $agreement * 100), 1);
|
||
|
||
return [$direction, $confidenceScore];
|
||
}
|
||
|
||
/**
|
||
* How well the enabled signals line up with the chosen direction.
|
||
* - aligned signal: full credit (signal_confidence × weight)
|
||
* - one side stable, other directional: half credit
|
||
* - opposing signals: no credit
|
||
*
|
||
* Range: 0 (full disagreement) → 1 (unanimous).
|
||
*
|
||
* @param array<string, array{confidence: float, direction: string, enabled: bool}> $signals
|
||
* @param array<string, float> $weights
|
||
*/
|
||
private function computeAgreement(array $signals, array $weights, string $finalDirection): float
|
||
{
|
||
$finalDir = match ($finalDirection) {
|
||
'up' => 1,
|
||
'down' => -1,
|
||
default => 0,
|
||
};
|
||
|
||
$credit = 0.0;
|
||
$maxCredit = 0.0;
|
||
|
||
foreach ($weights as $key => $weight) {
|
||
$signal = $signals[$key] ?? null;
|
||
if (! $signal || ! $signal['enabled']) {
|
||
continue;
|
||
}
|
||
|
||
$maxCredit += $signal['confidence'] * $weight;
|
||
|
||
$signalDir = match ($signal['direction']) {
|
||
'up' => 1,
|
||
'down' => -1,
|
||
default => 0,
|
||
};
|
||
|
||
if ($signalDir === $finalDir) {
|
||
$credit += $signal['confidence'] * $weight;
|
||
} elseif ($signalDir === 0 || $finalDir === 0) {
|
||
$credit += 0.5 * $signal['confidence'] * $weight;
|
||
}
|
||
}
|
||
|
||
return $maxCredit > 0.0 ? $credit / $maxCredit : 0.0;
|
||
}
|
||
|
||
/**
|
||
* Yesterday / today / tomorrow snapshot + last-7-days series.
|
||
* Regional (50km) when coordinates are given, with national fallback when
|
||
* regional data is empty.
|
||
*
|
||
* @return array{
|
||
* yesterday_avg: ?float,
|
||
* today_avg: float,
|
||
* tomorrow_estimated_avg: ?float,
|
||
* yesterday_today_delta_pence: ?float,
|
||
* last_7_days_series: array<int, array{date: string, avg: float}>,
|
||
* last_7_days_change_pence: ?float,
|
||
* cheapest_day: ?array{date: string, avg: float},
|
||
* priciest_day: ?array{date: string, avg: float},
|
||
* is_regional: bool
|
||
* }
|
||
*/
|
||
private function computeWeeklySummary(FuelType $fuelType, ?float $lat, ?float $lng, float $todayAvg, float $slope): array
|
||
{
|
||
$yesterdayAvg = $this->getDailyAverage($fuelType, now()->subDay(), $lat, $lng);
|
||
[$series, $usedRegional] = $this->getDailySeries($fuelType, 7, $lat, $lng);
|
||
|
||
$tomorrowEstimated = $todayAvg > 0.0 ? round($todayAvg + $slope, 1) : null;
|
||
$yesterdayTodayDelta = $yesterdayAvg !== null ? round($todayAvg - $yesterdayAvg, 1) : null;
|
||
|
||
$cheapestDay = null;
|
||
$priciestDay = null;
|
||
$weekChange = null;
|
||
|
||
if (count($series) >= 2) {
|
||
$byPrice = $series;
|
||
usort($byPrice, fn ($a, $b) => $a['avg'] <=> $b['avg']);
|
||
$cheapestDay = $byPrice[0];
|
||
$priciestDay = $byPrice[count($byPrice) - 1];
|
||
$weekChange = round(end($series)['avg'] - $series[0]['avg'], 1);
|
||
}
|
||
|
||
return [
|
||
'yesterday_avg' => $yesterdayAvg,
|
||
'today_avg' => $todayAvg,
|
||
'tomorrow_estimated_avg' => $tomorrowEstimated,
|
||
'yesterday_today_delta_pence' => $yesterdayTodayDelta,
|
||
'last_7_days_series' => $series,
|
||
'last_7_days_change_pence' => $weekChange,
|
||
'cheapest_day' => $cheapestDay,
|
||
'priciest_day' => $priciestDay,
|
||
'is_regional' => $usedRegional,
|
||
];
|
||
}
|
||
|
||
private function getDailyAverage(FuelType $fuelType, CarbonInterface $date, ?float $lat, ?float $lng): ?float
|
||
{
|
||
$dateString = $date->toDateString();
|
||
|
||
if ($lat !== null && $lng !== null) {
|
||
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
|
||
|
||
$regional = DB::table('station_prices')
|
||
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||
->where('station_prices.fuel_type', $fuelType->value)
|
||
->whereDate('station_prices.price_effective_at', $dateString)
|
||
->whereRaw($radiusSql, $radiusBindings)
|
||
->avg('station_prices.price_pence');
|
||
|
||
if ($regional !== null) {
|
||
return round((float) $regional / 100, 1);
|
||
}
|
||
}
|
||
|
||
$national = DB::table('station_prices')
|
||
->where('fuel_type', $fuelType->value)
|
||
->whereDate('price_effective_at', $dateString)
|
||
->avg('price_pence');
|
||
|
||
return $national !== null ? round((float) $national / 100, 1) : null;
|
||
}
|
||
|
||
/**
|
||
* @return array{0: array<int, array{date: string, avg: float}>, 1: bool}
|
||
*/
|
||
private function getDailySeries(FuelType $fuelType, int $days, ?float $lat, ?float $lng): array
|
||
{
|
||
$rows = collect();
|
||
$usedRegional = false;
|
||
|
||
if ($lat !== null && $lng !== null) {
|
||
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
|
||
|
||
$rows = DB::table('station_prices')
|
||
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||
->where('station_prices.fuel_type', $fuelType->value)
|
||
->where('station_prices.price_effective_at', '>=', now()->subDays($days)->startOfDay())
|
||
->whereRaw($radiusSql, $radiusBindings)
|
||
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
||
->groupBy('day')
|
||
->orderBy('day')
|
||
->get();
|
||
|
||
$usedRegional = $rows->isNotEmpty();
|
||
}
|
||
|
||
if ($rows->isEmpty()) {
|
||
$rows = DB::table('station_prices')
|
||
->where('fuel_type', $fuelType->value)
|
||
->where('price_effective_at', '>=', now()->subDays($days)->startOfDay())
|
||
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
|
||
->groupBy('day')
|
||
->orderBy('day')
|
||
->get();
|
||
}
|
||
|
||
$series = $rows->map(fn ($r): array => [
|
||
'date' => (string) $r->day,
|
||
'avg' => round((float) $r->avg_price / 100, 1),
|
||
])->values()->all();
|
||
|
||
return [$series, $usedRegional];
|
||
}
|
||
|
||
/**
|
||
* @param array{enabled: bool, detail: string, direction: string} $trend
|
||
* @param array{enabled: bool, detail: string, direction: string} $brandBehaviour
|
||
* @param array{enabled: bool, detail: string, direction: string} $dayOfWeek
|
||
*/
|
||
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour, array $dayOfWeek): string
|
||
{
|
||
$parts = [];
|
||
|
||
if ($trend['enabled'] && abs($slope) >= self::SLOPE_THRESHOLD_PENCE) {
|
||
$parts[] = $trend['detail'];
|
||
}
|
||
|
||
if ($brandBehaviour['enabled'] && $brandBehaviour['direction'] !== 'stable') {
|
||
$parts[] = $brandBehaviour['detail'];
|
||
}
|
||
|
||
if ($dayOfWeek['enabled']) {
|
||
$parts[] = $dayOfWeek['detail'];
|
||
}
|
||
|
||
if (empty($parts)) {
|
||
return match ($direction) {
|
||
'up' => 'Mild upward signals — top up soon if you\'re nearby.',
|
||
'down' => 'Mild downward signals — wait a day or two if your tank can hold.',
|
||
default => 'No clear pattern — fill up at the cheapest station near you now.',
|
||
};
|
||
}
|
||
|
||
return implode(' ', $parts);
|
||
}
|
||
}
|