Files
fuelboard/Shared/FuelPriceProvider.swift
T
FuelBoard Contributor 7c2eeb2604 Best Value: baseline on the closest eligible price and gate on freshness
- Add FuelPriceFreshness: selected-fuel-aware eligibility plus a shared
  comparison confidence, so a saving is presented as verified only when
  both the candidate and the baseline pass the gate for the fuel on screen.
- Rank against the closest station with an eligible selected-fuel price
  instead of the physically closest station, so a single stale neighbour
  can no longer invalidate every comparison in the list. Ordinary Closest
  sorting still includes every priced station.
- Keep ineligible prices visible, fall back to pump-price order, and label
  Best Value unavailable when no comparison qualifies.
- Widget: same baseline and confidence path as the app.
- Tests: stale-closest baseline, alert baseline, eligibility policy,
  boundary and missing-timestamp regressions.
2026-10-15 21:37:24 +01:00

305 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FuelPriceProvider.swift — data-source seam for FuelBoard.
//
// The official UK Fuel Finder API (api.fuelfinder.service.gov.uk) is the live
// source for station-level prices, but it requires GOV.UK One Login + OAuth 2.0
// client credentials. For the scaffold we ship SampleFuelProvider (realistic
// stations around a location, works offline), plus the FuelFinderProvider
// skeleton with the exact integration steps documented inline so wiring the
// live API later is a drop-in: change `activeProvider` to `.fuelFinder`.
import Foundation
// MARK: - Provider protocol
protocol FuelPriceProviding {
/// Fetch stations with prices. `location` may be nil (sort by price only).
/// `radiusKM` nil = full dataset (device filters); set = focused server-side
/// radius (alert path). Throws on failure so callers can fall back.
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation]
}
enum FuelPriceProvider {
/// Default: the live chain — public GitHub mirror (primary, off-LAN) →
/// bundled dump/cache fallback in callers. No private LAN fallback ships in
/// the app binary.
static let active: FuelPriceProviding = LiveChainProvider()
/// Decodes a mirror payload into stations, applying the defensive price
/// band. Internal so the unit-test target can exercise the guard.
static func decodeStations(from data: Data) throws -> [FuelStation] {
let payload = try JSONDecoder().decode(MirrorResponse.self, from: data)
return payload.stations.map { relay in
FuelStation(
id: relay.id ?? relay.name ?? UUID().uuidString,
name: (relay.name ?? "Unknown").sanitizedStationTitle,
brand: relay.brand ?? "",
address: relay.address ?? "",
postcode: relay.postcode ?? "",
lat: relay.lat ?? 0,
lng: relay.lng ?? 0,
prices: relay.allPrices,
priceUpdated: relay.priceUpdatedDate?.timeIntervalSince1970,
priceUpdatedByFuel: relay.priceUpdatedByFuel,
priceUpdatedByFuelRaw: relay.priceUpdatedByFuelRaw
)
}
}
/// Price sanity band (pence/litre) — anything outside is mirror regression
/// garbage and must never reach calculations (shared by the live decode
/// and the history mirror decoder).
static let priceBand: ClosedRange<Double> = 50...500
/// Maps mirror grade keys (E5/E10/DIESEL…) to FuelType with the defensive
/// band guard. Shared by the mirror decode and the history mirror parser so
/// both surfaces apply identical sanitisation.
static func mapGrades(_ prices: [String: Double]) -> [FuelType: Double] {
var result: [FuelType: Double] = [:]
for (grade, value) in prices {
guard priceBand.contains(value) else { continue }
switch grade.uppercased() {
case "E10": result[.e10] = value
case "E5": result[.e5] = value
case "DIESEL", "B7", "B7S", "B7P", "B10": result[.diesel] = value
default: break
}
}
return result
}
static func fuelType(for grade: String) -> FuelType? {
switch grade.uppercased() {
case "E10": return .e10
case "E5": return .e5
case "DIESEL", "B7", "B7S", "B7P", "B10": return .diesel
default: return nil
}
}
/// Decodes mirror envelope metadata (source, dataset update time) — the
/// About section shows these so the user can see which data source is
/// live and how fresh the GOV.UK data itself is. The fields are additive
/// on the mirror, so older snapshots simply return nil values.
static func decodeRelayMeta(from data: Data) -> RelayMeta? {
guard let payload = try? JSONDecoder().decode(MirrorResponse.self, from: data) else {
return nil
}
return RelayMeta(
source: payload.source,
stationCount: payload.stationsCount,
dataUpdated: payload.dataUpdated
)
}
}
// MARK: - Mirror payload envelope
private struct MirrorResponse: Codable {
/// Envelope metadata surfaced in Settings → About so the user can see
/// which mirror source is live and how fresh the GOV.UK data is.
let source: String?
let stationsCount: Int?
let dataUpdated: String?
enum CodingKeys: String, CodingKey {
case source, stations
case stationsCount = "stations_count"
case dataUpdated = "data_updated"
}
struct MirrorStation: Codable {
let id: String?
let name: String?
let brand: String?
let address: String?
let postcode: String?
let lat: Double?
let lng: Double?
let price: Double?
let prices: [String: Double]?
let priceUpdated: String?
let priceUpdatedByGrade: [String: String]?
let priceUpdatedByFuelRawValues: [String: String]?
enum CodingKeys: String, CodingKey {
case id, name, brand, address, postcode, lat, lng, price, prices
case priceUpdated = "price_updated"
case priceUpdatedByGrade = "price_updated_by_grade"
case priceUpdatedByFuelRawValues = "price_updated_by_fuel"
}
var priceUpdatedDate: Date? {
FuelPriceTimestamp.parse(priceUpdated)
}
var priceUpdatedByFuelRaw: [FuelType: String]? {
let raw = priceUpdatedByGrade ?? priceUpdatedByFuelRawValues
guard let raw else { return nil }
let parsed = raw.reduce(into: [FuelType: String]()) { result, item in
guard let fuel = FuelPriceProvider.fuelType(for: item.key) else { return }
result[fuel] = item.value
}
return parsed.isEmpty ? nil : parsed
}
var priceUpdatedByFuel: [FuelType: TimeInterval]? {
let raw = priceUpdatedByGrade ?? priceUpdatedByFuelRawValues
guard let raw else { return nil }
let parsed = raw.reduce(into: [FuelType: TimeInterval]()) { result, item in
guard let fuel = FuelPriceProvider.fuelType(for: item.key),
let date = FuelPriceTimestamp.parse(item.value) else { return }
result[fuel] = date.timeIntervalSince1970
}
return parsed.isEmpty ? nil : parsed
}
/// Map mirror grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
/// station sells — one fetch populates every fuel tab. Defensive guard:
/// any grade outside the 50–500 pence/litre band is dropped, so a mirror
/// regression (e.g. the band being removed upstream) can't re-poison
/// the nationwide cheapest reference with 1.3p / 1589p garbage.
var allPrices: [FuelType: Double] {
var result = FuelPriceProvider.mapGrades(prices ?? [:])
// Backwards-compat: mirror versions without `prices` still send `price`.
if result.isEmpty, let price, FuelPriceProvider.priceBand.contains(price) {
result[.e10] = price
}
return result
}
}
let stations: [MirrorStation]
}
/// Envelope metadata from the mirror response — which data source is live
/// (api/csv) and the freshest price-update timestamp the GOV.UK server
/// itself reports for the dataset. Displayed in Settings → About.
struct RelayMeta: Codable, Equatable {
let source: String?
let stationCount: Int?
let dataUpdated: String?
}
// MARK: - Sample provider (offline fallback only)
/// Representative stations spread across ENGLAND (one cluster per region) so
/// the fallback works anywhere in the country — it is NOT tied to one town.
/// Real UK-wide data comes from the public mirror (full-UK Fuel Finder CSV/API).
/// Prices in pence/litre.
struct SampleFuelProvider: FuelPriceProviding {
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
try await Task.sleep(nanoseconds: 300_000_000) // simulate fetch
return Self.sampleStations
}
static let sampleStations: [FuelStation] = [
// Yorkshire & the Humber (Halifax area)
FuelStation(id: "y1", name: "Morrisons Halifax", brand: "Morrisons",
address: "Haugh Shaw Road", postcode: "HX1 3TU",
lat: 53.7265, lng: -1.8580,
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9],
priceUpdated: nil),
FuelStation(id: "y2", name: "Asda Leeds Crown Point", brand: "Asda",
address: "Crown Point Road", postcode: "LS10 1ET",
lat: 53.7800, lng: -1.5300,
prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9],
priceUpdated: nil),
FuelStation(id: "y3", name: "Tesco Express York", brand: "Tesco",
address: "Fulford Road", postcode: "YO10 4AB",
lat: 53.9500, lng: -1.0800,
prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9],
priceUpdated: nil),
// North West
FuelStation(id: "nw1", name: "Sainsbury's Manchester", brand: "Sainsbury's",
address: "Regent Road", postcode: "M5 3QH",
lat: 53.4700, lng: -2.2800,
prices: [.e10: 139.9, .e5: 147.9, .diesel: 146.9],
priceUpdated: nil),
FuelStation(id: "nw2", name: "Shell Liverpool", brand: "Shell",
address: "Rice Lane", postcode: "L9 1AD",
lat: 53.4400, lng: -2.9600,
prices: [.e10: 142.9, .e5: 149.9, .diesel: 148.9],
priceUpdated: nil),
// Midlands
FuelStation(id: "m1", name: "Morrisons Birmingham", brand: "Morrisons",
address: "Hagley Road", postcode: "B16 8NA",
lat: 52.4700, lng: -1.9500,
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9],
priceUpdated: nil),
FuelStation(id: "m2", name: "Tesco Express Nottingham", brand: "Tesco",
address: "Mansfield Road", postcode: "NG5 2DP",
lat: 52.9900, lng: -1.1500,
prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9],
priceUpdated: nil),
// South East / London
FuelStation(id: "se1", name: "Asda Wembley", brand: "Asda",
address: "Great Central Way", postcode: "HA9 0DB",
lat: 51.5500, lng: -0.2800,
prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9],
priceUpdated: nil),
FuelStation(id: "se2", name: "BP Brighton", brand: "BP",
address: "Lewes Road", postcode: "BN2 3QB",
lat: 50.8300, lng: -0.1100,
prices: [.e10: 140.9, .diesel: 147.9],
priceUpdated: nil),
// South West
FuelStation(id: "sw1", name: "Esso Bristol", brand: "Esso",
address: "Wells Road", postcode: "BS4 2PP",
lat: 51.4400, lng: -2.5700,
prices: [.e10: 141.9, .e5: 148.9, .diesel: 147.9],
priceUpdated: nil),
// North East
FuelStation(id: "ne1", name: "Gulf Newcastle", brand: "Gulf",
address: "Scotswood Road", postcode: "NE4 7AA",
lat: 54.9700, lng: -1.6400,
prices: [.e10: 143.9, .e5: 151.9, .diesel: 149.9],
priceUpdated: nil),
]
}
// MARK: - Fuel Finder API provider (integration point)
/// Live provider skeleton for the official GOV.UK Fuel Finder API.
///
/// To activate:
/// 1. Sign in at developer.fuel-finder.service.gov.uk with GOV.UK One Login,
/// register an app, and generate client credentials (client_id + secret).
/// 2. Implement token fetch: POST to the token endpoint with OAuth 2.0
/// client_credentials grant → access_token (expires in 1h).
/// 3. GET prices with `Authorization: Bearer <token>` and decode the
/// stations JSON into [FuelStation].
///
/// Endpoints (per gov.uk docs):
/// base: https://api.fuelfinder.service.gov.uk
/// auth: base + /v1/token
/// prices: base + /v1/prices (full snapshot)
/// price: base + /v1/prices/GB-12345 (single station)
///
/// Then switch `FuelPriceProvider.active` to `FuelFinderProvider(...)`.
struct FuelFinderProvider: FuelPriceProviding {
let baseURL = URL(string: "https://api.fuelfinder.service.gov.uk")!
let clientID: String
let clientSecret: String
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
// TODO: OAuth token → GET /v1/prices → map to FuelStation.
// The live API requires authentication; see notes above.
throw FuelProviderError.notImplemented
}
}
enum FuelProviderError: LocalizedError {
case notImplemented
case relayUnavailable
case mirrorUnavailable
var errorDescription: String? {
switch self {
case .notImplemented:
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
case .relayUnavailable:
return "FuelBoard live data unavailable — showing cached/sample data."
case .mirrorUnavailable:
return "GitHub price mirror unreachable."
}
}
}