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 { 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 = 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 ) } 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 } } }