Files
fuel-price/app/Services/NationalFuelPredictionService.php
Ovidiu U 27c82ef103 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>
2026-04-29 19:43:28 +01:00

415 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
}
}