diff --git a/CLAUDE.md b/CLAUDE.md index 35d95ca..438333c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -279,6 +279,7 @@ never their graphics or brands. | A plain "what happens next" timeline with times | Flighty | Now's plan sentence, plan view, plan widget | | Price as bars by level, with the cheap window marked | Tibber, Octopus | Price chart: ink bars by thirds and a cheapest bracket, never red and green | | Calm lists, one prominent action, a quiet second one | Things, Apple Home | Pairing, mode sheet | +| A control with a few stops: a tick at each, one command when the finger lets go | Camera's zoom control | The planning style scale, in the mode sheet and under the plan | | Liquid Glass only where the system puts it (bars, sheets, buttons) | iOS 26 | Toolbars, sheets, glass buttons; no custom glass panels | | A trace that burns at its head and fades when the signal stops | Oscilloscopes, CRT terminals | Beams on Now, the live trace, the status dot | | One mono status line, and an inverse-video tag for what is selected | Terminal tools | The status line under each title, the scrub tag | diff --git a/appleApp/FTW/UI/ModeMenu.swift b/appleApp/FTW/UI/ModeMenu.swift index 6894153..70f202a 100644 --- a/appleApp/FTW/UI/ModeMenu.swift +++ b/appleApp/FTW/UI/ModeMenu.swift @@ -2,25 +2,27 @@ import FTWKit import SwiftUI /// How the home is run, in the bar beside the title: `Balanced ⌄`. A glass -/// menu with the plan, its planning style and whether the battery may sell, -/// and the manual modes. The title sits at the leading edge, so a longer -/// name moves only the button's own edge; the button sizes to its name -/// rather than holding the longest one's width, which would leave the status -/// line too little room on a small phone. +/// button that opens the mode sheet, sized to its content: the planning +/// style as a scale with five stops, whether the battery may sell, and the +/// manual modes. The title sits at the leading edge, so a longer name moves +/// only the button's own edge; the button sizes to its name rather than +/// holding the longest one's width, which would leave the status line too +/// little room on a small phone. /// /// The choice shows at once; the status line follows the box's receipt, /// and the outcome alone plays a haptic. Drawn from the cache, it stays /// inert until the box has spoken in this session. A phone that may not -/// change the mode sees the name and no menu. +/// change the mode sees the name and no button. struct ModeMenu: View { let mode: ModeModel + /// Opens the mode sheet. The root view presents it: a sheet held by a + /// view in the bar does not open again after it closed itself. + let open: () -> Void var body: some View { Group { if mode.mayControl { - Menu { - choices - } label: { + Button(action: open) { label(chevron: true) } .disabled(!mode.canControl) @@ -32,7 +34,9 @@ struct ModeMenu: View { .accessibilityValue(name ?? "") .accessibilityHint(mode.mayControl ? L("Change mode") : "") .accessibilityIdentifier("now.mode") - // A command answers once, when the box says what it did. + // A command answers once, when the box says what it did, also when + // the sheet was closed first. With the sheet open the two play as + // one. .sensoryFeedback(trigger: mode.outcome?.tone) { _, tone in ModeSheet.feedback(tone) } } @@ -61,68 +65,4 @@ struct ModeMenu: View { // Chrome: the bar holds its shape at every text size. .dynamicTypeSize(...DynamicTypeSize.xLarge) } - - @ViewBuilder private var choices: some View { - Section(L("How your home is run")) { - choice(L("Plan"), help: ModeWords.help(mode.info(mode.prefs?.mappedMode ?? ModeModel.planFallback)), selected: ModeWords.isPlan(mode.shown ?? ""), id: "plan") { - await mode.usePlan() - } - Picker(selection: styleBinding) { - ForEach(ModeModel.styles) { style in - Text(L(style.name)).tag(style.key) - } - } label: { - Label(L("Planning style"), systemImage: "slider.horizontal.3") - Text(mode.shownStyle.map { L($0.name) } ?? "") - } - .pickerStyle(.menu) - .disabled(mode.prefs == nil || !ModeWords.isPlan(mode.shown ?? "")) - Toggle(isOn: exportBinding) { - Label(L("Let the battery sell to the grid when it pays"), systemImage: "arrow.up.forward") - } - .disabled(mode.prefs == nil || !ModeWords.isPlan(mode.shown ?? "")) - } - if !mode.manualModes.isEmpty { - Section(L("Manual")) { - ForEach(mode.manualModes) { m in - choice(ModeWords.name(m), help: ModeWords.help(m), selected: mode.shown == m.key, id: m.key) { - await mode.set(m.key) - } - } - } - } - } - - private func choice(_ title: String, help: String, selected: Bool, id: String, _ run: @escaping () async -> Void) -> some View { - Button { - guard !selected, !mode.sending else { return } - Task { await run() } - } label: { - if selected { - Label(title, systemImage: "checkmark") - } else { - Text(title) - } - if !help.isEmpty { Text(help) } - } - .accessibilityAddTraits(selected ? .isSelected : []) - .accessibilityIdentifier("mode.menu.\(id)") - } - - private var styleBinding: Binding { - Binding( - get: { mode.shownStyle?.key ?? ModeModel.styles[2].key }, - set: { key in - guard let style = ModeModel.styles.first(where: { $0.key == key }) else { return } - Task { await mode.setStyle(style) } - } - ) - } - - private var exportBinding: Binding { - Binding( - get: { mode.shownExport ?? false }, - set: { on in Task { await mode.setExport(on) } } - ) - } } diff --git a/appleApp/FTW/UI/ModeSheet.swift b/appleApp/FTW/UI/ModeSheet.swift index 9de8635..9264d76 100644 --- a/appleApp/FTW/UI/ModeSheet.swift +++ b/appleApp/FTW/UI/ModeSheet.swift @@ -6,13 +6,27 @@ import SwiftUI /// at once; there is no Save. A tap answers with a haptic, the row waits /// with a turning mark while the box's mode keeps its tick, and the receipt /// sits in a bar of fixed height at the foot, so no row moves under the -/// finger. A mode the box applied closes the sheet. +/// finger. A mode the box applied closes the sheet. The style is a scale +/// with five stops, sent when the finger lets go; the sheet stays, and says +/// what the new plan moved. struct ModeSheet: View { let mode: ModeModel /// Opened as the home's settings, from the title's menu: the same /// choices, with this phone's own settings under them. var app: AppModel? + /// The plan, to say what a change of style moved. + var plan: PlanShare? + /// The change the sheet opened on, which is not its own to close for: + /// a sheet opened again just after a mode was applied stays open. + @State private var openedOn: ModeModel.Change? @Environment(\.dismiss) private var dismiss + + init(mode: ModeModel, app: AppModel? = nil, plan: PlanShare? = nil) { + self.mode = mode + self.app = app + self.plan = plan + _openedOn = State(initialValue: mode.change) + } /// The sheet's height for its content, measured once laid out. @State private var fitted: CGFloat = 520 @State private var detent: PresentationDetent = .height(520) @@ -65,7 +79,7 @@ struct ModeSheet: View { /// a moment later the sheet goes. A style or a sale stays, since the /// reader may change the other too. private func closeOnceApplied() async { - guard let change = mode.change, change.result == .applied, case .mode = change.ask else { return } + guard let change = mode.change, change != openedOn, change.result == .applied, case .mode = change.ask else { return } try? await Task.sleep(for: .milliseconds(900)) guard !Task.isCancelled else { return } dismiss() @@ -93,27 +107,16 @@ struct ModeSheet: View { await mode.usePlan() } // The plan's own settings stay in place whatever runs, so the - // list never changes length under the finger. - Picker(L("Planning style"), selection: styleBinding) { - ForEach(ModeModel.styles) { style in - Text(L(style.name)).tag(style.key) - } - } - .pickerStyle(.menu) - .disabled(!canSet || mode.prefs == nil || !onPlan) + // list never changes length under the finger. The scale rests + // by itself while a manual mode runs. + StyleScale(mode: mode, moved: plan?.moved) + .padding(.vertical, 6) Toggle(L("Let the battery sell to the grid when it pays"), isOn: exportBinding) .disabled(!canSet || mode.prefs == nil || !onPlan) // On is a state: lime, where the ink tint would hide the knob. .tint(Theme.live) } header: { Text(L("How your home is run")) - } footer: { - // The style's own help whatever runs: a footer that changed - // with the mode would move the rows below it under the finger. - if let style = mode.shownStyle { - Text(L(style.help)) - .contentTransition(.opacity) - } } } @@ -188,16 +191,6 @@ struct ModeSheet: View { .accessibilityIdentifier("mode.choice.\(id)") } - private var styleBinding: Binding { - Binding( - get: { mode.shownStyle?.key ?? ModeModel.styles[2].key }, - set: { key in - guard let style = ModeModel.styles.first(where: { $0.key == key }) else { return } - Task { await mode.setStyle(style) } - } - ) - } - private var exportBinding: Binding { Binding( get: { mode.shownExport ?? false }, diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index 3598ce1..3ceb05d 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -111,7 +111,7 @@ struct NodeView: View { if down { trace.hold(atMs: Date().timeIntervalSince1970 * 1000) } else { trace.release() } } .sheet(isPresented: $choosingMode) { - ModeSheet(mode: models.mode) + ModeSheet(mode: models.mode, plan: models.planShare) } .fullScreenCover(isPresented: $enlarged) { ChartFullScreen(site: site, node: node, home: home, timebase: $timebase, series: $series, timebases: timebases) diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift index 90dbfeb..13b2128 100644 --- a/appleApp/FTW/UI/NowView.swift +++ b/appleApp/FTW/UI/NowView.swift @@ -315,13 +315,16 @@ private struct PlanSection: View { let window = prices.prices let current = window.flatMap { w in w.slots.first { minute >= $0.startMs && minute < $0.startMs + $0.durationMs } } let shown = models.planShare.shown(nowMs: minute) + let hasCar = !loadpoints.points.isEmpty + let strip = PlanStrip.build(shown.entry, moment: shown.moment, hasCar: hasCar) NavigationLink(value: HomeRoute.plan) { PlanStripView( - strip: PlanStrip.build(shown.entry, moment: shown.moment, hasCar: !loadpoints.points.isEmpty), + strip: strip, sentence: shown.moment.nextText, placeholder: shown.moment.placeholder, priceNow: current.map { PriceStrip.text($0.totalMinor, window?.currency) }, - priceUnit: "\(PriceUnits.unit(window?.currency).label.uppercased()) \(PlanGlance.nowWord)" + priceUnit: "\(PriceUnits.unit(window?.currency).label.uppercased()) \(PlanGlance.nowWord)", + fade: StripFade.make(models.planShare.before, strip: strip, nowMs: minute, hasCar: hasCar) ) .equatable() } diff --git a/appleApp/FTW/UI/PlanDialCard.swift b/appleApp/FTW/UI/PlanDialCard.swift index b60fd5a..7638422 100644 --- a/appleApp/FTW/UI/PlanDialCard.swift +++ b/appleApp/FTW/UI/PlanDialCard.swift @@ -20,6 +20,8 @@ struct PlanDialCard: View { let dial: PlanDial /// The plan is old or the box could not plan. let grey: Bool + /// The dial before the person's last change, while its plan stands. + var fade: DialFade? @State private var scrub = ScrubModel() /// The quarter the finger was last on, so it cannot wind past the seam. @@ -33,6 +35,13 @@ struct PlanDialCard: View { ZStack { PlanDialFace(dial: dial, grey: grey) .equatable() + if let fade { + MovedFade(fresh: fade.fresh, mask: QuarterWedges(dial: fade.old, quarters: fade.moved)) { + PlanDialFace(dial: fade.old, grey: grey) + .equatable() + } + .id(fade.atMs) + } DialHand(dial: dial, model: scrub) DialCentre(dial: dial, model: scrub, spoken: spoken) } diff --git a/appleApp/FTW/UI/PlanFade.swift b/appleApp/FTW/UI/PlanFade.swift new file mode 100644 index 0000000..23a2c92 --- /dev/null +++ b/appleApp/FTW/UI/PlanFade.swift @@ -0,0 +1,106 @@ +import FTWKit +import SwiftUI + +/// The quarters a change moved, as the old plan drew them, over the new +/// plan: on the ground's colour, masked to those quarters, and faded out in +/// 0.2 s. Only the moved quarters cross-fade; the rest never change. Drawn +/// once, as the new plan arrives; a view opened later shows the new plan +/// at once. +struct MovedFade: View { + let mask: Mask + let content: Content + @State private var shown: Bool + + init(fresh: Bool, mask: Mask, @ViewBuilder content: () -> Content) { + self.mask = mask + self.content = content() + _shown = State(initialValue: fresh) + } + + var body: some View { + ZStack { + Theme.ground + content + } + .mask(mask) + .opacity(shown ? 1 : 0) + .allowsHitTesting(false) + .accessibilityHidden(true) + .onAppear { + guard shown else { return } + withAnimation(.easeOut(duration: 0.2)) { shown = false } + } + } +} + +/// The strip before a change, and the quarters the change moved. +struct StripFade: Equatable { + let old: PlanStrip + let moved: [Int] + let atMs: Double + let fresh: Bool + + /// Nil when the change moved nothing the strip draws. + static func make(_ before: (entry: PlanEntry, atMs: Double, fresh: Bool)?, strip: PlanStrip, nowMs: Double, hasCar: Bool) -> StripFade? { + guard let before else { return nil } + let old = PlanStrip.build(before.entry, moment: PlanGlance.live(before.entry, nowMs: nowMs), hasCar: hasCar) + let moved = strip.moved(from: old) + return moved.isEmpty ? nil : StripFade(old: old, moved: moved, atMs: before.atMs, fresh: before.fresh) + } +} + +/// The dial before a change, and the quarters the change moved. +struct DialFade: Equatable { + let old: PlanDial + let moved: [Int] + let atMs: Double + let fresh: Bool + + static func make(_ before: (entry: PlanEntry, atMs: Double, fresh: Bool)?, dial: PlanDial, nowMs: Double) -> DialFade? { + guard let before else { return nil } + let old = PlanDial(plan: before.entry, prices: nil, nowMs: nowMs) + let moved = dial.moved(from: old) + return moved.isEmpty ? nil : DialFade(old: old, moved: moved, atMs: before.atMs, fresh: before.fresh) + } +} + +/// The strip's columns for some quarters, top to bottom. +struct QuarterColumns: Shape { + let grid: SlotGrid + let quarters: [Int] + + func path(in rect: CGRect) -> Path { + var p = Path() + for i in quarters { + let x0 = grid.x(atMs: grid.startMs(i), width: rect.width) + let x1 = grid.x(atMs: grid.endMs(i), width: rect.width) + p.addRect(CGRect(x: rect.minX + x0 - 0.5, y: rect.minY, width: x1 - x0 + 1, height: rect.height)) + } + return p + } +} + +/// The dial's wedges for some quarters, from the middle out past the rim. +struct QuarterWedges: Shape { + let dial: PlanDial + let quarters: [Int] + + func path(in rect: CGRect) -> Path { + let c = CGPoint(x: rect.midX, y: rect.midY) + let r = max(rect.width, rect.height) + func at(_ a: Double) -> CGPoint { CGPoint(x: c.x + r * sin(a), y: c.y - r * cos(a)) } + var p = Path() + for i in quarters { + let a0 = dial.angle(dial.grid.startMs(i)) + var a1 = dial.angle(dial.grid.endMs(i)) + if a1 < a0 { a1 += 2 * .pi } + // A hair wider, so two wedges side by side leave no seam. + p.move(to: c) + p.addLine(to: at(a0 - 0.004)) + p.addLine(to: at((a0 + a1) / 2)) + p.addLine(to: at(a1 + 0.004)) + p.closeSubpath() + } + return p + } +} diff --git a/appleApp/FTW/UI/PlanStrip.swift b/appleApp/FTW/UI/PlanStrip.swift index 68cbb98..702ec76 100644 --- a/appleApp/FTW/UI/PlanStrip.swift +++ b/appleApp/FTW/UI/PlanStrip.swift @@ -21,6 +21,8 @@ struct PlanStripView: View, Equatable { let priceNow: String? /// "ÖRE NU", after the price. let priceUnit: String + /// The strip before the person's last change, while its plan stands. + var fade: StripFade? @State private var scrub = ScrubModel() @State private var width: CGFloat = 0 @@ -30,7 +32,7 @@ struct PlanStripView: View, Equatable { @ScaledMetric(relativeTo: .subheadline) private var figure: CGFloat = 13 nonisolated static func == (a: Self, b: Self) -> Bool { - a.strip == b.strip && a.sentence == b.sentence && a.placeholder == b.placeholder && a.priceNow == b.priceNow && a.priceUnit == b.priceUnit + a.strip == b.strip && a.sentence == b.sentence && a.placeholder == b.placeholder && a.priceNow == b.priceNow && a.priceUnit == b.priceUnit && a.fade == b.fade } /// Mono stops growing at accessibility1. @@ -43,6 +45,13 @@ struct PlanStripView: View, Equatable { ZStack { ChartSurface(drawing: PlanStripDrawing(strip: strip, layout: layout)) .equatable() + if let fade { + MovedFade(fresh: fade.fresh, mask: QuarterColumns(grid: fade.old.grid, quarters: fade.moved)) { + ChartSurface(drawing: PlanStripDrawing(strip: fade.old, layout: layout)) + .equatable() + } + .id(fade.atMs) + } PlanStripOverlay(strip: strip, model: scrub, layout: layout) } .frame(height: layout.height) diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index 7948638..0cf9ca1 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -18,14 +18,18 @@ struct PlanView: View { private var models: NodeModels { home.nodes } var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - PlanBody(site: home.site, models: models, loadpoints: home.loadpoints, prices: home.prices, style: style, chooseMode: { choosingMode = true }) + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 16) { + PlanBody(site: home.site, models: models, loadpoints: home.loadpoints, prices: home.prices, style: style, chooseMode: { choosingMode = true }) { levers in + withAnimation(.easeInOut(duration: 0.35)) { proxy.scrollTo(levers ? PlanBody.leversID : PlanBody.topID, anchor: levers ? .bottom : .top) } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: 640, alignment: .topLeading) + .frame(maxWidth: .infinity) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .frame(maxWidth: 640, alignment: .topLeading) - .frame(maxWidth: .infinity) } .scrollBounceBehavior(.basedOnSize) .background(Theme.ground) @@ -59,7 +63,7 @@ struct PlanView: View { home.prices.deactivate() } .sheet(isPresented: $choosingMode) { - ModeSheet(mode: models.mode) + ModeSheet(mode: models.mode, plan: models.planShare) } } } @@ -91,29 +95,49 @@ private struct PlanBody: View { let prices: PriceModel let style: PlanStyle let chooseMode: () -> Void + /// Scrolls to the levers, or back to the plan, for the style scale's + /// recording. + let reveal: (Bool) -> Void @State private var dials = DialMemo() + static let topID = "plan.top" + static let leversID = "plan.levers" + var body: some View { // The plan marks the minute: it redraws once a minute, not a second. let minute = site.minuteMs let moment = models.planShare.moment(nowMs: minute) + let moved = models.planShare.moved Group { switch style { case .dial: let dial = dials.dial(models.planShare.entry, nowMs: minute) VStack(alignment: .leading, spacing: 12) { - PlanDialCard(dial: dial, grey: moment.isGrey) + PlanDialCard(dial: dial, grey: moment.isGrey, fade: DialFade.make(models.planShare.before, dial: dial, nowMs: minute)) VStack(alignment: .leading, spacing: 6) { // The words are the dial's legend: each verb takes // the colour its ticks have. PlanStory(moment: moment) + MovedLine(text: moved) // Mono stops growing at accessibility1. Group { 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) + // What the plan rests on: the prices are known, + // the sun and the home's use are a forecast, and + // a style other than balanced leans on it less or + // more. + if let lean = styleLine { + Text(lean).foregroundStyle(Theme.ink3) + } } .font(Theme.mono()) .tracking(0.5) @@ -133,23 +157,26 @@ private struct PlanBody: View { Text(stateLine(moment)) .font(.footnote) .foregroundStyle(moment.state == .boxStale ? Theme.alarm : Theme.fgDim) + MovedLine(text: moved) } VStack(alignment: .leading, spacing: 6) { 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) } } + .id(Self.topID) PriceRow(prices: prices, minute: minute) // Under the dial the story already says what each row would. if style == .lanes { Events(moment: moment, entry: models.planShare.entry, loadpoints: loadpoints, style: style) } - CompactMode(mode: models.mode, choose: chooseMode) + Levers(mode: models.mode, loadpoints: loadpoints, minute: minute, choose: chooseMode, reveal: reveal) + .id(Self.leversID) } /// "PLANNED 16:51 · REPLANS EVERY 15 MIN", or that the box could not. @@ -159,6 +186,13 @@ private struct PlanBody: View { return PlanDial.planned(atMs: at, old: m.state == .old, zone: zone) } + /// "CAREFUL WITH THE FORECAST", when the planning style is not + /// balanced. Nothing when it is, or before the box has said. + private var styleLine: String? { + guard let style = models.mode.shownStyle, style.key != "balanced" else { return nil } + return NodeWords.format("%@ with the forecast", NodeWords.text(style.name)).uppercased() + } + /// When the box made it, or that it could not. private func stateLine(_ m: PlanGlance.Moment) -> String { if m.state == .boxStale { return L("Your box couldn't plan ahead just now, so it runs on safe defaults.") } @@ -232,6 +266,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 +283,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 { @@ -385,35 +431,102 @@ private struct PriceRow: View { } } -/// The mode in one row, for the plan it decides. -private struct CompactMode: View { +/// "New plan 16:53: stores 22:45 instead of 03:45.", after a change of the +/// person's own, until the next plan. +private struct MovedLine: View { + let text: String? + + var body: some View { + Group { + if let text { + Text(text) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.ink) + .fixedSize(horizontal: false, vertical: true) + .transition(.opacity) + .accessibilityIdentifier("plan.moved") + } + } + .animation(.easeOut(duration: 0.2), value: text) + } +} + +/// What the person can change, at the foot of the plan: the car's goal, +/// the mode, and while the plan runs its style as a scale with five stops. +private struct Levers: View { let mode: ModeModel + let loadpoints: LoadpointsModel + let minute: Double let choose: () -> Void + let reveal: (Bool) -> Void var body: some View { - if let label = mode.label { - Button(action: choose) { - HStack { - Kicker(L("Mode")) - Spacer(minLength: 8) - Text(label) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(mode.mayControl ? Theme.accent : Theme.fg) - if mode.mayControl { - Image(systemName: "chevron.up.chevron.down") - .font(.caption2.weight(.bold)) - .foregroundStyle(Theme.accent) - } + VStack(alignment: .leading, spacing: 0) { + if let lp = loadpoints.points.first { + NavigationLink(value: HomeRoute.car(lp.id)) { + row(L("Car"), value: goal(lp), tint: Theme.ink, symbol: "chevron.right") } - .padding(.vertical, 10) - .frame(minHeight: 44) - .contentShape(Rectangle()) + .buttonStyle(.plain) + .accessibilityIdentifier("plan.car") + Divider().overlay(Theme.hairline) + } + if let label { + Button(action: choose) { + row(L("Mode"), value: label, tint: mode.mayControl ? Theme.accent : Theme.ink, symbol: mode.mayControl ? "chevron.up.chevron.down" : nil) + } + .buttonStyle(.plain) + .disabled(!mode.canControl) + .accessibilityIdentifier("plan.mode") + } + if showsScale { + #if DEBUG + StyleScale(mode: mode, drives: true, reveal: reveal) + .padding(.top, 6) + .padding(.bottom, 12) + #else + StyleScale(mode: mode) + .padding(.top, 6) + .padding(.bottom, 12) + #endif } - .buttonStyle(.plain) - .disabled(!mode.canControl) - .accessibilityIdentifier("plan.mode") } } + + /// The scale stands in for the style while the plan runs and this + /// phone may change it; else the row names it. + private var showsScale: Bool { mode.mayControl && mode.shownStyle != nil && ModeWords.isPlan(mode.shown ?? "") } + + /// "Plan" when the scale below says the style, else "Plan · Balanced" + /// or a manual mode's name. + private var label: String? { + guard let key = mode.shown else { return mode.label } + return showsScale && ModeWords.isPlan(key) ? L("Plan") : mode.label + } + + /// "80 % by 07:00 on weekdays", the goal the plan charges for. + private func goal(_ lp: Loadpoint) -> String { + guard let s = lp.schedule else { return L("No goal") } + return EVText.goal(s, at: Date(timeIntervalSince1970: minute / 1000), zone: loadpoints.zone) + } + + private func row(_ kicker: String, value: String, tint: Color, symbol: String?) -> some View { + HStack(alignment: .firstTextBaseline) { + Kicker(kicker) + Spacer(minLength: 8) + Text(value) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(tint) + .multilineTextAlignment(.trailing) + if let symbol { + Image(systemName: symbol) + .font(.caption2.weight(.bold)) + .foregroundStyle(tint == Theme.ink ? Theme.ink3 : tint) + } + } + .padding(.vertical, 10) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } } private extension String { @@ -472,6 +585,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/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index 283377e..eb99752 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -82,7 +82,7 @@ struct HomeView: View { TitleMenu(app: app, sheet: $sheet) } ToolbarItem(placement: .topBarTrailing) { - ModeMenu(mode: home.nodes.mode) + ModeMenu(mode: home.nodes.mode) { sheet = .mode } } } .navigationDestination(for: HomeRoute.self) { route in @@ -107,7 +107,8 @@ struct HomeView: View { } .sheet(item: $sheet) { which in switch which { - case .settings: ModeSheet(mode: home.nodes.mode, app: app) + case .settings: ModeSheet(mode: home.nodes.mode, app: app, plan: home.nodes.planShare) + case .mode: ModeSheet(mode: home.nodes.mode, plan: home.nodes.planShare) case .about(let start): AboutView(app: app, start: start) } } @@ -127,9 +128,10 @@ struct HomeView: View { } } -/// What the title's menu opens over Now. +/// What the title's menu and the mode button open over Now. enum HomeSheet: Hashable, Identifiable { case settings + case mode case about(AboutView.Start) var id: Self { self } } diff --git a/appleApp/FTW/UI/StyleScale.swift b/appleApp/FTW/UI/StyleScale.swift new file mode 100644 index 0000000..2b5c018 --- /dev/null +++ b/appleApp/FTW/UI/StyleScale.swift @@ -0,0 +1,298 @@ +import FTWKit +import SwiftUI + +/// The planning style as one control with five stops, very careful at the +/// left and very bold at the right. A finger moves the thumb from stop to +/// stop with a `.selection` tick at each; the style's name and help under +/// it follow the thumb; one command goes on release, and only when the stop +/// changed. The status line carries the receipt. +/// +/// Drawn from the cache it shows the box's style and rests until the box +/// has spoken; before the box has said a style it has no thumb. It rests +/// too while a manual mode runs and while a change is on its way. +/// VoiceOver steps it a style at a time, reading the name and the help, +/// and each step goes to the box. With Reduce Motion the thumb jumps. +/// +/// Two shapes share the logic: a straight scale, used, and a short arc in +/// the dial's ticks (`-FTWStyleControl arc`), kept to compare the two. +struct StyleScale: View { + let mode: ModeModel + /// "New plan 16:53: …", after a change, until the next plan. + var moved: String? + var look: Look = .chosen + #if DEBUG + /// With `-FTWStyleScrub YES`, this scale moves itself a few stops and + /// lets go, for recordings. `reveal` scrolls it into view first, and + /// back to the plan after. + var drives = false + var reveal: ((Bool) -> Void)? + #endif + + enum Look: String { + case line, arc + + static var chosen: Look { + #if DEBUG + if let raw = UserDefaults.standard.string(forKey: "FTWStyleControl"), let look = Look(rawValue: raw) { return look } + #endif + return .line + } + } + + /// The stop under the finger. + @State private var held: Int? + /// The stop let go on, until the box has answered. + @State private var asked: Int? + @State private var size: CGSize = .zero + @GestureState private var dragging = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dynamicTypeSize) private var typeSize + @ScaledMetric(relativeTo: .caption2) private var kicker: CGFloat = 10.5 + + private static let styles = ModeModel.styles + private static let thumb: CGFloat = 28 + + /// The box's style, or the one asked for while it is on its way. + private var boxStop: Int? { mode.shownStyle.flatMap { s in Self.styles.firstIndex(of: s) } } + private var stop: Int? { held ?? asked ?? boxStop } + + /// A change can go: the box has spoken, the plan runs, and nothing else + /// is on its way. + private var live: Bool { + mode.canControl && mode.connected && !mode.sending && mode.prefs != nil && ModeWords.isPlan(mode.shown ?? "") + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + control + // Its own element, so VoiceOver reads what moved. + if let moved { + Text(moved) + .font(.footnote.weight(.medium)) + .foregroundStyle(Theme.ink) + .fixedSize(horizontal: false, vertical: true) + .transition(.opacity) + .accessibilityIdentifier("style.moved") + } + } + .animation(.easeOut(duration: 0.2), value: moved) + } + + private var control: some View { + VStack(alignment: .leading, spacing: 8) { + ends + track + .frame(height: look == .line ? 32 : 84) + .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } + .contentShape(Rectangle()) + .gesture(drag, isEnabled: live) + .onChange(of: dragging) { _, now in if !now { release() } } + caption + } + .opacity(live || mode.sending ? 1 : 0.55) + .sensoryFeedback(trigger: held) { old, new in + guard let new, new != (old ?? boxStop) else { return nil } + return .selection + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(L("Planning style")) + .accessibilityValue(stop.map { "\(L(Self.styles[$0].name)). \(L(Self.styles[$0].help))" } ?? "") + .accessibilityHint(live ? L("Swipe up or down to change the style.") : "") + .accessibilityAdjustableAction { direction in + guard live, let from = stop else { return } + let to = direction == .increment ? from + 1 : from - 1 + guard Self.styles.indices.contains(to) else { return } + send(to) + } + .accessibilityIdentifier("style.scale") + #if DEBUG + .task { await drive() } + #endif + } + + /// "CAREFUL" at one end and "BOLD" at the other, in the kickers' mono. + private var ends: some View { + HStack { + Text(verbatim: "← \(L("Careful").uppercased())") + Spacer(minLength: 8) + Text(verbatim: "\(L("Bold").uppercased()) →") + } + .font(.system(size: min(kicker, 15), weight: .medium, design: .monospaced)) + .tracking(0.5) + .foregroundStyle(Theme.ink3) + .lineLimit(1) + } + + private var track: some View { + ZStack(alignment: .topLeading) { + Canvas { context, size in + draw(&context, size) + } + if let stop, size.width > 0 { + let p = point(Self.styles.count == 1 ? 0.5 : ModeModel.fraction(of: stop), in: size) + Circle() + .fill(Theme.ink) + .shadow(color: .black.opacity(0.25), radius: 3, y: 1) + .frame(width: Self.thumb, height: Self.thumb) + .position(p) + .animation(reduceMotion ? nil : Theme.spring, value: stop) + .transition(.opacity) + } + } + } + + /// The groove and its five stops; the stop the thumb is on is ink. + private func draw(_ context: inout GraphicsContext, _ size: CGSize) { + let n = Self.styles.count + var groove = Path() + switch look { + case .line: + groove.move(to: point(0, in: size)) + groove.addLine(to: point(1, in: size)) + case .arc: + groove.move(to: point(0, in: size)) + for i in 1...40 { groove.addLine(to: point(Double(i) / 40, in: size)) } + } + context.stroke(groove, with: .color(Theme.ink3.opacity(0.35)), style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round)) + for i in 0.. some View { + Text("\(Text(L(s.name)).fontWeight(.semibold).foregroundStyle(Theme.ink)) · \(Text(L(s.brief)).foregroundStyle(Theme.ink2))") + .font(.subheadline) + .foregroundStyle(Theme.ink2) + .lineLimit(typeSize.isAccessibilitySize ? nil : 1) + .minimumScaleFactor(0.8) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: The finger + + private var drag: some Gesture { + DragGesture(minimumDistance: 0) + .updating($dragging) { _, state, _ in state = true } + .onChanged { held = ModeModel.stop(at: fraction(at: $0.location)) } + .onEnded { _ in release() } + } + + /// One command, on release, when the stop changed. + private func release() { + guard let h = held else { return } + held = nil + guard h != boxStop else { return } + send(h) + } + + private func send(_ i: Int) { + asked = i + Task { + await mode.setStyle(Self.styles[i]) + asked = nil + } + } + + // MARK: Where things sit + + /// The arc spans this far either side of its top, in radians. + private static let sweep = 0.62 + + /// A place along the scale, 0 to 1, as a point in the track. `radius` + /// moves it across the arc, outward when positive. + private func point(_ f: Double, in size: CGSize, radius: CGFloat = 0) -> CGPoint { + let inset = Self.thumb / 2 + 2 + switch look { + case .line: + return CGPoint(x: inset + (size.width - 2 * inset) * f, y: size.height / 2) + case .arc: + let (centre, r) = arc(size) + let a = -Self.sweep + 2 * Self.sweep * f + return CGPoint(x: centre.x + (r + radius) * sin(a), y: centre.y - (r + radius) * cos(a)) + } + } + + /// The place along the scale nearest a point. + private func fraction(at p: CGPoint) -> Double { + let inset = Self.thumb / 2 + 2 + switch look { + case .line: + guard size.width > 2 * inset else { return 0.5 } + return min(1, max(0, (p.x - inset) / (size.width - 2 * inset))) + case .arc: + let (centre, _) = arc(size) + let a = atan2(p.x - centre.x, centre.y - p.y) + return min(1, max(0, (a + Self.sweep) / (2 * Self.sweep))) + } + } + + /// The arc's centre and radius: its ends meet the track's sides and its + /// top touches the track's top. + private func arc(_ size: CGSize) -> (CGPoint, CGFloat) { + let inset = Self.thumb / 2 + 2 + let r = (size.width / 2 - inset) / sin(Self.sweep) + return (CGPoint(x: size.width / 2, y: inset + r), r) + } + + #if DEBUG + private func drive() async { + guard drives, UserDefaults.standard.bool(forKey: "FTWStyleScrub") else { return } + while !live { + try? await Task.sleep(for: .milliseconds(200)) + if Task.isCancelled { return } + } + try? await Task.sleep(for: .seconds(1.5)) + for path in [[3, 4], [3, 2, 1], [2]] { + reveal?(true) + try? await Task.sleep(for: .seconds(1.2)) + for s in path { + held = s + try? await Task.sleep(for: .milliseconds(450)) + } + release() + // Back to the dial before the box's new plan lands, so its + // moved quarters fade in view. + try? await Task.sleep(for: .milliseconds(250)) + reveal?(false) + try? await Task.sleep(for: .seconds(4)) + while !live { + try? await Task.sleep(for: .milliseconds(200)) + if Task.isCancelled { return } + } + } + } + #endif +} diff --git a/appleApp/FTWCharts/PlanDialFace.swift b/appleApp/FTWCharts/PlanDialFace.swift index cf6ceea..f5b0113 100644 --- a/appleApp/FTWCharts/PlanDialFace.swift +++ b/appleApp/FTWCharts/PlanDialFace.swift @@ -7,7 +7,8 @@ import WidgetKit /// face, so the four never differ. /// /// From the outside in: the rim, a hairline with a 2° gap at the seam where -/// +24 h meets now, and the sun's arc on it from the box's forecast; the +/// +24 h meets now, and the sun's arc on it from the box's forecast, dashed +/// because it is a guess; the /// hours just outside it; 96 price ticks in eight levels, cyan where the /// battery stores, ink where it gives power, faint where it waits; stubs /// where the market has not priced a quarter yet; the car's dotted arc @@ -38,7 +39,9 @@ struct PlanDialFace: View, Equatable { rim(g).stroke(Theme.ink.opacity(0.18), style: StrokeStyle(lineWidth: 0.5, lineCap: .butt, lineJoin: .round)) HourLabels(g: g) } - sun(g).stroke(grey ? Theme.ink3 : Theme.solar, style: StrokeStyle(lineWidth: g.sunWidth, lineCap: .butt, lineJoin: .round)) + // The sun is the box's forecast, a guess: dashed, where the + // prices it plans on are drawn solid. + sun(g).stroke(grey ? Theme.ink3 : Theme.solar, style: StrokeStyle(lineWidth: g.sunWidth, lineCap: .butt, lineJoin: .round, dash: g.sunDash)) ticks(g, .waits).fill(grey ? Theme.ink3.opacity(0.45) : Theme.ink3.opacity(0.6)) ticks(g, .gives).fill(grey ? Theme.ink3.opacity(0.45) : Theme.ink) ticks(g, .stores).fill(grey ? Theme.ink3.opacity(0.45) : Theme.stores) @@ -215,6 +218,8 @@ struct DialGeometry { var carRadius: CGFloat { inner - unit * (size == .app ? 1.6 : 1.4) } var dot: CGFloat { max(1.4, side * 0.012) } var sunWidth: CGFloat { size == .app ? 2 : max(1.2, side * 0.015) } + /// The forecast's dash: short enough to read as a guess at any size. + var sunDash: [CGFloat] { size == .app ? [4, 3] : [max(1.5, side * 0.02), max(1.2, side * 0.016)] } /// The hours' and the car goal's mono size. var label: CGFloat { max(9, (side * 0.03).rounded()) } diff --git a/appleApp/FTWCharts/PlanStripDrawing.swift b/appleApp/FTWCharts/PlanStripDrawing.swift index 16ea1d3..e204534 100644 --- a/appleApp/FTWCharts/PlanStripDrawing.swift +++ b/appleApp/FTWCharts/PlanStripDrawing.swift @@ -98,6 +98,25 @@ struct PlanStripDrawing: ChartDrawing { for (third, path) in thirds { g.fill(path, with: .color(grey ? Theme.ink3.opacity(0.6) : Theme.ink.opacity(Self.ink(third)))) } + // Not priced yet: a hatched stub, so a guess never looks like a price. + if !strip.pending.isEmpty { + var stubs = Path(), hatch = Path() + let stub = PlanStripLayout.levelStep * 2 + for run in strip.pending { + let s = PlanStripLayout.span(first: run.first, last: run.last, grid: grid, width: width, scale: scale) + let rect = CGRect(x: s.x0, y: PlanStripLayout.priceBottom - stub, width: s.x1 - s.x0, height: stub) + stubs.addRect(rect) + var x = rect.minX - stub + while x < rect.maxX { + hatch.move(to: CGPoint(x: x, y: rect.maxY)) + hatch.addLine(to: CGPoint(x: x + stub, y: rect.minY)) + x += 3 + } + } + var clipped = g + clipped.clip(to: stubs) + clipped.stroke(hatch, with: .color(Theme.ink3), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) + } // Now, one pixel across the lanes and the bars. let nowX = snap(grid.x(atMs: strip.nowMs, width: width)) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 3264da5..1df877e 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -42,6 +42,9 @@ public final class SimulatedBox { /// `priced_coverage_pct`. Below 0.95 the grid widget marks its cost /// partial. public var pricedShare: Double = 1 + /// The hour tomorrow's prices publish, local. 24 holds them back all + /// day, as on a day the market is late. + public var publishHour: Double = 13 /// A Core with srcfl/ftw#1586 reads and sends the ready time as /// `time_of_day_min` on the household clock. Off, it is a Core that /// knows only `time_of_day_min_utc`. @@ -298,7 +301,7 @@ public final class SimulatedBox { let slotMs: Double = 900_000 // Tomorrow's rates publish at 13:00 local. let midnight = scheduler.nowMs - house.hourOfDay(scheduler.nowMs) * 3_600_000 - let published = midnight + (house.hourOfDay(scheduler.nowMs) >= 13 ? 48 : 24) * 3_600_000 + let published = midnight + (house.hourOfDay(scheduler.nowMs) >= publishHour ? 48 : 24) * 3_600_000 let end = min(to, published) var slots = [CBOR]() var t = (from / slotMs).rounded(.down) * slotMs @@ -359,6 +362,8 @@ public final class SimulatedBox { house.modeKey = house.batteryExport == "allowed" ? "planner_arbitrage" : "planner_passive_arbitrage" } controlRev += 1 + // New prefs make a new plan, as Core replans on them. + house.planRev += 1 result(cmdId, "applied", observed: house.safetyK) sendBulk(Envelope(t: "plan", b: sessionPlan().wire)) case Contract.opLoadpointHold: @@ -568,6 +573,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"): @@ -793,6 +810,9 @@ extension SimulatedBox { box.resultDelayMs = 900 box.house.chargerRampMs = 4_000 #if DEBUG + // For UI tests that look while a command waits: `-FTWDemoResultDelayMs + // 2500` answers later than XCTest's wait for the app to settle. + if UserDefaults.standard.object(forKey: "FTWDemoResultDelayMs") != nil { box.resultDelayMs = UserDefaults.standard.double(forKey: "FTWDemoResultDelayMs") } // For screenshots: `-FTWDemoCar unplugged` and the like. if let name = UserDefaults.standard.string(forKey: "FTWDemoCar"), let car = DemoCar(rawValue: name) { car.apply(to: box.house, nowMs: scheduler.nowMs) @@ -804,6 +824,11 @@ extension SimulatedBox { // `-FTWDemoPartial YES` leaves a fifth of it unpriced. if UserDefaults.standard.bool(forKey: "FTWDemoSunny") { box.house.sunny = true } if UserDefaults.standard.bool(forKey: "FTWDemoPartial") { box.pricedShare = 0.8 } + // For the plan's pictures: `-FTWDemoUnpublished YES` holds + // tomorrow's prices back, and `-FTWDemoSafetyK 0.6` starts on + // another planning style. + if UserDefaults.standard.bool(forKey: "FTWDemoUnpublished") { box.publishHour = 24 } + if UserDefaults.standard.object(forKey: "FTWDemoSafetyK") != nil { box.house.safetyK = UserDefaults.standard.double(forKey: "FTWDemoSafetyK") } // For recordings of light and its decay: `-FTWDemoQuiet 8` keeps // the box quiet for 8 s in every 16, so Now goes from live to quiet // and back. diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift index 5be873c..7bbdf34 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift @@ -88,10 +88,12 @@ extension SimulatedHouse { /// What the battery does for one slot, in the plan's way: cheap hours /// fill it, spare sun goes in, dear hours cover the home. The margin the - /// household chose keeps more in it. + /// household chose keeps more in it: a careful plan buys in more hours + /// and gives power in fewer, so a change of style moves quarters. func decide(price: Double, mean: Double, pv: Double, base: Double, ev: Double, soc: Double, mode: String) -> Double { let net = base + ev + pv - let keep = 0.2 + 0.25 * min(1, safetyK) + let margin = min(1, safetyK) + let keep = 0.2 + 0.25 * margin switch mode { case "idle": return 0 case "charge": return soc < 0.995 ? batteryMaxW : 0 @@ -99,9 +101,9 @@ extension SimulatedHouse { case "peak_shaving": return net > 6_000 ? -(net - 6_000) : (pv < -500 ? min(batteryMaxW, -net) : 0) default: if net < -200 { return min(batteryMaxW, -net) } - if price < mean * 0.7, soc < 0.9 { return 3_000 } + if price < mean * (0.6 + 0.2 * margin), soc < 0.9 { return 3_000 } if mode == "planner_arbitrage", price > mean * 1.35, soc > keep + 0.15 { return -batteryMaxW } - if price > mean * 1.1, soc > keep { return -min(batteryMaxW, max(0, net)) } + if price > mean * (1.02 + 0.3 * margin), soc > keep { return -min(batteryMaxW, max(0, net)) } return 0 } } @@ -132,6 +134,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 +149,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() @@ -215,7 +221,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/EnergyNode.swift b/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift index ec7fa37..9dbd1e9 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift @@ -192,5 +192,6 @@ public final class NodeModels { planShare = PlanShare(site: site, plan: plan, loadpoints: loadpoints, prices: prices, cache: cache) // A change the box applied is followed by its new plan. mode.applied = { [plan] in plan.follow() } + mode.willSend = { [plan] in plan.expectChange() } } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift index b65b077..281e643 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift @@ -23,17 +23,31 @@ public final class ModeModel { public let name: String public let k: Double public let help: String + /// The help in one line, under the style scale's thumb. + public let brief: String public var id: String { key } } public nonisolated static let styles: [Style] = [ - Style(key: "very_careful", name: "Very careful", k: 1, help: "Plans for a poor day: much less sun and more use than forecast. Keeps the most in the battery."), - Style(key: "careful", name: "Careful", k: 0.6, help: "Plans for less sun and more use than forecast."), - Style(key: "balanced", name: "Balanced", k: 0.3, help: "Plans for a little less sun and a little more use than forecast. A good start for most homes."), - Style(key: "bold", name: "Bold", k: 0.15, help: "Plans close to the forecast."), - Style(key: "very_bold", name: "Very bold", k: 0, help: "Plans on the forecast as it is. Earns the most when it is right and costs more when it is wrong."), + Style(key: "very_careful", name: "Very careful", k: 1, help: "Plans for a poor day: much less sun and more use than forecast. Keeps the most in the battery.", brief: "Plans for a poor day."), + Style(key: "careful", name: "Careful", k: 0.6, help: "Plans for less sun and more use than forecast.", brief: "Plans for less sun."), + Style(key: "balanced", name: "Balanced", k: 0.3, help: "Plans for a little less sun and a little more use than forecast. A good start for most homes.", brief: "Plans with a little margin."), + Style(key: "bold", name: "Bold", k: 0.15, help: "Plans close to the forecast.", brief: "Plans close to the forecast."), + Style(key: "very_bold", name: "Very bold", k: 0, help: "Plans on the forecast as it is. Earns the most when it is right and costs more when it is wrong.", brief: "Plans on the forecast as it is."), ] + /// The stop nearest a place along the scale, 0 at very careful and 1 + /// at very bold. + public nonisolated static func stop(at fraction: Double) -> Int { + guard fraction.isFinite else { return 2 } + return min(styles.count - 1, max(0, Int((fraction * Double(styles.count - 1)).rounded()))) + } + + /// Where a stop sits along the scale, 0 to 1. + public nonisolated static func fraction(of stop: Int) -> Double { + Double(min(styles.count - 1, max(0, stop))) / Double(styles.count - 1) + } + /// The style nearest a margin, the way the box page picks one. public nonisolated static func style(k: Double) -> Style { styles.min { abs($0.k - k) < abs($1.k - k) } ?? styles[2] @@ -100,6 +114,8 @@ public final class ModeModel { /// Runs after the box applied a change, so the plan can be read again. @ObservationIgnored public var applied: (@MainActor () -> Void)? + /// Runs as a change leaves for the box, before any answer. + @ObservationIgnored public var willSend: (@MainActor () -> Void)? @ObservationIgnored private unowned let site: SiteModel @ObservationIgnored private var clearing: Cancellable? @ObservationIgnored private var ask: LiveAsk? @@ -277,6 +293,7 @@ public final class ModeModel { guard !sending else { return } clearing?.cancel() change = Change(ask: ask) + willSend?() if !connected { change?.waiting = true let up = await stream(within: Self.streamWaitMs) diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 652a2e2..5942f80 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -138,6 +138,8 @@ public enum NodeWords { // The plan view "Opens the plan.": "Öppnar planen.", "Dial": "Tavla", + "battery level": "batteriets nivå", + "%@ with the forecast": "%@ med prognosen", "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.", @@ -165,6 +167,10 @@ public enum NodeWords { "Plans for less sun and more use than forecast.": "Planerar för mindre sol och mer förbrukning än prognosen.", "Plans for a little less sun and a little more use than forecast. A good start for most homes.": "Planerar för lite mindre sol och lite mer förbrukning än prognosen. En bra start för de flesta hem.", "Plans close to the forecast.": "Planerar nära prognosen.", + "Plans for a poor day.": "Planerar för en dålig dag.", + "Plans for less sun.": "Planerar för mindre sol.", + "Plans with a little margin.": "Planerar med lite marginal.", + "Plans on the forecast as it is.": "Planerar efter prognosen som den är.", "Plans on the forecast as it is. Earns the most when it is right and costs more when it is wrong.": "Planerar efter prognosen som den är. Tjänar mest när den stämmer och kostar mer när den slår fel.", "Let the battery sell to the grid when it pays": "Låt batteriet sälja till elnätet när det lönar sig", "A manual mode runs until you choose another. FTW does not plan meanwhile.": "Ett manuellt läge gäller tills du väljer ett annat. FTW planerar inte under tiden.", @@ -210,6 +216,8 @@ public enum NodeWords { "Mode: %@": "Läge: %@", "Change": "Byt", "Change mode": "Byt läge", + "Swipe up or down to change the style.": "Svep uppåt eller nedåt för att byta stil.", + "No goal": "Inget mål", "How your home is run": "Så styrs ditt hem", "Manual": "Manuellt", "Planning style": "Planeringsstil", diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift index 46f785f..782acaa 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 } } @@ -29,6 +31,8 @@ public final class PlanModel { nonisolated static let reaskMs: Double = 15 * 60_000 nonisolated static let followMs: Double = 3_000 nonisolated static let followTries = 6 + /// How long plans after a change still count as that change. + nonisolated static let moveHoldMs: Double = 2 * 60_000 /// The plan on screen: the box's latest word, or the plan an earlier /// launch kept, dated, until the box answers. The same plan again @@ -46,6 +50,17 @@ public final class PlanModel { /// pushed. public private(set) var receivedAtMs: Double? public private(set) var problem: String? + /// The plan before a change of the person's own and when the new one + /// came, so a screen can say what moved. Gone with the next plan. + public struct Move: Equatable, Sendable { + public let before: Plan + public let atMs: Double + } + public private(set) var move: Move? + /// 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? @@ -54,6 +69,9 @@ public final class PlanModel { @ObservationIgnored private var forecastActive = 0 @ObservationIgnored private var want = 0 @ObservationIgnored private var following: Cancellable? + /// The plan on screen when the person sent a change, and when, until + /// the box replans. + @ObservationIgnored private var awaiting: (plan: Plan, atMs: Double)? /// The plan on the session as last taken, so a pushed one is taken once. @ObservationIgnored private var sessionPlan: Plan? /// Runs each time the box has sent its plan, the same or a new one, so @@ -85,7 +103,22 @@ public final class PlanModel { } private func take(_ p: Plan) { + let previous = plan if let next = Stamped.taking(Stamped(p, asOfMs: site.nowMs, origin: .relay), over: stamped) { stamped = next } + guard let previous, p.rev != previous.rev else { return } + // The plan a change of the person's own brought is set beside the + // one before it. Plans that follow within a couple of minutes are + // the same change settling; a later one is the box's own replan. + if let before = awaiting { + awaiting = nil + if site.nowMs - before.atMs < Self.moveHoldMs { + move = Move(before: before.plan, atMs: site.nowMs) + return + } + } + if let move, site.nowMs - move.atMs >= Self.moveHoldMs { + self.move = nil + } } public func activate(forecast: Bool = false) { @@ -135,10 +168,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, @@ -146,10 +190,18 @@ 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) } } + /// The person sent a change that moves the plan: the plan now on screen + /// is the one to compare the next with. Its plan may come before the + /// box's receipt does. + public func expectChange() { + if let plan { awaiting = (plan, site.nowMs) } + } + /// The box acknowledges a change before its planner has replanned, so /// the plan is asked for again a few times, three seconds apart, until /// its revision moves. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift index 28666c3..445b07f 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 @@ -97,6 +105,26 @@ public final class PlanShare { return (entry, PlanGlance.waiting(entry, nowMs: nowMs, note: plan.problem)) } + /// The plan as it stood before the person's own change, drawn as the + /// plan is, and when the change's plan came. `fresh` while that was + /// moments ago, so a view drawing it now draws its arrival. Nil once + /// the box replans of its own accord. + public var before: (entry: PlanEntry, atMs: Double, fresh: Bool)? { + guard let move = plan.move else { return nil } + let entry = PlanEntry.live(plan: move.before, points: loadpoints.points, windows: loadpoints.windows, prices: prices.prices, timeZone: loadpoints.zone.identifier, daylight: daylight) + return (entry, move.atMs, site.scheduler.nowMs - move.atMs < Self.arrivalMs) + } + + /// How long a new plan counts as arriving. + nonisolated static let arrivalMs: Double = 1_500 + + /// "New plan 16:53: stores 22:45 instead of 03:45.", after a change of + /// the person's own, until the next plan. + public var moved: String? { + guard let before else { return nil } + return PlanGlance.moved(from: before.entry, to: entry, atMs: before.atMs) + } + /// What would make the record different. Cheap enough for every tick. var key: String { let w = loadpoints.points.map { lp in "\(lp.id):\((loadpoints.windows[lp.id] ?? lp.planWindows).map { "\($0.fromMs)-\($0.toMs)" }.joined(separator: ",")):\(lp.targetSocPct ?? lp.schedule?.socPct ?? -1)" } diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift index 07329a1..ce56793 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 { @@ -186,6 +192,13 @@ public struct PlanDial: Equatable, Sendable { return lo == 0 ? nil : lo - 1 } + /// The quarters whose battery or car a new plan changed, on the same + /// turn. A dial that moved on by a quarter compares nothing. + public func moved(from old: PlanDial) -> [Int] { + guard old.grid == grid, old.quarters.count == quarters.count else { return [] } + return quarters.indices.filter { old.quarters[$0].battery != quarters[$0].battery || old.quarters[$0].car != quarters[$0].car } + } + // MARK: Where things sit /// Radians clockwise from the top for a moment on the household clock: @@ -256,6 +269,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 { @@ -280,6 +294,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/Charts/PlanStrip.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift index 5e68cae..25f1d8b 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift @@ -44,13 +44,16 @@ public struct PlanStrip: Equatable, Sendable { public let batteryRuns: [Run] public let carRuns: [Run] public let priceRuns: [Run] + /// Stretches the market has not priced yet: drawn as hatched stubs, + /// never as a level. + public let pending: [Run] /// Local three-hour marks: "18", "21", "00". public let hours: [Tick] /// 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 +143,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))) @@ -173,6 +178,7 @@ public struct PlanStrip: Equatable, Sendable { batteryRuns: runs(battery), carRuns: runs(car.map { $0 ? true : nil }), priceRuns: runs(levels), + pending: runs(levels.map { $0 == nil ? true : nil }), hours: hours, landmarks: landmarks, readouts: readouts, @@ -182,6 +188,16 @@ public struct PlanStrip: Equatable, Sendable { ) } + /// The quarters whose battery or car a new plan changed, on the same + /// quarters. A strip that moved on by a quarter compares nothing: that + /// is the clock, not a new plan. + public func moved(from old: PlanStrip) -> [Int] { + guard old.grid == grid else { return [] } + return (0.. Int { diff --git a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift index a5a63ee..c0733b7 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 @@ -489,6 +528,41 @@ public enum PlanGlance { } } + /// What a new plan moved, in one line: "New plan 16:53: stores 22:45 + /// instead of 03:45." The first move that changed, by when it starts: + /// the battery's charging, its giving power or the car's charge, moved, + /// new or gone. Nil when nothing a person would see moved. + public static func moved(from old: PlanEntry, to new: PlanEntry, atMs t: Double, calendar given: Calendar? = nil) -> String? { + let calendar = given ?? .household(new.timeZone) + let clock = Clock(calendar: calendar) + let before = events(old, fromMs: t, toMs: t + horizonMs).filter { $0.kind != .cheap } + let after = events(new, fromMs: t, toMs: t + horizonMs).filter { $0.kind != .cheap } + let made = ahead(t, asEnd: false, from: t, clock: clock) + func word(_ k: Kind) -> String { + switch k { + case .charge: return PlanWord.stores.text + case .discharge: return PlanWord.gives.text + case .car, .cheap: return PlanWord.carChargesWord.text + } + } + func at(_ e: Event) -> String { ahead(e.startMs, asEnd: false, from: t, clock: clock) } + var lines: [(ms: Double, text: String)] = [] + for kind in [Kind.charge, .discharge, .car] { + let a = before.first { $0.kind == kind }, b = after.first { $0.kind == kind } + switch (a, b) { + case let (a?, b?) where abs(a.startMs - b.startMs) >= 60_000: + lines.append((min(a.startMs, b.startMs), PlanWord.movedInstead.format(made, word(kind), at(b), at(a)))) + case let (nil, b?): + lines.append((b.startMs, PlanWord.movedNew.format(made, word(kind), at(b)))) + case let (a?, nil): + lines.append((a.startMs, PlanWord.movedGone.format(made, word(kind), at(a)))) + default: + break + } + } + return lines.min { $0.ms < $1.ms }?.text + } + /// "02–05", "02:15–04:47", "now–05". Tomorrow is named only when the /// clock alone would read as later today, as the price widget does: at /// 20:00 "02–04" can only be tonight, at 14:00 "15–17" cannot. @@ -583,6 +657,13 @@ enum PlanWord: String, CaseIterable { case whySun = "%@ from spare sun" case whySell = "%@, as selling pays" case whyLimit = "%@ to hold the grid limit" + // What a new plan moved. + case movedInstead = "New plan %@: %@ %@ instead of %@." + case movedNew = "New plan %@: %@ %@." + case movedGone = "New plan %@: no longer %@ %@." + // 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/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/en.lproj/Plan.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings index c9ee627..b3084c2 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings @@ -42,6 +42,11 @@ "%@ from spare sun" = "%@ from spare sun"; "%@, as selling pays" = "%@, as selling pays"; "%@ to hold the grid limit" = "%@ to hold the grid limit"; +"New plan %@: %@ %@ instead of %@." = "New plan %@: %@ %@ instead of %@."; +"New plan %@: %@ %@." = "New plan %@: %@ %@."; +"New plan %@: no longer %@ %@." = "New plan %@: no longer %@ %@."; +"%@, 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/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/Sources/FTWShared/Resources/sv.lproj/Plan.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings index e3d7e53..e11cd65 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings @@ -42,6 +42,11 @@ "%@ 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"; +"New plan %@: %@ %@ instead of %@." = "Ny plan %@: %@ %@ i stället för %@."; +"New plan %@: %@ %@." = "Ny plan %@: %@ %@."; +"New plan %@: no longer %@ %@." = "Ny plan %@: %@ inte längre %@."; +"%@, 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..b2edd9a 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() @@ -500,9 +513,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 @@ -570,6 +606,41 @@ import FTWShared } } + /// A change of the person's own sets the new plan beside the one + /// before it, until the next plan; the stops of the style's scale sit + /// at even steps. + @MainActor + @Test func aChangeShowsWhatThePlanMoved() async throws { + try await AppWords.$language.withValue(.en) { + let rig = DemoRig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let mode = home.nodes.mode, plan = home.nodes.plan + mode.activate() + plan.activate() + await rig.run(1_500) + let before = try #require(plan.plan) + #expect(plan.move == nil) + let change = Task { await mode.setStyle(ModeModel.styles[0]) } + await rig.run(2_000) + await change.value + await rig.run(8_000) + let move = try #require(plan.move) + #expect(move.before == before) + #expect(plan.plan?.rev != before.rev) + // Asked again past the hold, the box's next plan is its own. + await rig.run(PlanModel.moveHoldMs + 1_000) + mode.deactivate() + plan.follow() + await rig.run(1_000) + #expect(plan.move == nil || plan.move == move) + } + #expect(ModeModel.stop(at: 0) == 0 && ModeModel.stop(at: 1) == 4) + #expect(ModeModel.stop(at: 0.6) == 2 && ModeModel.stop(at: 0.63) == 3) + #expect(ModeModel.stop(at: -1) == 0 && ModeModel.stop(at: .nan) == 2) + #expect(ModeModel.fraction(of: 2) == 0.5 && ModeModel.fraction(of: 9) == 1) + } + @MainActor @Test func aRefusalSnapsBack() async throws { try await AppWords.$language.withValue(.en) { diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift index f5268f9..c1544f6 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift @@ -119,6 +119,36 @@ 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}%") + } + + /// Only the quarters a new plan changed cross-fade on the dial. + @Test func onlyTheMovedTicksChange() { + var plan = watch.plan() + let a = PlanDial(plan: plan, prices: watch.prices(), nowMs: at(14, 12, 7)) + plan.battery = plan.battery?.map { s in + var s = s + if s.startMs >= at(14, 22) && s.startMs < at(14, 23) { s.batteryW = 40 } + return s + } + let b = PlanDial(plan: plan, prices: watch.prices(), nowMs: at(14, 12, 7)) + let ten = a.grid.index(atMs: at(14, 22))! + #expect(b.moved(from: a) == Array(ten..<(ten + 4))) + #expect(PlanDial(plan: plan, prices: watch.prices(), nowMs: at(14, 12, 20)).moved(from: a).isEmpty) + } + @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/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) + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift index 053ffdd..2caee88 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift @@ -184,4 +184,99 @@ 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) + } + + /// A quarter the market has not priced is a stub, never a level. + @Test func unpricedQuartersAreStubs() { + var entry = glance.night() + entry.prices = entry.prices.filter { $0.startMs < at(13, 4) } + let s = PlanStrip.build(entry, moment: PlanGlance.live(entry, nowMs: at(12, 20, 5), calendar: calendar), hasCar: true) + // 04:00 is quarter 32; the window runs to 08:00. + #expect(s.pending == [.init(first: 32, count: 16, value: true)]) + #expect(s.levels[31] != nil && s.levels[32] == nil) + #expect(strip(at: at(12, 20, 5)).pending.isEmpty) + } + + // MARK: What a new plan moved + + /// `night()` with the charging moved from 22:00 to 02:00. + func movedCharge() -> PlanEntry { + var entry = glance.night() + entry.battery = entry.battery?.map { s in + var s = s + if s.startMs >= at(12, 22) && s.startMs < at(12, 23, 30) { s.batteryW = 40 } + if s.startMs >= at(13, 2) && s.startMs < at(13, 3, 30) { s.batteryW = 3_000 } + return s + } + return entry + } + + @Test func aNewPlanSaysWhatItMoved() { + let old = glance.night(), new = movedCharge() + #expect(PlanGlance.moved(from: old, to: new, atMs: at(12, 20, 5), calendar: calendar) == "New plan 20:05: stores 02:00 instead of 22:00.") + // Nothing a person would see moved: nothing to say. + #expect(PlanGlance.moved(from: old, to: old, atMs: at(12, 20, 5), calendar: calendar) == nil) + // A move that is new, or gone. + var none = old + none.charges = [] + #expect(PlanGlance.moved(from: none, to: old, atMs: at(12, 20, 5), calendar: calendar) == "New plan 20:05: car charges 01:00.") + #expect(PlanGlance.moved(from: old, to: none, atMs: at(12, 20, 5), calendar: calendar) == "New plan 20:05: no longer car charges 01:00.") + } + + /// Only the quarters a new plan changed cross-fade; a strip that moved + /// on by a quarter is the clock, not a new plan. + @Test func onlyTheMovedQuartersChange() { + let a = PlanStrip.build(glance.night(), moment: PlanGlance.live(glance.night(), nowMs: at(12, 20, 5), calendar: calendar), hasCar: true) + let entry = movedCharge() + let b = PlanStrip.build(entry, moment: PlanGlance.live(entry, nowMs: at(12, 20, 5), calendar: calendar), hasCar: true) + #expect(b.moved(from: a) == Array(8..<14) + Array(24..<30)) + #expect(a.moved(from: a).isEmpty) + let later = PlanStrip.build(entry, moment: PlanGlance.live(entry, nowMs: at(12, 20, 20), calendar: calendar), hasCar: true) + #expect(later.moved(from: a).isEmpty) + } } diff --git a/appleApp/FTWUITests/NowFitsTests.swift b/appleApp/FTWUITests/NowFitsTests.swift index e4590f6..ead7bbb 100644 --- a/appleApp/FTWUITests/NowFitsTests.swift +++ b/appleApp/FTWUITests/NowFitsTests.swift @@ -168,26 +168,29 @@ final class NowFitsTests: XCTestCase { XCTAssertTrue(app.navigationBars["Are we in control?"].waitForNonExistence(timeout: 5)) } - /// A mode from the menu beside the title: the button names it at once, - /// the status line walks the box's receipt, and then returns. + /// A mode from the button beside the title: its sheet closes once the + /// box applied it, the button names it, the status line walks the box's + /// receipt, and then returns. @MainActor func testTheModeMenuChangesTheModeAndTheLineFollowsTheBox() throws { let app = launchDemo() let button = app.buttons["now.mode"] let line = app.buttons["status.line"] XCTAssertTrue(button.waitForExistence(timeout: 10)) - // The menu rests until the box has spoken. + // The button rests until the box has spoken. Thread.sleep(forTimeInterval: 3) XCTAssertEqual(button.value as? String, "Balanced") let frame = button.frame button.tap() - let own = app.buttons["Own use"] + let own = app.buttons["mode.choice.self_consumption"] XCTAssertTrue(own.waitForExistence(timeout: 5)) shoot(app, "mode-menu") + reveal(own, in: app) own.tap() let confirmed = NSPredicate(format: "value CONTAINS 'CONFIRMED'") expectation(for: confirmed, evaluatedWith: line) waitForExpectations(timeout: 10) + XCTAssertTrue(own.waitForNonExistence(timeout: 5), "the sheet stayed open after the box applied the mode") shoot(app, "mode-confirmed") XCTAssertEqual(button.value as? String, "Own use") XCTAssertEqual(button.frame.maxX, frame.maxX, accuracy: 0.5, "the mode button moved its trailing edge") @@ -196,10 +199,55 @@ final class NowFitsTests: XCTestCase { waitForExpectations(timeout: 10) // Back to the plan, for the next test. button.tap() - let plan = app.buttons["Plan"] + let plan = app.buttons["mode.choice.plan"] XCTAssertTrue(plan.waitForExistence(timeout: 5)) plan.tap() - Thread.sleep(forTimeInterval: 2) + XCTAssertTrue(plan.waitForNonExistence(timeout: 10)) + } + + /// The planning style from Now's button: a finger drags the scale two + /// stops and lets go, one command goes, the box confirms it, the sheet + /// stays and says what the new plan moved. A second drag goes back. + @MainActor + func testTheStyleScaleSendsOnceWhenTheFingerLetsGo() throws { + let app = launchDemo() + let button = app.buttons["now.mode"] + let line = app.buttons["status.line"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + Thread.sleep(forTimeInterval: 3) + XCTAssertEqual(button.value as? String, "Balanced") + button.tap() + let scale = app.descendants(matching: .any).matching(identifier: "style.scale").firstMatch + XCTAssertTrue(scale.waitForExistence(timeout: 5)) + XCTAssertTrue((scale.value as? String ?? "").hasPrefix("Balanced."), "\(String(describing: scale.value))") + shoot(app, "style-open") + // The track sits under the ends' line; its stops are spread across + // the width inside the thumb's half. + let f = scale.frame + let y = f.minY + 14 + 8 + 16 + func stop(_ i: Int) -> XCUICoordinate { + let x = f.minX + 16 + (f.width - 32) * CGFloat(i) / 4 + return app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x, dy: y)) + } + stop(2).press(forDuration: 0.2, thenDragTo: stop(4), withVelocity: 300, thenHoldForDuration: 0.3) + let confirmed = NSPredicate(format: "value CONTAINS 'CONFIRMED'") + expectation(for: confirmed, evaluatedWith: line) + waitForExpectations(timeout: 10) + XCTAssertTrue((scale.value as? String ?? "").hasPrefix("Very bold."), "\(String(describing: scale.value))") + // The sheet stays for the other settings, and says what moved. + XCTAssertTrue(scale.exists) + XCTAssertTrue(app.staticTexts["style.moved"].waitForExistence(timeout: 5), "no line says what the new plan moved") + shoot(app, "style-moved") + // VoiceOver reads it as one element with the style's name and help. + XCTAssertEqual(scale.label, "Planning style") + // Back to where it was, for the next test. + Thread.sleep(forTimeInterval: 1) + stop(4).press(forDuration: 0.2, thenDragTo: stop(2), withVelocity: 300, thenHoldForDuration: 0.3) + expectation(for: NSPredicate(format: "value BEGINSWITH 'Balanced.'"), evaluatedWith: scale) + waitForExpectations(timeout: 10) + app.buttons["mode.close"].tap() + expectation(for: NSPredicate(format: "value == 'Balanced'"), evaluatedWith: button) + waitForExpectations(timeout: 10) } /// At the largest accessibility size the drawing gives way to a row @@ -257,7 +305,11 @@ final class NowFitsTests: XCTestCase { /// the finger, and closes once the box applied the mode. @MainActor func testTheModeSheetFitsWaitsInPlaceAndClosesOnceApplied() throws { - let app = launchDemo() + // The box answers after 2.5 s, so the look while it waits comes well + // before: XCTest's wait for the app to settle after a tap can take a + // second, and the turning mark keeps it busy that long. + let app = launchDemo(more: ["-FTWDemoResultDelayMs", "2500"]) + XCTAssertTrue(app.buttons["now.node.home"].waitForExistence(timeout: 15), "Now never drew the home") let button = app.buttons["now.mode"] // The choices rest until the stream is up. openSettings(app) @@ -271,14 +323,11 @@ final class NowFitsTests: XCTestCase { // Every choice the sheet opened with is on screen: the sheet fits // its content, or is full height and scrolls. let window = app.windows.firstMatch.frame - if !window.contains(idle.frame) { - app.swipeUp() - Thread.sleep(forTimeInterval: 1) - } + reveal(idle, in: app) XCTAssertTrue(window.contains(idle.frame), "\(idle.frame) is outside \(window)") let top = idle.frame.minY idle.tap() - // The demo box answers after 0.9 s; look well before that. + // Look well before the box answers. Thread.sleep(forTimeInterval: 0.15) XCTAssertTrue(plan.isSelected, "the box's mode lost its tick before the box answered") XCTAssertEqual(idle.frame.minY, top, accuracy: 1, "the row moved under the finger") @@ -327,6 +376,21 @@ final class NowFitsTests: XCTestCase { } @MainActor + /// Scrolls the mode sheet a little at a time until the middle of a row, + /// where a tap lands, clears the receipt bar at its foot: on a small + /// phone the sheet is full height and scrolls. The bar and the home + /// indicator take less than 100 pt. Short, slow drags keep the rows + /// above in place. + private func reveal(_ row: XCUIElement, in app: XCUIApplication) { + let window = app.windows.firstMatch.frame + for _ in 0..<4 where row.frame.midY > window.maxY - 100 { + let list = app.collectionViews.firstMatch + list.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) + .press(forDuration: 0.1, thenDragTo: list.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)), withVelocity: .slow, thenHoldForDuration: 0.3) + Thread.sleep(forTimeInterval: 0.6) + } + } + private func shoot(_ app: XCUIApplication, _ name: String) { let shot = XCTAttachment(screenshot: app.screenshot()) shot.name = name