Clarify Best Value route confidence
This commit is contained in:
+76
-13
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user