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 $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 $signals * @param array $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, * 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, 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); } }