Repository navigation
iPhone: one honest number for what the plan saves (plan pass 3) #59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 } | ||
|
Comment on lines
+144
to
+145
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| 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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This lower bound does not ensure that the demo plan ends near its starting level: it blocks discharge below the floor, but later cheap-price or solar charging can still leave the battery far above the start. At the canonical
DemoRigstart (12 Oct at noon in Stockholm, SOC 0.62), the 24-hour plan finishes around 0.91, soPlanSaving.savedMinorrejects it for exceeding the 5% slack and the demo still shows no saving. The terminal handling needs to constrain or rebalance upward drift too.Useful? React with 👍 / 👎.