Files
fuelboard/Shared/FuelSavings.swift
T
FuelBoard Contributor d7a0ecb79a Add Worth the Drive fuel savings with road-routed estimates
Optional Fuel Savings feature (off by default): vehicle MPG in UK imperial gallons, fill amount in pounds or litres, persisted through the existing keychain/app-group store. Ranks the WHOLE station pool by estimated net saving (fuel-price saving at each pump minus the extra return-trip driving cost) using MapKit road distances with a straight-line fallback marked (est.).

The closest station with a valid price for the selected fuel is the comparison baseline and is labelled "Closest station"; other rows read "Save £X.XX after travel" / "£X.XX worse off after travel" / "Break even". Labels live in the shared model so they are unit-tested. The TOP badge still marks the cheapest pump price and reads "CHEAPEST" when the list is ranked by net saving.

Road routing now prioritises the Worth-the-Drive candidates plus the baseline and merges cache entries instead of replacing them, so far-out stations get real routes rather than permanent straight-line estimates. Numeric fields gain a Done button above the keypad (decimalPad has no Return key); the fill-up control is hidden unless the feature is configured; Favourites shares one session fill amount with the station list. 131 tests.
2026-10-11 14:34:53 +01:00

389 lines
15 KiB
Swift

import Foundation
enum FuelSavingsFillUnit: String, Codable, CaseIterable, Identifiable {
case pounds
case litres
var id: String { rawValue }
var displayName: String {
switch self {
case .pounds: return "£"
case .litres: return "Litres"
}
}
}
struct FuelSavingsFillAmount: Codable, Equatable {
var value: Double
var unit: FuelSavingsFillUnit
var isValid: Bool {
value.isFinite && value > 0 && value <= FuelSavingsCalculator.maximumFillAmount(for: unit)
}
}
struct FuelSavingsSettings: Codable, Equatable {
var enabled: Bool
var mpg: Double
var preferredAmount: FuelSavingsFillAmount
static let defaultMPG: Double = 45
static let defaultPreferredAmount = FuelSavingsFillAmount(value: 40, unit: .pounds)
static var defaults: FuelSavingsSettings {
FuelSavingsSettings(enabled: false, mpg: defaultMPG, preferredAmount: defaultPreferredAmount)
}
var isConfigured: Bool {
enabled && FuelSavingsCalculator.isValidMPG(mpg) && preferredAmount.isValid
}
}
enum FuelSavingsStatus: String, Codable, Equatable {
case worthTheDrive
case breakEven
case notWorthTheDrive
/// The comparison baseline itself — the station every other saving is
/// measured against. Shown as "Closest station", never "Break even".
case baseline
var displayName: String {
switch self {
case .worthTheDrive: return "Worth the drive"
case .breakEven: return "Break even"
case .notWorthTheDrive: return "Not worth the drive"
case .baseline: return FuelSavingsResult.baselineHeadline
}
}
}
/// How the journey distances behind an estimate were obtained.
enum FuelSavingsDistanceBasis: String, Codable, Equatable {
/// Measured road route, out and back, from the routing service.
case roadRoute
/// Straight-line out-and-back estimate, used until a route is measured.
case straightLine
var isEstimate: Bool { self == .straightLine }
}
struct FuelSavingsResult: Codable, Equatable {
let fillLitres: Double
let grossSavingGBP: Double
let extraDrivingCostGBP: Double
let extraDistanceMiles: Double
let netSavingGBP: Double
let status: FuelSavingsStatus
let distanceBasis: FuelSavingsDistanceBasis
init(fillLitres: Double,
grossSavingGBP: Double,
extraDrivingCostGBP: Double,
extraDistanceMiles: Double,
netSavingGBP: Double,
status: FuelSavingsStatus,
distanceBasis: FuelSavingsDistanceBasis = .roadRoute) {
self.fillLitres = fillLitres
self.grossSavingGBP = grossSavingGBP
self.extraDrivingCostGBP = extraDrivingCostGBP
self.extraDistanceMiles = extraDistanceMiles
self.netSavingGBP = netSavingGBP
self.status = status
self.distanceBasis = distanceBasis
}
}
/// Money formatting for savings strings, in one place so the exact wording the
/// UI shows is testable.
enum FuelSavingsFormat {
static func money(_ value: Double) -> String {
String(format: "£%.2f", value)
}
}
extension FuelSavingsResult {
/// Baseline row label — required wording.
static let baselineHeadline = "Closest station"
/// Neutral result label — required wording.
static let breakEvenHeadline = "Break even"
/// The same estimate relabelled as the comparison baseline: nothing to save
/// or lose against itself, and no extra travel.
func asBaseline() -> FuelSavingsResult {
FuelSavingsResult(
fillLitres: fillLitres,
grossSavingGBP: 0,
extraDrivingCostGBP: 0,
extraDistanceMiles: 0,
netSavingGBP: 0,
status: .baseline,
distanceBasis: distanceBasis
)
}
/// Row headline, e.g. "Save £0.52 after travel", "£0.31 worse off after
/// travel", "Break even", "Closest station".
///
/// `compact` (list rows) also marks a straight-line estimate with "(est.)";
/// the detail view spells that out in full instead.
func headline(compact: Bool = false) -> String {
let estimate = (compact && distanceBasis.isEstimate) ? " (est.)" : ""
switch status {
case .baseline:
return Self.baselineHeadline
case .breakEven:
return Self.breakEvenHeadline
case .worthTheDrive:
return "Save \(FuelSavingsFormat.money(netSavingGBP)) after travel\(estimate)"
case .notWorthTheDrive:
return "\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel\(estimate)"
}
}
}
/// One station's journey distance, plus how it was obtained.
struct FuelSavingsJourney: Equatable {
let miles: Double
let basis: FuelSavingsDistanceBasis
}
enum FuelSavingsAvailability: Equatable {
case available(FuelSavingsResult)
case unavailable(String)
var result: FuelSavingsResult? {
if case .available(let result) = self { return result }
return nil
}
}
struct FuelSavingsRankedStation: Equatable {
let stationID: String
let result: FuelSavingsAvailability
let distanceMiles: Double
let pricePencePerLitre: Double
}
enum FuelSavingsError: Error, Equatable {
case invalidMPG
case invalidAmount
case invalidPrice
case invalidDistance
}
enum FuelSavingsCalculator {
static let imperialGallonLitres = 4.54609
static let breakEvenToleranceGBP = 0.05
static let minimumMPG: Double = 5
static let maximumMPG: Double = 200
static func maximumFillAmount(for unit: FuelSavingsFillUnit) -> Double {
switch unit {
case .pounds: return 500
case .litres: return 250
}
}
static func isValidMPG(_ mpg: Double) -> Bool {
mpg.isFinite && mpg >= minimumMPG && mpg <= maximumMPG
}
static func fuelUsedLitres(distanceMiles: Double, mpg: Double) -> Double {
distanceMiles / mpg * imperialGallonLitres
}
static func fillLitres(amount: FuelSavingsFillAmount, baselinePricePencePerLitre: Double) throws -> Double {
guard amount.isValid else { throw FuelSavingsError.invalidAmount }
switch amount.unit {
case .litres:
return amount.value
case .pounds:
guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0 else {
throw FuelSavingsError.invalidPrice
}
return amount.value / (baselinePricePencePerLitre / 100.0)
}
}
static func convert(amount: FuelSavingsFillAmount,
to unit: FuelSavingsFillUnit,
referencePricePencePerLitre: Double) throws -> FuelSavingsFillAmount {
guard referencePricePencePerLitre.isFinite, referencePricePencePerLitre > 0 else {
throw FuelSavingsError.invalidPrice
}
guard amount.isValid else { throw FuelSavingsError.invalidAmount }
guard amount.unit != unit else { return amount }
switch (amount.unit, unit) {
case (.pounds, .litres):
return FuelSavingsFillAmount(
value: amount.value / (referencePricePencePerLitre / 100.0),
unit: .litres
)
case (.litres, .pounds):
return FuelSavingsFillAmount(
value: amount.value * (referencePricePencePerLitre / 100.0),
unit: .pounds
)
case (.pounds, .pounds), (.litres, .litres):
return amount
}
}
static func calculate(mpg: Double,
amount: FuelSavingsFillAmount,
baselinePricePencePerLitre: Double,
candidatePricePencePerLitre: Double,
baselineJourneyMiles: Double,
candidateJourneyMiles: Double,
distanceBasis: FuelSavingsDistanceBasis = .roadRoute,
breakEvenToleranceGBP: Double = Self.breakEvenToleranceGBP) throws -> FuelSavingsResult {
guard isValidMPG(mpg) else { throw FuelSavingsError.invalidMPG }
guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0,
candidatePricePencePerLitre.isFinite, candidatePricePencePerLitre > 0 else {
throw FuelSavingsError.invalidPrice
}
guard baselineJourneyMiles.isFinite, candidateJourneyMiles.isFinite,
baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else {
throw FuelSavingsError.invalidDistance
}
let fillLitres = try fillLitres(amount: amount, baselinePricePencePerLitre: baselinePricePencePerLitre)
let baselinePriceGBP = baselinePricePencePerLitre / 100.0
let candidatePriceGBP = candidatePricePencePerLitre / 100.0
let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP)
let extraDistance = max(0, candidateJourneyMiles - baselineJourneyMiles)
let extraFuelLitres = fuelUsedLitres(distanceMiles: extraDistance, mpg: mpg)
let extraFuelCost = extraFuelLitres * candidatePriceGBP
let netSaving = grossSaving - extraFuelCost
let status: FuelSavingsStatus
if netSaving > breakEvenToleranceGBP {
status = .worthTheDrive
} else if netSaving < -breakEvenToleranceGBP {
status = .notWorthTheDrive
} else {
status = .breakEven
}
return FuelSavingsResult(
fillLitres: fillLitres,
grossSavingGBP: grossSaving,
extraDrivingCostGBP: extraFuelCost,
extraDistanceMiles: extraDistance,
netSavingGBP: netSaving,
status: status,
distanceBasis: distanceBasis
)
}
static func sortedForWorthTheDrive(_ stations: [FuelSavingsRankedStation]) -> [FuelSavingsRankedStation] {
stations.sorted { lhs, rhs in
switch (lhs.result.result, rhs.result.result) {
case let (l?, r?):
if l.netSavingGBP != r.netSavingGBP { return l.netSavingGBP > r.netSavingGBP }
if lhs.distanceMiles != rhs.distanceMiles { return lhs.distanceMiles < rhs.distanceMiles }
if lhs.pricePencePerLitre != rhs.pricePencePerLitre { return lhs.pricePencePerLitre < rhs.pricePencePerLitre }
return lhs.stationID < rhs.stationID
case (_?, nil):
return true
case (nil, _?):
return false
case (nil, nil):
if lhs.distanceMiles != rhs.distanceMiles { return lhs.distanceMiles < rhs.distanceMiles }
return lhs.stationID < rhs.stationID
}
}
}
}
/// Turns stations into savings estimates.
///
/// Pure and injectable (no MapKit, CoreLocation or SwiftUI) so the ranking
/// rules are unit tested directly.
enum FuelSavingsEstimator {
/// Savings for a single station against `baseline`, or nil when there is
/// nothing to show at all (feature off).
static func availability(station: FuelStation,
fuel: FuelType,
baseline: FuelStation?,
settings: FuelSavingsSettings,
amount: FuelSavingsFillAmount,
journey: FuelSavingsJourney?,
baselineJourney: FuelSavingsJourney?) -> FuelSavingsAvailability? {
guard settings.isConfigured else { return nil }
guard let baseline, let baselineJourney else { return .unavailable("No comparison station") }
guard let baselinePrice = baseline.prices[fuel] else { return .unavailable("No comparison station") }
guard let price = station.prices[fuel] else { return .unavailable("No price") }
guard let journey else { return .unavailable("Distance unavailable") }
// Both legs must be measured for the estimate to count as a road-route
// one; otherwise it is reported as a straight-line estimate.
let basis: FuelSavingsDistanceBasis =
(journey.basis == .roadRoute && baselineJourney.basis == .roadRoute) ? .roadRoute : .straightLine
do {
let result = try FuelSavingsCalculator.calculate(
mpg: settings.mpg,
amount: amount,
baselinePricePencePerLitre: baselinePrice,
candidatePricePencePerLitre: price,
baselineJourneyMiles: baselineJourney.miles,
candidateJourneyMiles: journey.miles,
distanceBasis: basis
)
// The baseline compares against itself: label it as the closest
// station rather than a meaningless "Break even".
return .available(station.id == baseline.id ? result.asBaseline() : result)
} catch {
return .unavailable("Distance unavailable")
}
}
/// Ranks a whole pool for the Worth the Drive sort.
///
/// EVERY priced station in the pool gets an estimate, not just the handful
/// whose road routes happen to have been measured: journeys that have no
/// measured route fall back to a straight-line out-and-back estimate (see
/// `FuelSavingsDistanceBasis`). Capping this to a shortlist dropped cheaper
/// stations that were a few miles further away — the exact stations the
/// sort exists to surface — and left later pages with no savings at all.
static func ranked(_ stations: [FuelStation],
fuel: FuelType,
baseline: FuelStation?,
settings: FuelSavingsSettings,
amount: FuelSavingsFillAmount,
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> [FuelStation] {
var journeys: [String: FuelSavingsJourney] = [:]
journeys.reserveCapacity(stations.count)
for station in stations {
if let journey = journeyMiles(station) {
journeys[station.id] = journey
}
}
let baselineJourney = baseline.flatMap { journeys[$0.id] }
let ranked = stations.map { station -> FuelSavingsRankedStation in
let journey = journeys[station.id]
let availability = availability(station: station,
fuel: fuel,
baseline: baseline,
settings: settings,
amount: amount,
journey: journey,
baselineJourney: baselineJourney) ?? .unavailable("Unavailable")
return FuelSavingsRankedStation(
stationID: station.id,
result: availability,
distanceMiles: journey?.miles ?? .greatestFiniteMagnitude,
pricePencePerLitre: station.prices[fuel] ?? .infinity
)
}
let order = Dictionary(
uniqueKeysWithValues: FuelSavingsCalculator.sortedForWorthTheDrive(ranked)
.enumerated()
.map { ($0.element.stationID, $0.offset) }
)
return stations.sorted { lhs, rhs in
let left = order[lhs.id] ?? Int.max
let right = order[rhs.id] ?? Int.max
if left != right { return left < right }
return lhs.id < rhs.id
}
}
}