Remove prediction API endpoint and integrate into stations search
Consolidate prediction functionality by merging /api/prediction endpoint into /api/stations response. Move prediction logic from PredictionController into StationController, returning prediction data alongside station results. Replace usePrediction composable with unified useStations that returns {stations, meta, prediction}. Remove PredictionRequest, related tests, and unused Vue components (FuelFinderTest, MapTest, RecommendationTest, StationListTest). Add PredictionFull component and UpsellBanner. Extend NationalFuelPredictionService to include weekly_summary (7-day series, yesterday/today averages, cheapest/priciest days) and oil signal from price_predictions table. Update Home.vue to consume prediction from stations response. Add Plan::resolveCadenceForUser helper and configure Cashier to use custom Subscription model.
This commit is contained in:
@@ -63,10 +63,19 @@ class AuthController extends Controller
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$subscription = $user->subscription();
|
||||
|
||||
$expiresAt = $subscription?->ends_at ?? $subscription?->current_period_end;
|
||||
|
||||
return response()->json(array_merge(
|
||||
$user->toArray(),
|
||||
['tier' => Plan::resolveForUser($user)->name],
|
||||
[
|
||||
'tier' => Plan::resolveForUser($user)->name,
|
||||
'subscription_cancelled' => $subscription?->canceled() ?? false,
|
||||
'subscription_cadence' => Plan::resolveCadenceForUser($user),
|
||||
'subscribed_at' => $subscription?->created_at?->toIso8601String(),
|
||||
'subscription_expires_at' => $expiresAt?->toIso8601String(),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\PredictionRequest;
|
||||
use App\Services\NationalFuelPredictionService;
|
||||
use App\Services\PlanFeatures;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PredictionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NationalFuelPredictionService $predictionService,
|
||||
) {}
|
||||
|
||||
public function index(PredictionRequest $request): JsonResponse
|
||||
{
|
||||
$lat = $request->filled('lat') ? (float) $request->input('lat') : null;
|
||||
$lng = $request->filled('lng') ? (float) $request->input('lng') : null;
|
||||
|
||||
$result = $this->predictionService->predict($lat, $lng);
|
||||
|
||||
$user = $request->user();
|
||||
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
|
||||
|
||||
if (! $canSeeFull) {
|
||||
return response()->json([
|
||||
'fuel_type' => $result['fuel_type'],
|
||||
'predicted_direction' => $result['predicted_direction'],
|
||||
'tier_locked' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ use App\Http\Requests\Api\NearbyStationsRequest;
|
||||
use App\Http\Resources\Api\StationResource;
|
||||
use App\Models\Search;
|
||||
use App\Models\Station;
|
||||
use App\Models\User;
|
||||
use App\Services\NationalFuelPredictionService;
|
||||
use App\Services\PlanFeatures;
|
||||
use App\Services\PostcodeService;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -16,7 +19,10 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class StationController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PostcodeService $postcodeService) {}
|
||||
public function __construct(
|
||||
private readonly PostcodeService $postcodeService,
|
||||
private readonly NationalFuelPredictionService $predictionService,
|
||||
) {}
|
||||
|
||||
public function index(NearbyStationsRequest $request): JsonResponse
|
||||
{
|
||||
@@ -115,6 +121,31 @@ class StationController extends Controller
|
||||
'outdated' => (int) $reliabilityCounts->get(PriceReliability::Outdated->value, 0),
|
||||
],
|
||||
],
|
||||
'prediction' => $this->predictionFor($request->user(), $lat, $lng),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the prediction payload for embedding in the search response.
|
||||
* Free/guest users get a stripped teaser; users with the ai_predictions
|
||||
* feature get the full multi-signal payload.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function predictionFor(?User $user, float $lat, float $lng): array
|
||||
{
|
||||
$result = $this->predictionService->predict($lat, $lng);
|
||||
|
||||
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
|
||||
|
||||
if (! $canSeeFull) {
|
||||
return [
|
||||
'fuel_type' => $result['fuel_type'],
|
||||
'predicted_direction' => $result['predicted_direction'],
|
||||
'tier_locked' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Enums\PlanTier;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Cashier\Checkout;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class BillingController extends Controller
|
||||
@@ -12,7 +13,7 @@ class BillingController extends Controller
|
||||
/**
|
||||
* Redirect the user to a Stripe Checkout session for the requested plan + cadence.
|
||||
*/
|
||||
public function checkout(Request $request, string $tier, string $cadence): Response|RedirectResponse
|
||||
public function checkout(Request $request, string $tier, string $cadence): Response|RedirectResponse|Checkout
|
||||
{
|
||||
abort_unless(in_array($tier, [PlanTier::Basic->value, PlanTier::Plus->value, PlanTier::Pro->value], true), 404);
|
||||
abort_unless(in_array($cadence, ['monthly', 'annual'], true), 404);
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PredictionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'lng' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Jobs\SendPaymentFailedReminderJob;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -28,15 +29,27 @@ final class HandleStripeWebhook
|
||||
|
||||
match ($type) {
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated' => $this->bustPlanCache($user),
|
||||
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user),
|
||||
'customer.subscription.updated' => $this->handleSubscriptionUpserted($user, $event->payload['data']['object'] ?? []),
|
||||
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user, $event->payload['data']['object'] ?? []),
|
||||
'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user),
|
||||
'invoice.payment_failed' => $this->handlePaymentFailed($user),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function handleSubscriptionDeleted(User $user): void
|
||||
/**
|
||||
* @param array<string, mixed> $stripeSubscription
|
||||
*/
|
||||
private function handleSubscriptionUpserted(User $user, array $stripeSubscription): void
|
||||
{
|
||||
$this->syncPeriodFromStripePayload($stripeSubscription);
|
||||
$this->bustPlanCache($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $stripeSubscription
|
||||
*/
|
||||
private function handleSubscriptionDeleted(User $user, array $stripeSubscription): void
|
||||
{
|
||||
UserNotificationPreference::query()
|
||||
->where('user_id', $user->id)
|
||||
@@ -45,9 +58,52 @@ final class HandleStripeWebhook
|
||||
|
||||
$user->forceFill(['grace_period_until' => null])->save();
|
||||
|
||||
$this->syncPeriodFromStripePayload($stripeSubscription);
|
||||
$this->bustPlanCache($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror current_period_start / current_period_end from a Stripe subscription
|
||||
* payload onto our local row so we don't depend on Stripe at read time.
|
||||
*
|
||||
* Stripe API ≤ 2024-11-19 places the period fields at the root of the
|
||||
* subscription; later versions move them to items.data[0]. We accept either.
|
||||
*
|
||||
* @param array<string, mixed> $stripeSubscription
|
||||
*/
|
||||
private function syncPeriodFromStripePayload(array $stripeSubscription): void
|
||||
{
|
||||
$stripeId = $stripeSubscription['id'] ?? null;
|
||||
|
||||
if ($stripeId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscription = Subscription::where('stripe_id', $stripeId)->first();
|
||||
|
||||
if ($subscription === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$start = $stripeSubscription['current_period_start']
|
||||
?? ($stripeSubscription['items']['data'][0]['current_period_start'] ?? null);
|
||||
|
||||
$end = $stripeSubscription['current_period_end']
|
||||
?? ($stripeSubscription['items']['data'][0]['current_period_end'] ?? null);
|
||||
|
||||
$subscription->stripe_data = $stripeSubscription;
|
||||
|
||||
if ($start !== null) {
|
||||
$subscription->current_period_start = Carbon::createFromTimestamp($start);
|
||||
}
|
||||
|
||||
if ($end !== null) {
|
||||
$subscription->current_period_end = Carbon::createFromTimestamp($end);
|
||||
}
|
||||
|
||||
$subscription->save();
|
||||
}
|
||||
|
||||
private function handlePaymentSucceeded(User $user): void
|
||||
{
|
||||
$user->forceFill(['grace_period_until' => null])->save();
|
||||
|
||||
@@ -80,6 +80,41 @@ class Plan extends Model
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active subscription cadence for a user.
|
||||
* Returns 'monthly' | 'annual', or null if the user has no paid subscription.
|
||||
*/
|
||||
public static function resolveCadenceForUser(User $user): ?string
|
||||
{
|
||||
if (! method_exists($user, 'subscriptions')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$priceId = $user->subscriptions()->active()->value('stripe_price');
|
||||
|
||||
if ($priceId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$plan = static::where('stripe_price_id_monthly', $priceId)
|
||||
->orWhere('stripe_price_id_annual', $priceId)
|
||||
->first();
|
||||
|
||||
if ($plan === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($plan->stripe_price_id_monthly === $priceId) {
|
||||
return 'monthly';
|
||||
}
|
||||
|
||||
if ($plan->stripe_price_id_annual === $priceId) {
|
||||
return 'annual';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(function (): void {
|
||||
|
||||
@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
|
||||
class PricePrediction extends Model
|
||||
@@ -39,11 +38,17 @@ class PricePrediction extends Model
|
||||
*/
|
||||
public function scopeBestFirst(Builder $query): Builder
|
||||
{
|
||||
$priority = implode(', ', array_map(
|
||||
fn (string $v) => "'$v'",
|
||||
[PredictionSource::LlmWithContext->value, PredictionSource::Llm->value, PredictionSource::Ewma->value],
|
||||
));
|
||||
$priority = [
|
||||
PredictionSource::LlmWithContext->value,
|
||||
PredictionSource::Llm->value,
|
||||
PredictionSource::Ewma->value,
|
||||
];
|
||||
|
||||
return $query->orderByRaw("FIELD(source, $priority)");
|
||||
$cases = '';
|
||||
foreach ($priority as $rank => $source) {
|
||||
$cases .= " WHEN '$source' THEN $rank";
|
||||
}
|
||||
|
||||
return $query->orderByRaw("CASE source$cases ELSE ".count($priority).' END');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\HandleStripeWebhook;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\ApiLogger;
|
||||
use App\Services\LlmPrediction\AnthropicPredictionProvider;
|
||||
use App\Services\LlmPrediction\GeminiPredictionProvider;
|
||||
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -41,6 +43,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
$this->configureDefaults();
|
||||
|
||||
Cashier::useSubscriptionModel(Subscription::class);
|
||||
|
||||
Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Services;
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class NationalFuelPredictionService
|
||||
@@ -12,6 +13,12 @@ class NationalFuelPredictionService
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -40,13 +47,14 @@ class NationalFuelPredictionService
|
||||
$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');
|
||||
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness', 'oil');
|
||||
|
||||
[$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates);
|
||||
|
||||
@@ -65,6 +73,8 @@ class NationalFuelPredictionService
|
||||
default => 'no_signal',
|
||||
};
|
||||
|
||||
$weeklySummary = $this->computeWeeklySummary($fuelType, $lat, $lng, $currentAvg, $slope);
|
||||
|
||||
return [
|
||||
'fuel_type' => $fuelType->value,
|
||||
'current_avg' => $currentAvg,
|
||||
@@ -73,10 +83,11 @@ class NationalFuelPredictionService
|
||||
'confidence_score' => $confidenceScore,
|
||||
'confidence_label' => $confidenceLabel,
|
||||
'action' => $action,
|
||||
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour),
|
||||
'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,
|
||||
@@ -84,6 +95,7 @@ class NationalFuelPredictionService
|
||||
'national_momentum' => $nationalMomentum,
|
||||
'regional_momentum' => $regionalMomentum,
|
||||
'price_stickiness' => $stickiness,
|
||||
'oil' => $oil,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -138,7 +150,7 @@ class NationalFuelPredictionService
|
||||
default => 'stable',
|
||||
};
|
||||
$absSlope = abs($slope);
|
||||
$score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / 2.0) * ($slope > 0 ? 1 : -1);
|
||||
$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']})"
|
||||
@@ -204,8 +216,8 @@ class NationalFuelPredictionService
|
||||
|
||||
$uniqueDays = $rows->pluck('day')->unique()->count();
|
||||
|
||||
if ($uniqueDays < 56) {
|
||||
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need 56)");
|
||||
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'));
|
||||
@@ -214,9 +226,11 @@ class NationalFuelPredictionService
|
||||
$todayAvg = $dowAverages->get($todayDow, $weekAvg);
|
||||
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
|
||||
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
$cheapestDayName = $dayNames[($cheapestDow - 1) % 7] ?? 'Unknown';
|
||||
$weekRange = round(($dowAverages->max() - $dowAverages->min()) / 100, 1);
|
||||
$tomorrowDelta = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
|
||||
$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',
|
||||
@@ -226,11 +240,34 @@ class NationalFuelPredictionService
|
||||
|
||||
$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' => "Cheapest day: {$cheapestDayName}. Weekly range: {$weekRange}p. Tomorrow typically {$tomorrowDelta}p less than today.",
|
||||
'detail' => implode(' ', $parts),
|
||||
'data_points' => $uniqueDays,
|
||||
'enabled' => true,
|
||||
];
|
||||
@@ -386,6 +423,63 @@ class NationalFuelPredictionService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
@@ -400,46 +494,64 @@ class NationalFuelPredictionService
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted aggregate of enabled signals.
|
||||
* Returns [direction string, confidence score 0-100].
|
||||
* Aggregate enabled signals into a final direction + confidence score.
|
||||
*
|
||||
* @param array<string, array{score: float, confidence: float, enabled: bool}> $signals
|
||||
* 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.50,
|
||||
'trend' => 0.20,
|
||||
'regionalMomentum' => 0.35,
|
||||
'oil' => 0.20,
|
||||
'trend' => 0.15,
|
||||
'dayOfWeek' => 0.15,
|
||||
'brandBehaviour' => 0.10,
|
||||
'stickiness' => 0.05,
|
||||
]
|
||||
: [
|
||||
'trend' => 0.45,
|
||||
'trend' => 0.30,
|
||||
'oil' => 0.25,
|
||||
'dayOfWeek' => 0.20,
|
||||
'brandBehaviour' => 0.25,
|
||||
'brandBehaviour' => 0.15,
|
||||
'stickiness' => 0.10,
|
||||
];
|
||||
|
||||
$weightedSum = 0.0;
|
||||
$totalWeight = 0.0;
|
||||
$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']) {
|
||||
$weightedSum += $signal['score'] * $signal['confidence'] * $weight;
|
||||
$totalWeight += $weight;
|
||||
if (! $signal || ! $signal['enabled']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalEnabledWeight += $weight;
|
||||
$confidenceWeightedSum += $signal['confidence'] * $weight;
|
||||
|
||||
if ($signal['direction'] !== 'stable') {
|
||||
$directionalScoreSum += $signal['score'] * $signal['confidence'] * $weight;
|
||||
$directionalWeightSum += $weight;
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalWeight < 0.01) {
|
||||
if ($totalEnabledWeight < 0.01) {
|
||||
return ['stable', 0.0];
|
||||
}
|
||||
|
||||
$normalised = $weightedSum / $totalWeight;
|
||||
$confidenceScore = round(min(100.0, abs($normalised) * 100), 1);
|
||||
$normalised = $directionalWeightSum > 0.01
|
||||
? $directionalScoreSum / $directionalWeightSum
|
||||
: 0.0;
|
||||
|
||||
$direction = match (true) {
|
||||
$normalised >= 0.1 => 'up',
|
||||
@@ -447,9 +559,175 @@ class NationalFuelPredictionService
|
||||
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) {
|
||||
$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('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat])
|
||||
->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) {
|
||||
$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('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat])
|
||||
->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.
|
||||
@@ -491,7 +769,12 @@ class NationalFuelPredictionService
|
||||
return ['slope' => $slope, 'r_squared' => $rSquared];
|
||||
}
|
||||
|
||||
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour): string
|
||||
/**
|
||||
* @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 = [];
|
||||
|
||||
@@ -503,8 +786,16 @@ class NationalFuelPredictionService
|
||||
$parts[] = $brandBehaviour['detail'];
|
||||
}
|
||||
|
||||
if ($dayOfWeek['enabled']) {
|
||||
$parts[] = $dayOfWeek['detail'];
|
||||
}
|
||||
|
||||
if (empty($parts)) {
|
||||
return 'No clear pattern — fill up at the cheapest station near you now.';
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user