Clarify Best Value route confidence

This commit is contained in:
FuelBoard Contributor
2026-10-13 10:30:41 +01:00
parent d8a85b7aa0
commit 9aa8a2ede1
10 changed files with 338 additions and 46 deletions
+76 -13
View File
@@ -68,6 +68,17 @@ enum FuelSavingsDistanceBasis: String, Codable, Equatable {
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
@@ -76,6 +87,7 @@ struct FuelSavingsResult: Codable, Equatable {
let netSavingGBP: Double
let status: FuelSavingsStatus
let distanceBasis: FuelSavingsDistanceBasis
let journeyConfidence: JourneyConfidence
init(fillLitres: Double,
grossSavingGBP: Double,
@@ -83,7 +95,8 @@ struct FuelSavingsResult: Codable, Equatable {
extraDistanceMiles: Double,
netSavingGBP: Double,
status: FuelSavingsStatus,
distanceBasis: FuelSavingsDistanceBasis = .roadRoute) {
distanceBasis: FuelSavingsDistanceBasis = .roadRoute,
journeyConfidence: JourneyConfidence? = nil) {
self.fillLitres = fillLitres
self.grossSavingGBP = grossSavingGBP
self.extraDrivingCostGBP = extraDrivingCostGBP
@@ -91,6 +104,7 @@ struct FuelSavingsResult: Codable, Equatable {
self.netSavingGBP = netSavingGBP
self.status = status
self.distanceBasis = distanceBasis
self.journeyConfidence = journeyConfidence ?? (distanceBasis.isEstimate ? .estimated : .confirmed)
}
}
@@ -115,9 +129,17 @@ enum FuelSavingsFormat {
/// 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? {
static func extraTravelLabel(extraDistanceMiles: Double,
unit: DistanceUnit,
confidence: JourneyConfidence = .confirmed) -> String? {
guard extraDistanceMiles > 0.05 else { return nil }
return "\(distance(extraDistanceMiles, unit: unit)) more driving than closest"
let distanceText = distance(extraDistanceMiles, unit: unit)
switch confidence {
case .confirmed:
return "\(distanceText) more driving than closest"
case .estimated:
return "Approx. \(distanceText) additional travel"
}
}
}
@@ -137,26 +159,28 @@ extension FuelSavingsResult {
extraDistanceMiles: 0,
netSavingGBP: 0,
status: .baseline,
distanceBasis: distanceBasis
distanceBasis: distanceBasis,
journeyConfidence: journeyConfidence
)
}
/// 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.
/// 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 estimate = (compact && distanceBasis.isEstimate) ? " (est.)" : ""
let prefix = journeyConfidence.isEstimated ? "~" : ""
switch status {
case .baseline:
return Self.baselineHeadline
case .breakEven:
return Self.breakEvenHeadline
case .worthTheDrive:
return "Save \(FuelSavingsFormat.money(netSavingGBP)) after travel\(estimate)"
return "\(prefix)Save \(FuelSavingsFormat.money(netSavingGBP)) after travel"
case .notWorthTheDrive:
return "\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel\(estimate)"
return "\(prefix)\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel"
}
}
@@ -174,6 +198,15 @@ extension FuelSavingsResult {
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 {
@@ -300,6 +333,7 @@ enum FuelSavingsCalculator {
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,
@@ -333,7 +367,8 @@ enum FuelSavingsCalculator {
extraDistanceMiles: extraDistance,
netSavingGBP: netSaving,
status: status,
distanceBasis: distanceBasis
distanceBasis: distanceBasis,
journeyConfidence: journeyConfidence
)
}
@@ -362,6 +397,29 @@ enum FuelSavingsCalculator {
/// 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,
@@ -376,10 +434,14 @@ enum FuelSavingsEstimator {
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.
// 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,
@@ -388,7 +450,8 @@ enum FuelSavingsEstimator {
candidatePricePencePerLitre: price,
baselineJourneyMiles: baselineJourney.miles,
candidateJourneyMiles: journey.miles,
distanceBasis: basis
distanceBasis: basis,
journeyConfidence: confidence
)
// The baseline compares against itself: label it as the closest
// station rather than a meaningless "Break even".
+35
View File
@@ -1343,6 +1343,29 @@ struct FuelStore {
roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) != nil
}
/// True only for current-format route cache entries with a separately
/// measured return route. Legacy one-way entries are still usable for journey
/// arithmetic (`outward × 2`) but Best Value must describe them as estimated.
static func hasMeasuredReturnRoadDistance(for station: FuelStation, userLat: Double, userLng: Double) -> Bool {
roadDistanceEntry(for: station, userLat: userLat, userLng: userLng)?.returnMeters != nil
}
static func roadDistanceDiagnostics(for station: FuelStation, userLat: Double, userLng: Double) -> RoadDistanceDiagnostics {
let displayKM = displayDistanceKM(station: station, userLat: userLat, userLng: userLng)
let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng)
let outward = entry?.meters
let returnMeters = entry?.returnMeters
return RoadDistanceDiagnostics(
displayDistanceKM: displayKM,
displaySource: outward == nil ? .straightLine : .roadRoute,
outwardMeters: outward,
returnMeters: returnMeters,
totalJourneyMeters: outward.map { $0 + (returnMeters ?? $0) },
hasMeasuredReturn: returnMeters != nil,
hasValidCacheEntry: entry != nil
)
}
private static func roadDistanceEntry(for station: FuelStation, userLat: Double, userLng: Double) -> CachedRoadDistance? {
guard let cache = loadRoadDistances(),
let entry = cache.entries[station.id] else { return nil }
@@ -1432,6 +1455,18 @@ struct FuelStore {
}
}
/// Lightweight description of the distance value shown for a station and any
/// usable road-route cache behind it. No user coordinates are included.
struct RoadDistanceDiagnostics: Equatable {
let displayDistanceKM: Double
let displaySource: FuelSavingsDistanceBasis
let outwardMeters: Double?
let returnMeters: Double?
let totalJourneyMeters: Double?
let hasMeasuredReturn: Bool
let hasValidCacheEntry: Bool
}
/// Cached Apple-Maps road distances for nearby stations (see
/// A single cached road distance plus the station pin it was routed to. Keeping
/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a