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
21 changes: 20 additions & 1 deletion appleApp/FTW/UI/PlanView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -232,6 +232,7 @@ private final class DialMemo {
/// What each colour on the lanes means.
private struct Legend: View {
let kinds: Set<PlanGlance.Kind>
var level = false

var body: some View {
ViewThatFits(in: .horizontal) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
10 changes: 8 additions & 2 deletions appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand All @@ -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)),
Expand Down
1 change: 1 addition & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 4 additions & 1 deletion appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand Down Expand Up @@ -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)
}
}

Expand Down
14 changes: 11 additions & 3 deletions appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) } })

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 Do not apply cached levels to a fresh plan

On boxes that predate socPermille, reopening the app within the cached forecast horizon can combine a newly received plan with stale battery levels: PlanModel initializes forecastStamp from the persisted forecast, while Now calls plan.activate() without requesting a new forecast. Because this line matches that cached document to the fresh plan solely by timestamps, Now can report percentages from an earlier plan after the mode or forecast has changed. Only use a current-session forecast that corresponds to this plan, or omit the fallback level when its provenance cannot be established.

Useful? React with 👍 / 👎.

}

/// The box's solar forecast as the dial's sky, from the box's latest
Expand Down
13 changes: 10 additions & 3 deletions appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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..<Self.count).map { price($0) }
let known = minors.compactMap { $0 }
Expand All @@ -103,7 +106,8 @@ public struct PlanDial: Equatable, Sendable {

let quarters = (0..<Self.count).map { i in
let start = grid.startMs(i), end = grid.endMs(i)
let w = batteryW(i)
let slot = planned(i)
let w = slot?.batteryW
let action: Battery = w.map { $0 > PlanGlance.idleW ? .stores : ($0 < -PlanGlance.idleW ? .gives : .waits) } ?? .waits
return Quarter(
startMs: start,
Expand All @@ -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
)
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>
/// 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
Expand Down Expand Up @@ -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)))
Expand Down
Loading
Loading