getCurrentAverage($fuelType, $lat, $lng); $trend = $this->computeTrendSignal($fuelType); $dayOfWeek = $this->computeDayOfWeekSignal($fuelType); $brandBehaviour = $this->computeBrandBehaviourSignal($fuelType); $stickiness = $this->computeStickinessSignal($fuelType); $oil = $this->computeOilSignal(); $nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions'); $regionalMomentum = $hasCoordinates ? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng) : $this->disabledSignal('No coordinates provided for regional momentum analysis'); $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; } /** * Linear regression on daily national average prices. * Tries 5-day lookback first; falls back to 14-day if R² < threshold. * * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float} */ private function computeTrendSignal(FuelType $fuelType): array { foreach ([5, 14] as $lookbackDays) { $rows = DB::table('station_prices') ->where('fuel_type', $fuelType->value) ->where('price_effective_at', '>=', now()->subDays($lookbackDays)) ->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price') ->groupBy('day') ->orderBy('day') ->get(); if ($rows->count() < 2) { continue; } $regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all()); if ($regression['r_squared'] >= self::R_SQUARED_THRESHOLD) { $slope = $regression['slope']; $direction = match (true) { $slope >= self::SLOPE_THRESHOLD_PENCE => 'up', $slope <= -self::SLOPE_THRESHOLD_PENCE => 'down', default => 'stable', }; $absSlope = abs($slope); $score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / self::SLOPE_SATURATION_PENCE) * ($slope > 0 ? 1 : -1); $projected = round($slope * $lookbackDays, 1); $detail = $direction === 'stable' ? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})" : sprintf( '%s at %sp/day over %d days (R²=%s, ~%s%sp in %dd)', $slope > 0 ? 'Rising' : 'Falling', abs(round($slope, 2)), $lookbackDays, round($regression['r_squared'], 2), $projected > 0 ? '+' : '', $projected, self::PREDICTION_HORIZON_DAYS, ); if ($lookbackDays === 5) { $detail .= ' [Adaptive lookback active]'; } return [ 'score' => $score, 'confidence' => min(1.0, $regression['r_squared']), 'direction' => $direction, 'detail' => $detail, 'data_points' => $rows->count(), 'enabled' => true, 'slope' => round($slope, 3), 'r_squared' => round($regression['r_squared'], 3), ]; } } return [ 'score' => 0.0, 'confidence' => 0.0, 'direction' => 'stable', 'detail' => 'Insufficient price history or noisy data (R² below threshold)', 'data_points' => 0, 'enabled' => false, 'slope' => 0.0, 'r_squared' => 0.0, ]; } /** * Compare today's average price against the per-weekday average over 90 days. * Requires 56+ days of history to activate. * * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */ private function computeDayOfWeekSignal(FuelType $fuelType): 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', $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::DAY_OF_WEEK_MIN_DAYS) { return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need ".self::DAY_OF_WEEK_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, ]; } /** * Compare supermarket vs non-supermarket 7-day price trend. * Detects divergence where one group has moved but the other hasn't yet. * * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */ private function computeBrandBehaviourSignal(FuelType $fuelType): array { $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(7)) ->selectRaw('stations.is_supermarket, DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price') ->groupBy('stations.is_supermarket', 'day') ->orderBy('day') ->get(); $supermarket = $rows->where('is_supermarket', 1)->values(); $major = $rows->where('is_supermarket', 0)->values(); if ($supermarket->count() < 2 || $major->count() < 2) { return $this->disabledSignal('Insufficient brand data for comparison'); } $supermarketSlope = $this->linearRegression($supermarket->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope']; $majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope']; $divergence = round(abs($supermarketSlope - $majorSlope) * 7, 1); $supermarketChange = round($supermarketSlope * 7, 1); $majorChange = round($majorSlope * 7, 1); if ($divergence < 1.0) { return [ 'score' => 0.0, 'confidence' => 0.5, 'direction' => 'stable', 'detail' => 'Supermarkets and majors moving in sync.', 'data_points' => $rows->count(), 'enabled' => true, ]; } $leaderChange = abs($supermarketChange) > abs($majorChange) ? $supermarketChange : $majorChange; $direction = $leaderChange > 0 ? 'up' : 'down'; $leader = abs($supermarketChange) > abs($majorChange) ? 'Supermarkets' : 'Majors'; $follower = $leader === 'Supermarkets' ? 'majors' : 'supermarkets'; $leaderAbs = abs($leaderChange); $followerChange = $leader === 'Supermarkets' ? abs($majorChange) : abs($supermarketChange); return [ 'score' => $direction === 'up' ? 1.0 : -1.0, 'confidence' => min(1.0, $divergence / 5.0), 'direction' => $direction, 'detail' => "{$leader} ".($leaderChange > 0 ? 'rose' : 'fell')." {$leaderAbs}p vs {$follower} {$followerChange}p (divergence: {$divergence}p). Expect {$follower} to follow.", 'data_points' => $rows->count(), 'enabled' => true, ]; } /** * Average hold duration (days between price changes) as a confidence modifier. * Requires 30+ days of history. Returns a score between -0.1 and +0.1. * * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */ private function computeStickinessSignal(FuelType $fuelType): array { $isSqlite = DB::connection()->getDriverName() === 'sqlite'; $diffExpr = $isSqlite ? 'CAST((julianday(MAX(price_effective_at)) - julianday(MIN(price_effective_at))) AS INTEGER)' : 'DATEDIFF(MAX(price_effective_at), MIN(price_effective_at))'; $rows = DB::table('station_prices') ->where('fuel_type', $fuelType->value) ->where('price_effective_at', '>=', now()->subDays(30)) ->selectRaw("station_id, COUNT(*) as changes, {$diffExpr} as span_days") ->groupBy('station_id') ->having('changes', '>', 1) ->having('span_days', '>', 0) ->get(); if ($rows->count() < 10) { return $this->disabledSignal('Insufficient stickiness data (need 10+ stations with price history)'); } $avgHoldDays = $rows->avg(fn ($r) => $r->span_days / ($r->changes - 1)); $avgHoldDays = round((float) $avgHoldDays, 1); $score = match (true) { $avgHoldDays < 2 => -0.1, $avgHoldDays > 5 => 0.1, default => 0.0, }; $detail = match (true) { $avgHoldDays < 2 => "Volatile prices (avg hold: {$avgHoldDays} days) — harder to predict.", $avgHoldDays > 5 => "Sticky prices (avg hold: {$avgHoldDays} days) — more predictable.", default => "Normal hold period (avg: {$avgHoldDays} days).", }; return [ 'score' => $score, 'confidence' => min(1.0, $rows->count() / 200), 'direction' => 'stable', 'detail' => $detail, 'data_points' => $rows->count(), 'enabled' => true, ]; } /** * Placeholder for regional momentum signal (requires lat/lng). * Compares local station prices vs national average trend. * * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */ private function computeRegionalMomentumSignal(FuelType $fuelType, float $lat, float $lng): array { // Regional momentum: compare trend of stations within 50km vs national trend [$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(14)) ->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(); if ($rows->count() < 3) { return $this->disabledSignal('Insufficient regional data'); } $regionalRegression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all()); $direction = match (true) { $regionalRegression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up', $regionalRegression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down', default => 'stable', }; return [ 'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7), 'confidence' => min(1.0, $regionalRegression['r_squared']), 'direction' => $direction, 'detail' => 'Regional trend: '.round($regionalRegression['slope'], 2).'p/day (R²='.round($regionalRegression['r_squared'], 2).')', 'data_points' => $rows->count(), 'enabled' => true, ]; } /** * Reads the most recent Brent crude prediction (LLM preferred, EWMA fallback) * covering today or later. Sourced from price_predictions, which OilPriceService * populates daily. * * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */ private function computeOilSignal(): array { $prediction = null; foreach (['llm_with_context', 'llm', 'ewma'] as $source) { $prediction = DB::table('price_predictions') ->where('source', $source) ->where('predicted_for', '>=', now()->toDateString()) ->orderByDesc('predicted_for') ->orderByDesc('generated_at') ->first(); if ($prediction !== null) { break; } } if ($prediction === null) { return $this->disabledSignal('No oil price prediction available'); } $direction = match ($prediction->direction) { 'rising' => 'up', 'falling' => 'down', default => 'stable', }; $score = match ($direction) { 'up' => 1.0, 'down' => -1.0, default => 0.0, }; $confidence = round(((float) $prediction->confidence) / 100, 2); return [ 'score' => $score, 'confidence' => $confidence, 'direction' => $direction, 'detail' => sprintf( 'Brent crude %s (%s, %d%% confidence)', $prediction->direction, $prediction->source, (int) $prediction->confidence, ), 'data_points' => 1, 'enabled' => true, ]; } /** @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]; } /** * Least-squares linear regression. * x is the array index (day number), y is the price value. * * @param float[] $values * @return array{slope: float, r_squared: float} */ private function linearRegression(array $values): array { $n = count($values); if ($n < 2) { return ['slope' => 0.0, 'r_squared' => 0.0]; } $xMean = ($n - 1) / 2.0; $yMean = array_sum($values) / $n; $numerator = 0.0; $denominator = 0.0; foreach ($values as $i => $y) { $x = $i - $xMean; $numerator += $x * ($y - $yMean); $denominator += $x * $x; } $slope = $denominator > 0.0 ? $numerator / $denominator : 0.0; $ssRes = 0.0; $ssTot = 0.0; foreach ($values as $i => $y) { $predicted = $yMean + $slope * ($i - $xMean); $ssRes += ($y - $predicted) ** 2; $ssTot += ($y - $yMean) ** 2; } $rSquared = $ssTot > 0.0 ? max(0.0, 1.0 - ($ssRes / $ssTot)) : 0.0; return ['slope' => $slope, 'r_squared' => $rSquared]; } /** * @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); } }