Calculation audit (brief config: 30 MPG imperial, £60 fill-up, baseline
Rontec Shelf 171.9p at 0.4 mi):
- One mile<->kilometre constant pair (kilometresPerMile 1.609344,
metresPerMile 1609.344) replaces the mixed 1609.34 / 0.621371 / 1.60934
used in the savings path. Effect ~2 ppm (invisible), but the conversions
are now auditable in one place.
- New FuelSavingsCalculationAuditTests (20 deterministic tests, no live
MKDirections) pins the brief's three stations - Tesco 170.9p/2.0 mi,
MFG Hare & Hounds 172.9p/2.8 mi, PGG Napoleon 171.9p/2.8 mi - plus
imperial-vs-US gallon, MPG and budget scaling, the break-even tolerance
boundary, the nearer-candidate clamp, route-unavailable, mixed-basis
estimates and display-rounding boundaries.
- Formula unchanged, and verified correct: fixed quantity (budget / baseline
price), pump saving, round-trip extra distance clamped at zero, travel fuel
priced at the candidate station, +/-5p break even, baseline labelled
"Closest station", sorting on the unrounded net saving. The brief's "£0.18"
for Tesco is the rounded 1 dp extra-distance caption (2.02-2.05 mi reads as
"2.0 mi"), not a rounding bug in the money.
UI (uncommitted since d7a0ecb):
- Done bar drawn in the bottom safe area from keyboard notifications:
.toolbar(placement: .keyboard) is device-dependent (present on the 15 Pro,
absent on the SE on the same iOS build).
- Favourite star in the More-info heading, wired to the same fuel-scoped
toggle as the listing rows at all three call sites.
- CHEAPEST/TOP badge moved above the price so it reads as part of it.
- Listing rows carry the real session fill-up amount instead of a hard zero
(the compact banner does not print it today; a future row layout would).
153 tests, 0 failures. Simulator and device builds succeed.
428 lines
18 KiB
Swift
428 lines
18 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)
|
||
}
|
||
|
||
/// A distance stored in MILES, formatted in the display unit. The figures
|
||
/// behind an estimate are stored in miles whatever the user's unit, so this
|
||
/// is the one place that conversion lives.
|
||
static func distance(_ miles: Double, unit: DistanceUnit) -> String {
|
||
// Convert miles→km FIRST, then let the unit format it. `unit.toKM` would
|
||
// convert by the CURRENT unit instead, leaving a km user's figure
|
||
// unlabelled and 1.6× too large.
|
||
unit.format(miles * FuelSavingsCalculator.kilometresPerMile)
|
||
}
|
||
|
||
/// Row label for the extra driving a saving has to beat. The stored figure is
|
||
/// always MILES while the row's distance line is one-way, so the wording says
|
||
/// "more driving": the extra is an out-and-back figure and can legitimately
|
||
/// exceed the one-way distance, which read as a contradiction before.
|
||
static func extraTravelLabel(extraDistanceMiles: Double, unit: DistanceUnit) -> String? {
|
||
guard extraDistanceMiles > 0.05 else { return nil }
|
||
return "\(distance(extraDistanceMiles, unit: unit)) more driving than closest"
|
||
}
|
||
}
|
||
|
||
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)"
|
||
}
|
||
}
|
||
|
||
/// Row capsule copy: the headline, plus what the baseline row IS. Without the
|
||
/// suffix the baseline capsule just repeated the status word above it
|
||
/// ("Closest station" twice) and said nothing about the saving being measured
|
||
/// against it.
|
||
func capsuleHeadline() -> String {
|
||
let headline = headline(compact: true)
|
||
return status == .baseline ? "\(headline) · your baseline" : headline
|
||
}
|
||
}
|
||
|
||
/// 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 {
|
||
/// 1 UK (imperial) gallon. Must never be the US gallon (3.785411784 L):
|
||
/// UK pumps are priced per litre and drivers quote UK MPG, so a US gallon
|
||
/// would understate every travel cost by 17%.
|
||
static let imperialGallonLitres = 4.54609
|
||
/// Exact mile↔kilometre factors (1 mile = 1.609344 km). Every figure the
|
||
/// savings maths keeps is in MILES, and every displayed or fallback
|
||
/// distance converts through these, so one quantity is never converted with
|
||
/// two different constants. The audit found 1.60934, 1609.34 and 0.621371
|
||
/// mixed in the same path — a ~2 parts-per-million difference, immaterial to
|
||
/// the money, but three constants where there should be one.
|
||
static let kilometresPerMile = 1.609344
|
||
static let metresPerMile = 1609.344
|
||
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
|
||
}
|
||
}
|
||
}
|