The 5 haversine SQL fragments duplicated across StationController and NationalFuelPredictionService disagreed on float-clamping (LEAST only, GREATEST/LEAST, vs. CASE WHEN). Centralised in App\Services\HaversineQuery with the safe GREATEST(-1.0, LEAST(1.0, …)) form everywhere. withinKm() embeds the radius as a numeric literal (sprintf %F) because PDO + SQLite binds float parameters as strings by default, which breaks numeric comparison against the haversine expression — a NULL filter would silently match all rows. Coordinates remain bound positionally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
812 lines
31 KiB
PHP
812 lines
31 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Enums\FuelType;
|
||
use App\Models\StationPriceCurrent;
|
||
use Carbon\CarbonInterface;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
class NationalFuelPredictionService
|
||
{
|
||
private const float R_SQUARED_THRESHOLD = 0.5;
|
||
|
||
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
||
|
||
/** Slope (pence/day) at which trend score saturates to ±1.0. */
|
||
private const float SLOPE_SATURATION_PENCE = 0.5;
|
||
|
||
/** Minimum unique days of history for the day-of-week signal to activate. */
|
||
private const int DAY_OF_WEEK_MIN_DAYS = 21;
|
||
|
||
private const int PREDICTION_HORIZON_DAYS = 7;
|
||
|
||
/**
|
||
* @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;
|
||
|
||
$currentAvg = $this->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<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];
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|