Files
fuelboard/Shared/FuelSavings.swift
T

516 lines
21 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.
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 }
}
/// Whether a Best Value comparison is backed by complete road-route data.
enum JourneyConfidence: String, Codable, Equatable {
/// Both baseline and candidate have measured outward and return road routes.
case confirmed
/// At least one journey is straight-line, missing, failed, or using a legacy
/// one-way road cache whose return leg is inferred rather than measured.
case estimated
var isEstimated: Bool { self == .estimated }
}
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
let journeyConfidence: JourneyConfidence
init(fillLitres: Double,
grossSavingGBP: Double,
extraDrivingCostGBP: Double,
extraDistanceMiles: Double,
netSavingGBP: Double,
status: FuelSavingsStatus,
distanceBasis: FuelSavingsDistanceBasis = .roadRoute,
journeyConfidence: JourneyConfidence? = nil) {
self.fillLitres = fillLitres
self.grossSavingGBP = grossSavingGBP
self.extraDrivingCostGBP = extraDrivingCostGBP
self.extraDistanceMiles = extraDistanceMiles
self.netSavingGBP = netSavingGBP
self.status = status
self.distanceBasis = distanceBasis
self.journeyConfidence = journeyConfidence ?? (distanceBasis.isEstimate ? .estimated : .confirmed)
}
}
/// 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,
confidence: JourneyConfidence = .confirmed) -> String? {
guard extraDistanceMiles > 0.05 else { return nil }
let distanceText = distance(extraDistanceMiles, unit: unit)
switch confidence {
case .confirmed:
return "\(distanceText) more driving than closest"
case .estimated:
return "Approx. \(distanceText) additional travel"
}
}
}
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,
journeyConfidence: journeyConfidence
)
}
/// Row headline, e.g. "Save £0.52 after travel", "£0.31 worse off after
/// travel", "Break even", "Closest station".
///
/// Estimated comparisons are prefixed with `~` for positive/negative money
/// results. Break-even/baseline remain words, with estimate context carried
/// by the extra-distance caption or detail explanation.
func headline(compact: Bool = false) -> String {
let prefix = journeyConfidence.isEstimated ? "~" : ""
switch status {
case .baseline:
return Self.baselineHeadline
case .breakEven:
return Self.breakEvenHeadline
case .worthTheDrive:
return "\(prefix)Save \(FuelSavingsFormat.money(netSavingGBP)) after travel"
case .notWorthTheDrive:
return "\(prefix)\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel"
}
}
/// 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
let confidence: JourneyConfidence
init(miles: Double,
basis: FuelSavingsDistanceBasis,
confidence: JourneyConfidence? = nil) {
self.miles = miles
self.basis = basis
self.confidence = confidence ?? (basis.isEstimate ? .estimated : .confirmed)
}
}
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
}
}
/// Bounds for every fill-up SLIDER (Settings and the in-list adjustment
/// share them, so the two controls can never offer different ranges). The
/// validity ceiling above is deliberately wider, so a stored outlier is
/// displayed rather than destroyed by the control that shows it.
static func fillAmountSliderRange(for unit: FuelSavingsFillUnit) -> ClosedRange<Double> {
switch unit {
case .pounds: return 10...100
case .litres: return 10...80
}
}
static func fillAmountSliderStep(for unit: FuelSavingsFillUnit) -> Double {
switch unit {
case .pounds: return 5
case .litres: return 1
}
}
/// Bounds for the average-MPG slider. The validity bounds (5...200) are
/// wide enough for any stored figure but far too wide to fine-tune by
/// touch; every real-world UK car sits inside this range.
static let mpgSliderRange: ClosedRange<Double> = 10...100
static let mpgSliderStep: Double = 1
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,
journeyConfidence: JourneyConfidence? = nil,
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,
journeyConfidence: journeyConfidence
)
}
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 {
/// Baseline station for Best Value: the station with the shortest journey
/// among stations that sell the selected fuel, using the SAME journey source
/// the savings calculation will use. This keeps the row labelled "Closest
/// station" and every "more driving than closest" value tied to one
/// reference, instead of mixing a separate straight-line baseline with
/// road-route savings.
static func baselineStation(_ stations: [FuelStation],
fuel: FuelType,
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> FuelStation? {
var bestStation: FuelStation?
var bestMiles = Double.infinity
for station in stations where station.prices[fuel] != nil {
guard let journey = journeyMiles(station) else { continue }
let miles = journey.miles
guard miles.isFinite, miles >= 0 else { continue }
if miles < bestMiles {
bestMiles = miles
bestStation = station
}
}
return bestStation
}
/// 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 journeys must be measured, complete road routes for the comparison
// to be confirmed. Legacy one-way caches keep the road-route basis for
// distance arithmetic but are still provisional because the return leg is
// inferred rather than measured.
let basis: FuelSavingsDistanceBasis =
(journey.basis == .roadRoute && baselineJourney.basis == .roadRoute) ? .roadRoute : .straightLine
let confidence: JourneyConfidence =
(journey.confidence == .confirmed && baselineJourney.confidence == .confirmed) ? .confirmed : .estimated
do {
let result = try FuelSavingsCalculator.calculate(
mpg: settings.mpg,
amount: amount,
baselinePricePencePerLitre: baselinePrice,
candidatePricePencePerLitre: price,
baselineJourneyMiles: baselineJourney.miles,
candidateJourneyMiles: journey.miles,
distanceBasis: basis,
journeyConfidence: confidence
)
// 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 Best Value 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
)
}
var order: [String: Int] = [:]
for (offset, rankedStation) in FuelSavingsCalculator.sortedForWorthTheDrive(ranked).enumerated() {
if order[rankedStation.stationID] == nil {
order[rankedStation.stationID] = 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
}
}
}