Repository navigation
iPhone: a Grid today widget, with today's energy over the grid, its cost and the day as a chart #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cc0deee
770a410
420eeb0
73cbe78
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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", | ||
| ] | ||
| } | ||
| } |
| 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"]) | ||
| // 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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a non-SEK home opens, this load runs concurrently with 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)) | ||
| ) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a request remains in flight across a quarter boundary—especially midnight—
LiveAskcan start the newly wanted request without cancelling the previous one, butload()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 callingshareGridToday.Useful? React with 👍 / 👎.