Skip to content
Merged
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
4 changes: 3 additions & 1 deletion appleApp/FTW/FTWApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ struct FTWApp: App {
// A paired phone paints its cached home before anything else runs.
model.launch()
#if DEBUG
// For screenshots: `-FTWDemo YES` opens the simulated home.
// For screenshots: `-FTWDemo YES` opens the simulated home, and
// `-FTWDemoWidgets YES` lets it leave its records for the widgets.
model.demoSharesWidgets = UserDefaults.standard.bool(forKey: "FTWDemoWidgets")
if UserDefaults.standard.bool(forKey: "FTWDemo") { model.startDemo() }
// For end-to-end tests: `-FTWPairLink <url>` offers a pairing link
// as a scanned QR would, where the simulator has no camera.
Expand Down
15 changes: 11 additions & 4 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ public final class SimulatedBox {
/// estimate meanwhile and the data grows into it. 0 sends all at once.
public var holdTileMs: Double = 0
private var heldTile = false
/// The share of today the box has prices for, as
/// `priced_coverage_pct`. Below 0.95 the grid widget marks its cost
/// partial.
public var pricedShare: Double = 1
/// 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 @@ -480,10 +484,8 @@ public final class SimulatedBox {
return (200, ["days": .array(rows)])
case ("GET", "/api/savings/daily"):
let days = max(1, min(90, Int(query["days"] ?? "7") ?? 7))
let rows: [JSON] = (0..<days).reversed().map { offset in
["day": .string(house.localDay(now - Double(offset) * 86_400_000)), "saved_ore": .number((1_400 + 900 * house.wobble(Double(offset) * 1e6, 5)).rounded()), "resolution": "slot"]
}
return (200, ["days": .array(rows)])
let rows: [JSON] = (0..<days).reversed().map { savingsRow(dayOffset: $0, nowMs: now) }
return (200, ["days": .array(rows), "tz": .string(house.timeZone.identifier), "value_scope": "site_total"])
case ("GET", "/api/loadpoints"):
return (200, ["enabled": true, "loadpoints": [loadpointJSON()]])
case ("GET", "/api/planner/prefs"):
Expand Down Expand Up @@ -732,6 +734,11 @@ extension SimulatedBox {
}
// For recordings: `-FTWDemoGap YES` holds a tile back for 2 s.
if UserDefaults.standard.bool(forKey: "FTWDemoGap") { box.holdTileMs = 2_000 }
// For the grid widget: `-FTWDemoSunny YES` makes today a long
// summer's day that sells more than it buys, and
// `-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 }
#endif
current = box
return LoopbackCarrier(box: box, scheduler: scheduler)
Expand Down
83 changes: 83 additions & 0 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedGridDay.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import Foundation
import FTWKit

/// The demo's day over the grid, from the same five-minute buckets its
/// history serves, so the grid widget's totals and its chart add up as they
/// do on a real box. Priced the way Core prices it: each quarter hour's
/// import at that quarter's price, less its export at the spot price.
extension SimulatedHouse {
struct GridDay: Equatable {
var importWh = 0.0
var exportWh = 0.0
var importCostOre = 0.0
var exportRevenueOre = 0.0
/// From local midnight to now.
var expectedMs = 0.0
}

/// The spot price the demo pays for export: the share of the total
/// price the plan's spot column carries (`mpcActions`).
static let spotShare = 0.6

/// Local midnight before `ms`, on the house's clock.
func midnight(_ ms: Double) -> Double {
ms - hourOfDay(ms) * 3_600_000
}

/// Today so far, bucket by bucket. A bucket still filling holds its
/// energy so far, as the box's ledger does.
func gridDay(nowMs: Double) -> GridDay {
let from = midnight(nowMs)
var day = GridDay(expectedMs: nowMs - from)
var t = from
while t < nowMs {
if let b = bucket(t, nowMs: nowMs) {
let kwh = b.grid * Self.bucketMs / 3_600_000 / 1000
let quarter = (t / 900_000).rounded(.down) * 900_000
let price = priceAt(quarter + 450_000)
if kwh > 0 {
day.importWh += kwh * 1000
day.importCostOre += kwh * price
} else {
day.exportWh -= kwh * 1000
day.exportRevenueOre -= kwh * price * Self.spotShare
}
}
t += Self.bucketMs
}
return day
}
}

extension SimulatedBox {
/// One day of `GET /api/savings/daily`, in Core's fields. Today comes
/// from the buckets; earlier days from the made-up ledger, priced at a
/// flat day's average.
func savingsRow(dayOffset: Int, nowMs: Double) -> JSON {
let day = house.localDay(nowMs - Double(dayOffset) * 86_400_000)
let saved = (1_400 + 900 * house.wobble(Double(dayOffset) * 1e6, 5)).rounded()
var g: SimulatedHouse.GridDay
if dayOffset == 0 {
g = house.gridDay(nowMs: nowMs)
} else {
let l = house.ledger(dayOffset: dayOffset, nowMs: nowMs)
g = SimulatedHouse.GridDay(importWh: l.imp, exportWh: l.exp, importCostOre: l.imp / 1000 * 95, exportRevenueOre: l.exp / 1000 * 95 * SimulatedHouse.spotShare, expectedMs: 86_400_000)
}
let expected = g.expectedMs.rounded()
return [
"day": .string(day),
"import_wh": .number(g.importWh.rounded()),
"export_wh": .number(g.exportWh.rounded()),
"import_cost_ore": .number(g.importCostOre.rounded()),
"export_revenue_ore": .number(g.exportRevenueOre.rounded()),
"actual_cost_ore": .number((g.importCostOre - g.exportRevenueOre).rounded()),
"saved_ore": .number(saved),
"expected_ms": .number(expected),
"history_covered_ms": .number(expected),
"priced_covered_ms": .number((expected * pricedShare).rounded()),
"history_coverage_pct": 1,
"priced_coverage_pct": .number(pricedShare),
"resolution": "slot",
]
}
}
6 changes: 5 additions & 1 deletion appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ public final class SimulatedHouse {
public let batteryMaxW: Double = 5_000
public let fuseAmps: Double = 25
public let timeZone: TimeZone
/// A clear midsummer's day whatever the date, for showing a day that
/// sells more than it buys.
public var sunny = false

public private(set) var socFraction: Double = 0.62
public private(set) var batteryW: Double = 0
Expand Down Expand Up @@ -153,6 +156,7 @@ public final class SimulatedHouse {

/// How far the year is toward midsummer: 1 in June, 0 in December.
func season(_ ms: Double) -> Double {
if sunny { return 1 }
var cal = Calendar(identifier: .gregorian)
cal.timeZone = timeZone
let day = Double(cal.ordinality(of: .day, in: .year, for: Date(timeIntervalSince1970: ms / 1000)) ?? 172)
Expand All @@ -167,7 +171,7 @@ public final class SimulatedHouse {
let half = 3.5 + 4.5 * s
guard h > 12.5 - half, h < 12.5 + half else { return 0 }
let sun = sin(Double.pi * (h - 12.5 + half) / (2 * half))
let cloud = 0.85 + 0.15 * wobble(ms / 600_000, 3)
let cloud = sunny ? 1 : 0.85 + 0.15 * wobble(ms / 600_000, 3)
return -(pvPeakW * (0.2 + 0.8 * s) * max(0, sun) * max(0, sun) * cloud).rounded()
}

Expand Down
11 changes: 10 additions & 1 deletion appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public final class HomeModels {
public let loadpoints: LoadpointsModel
/// Live, history, plan and mode for Now's nodes.
public let nodes: NodeModels
/// Today over the grid, for the grid widget.
public let gridToday: GridTodayShare

init(site: SiteModel) {
self.site = site
Expand All @@ -23,6 +25,7 @@ public final class HomeModels {
savings = SavingsModel(site: site)
loadpoints = LoadpointsModel(site: site, charging: charging)
nodes = NodeModels(site: site, loadpoints: loadpoints, prices: prices)
gridToday = GridTodayShare(site: site, history: nodes.history, prices: prices)
}
}

Expand Down Expand Up @@ -54,6 +57,10 @@ public final class AppModel {
/// A pairing link that arrived from outside, shown before it is trusted.
public var offeredLink: String?
public private(set) var isDemo = false
/// The demo leaves its records for the widgets under the home "demo".
/// Only a Debug build sets it, to photograph the widgets; the demo
/// otherwise writes nothing.
public var demoSharesWidgets = false

@ObservationIgnored public let vault: Vault
@ObservationIgnored public let sites: SiteList
Expand Down Expand Up @@ -258,7 +265,9 @@ public final class AppModel {
public func startDemo() {
guard let demo else { return }
home?.site.destroy()
let site = SiteModel(siteId: nil, build: build, ua: ua, scheduler: scheduler, files: nil, isDemo: true)
let shares = demoSharesWidgets && shared != nil
let site = SiteModel(siteId: shares ? "demo" : nil, build: build, ua: ua, scheduler: scheduler, files: nil, shared: shares ? shared : nil, isDemo: true, demoShares: shares)
if shares { site.sharedWritten = sharedWritten }
site.ceilingW = 11_000
home = HomeModels(site: site)
isDemo = true
Expand Down
96 changes: 96 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/GridTodayShare.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import Foundation
import FTWShared

/// Leaves today over the grid for the grid widget: the box's totals and cost
/// from `GET /api/savings/daily?days=1`, and the grid's power since midnight
/// from the history the grid's node view draws.
///
/// It reads while the app is open and on screen, once a quarter hour, the
/// step of the widget's chart, and again when the household's day turns.
/// Each read is one record, dated when the totals arrived, and the app wakes
/// the widgets after it lands. No timer runs while the app is hidden: the
/// widget says how old its figures are, and after midnight that they are
/// yesterday's.
@MainActor
public final class GridTodayShare {
private unowned let site: SiteModel
private unowned let history: HistoryModel
private unowned let prices: PriceModel
private var ask: LiveAsk?

public init(site: SiteModel, history: HistoryModel, prices: PriceModel) {
self.site = site
self.history = history
self.prices = prices
// A home that leaves nothing for widgets has nothing to read for.
guard site.sharesWidgets else { return }
ask = LiveAsk(site: site, want: { [weak self] in self?.wanted }, ask: { [weak self] in try await self?.load() })
}

/// What would make the record out of date: a new quarter hour, or a new
/// day on the household's clock.
var wanted: String? {
guard site.documentVisible, site.hasPassthrough else { return nil }
let day = GridTodayEntry.dayName(site.nowMs, calendar: calendar)
return "grid today \(day) \(Int(site.nowMs / GridTodayEntry.quarterMs))"
}

private var calendar: Calendar { .household(site.session.box?.tz) }

func load() async throws {
let readAt = site.nowMs
let wire = try await site.callBox(.get, "/api/savings/daily", query: ["days": "1"])

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 Prevent older grid loads from overwriting newer ones

If a request remains in flight across a quarter boundary—especially midnight—LiveAsk can start the newly wanted request without cancelling the previous one, but load() has no generation check. The older request can therefore finish last and overwrite the newer record; at midnight that leaves yesterday's entry as the latest cache and the widget grey until the next quarter-hour. Discard results whose request generation is no longer current before calling shareGridToday.

Useful? React with 👍 / 👎.

// A box with no price zone answers no days at all: nothing to leave.
guard let row = wire?["days"]?.array?.last, let totals = Self.totals(row) else { return }
let calendar = self.calendar
guard let day = GridTodayEntry.bounds(day: totals.day, calendar: calendar) else { return }

// The history the grid's node view reads. A failure leaves the
// chart as last read; the totals are written all the same, and the
// ask comes again.
var failure: Error?
if history.available {
do { try await history.load() } catch { failure = error }
}
let steps = history.frame.map { frame in
HistoryModel.samples(frame, node: .grid, fromMs: day.startMs).map { (startMs: $0.startMs, stepMs: $0.stepMs, watts: $0.watts) }
} ?? []
let entry = GridTodayEntry(
day: totals.day,
dayStartMs: day.startMs,
dayEndMs: day.endMs,
importWh: totals.importWh,
exportWh: totals.exportWh,
costMinor: totals.costMinor,
pricedCoverage: totals.pricedCoverage,
currency: prices.currency,

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 Wait for a known currency before caching the cost

When a non-SEK home opens, this load runs concurrently with PriceModel.load(), while PriceModel.currency falls back to "SEK" until that separate request completes (NowModels.swift). If the savings/history requests finish first—or the price request temporarily fails—the widget persists a correctly valued cost with the wrong kr unit, and it is not corrected until a later quarter-hour refresh. Use currency supplied by the savings response or defer/rewrite the record once the price model has an authoritative currency.

Useful? React with 👍 / 👎.

timeZone: site.session.box?.tz,
quarters: GridTodayEntry.quarters(steps, dayStartMs: day.startMs, untilMs: min(day.endMs, site.nowMs))
)
site.shareGridToday(entry, readAtMs: readAt, srcState: site.freshness(.grid).srcState)
if let failure { throw failure }
}

struct Totals: Equatable {
let day: String
let importWh: Double
let exportWh: Double
let costMinor: Double?
let pricedCoverage: Double
}

/// One row of the box's answer, as it gave it. The cost is the box's own
/// `actual_cost_ore`, import cost less export revenue; a day the box had
/// no prices for has none.
static func totals(_ row: JSON) -> Totals? {
guard let day = row["day"]?.string, day.count == 10 else { return nil }
let priced = row["resolution"]?.string != "no_prices"
return Totals(
day: day,
importWh: EnergyFormat.wholeWh(row["import_wh"]?.number),
exportWh: EnergyFormat.wholeWh(row["export_wh"]?.number),
costMinor: priced ? row["actual_cost_ore"]?.number : nil,
pricedCoverage: min(1, max(0, row["priced_coverage_pct"]?.number ?? 0))
)
}
}
22 changes: 20 additions & 2 deletions appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ public final class SiteModel {
/// The prices last left for the widgets, so the same prices again are
/// neither written nor reloaded.
@ObservationIgnored private var lastPrices: PriceEntry?
/// Today over the grid as last left, so the same figures again are not
/// written.
@ObservationIgnored private var lastGridToday: GridTodayEntry?
/// The latest write to `shared`. Each waits for the one before, so they
/// land in the order they were asked for.
@ObservationIgnored private var writing: Task<Void, Never>?
Expand Down Expand Up @@ -149,11 +152,14 @@ public final class SiteModel {
/// One display frame: what arrives within it is drawn together.
static let coalesceMs: Double = 16

public init(siteId: String?, build: String, ua: String, scheduler: Scheduler, files: SealedFiles?, shared: AppGroupFiles? = nil, isDemo: Bool = false) {
/// `demoShares` lets a demo leave its records for the widgets. Only a
/// Debug build asks for it, to photograph them; the demo otherwise
/// writes nothing.
public init(siteId: String?, build: String, ua: String, scheduler: Scheduler, files: SealedFiles?, shared: AppGroupFiles? = nil, isDemo: Bool = false, demoShares: Bool = false) {
self.siteId = siteId
self.scheduler = scheduler
self.files = files
self.shared = isDemo ? nil : shared
self.shared = isDemo && !demoShares ? nil : shared
self.isDemo = isDemo
nowMs = scheduler.nowMs
minuteMs = (scheduler.nowMs / 60_000).rounded(.down) * 60_000
Expand Down Expand Up @@ -739,6 +745,17 @@ public final class SiteModel {
write(CacheRecord(siteId: siteId, carrier: via ?? session.carrier, srcState: answering ? .live : .stale, readAtMs: now, value: entry), to: shared)
}

/// Whether this home leaves records for the widgets.
var sharesWidgets: Bool { siteId != nil && shared != nil }

/// Leave today over the grid for the grid widget, dated when the box's
/// totals arrived. `srcState` is the grid meter's as it was then.
func shareGridToday(_ entry: GridTodayEntry, readAtMs: Double, srcState: SourceState) {
guard let siteId, let shared, entry != lastGridToday else { return }
lastGridToday = entry
write(CacheRecord(siteId: siteId, carrier: session.carrier, srcState: srcState, readAtMs: readAtMs, value: entry), to: shared)
}

/// Leave the plan for the plan widget. `sentAtMs` is when the box sent
/// it, which is what the widget dates it by.
func sharePlan(_ entry: PlanEntry, sentAtMs: Double) {
Expand Down Expand Up @@ -766,6 +783,7 @@ public final class SiteModel {
// better than no app; the next read tries again.
log.error("could not write \(Value.name, privacy: .public) for widgets: \(error.localizedDescription, privacy: .public)")
if Value.self == PriceEntry.self { self?.lastPrices = nil }
if Value.self == GridTodayEntry.self { self?.lastGridToday = nil }
}
}
writing = task
Expand Down
Loading
Loading