From b72ec48a442ce07f8289a399af8cfb614c770716 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 19:40:43 +0200 Subject: [PATCH] iPhone: the plan says where the battery ends up (plan pass 2) Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/PlanView.swift | 21 ++++++- .../Sources/FTWDemo/SimulatedHistory.swift | 2 +- .../Sources/FTWKit/Protocol/HistoryPlan.swift | 10 +++- .../Sources/FTWKit/State/NodeWords.swift | 1 + .../Sources/FTWKit/State/PlanModel.swift | 5 +- .../Sources/FTWKit/State/PlanShare.swift | 14 ++++- .../Sources/FTWShared/Charts/PlanDial.swift | 13 +++- .../Sources/FTWShared/Charts/PlanStrip.swift | 8 ++- .../FTWKit/Sources/FTWShared/PlanGlance.swift | 60 ++++++++++++++++--- .../FTWShared/Resources/en.lproj/Plan.strings | 2 + .../FTWShared/Resources/sv.lproj/Plan.strings | 2 + .../FTWKit/Tests/FTWKitTests/NodeTests.swift | 23 +++++++ .../Tests/FTWSharedTests/PlanDialTests.swift | 15 +++++ .../Tests/FTWSharedTests/PlanStripTests.swift | 46 ++++++++++++++ 14 files changed, 199 insertions(+), 23 deletions(-) diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index 7948638..e47b1cc 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -138,7 +138,7 @@ private struct PlanBody: View { PlanLanes(moment: moment) .equatable() .frame(height: 140) - Legend(kinds: Set(moment.events.map(\.kind))) + Legend(kinds: Set(moment.events.map(\.kind)), level: !moment.levels.isEmpty) } } .transition(.opacity) @@ -232,6 +232,7 @@ private final class DialMemo { /// What each colour on the lanes means. private struct Legend: View { let kinds: Set + var level = false var body: some View { ViewThatFits(in: .horizontal) { @@ -248,6 +249,17 @@ private struct Legend: View { if kinds.contains(.charge) { item(.charge, L("charging")) } if kinds.contains(.discharge) { item(.discharge, L("discharging")) } if kinds.contains(.cheap) { item(.cheap, L("Cheapest")) } + if level { + HStack(spacing: 4) { + Path { p in + p.move(to: CGPoint(x: 0, y: 3)) + p.addLine(to: CGPoint(x: 12, y: 3)) + } + .stroke(Theme.ink2, style: StrokeStyle(lineWidth: 1.25, lineCap: .round, lineJoin: .round, dash: [3, 3])) + .frame(width: 12, height: 6) + Text(L("battery level")).lineLimit(1) + } + } } private func item(_ kind: PlanGlance.Kind, _ text: String) -> some View { @@ -472,6 +484,13 @@ struct PlanLanes: View, Equatable { } } } + // Where the plan leads the battery: a ghost, dashed, because it + // is a plan and not a reading. 0 % at the foot, 100 % at the top. + ForEach(moment.levels.filter { $0.ms >= from && $0.ms <= to }, id: \.ms) { p in + LineMark(x: .value("Time", date(p.ms)), y: .value("Level", p.pct / 100), series: .value("Series", "level")) + .foregroundStyle(grey ? Theme.fgMuted : Theme.ink2) + .lineStyle(StrokeStyle(lineWidth: 1.25, lineCap: .round, lineJoin: .round, dash: [3, 4])) + } RuleMark(x: .value("Now", date(from))) .foregroundStyle(Theme.accentFill) .lineStyle(StrokeStyle(lineWidth: 1.5)) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift index 5be873c..f72197a 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift @@ -215,7 +215,7 @@ extension SimulatedBox { let plan = Plan( rev: house.planRev, uptimeMs: uptimeMs, - slots: steps.map { PlanSlot(startMs: $0.startMs.rounded(), durationMs: $0.durationMs.rounded(), batteryW: $0.batteryW, gridW: $0.gridW, priceMinor: $0.priceMinor, reason: $0.reason) }, + slots: steps.map { PlanSlot(startMs: $0.startMs.rounded(), durationMs: $0.durationMs.rounded(), batteryW: $0.batteryW, gridW: $0.gridW, priceMinor: $0.priceMinor, reason: $0.reason, socPermille: ($0.soc * 1000).rounded()) }, stale: false, ceilingW: nil ) diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift index d7560b0..ddeea0d 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift @@ -135,15 +135,19 @@ public struct PlanSlot: Equatable, Sendable, Identifiable, Codable { public var priceMinor: Double? /// A stable code, never prose. The app owns every word. public var reason: String + /// The battery's planned level at the end of the slot, in thousandths. + /// Nil from a box that does not send it. + public var socPermille: Double? public var id: Double { startMs } - public init(startMs: Double, durationMs: Double, batteryW: Double, gridW: Double, priceMinor: Double?, reason: String) { + public init(startMs: Double, durationMs: Double, batteryW: Double, gridW: Double, priceMinor: Double?, reason: String, socPermille: Double? = nil) { self.startMs = startMs self.durationMs = durationMs self.batteryW = batteryW self.gridW = gridW self.priceMinor = priceMinor self.reason = reason + self.socPermille = socPermille } public var endMs: Double { startMs + durationMs } @@ -180,7 +184,8 @@ public struct Plan: Equatable, Sendable, Codable { batteryW: s["batteryW"]?.double ?? 0, gridW: s["gridW"]?.double ?? 0, priceMinor: s["priceMinor"]?.double, - reason: s["reason"]?.string ?? "idle" + reason: s["reason"]?.string ?? "idle", + socPermille: s["socPermille"]?.double ) } stale = c["stale"]?.bool ?? false @@ -196,6 +201,7 @@ public struct Plan: Equatable, Sendable, Codable { ("batteryW", .ms(s.batteryW)), ("gridW", .ms(s.gridW)), ("reason", .text(s.reason)), ] slot.append(("priceMinor", s.priceMinor.map { .ms($0) } ?? .null)) + if let soc = s.socPermille { slot.append(("socPermille", .ms(soc))) } return .map(slot) })), ("stale", .bool(stale)), diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 652a2e2..6228584 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -138,6 +138,7 @@ public enum NodeWords { // The plan view "Opens the plan.": "Öppnar planen.", "Dial": "Tavla", + "battery level": "batteriets nivå", "Lanes": "Banor", "Show the plan as": "Visa planen som", "Swipe up or down to move an hour.": "Svep uppåt eller nedåt för att flytta en timme.", diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift index 46f785f..d3165c6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift @@ -22,6 +22,8 @@ public final class PlanModel { public let pvW: Double public let loadW: Double public let priceMinor: Double? + /// The battery's planned level at the end of the slot, in percent. + public var levelPct: Double? = nil public var id: Double { startMs } } @@ -146,7 +148,8 @@ public final class PlanModel { let end = slotStart + len * 60_000 let pv = a["forecast_pv_w"]?.number ?? a["pv_w"]?.number ?? 0 let load = a["forecast_load_w"]?.number ?? a["load_w"]?.number ?? 0 - return Forecast(startMs: start, durationMs: max(0, end - start), pvW: abs(pv), loadW: max(0, load), priceMinor: a["price_ore"]?.number) + let soc = a["soc"]?.number.flatMap { $0.isFinite && $0 >= 0 && $0 <= 1 ? $0 * 100 : nil } + return Forecast(startMs: start, durationMs: max(0, end - start), pvW: abs(pv), loadW: max(0, load), priceMinor: a["price_ore"]?.number, levelPct: soc) } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift index 28666c3..955001a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift @@ -6,8 +6,15 @@ extension PlanEntry { /// car's charging windows with the level it charges toward, and the /// prices under them. Prices come from the price model, or else from the /// plan's own slots. - public static func live(plan: Plan?, points: [Loadpoint], windows: [String: [Loadpoint.Window]], prices: Prices?, timeZone: String? = nil, daylight: Daylight? = nil) -> PlanEntry { - let battery = plan.map { p in p.slots.map { Slot(startMs: $0.startMs, durationMs: $0.durationMs, batteryW: $0.batteryW, reason: $0.reason) } } + /// `levels` stands in for a box that sends no level on its plan: the + /// planner's own document's levels, by the slot they end. + public static func live(plan: Plan?, points: [Loadpoint], windows: [String: [Loadpoint.Window]], prices: Prices?, timeZone: String? = nil, daylight: Daylight? = nil, levels: [(startMs: Double, endMs: Double, pct: Double)] = []) -> PlanEntry { + func level(_ s: PlanSlot) -> Double? { + if let pm = s.socPermille, pm.isFinite, pm >= 0, pm <= 1000 { return pm / 10 } + let end = s.startMs + s.durationMs + return levels.first { $0.startMs < end && end <= $0.endMs + 1 }?.pct + } + let battery = plan.map { p in p.slots.map { Slot(startMs: $0.startMs, durationMs: $0.durationMs, batteryW: $0.batteryW, reason: $0.reason, levelPct: level($0)) } } var charges = [Charge]() for lp in points { for w in windows[lp.id] ?? lp.planWindows { @@ -66,7 +73,8 @@ public final class PlanShare { /// The plan as Now and the plan dial draw it, with the sun the box /// forecasts once the app has read the forecast. public var entry: PlanEntry { - PlanEntry.live(plan: plan.plan, points: loadpoints.points, windows: loadpoints.windows, prices: prices.prices, timeZone: loadpoints.zone.identifier, daylight: daylight) + PlanEntry.live(plan: plan.plan, points: loadpoints.points, windows: loadpoints.windows, prices: prices.prices, timeZone: loadpoints.zone.identifier, daylight: daylight, + levels: plan.forecast.compactMap { f in f.levelPct.map { (f.startMs, f.startMs + f.durationMs, $0) } }) } /// The box's solar forecast as the dial's sky, from the box's latest diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift index 07329a1..f4aadd8 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift @@ -36,6 +36,9 @@ public struct PlanDial: Equatable, Sendable { public let car: Bool /// The goal, on the car's last quarter of a window. public let carGoalPct: Double? + /// Where the plan leaves the battery by the quarter's end, in + /// percent. Nil when the box did not say. + public let levelPct: Double? } public let grid: SlotGrid @@ -90,7 +93,7 @@ public struct PlanDial: Equatable, Sendable { return next < items.count && items[next][keyPath: start] < b ? items[next] : nil } func price(_ q: Int) -> Double? { holding(slots, q, start: \.startMs, length: \.durationMs)?.totalMinor } - func batteryW(_ q: Int) -> Double? { holding(battery, q, start: \.startMs, length: \.durationMs)?.batteryW } + func planned(_ q: Int) -> PlanEntry.Slot? { holding(battery, q, start: \.startMs, length: \.durationMs) } let minors = (0.. PlanGlance.idleW ? .stores : ($0 < -PlanGlance.idleW ? .gives : .waits) } ?? .waits return Quarter( startMs: start, @@ -112,7 +116,8 @@ public struct PlanDial: Equatable, Sendable { battery: action, batteryW: w, car: charges.contains { $0.fromMs < end && $0.toMs > start }, - carGoalPct: goals[i] + carGoalPct: goals[i], + levelPct: slot?.levelPct ) } @@ -159,6 +164,7 @@ public struct PlanDial: Equatable, Sendable { case (.gives, let w?): parts.append(DialWord.gives.format(Words.decimal(PowerFormat.text(w)).uppercased())) case (.waits, _): parts.append(DialWord.waits.text) } + if let level = q.levelPct { parts.append(PlanGlance.percent(level)) } if let goal = q.carGoalPct { parts.append(DialWord.carTo.format(PlanGlance.percent(goal))) } else if q.car { @@ -256,6 +262,7 @@ public struct PlanDial: Equatable, Sendable { case (.gives, let w?): parts.append("\(WatchWord.batteryGives.text) \(Words.decimal(PowerFormat.text(w)))") case (.waits, _): parts.append(WatchWord.batteryWaits.text) } + if let level = q.levelPct { parts.append(PlanGlance.percent(level)) } if let goal = q.carGoalPct { parts.append(WatchWord.carGoal.format(PlanGlance.percent(goal))) } else if q.car { diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift index 5e68cae..fddce9d 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift @@ -49,8 +49,8 @@ public struct PlanStrip: Equatable, Sendable { /// Quarters that give a light impact under a finger: now, either end, /// the cheapest two hours' edges and each car goal. public let landmarks: Set - /// What a finger reads per quarter: "02:15 · 31 öre · stores · car - /// charges". + /// What a finger reads per quarter: "02:15 · 31 öre · stores · 58 % · + /// car charges". public let readouts: [String] /// "NEXT 12 H", "PLANNED 14:05" from the cache, "OLD PLAN · 14:05". public let kicker: String @@ -140,12 +140,14 @@ public struct PlanStrip: Equatable, Sendable { } else { parts.append(PlanWord.noPrice.text) } - if planned[i] != nil { + if let slot = planned[i] { switch battery[i] { case .stores?: parts.append(PlanWord.stores.text) case .gives?: parts.append(PlanWord.gives.text) case nil: parts.append(PlanWord.waits.text) } + // Where the plan leaves the battery by the quarter's end. + if let level = slot.levelPct { parts.append(PlanGlance.percent(level)) } } if let goal = goals[i] { parts.append(PlanWord.carGoal.format(PlanGlance.percent(goal))) diff --git a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift index a5a63ee..4e2e07b 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift @@ -16,12 +16,16 @@ public struct PlanEntry: CacheValue { public var batteryW: Double /// The box's reason code, never prose. public var reason: String + /// The level the plan leads the battery to by the end of the slot, + /// in percent. Nil when the box did not say. + public var levelPct: Double? - public init(startMs: Double, durationMs: Double, batteryW: Double, reason: String) { + public init(startMs: Double, durationMs: Double, batteryW: Double, reason: String, levelPct: Double? = nil) { self.startMs = startMs self.durationMs = durationMs self.batteryW = batteryW self.reason = reason + self.levelPct = levelPct } } @@ -143,9 +147,11 @@ public enum PlanGlance { public let energyWh: Double? /// The box's reason code for the battery's first slot in it. public let reason: String? + /// Where the battery's run leaves it, in percent, when the box said. + public let endLevelPct: Double? public var id: String { "\(kind.rawValue)-\(Int64(startMs))" } - public init(kind: Kind, startMs: Double, endMs: Double, peakW: Double? = nil, targetPct: Double? = nil, energyWh: Double? = nil, reason: String? = nil) { + public init(kind: Kind, startMs: Double, endMs: Double, peakW: Double? = nil, targetPct: Double? = nil, energyWh: Double? = nil, reason: String? = nil, endLevelPct: Double? = nil) { self.kind = kind self.startMs = startMs self.endMs = endMs @@ -153,6 +159,7 @@ public enum PlanGlance { self.targetPct = targetPct self.energyWh = energyWh self.reason = reason + self.endLevelPct = endLevelPct } } @@ -176,7 +183,7 @@ public enum PlanGlance { peak = max(peak, abs(slots[j].batteryW)) } if let kind, slots[i].startMs < end { - out.append(Event(kind: kind, startMs: max(t, slots[i].startMs), endMs: slots[j].startMs + slots[j].durationMs, peakW: peak, reason: slots[i].reason)) + out.append(Event(kind: kind, startMs: max(t, slots[i].startMs), endMs: slots[j].startMs + slots[j].durationMs, peakW: peak, reason: slots[i].reason, endLevelPct: slots[j].levelPct)) } i = j + 1 } @@ -251,6 +258,9 @@ public enum PlanGlance { /// next two moves and the car's charge, each with its reason. The /// verbs are marked so they can take their colour. public let story: [Phrase] + /// The battery's planned level at the end of each slot ahead, for a + /// ghost line over the lanes. Empty when the box did not say. + public let levels: [LevelPoint] /// The zone the clock in this moment is read in. public let timeZone: TimeZone @@ -278,7 +288,7 @@ public enum PlanGlance { let calendar = given ?? .household(read?.value.timeZone) guard let read else { return Moment(atMs: t, state: .nothing, readAtMs: nil, events: [], backdrop: [], currency: "", summary: PlanWord.openToSee.text, - next: nil, nextWhen: nil, nextWhat: nil, nextUntil: nil, cheapText: nil, laterText: nil, ageText: nil, nextText: PlanWord.openToSee.text, story: [], timeZone: calendar.timeZone) + next: nil, nextWhen: nil, nextWhat: nil, nextUntil: nil, cheapText: nil, laterText: nil, ageText: nil, nextText: PlanWord.openToSee.text, story: [], levels: [], timeZone: calendar.timeZone) } let old = t - read.record.readAtMs >= staleAfterMs let state: State = read.value.boxStale || read.srcState == .stale ? .boxStale : (old ? .old : .plan) @@ -303,7 +313,7 @@ public enum PlanGlance { let m = moment(entry, state: .waiting, readAtMs: nil, atMs: nowMs, calendar: calendar ?? .household(entry.timeZone)) guard let note else { return m } return Moment(atMs: m.atMs, state: m.state, readAtMs: nil, events: m.events, backdrop: m.backdrop, currency: m.currency, summary: note, - next: nil, nextWhen: nil, nextWhat: nil, nextUntil: nil, cheapText: nil, laterText: nil, ageText: nil, nextText: note, story: [], timeZone: m.timeZone) + next: nil, nextWhen: nil, nextWhat: nil, nextUntil: nil, cheapText: nil, laterText: nil, ageText: nil, nextText: note, story: [], levels: m.levels, timeZone: m.timeZone) } private static func moment(_ entry: PlanEntry, state: State, readAtMs: Double?, atMs t: Double, calendar: Calendar, inApp: Bool = false) -> Moment { @@ -363,6 +373,8 @@ public enum PlanGlance { ageText: age, nextText: nextText, story: state == .plan || state == .old ? story(list, entry: entry, from: t, clock: clock, ongoingIsNow: state == .plan) : [], + levels: (entry.battery ?? []).filter { $0.startMs + $0.durationMs > t && $0.startMs < t + horizonMs } + .compactMap { s in s.levelPct.map { LevelPoint(ms: s.startMs + s.durationMs, pct: $0) } }, timeZone: calendar.timeZone ) } @@ -392,6 +404,12 @@ public enum PlanGlance { return parts.joined(separator: " · ") } + /// The battery's planned level at a moment, in percent. + public struct LevelPoint: Equatable, Sendable { + public let ms: Double + public let pct: Double + } + /// One sentence about the plan, with its verb marked: the words in /// turns plain and marked, so a screen can colour what the battery or /// the car does. "Battery *gives power* until 23:30, when power costs @@ -417,7 +435,7 @@ public enum PlanGlance { static func sentence(_ events: [Event], entry: PlanEntry, from t: Double, clock: Clock, ongoingIsNow: Bool) -> String { let actions = events.filter { $0.kind != .cheap } guard let first = actions.first else { return PlanWord.nothingPlanned.text } - var text = clause(first, entry: entry, from: t, clock: clock, ongoingIsNow: ongoingIsNow).plain + var text = clause(first, entry: entry, from: t, clock: clock, ongoingIsNow: ongoingIsNow, level: isNews(first, entry: entry, from: t)).plain if let then = actions.dropFirst().first { let when = ahead(then.startMs, asEnd: false, from: t, clock: clock) let battery = first.kind == .charge || first.kind == .discharge @@ -443,8 +461,25 @@ public enum PlanGlance { .map { clause($0, entry: entry, from: t, clock: clock, ongoingIsNow: ongoingIsNow) } } - /// One event as a sentence with its verb marked and its reason. - static func clause(_ e: Event, entry: PlanEntry, from t: Double, clock: Clock, ongoingIsNow: Bool) -> Phrase { + /// Whether where a battery run leaves the battery is news on a line + /// with no room for more: a run that empties it to the lowest point of + /// the hours ahead, or one that fills it. + static func isNews(_ e: Event, entry: PlanEntry, from t: Double) -> Bool { + guard let end = e.endLevelPct else { return false } + switch e.kind { + case .charge: + return end >= 98 + case .discharge: + let ahead = (entry.battery ?? []).filter { $0.startMs + $0.durationMs > t && $0.startMs < t + horizonMs }.compactMap(\.levelPct) + return ahead.min().map { end <= $0 + 2 } ?? false + case .car, .cheap: + return false + } + } + + /// One event as a sentence with its verb marked and its reason, and + /// for a battery run where it leaves the battery. + static func clause(_ e: Event, entry: PlanEntry, from t: Double, clock: Clock, ongoingIsNow: Bool, level: Bool = true) -> Phrase { let start = ahead(e.startMs, asEnd: false, from: t, clock: clock) let end = ahead(e.endMs, asEnd: true, from: t, clock: clock) // "17:15–23:30", or "tomorrow 02:00–05:00": the day once. @@ -456,7 +491,11 @@ public enum PlanGlance { case .discharge: lead = under ? PlanWord.givesUntil.format(end) : PlanWord.givesAt.format(span) case .car, .cheap: lead = under ? PlanWord.carUntil.format(end) : PlanWord.carAt.format(span) } - return Phrase(kind: e.kind, marked: PlanWord.sentence.format(why(e, lead, entry: entry))) + var text = why(e, lead, entry: entry) + if level, let pct = e.endLevelPct, e.kind == .charge || e.kind == .discharge { + text = (e.kind == .charge ? PlanWord.levelUp : PlanWord.levelDown).format(text, percent(pct)) + } + return Phrase(kind: e.kind, marked: PlanWord.sentence.format(text)) } /// The box's reason, said as what drives it: the price when power is @@ -583,6 +622,9 @@ enum PlanWord: String, CaseIterable { case whySun = "%@ from spare sun" case whySell = "%@, as selling pays" case whyLimit = "%@ to hold the grid limit" + // Where a battery run leaves the battery. + case levelDown = "%@, and leaves %@" + case levelUp = "%@, and reaches %@" // The strip's header and what a finger reads on it. case next12h = "NEXT 12 H" case plannedAt = "PLANNED %@" diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings index c9ee627..d8be3e3 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings @@ -42,6 +42,8 @@ "%@ from spare sun" = "%@ from spare sun"; "%@, as selling pays" = "%@, as selling pays"; "%@ to hold the grid limit" = "%@ to hold the grid limit"; +"%@, and leaves %@" = "%@, and leaves %@"; +"%@, and reaches %@" = "%@, and reaches %@"; /* The strip's header and what a finger reads on it. */ "NEXT 12 H" = "NEXT 12 H"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings index e3d7e53..3944489 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings @@ -42,6 +42,8 @@ "%@ from spare sun" = "%@ med solens överskott"; "%@, as selling pays" = "%@ när det lönar sig att sälja"; "%@ to hold the grid limit" = "%@ för att hålla nätgränsen"; +"%@, and leaves %@" = "%@, och lämnar %@"; +"%@, and reaches %@" = "%@, och når %@"; /* The strip's header and what a finger reads on it. */ "NEXT 12 H" = "NÄSTA 12 H"; diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift index 125c6b3..98b2740 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift @@ -500,9 +500,32 @@ import FTWShared #expect(plan.slots.count > 40) #expect((plan.slots.first?.startMs ?? .infinity) <= rig.scheduler.nowMs) #expect(plan.slots.allSatisfy { $0.priceMinor != nil && PlanText.reasons[$0.reason] != nil }) + // The box says where each slot leaves the battery, in thousandths. + #expect(plan.slots.allSatisfy { ($0.socPermille ?? -1) >= 0 && ($0.socPermille ?? 2_000) <= 1_000 }) #expect(rig.session.state.plan == plan) } + /// The level travels on the wire when the box sends it, and stays nil + /// from a box that does not. + @Test func theLevelRoundTripsOnTheWire() { + let slot = PlanSlot(startMs: 1_000, durationMs: 900_000, batteryW: -1_200, gridW: 0, priceMinor: 160, reason: "expensive_import", socPermille: 624) + let plan = Plan(rev: 3, uptimeMs: 0, slots: [slot, PlanSlot(startMs: 901_000, durationMs: 900_000, batteryW: 0, gridW: 300, priceMinor: 90, reason: "idle")], stale: false, ceilingW: nil) + let back = Plan(plan.wire) + #expect(back.slots[0].socPermille == 624) + #expect(back.slots[1].socPermille == nil) + } + + /// The plan's entry takes the level from the wire, and from the + /// planner's own document for a box that sends none on its plan. + @Test func theEntryTakesTheLevelFromTheWireOrThePlanner() { + let a = PlanSlot(startMs: 0, durationMs: 900_000, batteryW: -1_200, gridW: 0, priceMinor: 160, reason: "expensive_import", socPermille: 624) + let b = PlanSlot(startMs: 900_000, durationMs: 900_000, batteryW: -1_200, gridW: 0, priceMinor: 160, reason: "expensive_import") + let plan = Plan(rev: 1, uptimeMs: 0, slots: [a, b], stale: false, ceilingW: nil) + let entry = PlanEntry.live(plan: plan, points: [], windows: [:], prices: nil, levels: [(startMs: 900_000, endMs: 1_800_000, pct: 58)]) + #expect(entry.battery?.map(\.levelPct) == [62.4, 58]) + #expect(PlanEntry.live(plan: plan, points: [], windows: [:], prices: nil).battery?.map(\.levelPct) == [62.4, nil]) + } + @Test func theBatteryInOneSentence() { let h = 3_600_000.0 let t = 1_791_799_200_000.0 diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift index f5268f9..c563dc6 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift @@ -119,6 +119,21 @@ import Testing #expect(d.quarters[0].minor == 160) } + /// The middle and the Crown say where the plan leaves the battery. + @Test func theMiddleSaysWhereTheBatteryIs() { + var plan = watch.plan() + plan.battery = plan.battery?.enumerated().map { i, s in + var s = s + s.levelPct = 50 + Double(i % 40) + return s + } + let d = PlanDial(plan: plan, prices: watch.prices(), nowMs: at(14, 12, 7)) + let ten = d.grid.index(atMs: at(14, 22))! + #expect(d.quarters[ten].levelPct == 50 + Double(ten % 40)) + #expect(d.doing[ten] == "STORES · 3.0 KW · \(50 + ten % 40)\u{00A0}%") + #expect(d.what(ten) == "Battery stores 3.0 kW · \(50 + ten % 40)\u{00A0}%") + } + @Test func thePlannedLine() { let zone = TimeZone(identifier: WatchTests.zone)! #expect(PlanDial.planned(atMs: at(14, 16, 51), old: false, zone: zone) == "PLANNED 16:51 · REPLANS EVERY 15 MIN") diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift index 053ffdd..9cdc3e1 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift @@ -184,4 +184,50 @@ import Testing #expect(s.readouts[0] == "20:00 · 160 öre · gives power") #expect(s.batteryRuns.first == .init(first: 0, count: 5, value: .gives)) } + + // MARK: Where the plan leaves the battery + + /// `night()` with levels: 80 % falling to 62 % by 21:15, rising to 100 % + /// by 23:30, waiting, then falling to 52 % by 08:00. + func withLevels() -> PlanEntry { + var entry = glance.night() + var level = 80.0 + entry.battery = entry.battery?.map { s in + var s = s + level = s.batteryW > 100 ? min(100, level + 7) : (s.batteryW < -100 ? max(20, level - (s.startMs >= at(13, 6) ? 6 : 3.6)) : level) + s.levelPct = level + return s + } + return entry + } + + @Test func aFingerReadsWhereTheBatteryIs() { + let entry = withLevels() + let s = PlanStrip.build(entry, moment: PlanGlance.live(entry, nowMs: at(12, 20, 5), calendar: calendar), hasCar: true) + #expect(s.readouts[0] == "20:00 · 160 öre · gives power · 76\u{00A0}%") + #expect(s.readouts[13] == "23:15 · 70.0 öre · stores · 100\u{00A0}%") + } + + /// Under the dial each battery move says where it leaves the battery; + /// on Now only when that is the news: the lowest point ahead, or full. + @Test func theLevelIsSaidWhereItMatters() { + let entry = withLevels() + let m = PlanGlance.live(entry, nowMs: at(12, 20, 5), calendar: calendar) + #expect(m.story.map(\.plain) == [ + "Battery gives power until 21:15, when power costs up to 160 öre, and leaves 62\u{00A0}%.", + "Battery stores 22:00–23:30 for 70.0 öre, and reaches 100\u{00A0}%.", + "Car charges 01:00–04:45, to 80\u{00A0}%.", + ]) + // 62 % is not the lowest ahead (52 % by 08:00), so Now leaves it out. + #expect(m.nextText == "Battery gives power until 21:15, when power costs up to 160 öre. Then stores 22:00 for 70.0 öre.") + // Filling up is news. + #expect(PlanGlance.live(entry, nowMs: at(12, 22, 5), calendar: calendar).nextText.hasPrefix("Battery stores until 23:30 for 70.0 öre, and reaches 100\u{00A0}%.")) + // So is the run that empties it to the lowest point ahead. + #expect(PlanGlance.live(entry, nowMs: at(13, 6, 5), calendar: calendar).nextText == "Battery gives power until 08:00, when power costs up to 160 öre, and leaves 52\u{00A0}%.") + // The lanes' ghost line: one point at each slot's end ahead. + #expect(m.levels.first == PlanGlance.LevelPoint(ms: at(12, 20, 15), pct: 76.4)) + #expect(m.levels.count == 49) + // A plan with no levels says nothing about them. + #expect(PlanGlance.live(glance.night(), nowMs: at(12, 20, 5), calendar: calendar).levels.isEmpty) + } }