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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions appleApp/FTW/UI/PlanView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ private struct PlanBody: View {
}
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)
}
Comment on lines +126 to +128

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Show the forecast style in the lanes layout too

When the user selects the supported Lanes presentation and the box reports a non-balanced planning style, this disclosure disappears because it is rendered only inside the .dial branch. The lanes branch still shows its equivalent planned-state line but never styleLine, so those users cannot see how strongly the displayed plan relies on the forecast; render the line in both layouts or move it outside the style switch.

Useful? React with 👍 / 👎.

}
.font(Theme.mono())
.tracking(0.5)
Expand Down Expand Up @@ -164,6 +171,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.") }
Expand Down
9 changes: 7 additions & 2 deletions appleApp/FTWCharts/PlanDialFace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,7 +38,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)
Expand Down Expand Up @@ -211,6 +214,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()) }

Expand Down
19 changes: 19 additions & 0 deletions appleApp/FTWCharts/PlanStripDrawing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
10 changes: 9 additions & 1 deletion appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -816,6 +819,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.
Expand Down
1 change: 1 addition & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ public enum NodeWords {
"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.",
Expand Down
4 changes: 4 additions & 0 deletions appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ public struct PlanStrip: Equatable, Sendable {
public let batteryRuns: [Run<Battery>]
public let carRuns: [Run<Bool>]
public let priceRuns: [Run<Int>]
/// Stretches the market has not priced yet: drawn as hatched stubs,
/// never as a level.
public let pending: [Run<Bool>]
/// Local three-hour marks: "18", "21", "00".
public let hours: [Tick]
/// Quarters that give a light impact under a finger: now, either end,
Expand Down Expand Up @@ -175,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,
Expand Down
11 changes: 11 additions & 0 deletions appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,15 @@ import Testing
// 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)
}
}
Loading