Files
fuelboard/Shared/FuelSavings.swift
FuelBoard Contributor 6f177837a5 Stop Approx. distances beside routed values; add Best Value alert mode
Distances (the "Approx. 1.5 mi" report):
- Refuse junk CoreLocation fixes (invalid accuracy, or a coarse >300 m fix
  within 3 km of the position already held; a coarse fix in another city is
  real travel and is still accepted).
- Resolve ONE display basis per render through FuelStore.distanceDisplayOrigin,
  used by rows, radius filter, sorting, savings figures and the Live Activity,
  so a stray fix can no longer re-base the list outside the route cache's 600 m
  ring while the routed values sit unused in the cache.
- Treat an incomplete route set as a state: retry it after 30 s rather than the
  full 10-minute throttle, and repair a stale incomplete flag once every
  in-radius station has a route (nothing left to route, flag stuck down).
- Widget extension resolves the same origin for fetch, radius, ordering and
  labels, and keeps its own fix in fuelboard.widgetLocation instead of
  overwriting the app's GPS fix.

Alerts:
- Best Value as a distinct alert mode on the existing geofence/notification
  pipeline, with a persisted threshold; reverts to cheapest when the feature is
  unconfigured and is not selectable while disabled.

Diagnostics (DEBUG, gated): FB_DIST_FOCUS now prints FB-DIST-ORIGIN (candidate,
accuracy, anchor, offset, resolved branch, served origin), FB_FAKE_FIX feeds a
synthetic fix through the real guard, FB_STALE_INCOMPLETE forces the stale-flag
state, and the route-fill status is surfaced in Settings behind debug mode.
2026-10-13 22:04:00 +01:00

606 lines
26 KiB
Swift
Raw Permalink 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 names
/// the RETURN journey: the figure is an out-and-back DELTA between two
/// journeys and can legitimately exceed the one-way distance, which read as a
/// contradiction when it said "more driving than closest".
static func extraTravelLabel(extraDistanceMiles: Double,
unit: DistanceUnit,
confidence: JourneyConfidence = .confirmed) -> String? {
guard extraDistanceMiles > 0.05 else { return nil }
let distanceText = distance(extraDistanceMiles, unit: unit)
return confidence == .estimated
? "Approx. \(distanceText) extra return travel"
: "\(distanceText) extra return travel"
}
/// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix
/// are spoken as words ("Approximately 6.4 miles extra return travel") because
/// synthesizers render "Approx." and "mi" inconsistently.
static func extraTravelSpokenLabel(extraDistanceMiles: Double,
unit: DistanceUnit,
confidence: JourneyConfidence = .confirmed) -> String? {
guard extraDistanceMiles > 0.05 else { return nil }
let distanceText = spokenDistance(extraDistanceMiles, unit: unit)
return confidence == .estimated
? "Approximately \(distanceText) extra return travel"
: "\(distanceText) extra return travel"
}
/// A distance stored in MILES, spoken: "6.4 miles" / "10.3 kilometres". The
/// miles→km hop is deliberate: the stored figure is miles while
/// `DistanceUnit.spoken` speaks a KM value, the same boundary `distance(_:unit:)`
/// crosses.
static func spokenDistance(_ miles: Double, unit: DistanceUnit) -> String {
unit.spoken(miles * FuelSavingsCalculator.kilometresPerMile)
}
}
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. "£1.22 better off after travel", "£0.31 worse off after
/// travel", "Break even", "Closest station".
///
/// Estimated comparisons are prefixed "Approx. " for positive/negative money
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive
/// tech may skip it) instead of saying "this is an estimate". Break-even and
/// baseline stay plain words, with the estimate context carried by the
/// extra-distance caption or the detail explanation.
func headline(compact: Bool = false) -> String {
let prefix = journeyConfidence.isEstimated ? "Approx. " : ""
switch status {
case .baseline:
return Self.baselineHeadline
case .breakEven:
return Self.breakEvenHeadline
case .worthTheDrive:
return "\(prefix)\(FuelSavingsFormat.money(netSavingGBP)) better off after travel"
case .notWorthTheDrive:
return "\(prefix)\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel"
}
}
/// VoiceOver form of `headline()`: "Approx." is spelled out so a synthesizer
/// cannot read the abbreviation as "approx" or drop it entirely.
func spokenHeadline() -> String {
headline().replacingOccurrences(of: "Approx. ", with: "Approximately ")
}
/// 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
}
struct BestValueAlertCandidate: Equatable {
let station: FuelStation
let result: FuelSavingsResult
}
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
}
}
}
static func bestValueAlertCandidate(
stations: [FuelStation],
fuel: FuelType,
settings: FuelSavingsSettings,
amount: FuelSavingsFillAmount,
minimumNetSavingGBP: Double,
journeyMiles: (FuelStation) -> FuelSavingsJourney?
) -> BestValueAlertCandidate? {
guard settings.isConfigured, minimumNetSavingGBP.isFinite else { return nil }
let sellers = stations.filter { $0.prices[fuel] != nil }
guard !sellers.isEmpty else { return nil }
let baseline = FuelSavingsEstimator.baselineStation(sellers, fuel: fuel, journeyMiles: journeyMiles)
var best: BestValueAlertCandidate?
var bestDistance = Double.greatestFiniteMagnitude
var bestPrice = Double.infinity
var baselineJourneyCache: FuelSavingsJourney?
var baselineJourneyLoaded = false
for station in sellers {
let journey = journeyMiles(station)
let baselineJourney: FuelSavingsJourney? = {
guard let baseline else { return nil }
if baseline.id == station.id { return journey }
if !baselineJourneyLoaded {
baselineJourneyCache = journeyMiles(baseline)
baselineJourneyLoaded = true
}
return baselineJourneyCache
}()
guard case .available(let result) = FuelSavingsEstimator.availability(
station: station,
fuel: fuel,
baseline: baseline,
settings: settings,
amount: amount,
journey: journey,
baselineJourney: baselineJourney
), result.status == .worthTheDrive,
result.netSavingGBP >= minimumNetSavingGBP else { continue }
let distance = journey?.miles ?? .greatestFiniteMagnitude
let price = station.prices[fuel] ?? .infinity
guard let current = best else {
best = BestValueAlertCandidate(station: station, result: result)
bestDistance = distance
bestPrice = price
continue
}
if result.netSavingGBP > current.result.netSavingGBP ||
(result.netSavingGBP == current.result.netSavingGBP && distance < bestDistance) ||
(result.netSavingGBP == current.result.netSavingGBP && distance == bestDistance && price < bestPrice) ||
(result.netSavingGBP == current.result.netSavingGBP && distance == bestDistance && price == bestPrice && station.id < current.station.id) {
best = BestValueAlertCandidate(station: station, result: result)
bestDistance = distance
bestPrice = price
}
}
return best
}
}
/// 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 "extra return travel" 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
}
}
}