Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions appleApp/FTW/UI/PlanView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
12 changes: 12 additions & 0 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
4 changes: 4 additions & 0 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebalance both sides of the demo's terminal SOC

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 DemoRig start (12 Oct at noon in Stockholm, SOC 0.62), the 24-hour plan finishes around 0.91, so PlanSaving.savedMinor rejects 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 👍 / 👎.

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()
Expand Down
15 changes: 15 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor pending metadata before publishing savings

When /api/mpc/plan returns meta.replanning or meta.outdated, this extracts and publishes the previous plan's saving anyway. After a mode or preference change, plan.get can advance first and stop the follow loop while this request returned the old plan, leaving the old saving displayed beneath the new plan until the next quarter-hour refresh. LoadpointsModel.load already treats these same flags as making plan-derived values unavailable; suppress the saving while either flag is set as well.

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,
Expand Down
1 change: 1 addition & 0 deletions appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand Down
62 changes: 62 additions & 0 deletions appleApp/FTWKit/Sources/FTWShared/PlanSaving.swift
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
Expand Up @@ -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 %@";
Original file line number Diff line number Diff line change
Expand Up @@ -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 %@";
13 changes: 13 additions & 0 deletions appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
44 changes: 44 additions & 0 deletions appleApp/FTWKit/Tests/FTWSharedTests/PlanSavingTests.swift
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)
}
}
Loading