From ee3727bd547f30542246f125a3ce4ad0b83c9be6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 21:06:46 +0200 Subject: [PATCH 1/2] iPhone: the honest saving under the dial (plan pass 3) Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/PlanView.swift | 5 ++ .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 12 ++++ .../Sources/FTWKit/State/PlanModel.swift | 15 +++++ .../Sources/FTWShared/Charts/PlanDial.swift | 1 + .../FTWKit/Sources/FTWShared/PlanSaving.swift | 62 +++++++++++++++++++ .../FTWShared/Resources/en.lproj/Dial.strings | 1 + .../FTWShared/Resources/sv.lproj/Dial.strings | 1 + .../FTWKit/Tests/FTWKitTests/NodeTests.swift | 13 ++++ .../FTWSharedTests/PlanSavingTests.swift | 44 +++++++++++++ 9 files changed, 154 insertions(+) create mode 100644 appleApp/FTWKit/Sources/FTWShared/PlanSaving.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/PlanSavingTests.swift diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index e47b1cc..5aa28d0 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -112,6 +112,11 @@ private struct PlanBody: View { if let cheapest = dial.cheapestText { Text(cheapest).foregroundStyle(Theme.ink2) } + // What the planner reckons the plan saves, only + // where it compares honestly. + if let saving = models.plan.saving?.text(currency: models.planShare.entry.currency, nowMs: minute, zone: dial.zone) { + Text(saving).foregroundStyle(Theme.ink2) + } Text(plannedLine(moment, zone: dial.zone)) .foregroundStyle(moment.state == .boxStale ? Theme.alarm : Theme.ink3) } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 3264da5..df25efd 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -568,6 +568,18 @@ public final class SimulatedBox { return (200, ["scope": "system", "since_ms": .number(since), "until_ms": .number(until), "truncated": false, "points": .array(carEnergyRows(sinceMs: since, untilMs: until))]) case ("GET", "/api/mpc/plan"): var plan: JSON = ["actions": .array(mpcActions())] + // The planner's own reckoning: its cost and the same hours with + // no battery, imports at the price and exports at the spot. + let steps = house.plan(nowMs: now) + func cost(_ grid: (SimulatedHouse.Step) -> Double) -> Double { + steps.reduce(0) { sum, s in + let kWh = grid(s) / 1000 * s.durationMs / 3_600_000 + return sum + kWh * (kWh >= 0 ? s.priceMinor : s.priceMinor * 0.6) + } + } + plan = plan.setting("total_cost_ore", .number(cost { $0.gridW }.rounded())) + .setting("initial_soc", .number(house.socFraction)) + .setting("baselines", ["no_battery_ore": .number(cost { $0.gridW - $0.batteryW }.rounded())]) if let p = house.carPlan(now) { plan = plan.setting("loadpoint_shortfall_wh", ["garage": .number(p.shortfallWh)]) } return (200, ["plan": plan, "meta": ["replanning": false, "outdated": false]]) case ("POST", "/api/loadpoints/{id}/vehicle"): diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift index d3165c6..88bc635 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift @@ -48,6 +48,10 @@ public final class PlanModel { /// pushed. public private(set) var receivedAtMs: Double? public private(set) var problem: String? + /// What the planner reckons its plan saves against no battery, from its + /// own document, read with the forecast. Nil until then, and when the + /// planner gave no baseline. + public private(set) var saving: PlanSaving? @ObservationIgnored private unowned let site: SiteModel @ObservationIgnored private var ask: LiveAsk? @@ -137,10 +141,21 @@ public final class PlanModel { func loadForecast() async throws { let wire = try await site.callBox(.get, "/api/mpc/plan") let next = Stamped(Self.forecast(wire?["plan"]?["actions"]?.array ?? []), asOfMs: site.nowMs, origin: .relay) + let worth = Self.saving(wire?["plan"]) + if worth != saving { saving = worth } if let taken = Stamped.taking(next, over: forecastStamp) { forecastStamp = taken } if !forecastLoaded { forecastLoaded = true } } + /// The plan's cost, the same hours with no battery, and the levels at + /// either end, from the planner's document. + nonisolated static func saving(_ plan: JSON?) -> PlanSaving? { + guard let plan, let cost = plan["total_cost_ore"]?.number, let none = plan["baselines"]?["no_battery_ore"]?.number, + let start = plan["initial_soc"]?.number, let last = plan["actions"]?.array?.last, + let end = last["soc"]?.number, let slotStart = last["slot_start_ms"]?.number, let len = last["slot_len_min"]?.number else { return nil } + return PlanSaving(costMinor: cost, noBatteryMinor: none, startLevel: start, endLevel: end, endMs: slotStart + len * 60_000) + } + static func forecast(_ actions: [JSON]) -> [Forecast] { actions.compactMap { a in guard let start = a["execution_start_ms"]?.number ?? a["slot_start_ms"]?.number, diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift index f4aadd8..89cb755 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift @@ -287,6 +287,7 @@ enum DialWord: String, CaseIterable { case noPrice = "NO PRICE YET" case planned = "PLANNED %@ · REPLANS EVERY 15 MIN" case plannedOld = "PLAN FROM %@ · OLD" + case saving = "ABOUT %@ LESS THAN NO BATTERY · TO %@" case title = "Plan dial" case summary = "The next 24 hours round a dial: the price, what the battery does and when the car charges." diff --git a/appleApp/FTWKit/Sources/FTWShared/PlanSaving.swift b/appleApp/FTWKit/Sources/FTWShared/PlanSaving.swift new file mode 100644 index 0000000..a10daff --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PlanSaving.swift @@ -0,0 +1,62 @@ +import Foundation + +/// What the plan is worth, as the box's planner reckons it: its own cost for +/// the hours it covers against the cost of the same hours with no battery. +/// Said only when the plan ends about where it starts, because a plan that +/// ends emptier has spent stored energy and the plain difference would +/// flatter it. Never against self-consumption, whose baseline drains the +/// battery and so reads cheaper than it is. +public struct PlanSaving: Equatable, Sendable, Codable { + /// The plan's cost and the no-battery cost, minor units. + public var costMinor: Double + public var noBatteryMinor: Double + /// The battery's level when the plan starts and where it leaves it, 0 to 1. + public var startLevel: Double + public var endLevel: Double + /// Where the plan's hours end. + public var endMs: Double + + /// How far the end may sit from the start and still compare honestly. + public static let levelSlack = 0.05 + + public init(costMinor: Double, noBatteryMinor: Double, startLevel: Double, endLevel: Double, endMs: Double) { + self.costMinor = costMinor + self.noBatteryMinor = noBatteryMinor + self.startLevel = startLevel + self.endLevel = endLevel + self.endMs = endMs + } + + /// The saving in minor units, or nil when it cannot be said honestly: + /// the levels at either end differ, a number is missing, or there is no + /// saving to speak of. + public var savedMinor: Double? { + guard [costMinor, noBatteryMinor, startLevel, endLevel].allSatisfy(\.isFinite), + abs(endLevel - startLevel) <= Self.levelSlack + 1e-9 else { return nil } + let saved = noBatteryMinor - costMinor + return saved >= 100 ? saved : nil + } + + /// "ABOUT 28 KR LESS THAN NO BATTERY · TO THU 24:00", or nil. + public func text(currency: String, nowMs: Double, zone: TimeZone) -> String? { + guard let saved = savedMinor else { return nil } + let amount = "\(Int((saved / 100).rounded())) \(Self.major(currency))" + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = zone + let until = Clock(calendar: calendar).at(endMs, asEnd: true, from: nowMs).uppercased(with: Words.locale) + return DialWord.saving.format(amount, until) + } + + /// The major unit a hundred minor units make. + static func major(_ currency: String) -> String { + switch currency.uppercased() { + case "SEK", "NOK", "DKK": return "KR" + case "EUR": return "€" + case "PLN": return "ZŁ" + case "CZK": return "KČ" + case "HUF": return "FT" + case "RON": return "LEI" + default: return currency.uppercased() + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Dial.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Dial.strings index 0aab377..ec653ca 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Dial.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Dial.strings @@ -10,3 +10,4 @@ "PLAN FROM %@ · OLD" = "PLAN FROM %@ · OLD"; "Plan dial" = "Plan dial"; "The next 24 hours round a dial: the price, what the battery does and when the car charges." = "The next 24 hours round a dial: the price, what the battery does and when the car charges."; +"ABOUT %@ LESS THAN NO BATTERY · TO %@" = "ABOUT %@ LESS THAN NO BATTERY · TO %@"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Dial.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Dial.strings index aa7e343..854a64a 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Dial.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Dial.strings @@ -10,3 +10,4 @@ "PLAN FROM %@ · OLD" = "PLAN FRÅN %@ · GAMMAL"; "Plan dial" = "Plantavla"; "The next 24 hours round a dial: the price, what the battery does and when the car charges." = "De kommande 24 timmarna runt en tavla: priset, vad batteriet gör och när bilen laddar."; +"ABOUT %@ LESS THAN NO BATTERY · TO %@" = "CIRKA %@ BILLIGARE ÄN UTAN BATTERI · TILL %@"; diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift index 98b2740..6000727 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift @@ -489,6 +489,19 @@ import FTWShared // MARK: The plan @Suite struct PlanTests { + /// What the plan saves comes from the planner's own document: its cost, + /// the no-battery baseline, and the levels at either end. + @Test func theSavingComesFromThePlannersDocument() { + let plan: JSON = [ + "total_cost_ore": 4350, "initial_soc": 1, + "baselines": ["no_battery_ore": 7168], + "actions": .array([["slot_start_ms": 1_000, "slot_len_min": 15, "soc": .number(0.98)]]), + ] + #expect(PlanModel.saving(plan) == PlanSaving(costMinor: 4350, noBatteryMinor: 7168, startLevel: 1, endLevel: 0.98, endMs: 901_000)) + // A plan without a baseline says nothing. + #expect(PlanModel.saving(["total_cost_ore": 4350, "initial_soc": 1]) == nil) + } + @MainActor @Test func thePlanArrivesAndLandsOnTheSession() async throws { let rig = Rig() diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanSavingTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanSavingTests.swift new file mode 100644 index 0000000..9f949e8 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanSavingTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing +@testable import FTWShared + +/// What the plan saves, said only where it compares honestly: the plan +/// ends about where it starts, there is a saving to speak of, and the +/// baseline is no battery at all. +@Suite struct PlanSavingTests { + let zone = TimeZone(identifier: "Europe/Stockholm")! + var calendar: Calendar { + var c = Calendar(identifier: .gregorian) + c.timeZone = zone + return c + } + + func at(_ day: Int, _ hour: Int) -> Double { + calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour))!.timeIntervalSince1970 * 1000 + } + + /// The home box on 14 Oct: 43.50 kr against 71.69 kr with no battery, + /// full at both ends, to Thursday 24:00. + @Test func theHomeBoxsPlanSays28Kronor() { + let s = PlanSaving(costMinor: 4_350.49, noBatteryMinor: 7_168.53, startLevel: 1, endLevel: 1, endMs: at(16, 0)) + #expect(s.savedMinor.map { ($0 / 100).rounded() } == 28) + #expect(s.text(currency: "SEK", nowMs: at(14, 16), zone: zone) == "ABOUT 28 KR LESS THAN NO BATTERY · TO THU 24:00") + // Within the day, no day is named. + let soon = PlanSaving(costMinor: 4_350.49, noBatteryMinor: 7_168.53, startLevel: 1, endLevel: 1, endMs: at(14, 22)) + #expect(soon.text(currency: "EUR", nowMs: at(14, 16), zone: zone) == "ABOUT 28 € LESS THAN NO BATTERY · TO 22:00") + } + + /// A plan that ends emptier has spent stored energy; the plain + /// difference would flatter it, so nothing is said. + @Test func aPlanThatEndsEmptierSaysNothing() { + #expect(PlanSaving(costMinor: 1_954, noBatteryMinor: 7_168, startLevel: 1, endLevel: 0.32, endMs: at(16, 0)).savedMinor == nil) + #expect(PlanSaving(costMinor: 4_350, noBatteryMinor: 7_168, startLevel: 0.5, endLevel: 0.55, endMs: at(16, 0)).savedMinor != nil) + #expect(PlanSaving(costMinor: 4_350, noBatteryMinor: 7_168, startLevel: 0.5, endLevel: 0.56, endMs: at(16, 0)).savedMinor == nil) + } + + @Test func noSavingToSpeakOfSaysNothing() { + #expect(PlanSaving(costMinor: 7_100, noBatteryMinor: 7_168, startLevel: 1, endLevel: 1, endMs: at(16, 0)).savedMinor == nil) + #expect(PlanSaving(costMinor: 8_000, noBatteryMinor: 7_168, startLevel: 1, endLevel: 1, endMs: at(16, 0)).savedMinor == nil) + #expect(PlanSaving(costMinor: .nan, noBatteryMinor: 7_168, startLevel: 1, endLevel: 1, endMs: at(16, 0)).savedMinor == nil) + } +} From e7d9d5932938bc0ff98f71c9856225e375125896 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 21:32:35 +0200 Subject: [PATCH 2/2] Demo: leave the battery near its start by the end of the day So the plan's saving compares honestly and the plan view shows it. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift index f72197a..15e8ac7 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift @@ -132,6 +132,9 @@ extension SimulatedHouse { let mean = prices.reduce(0, +) / Double(max(1, prices.count)) let car = carPlan(nowMs) var soc = socFraction + // The demo leaves the battery near where it found it by the end of + // its day, so what its plan is worth compares honestly. + let endFloor = socFraction - 0.02 var out = [Step]() while t < end { let start = max(t, nowMs) @@ -144,6 +147,7 @@ extension SimulatedHouse { var b = decide(price: price, mean: mean, pv: pv, base: base, ev: ev, soc: soc, mode: modeKey) if b > 0, soc >= 0.98 { b = 0 } if b < 0, soc <= Self.minSoc { b = 0 } + if b < 0, t >= end - 8 * 3_600_000, soc <= endFloor { b = 0 } b = b.rounded() soc = max(0, min(1, soc + b * len / 3_600_000 * (b > 0 ? 0.95 : 1 / 0.95) / batteryCapacityWh)) let grid = (base + ev + b + pv).rounded()