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
17 changes: 11 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,17 @@ native, and smooth enough to impress.
the time from process start to the first frame and the first content;
quote the numbers in any PR that touches launch.
- **The handshake runs beside the first frame, never ahead of it.**
- **No timer loops.** Lane 0 drives Now. Until the box streams charger
state (M5, or FTW Cloud C3), the chargers are read when someone starts
looking and the last read is over a minute old, after a command, and
when the stream says the car started or stopped drawing. The plan's
shortfall is read when the car view opens and when the car's plan
changes.
- **No timer loops.** Lane 0 drives Now. The box pushes its plan, with
each charger's windows, cable and shortfall, whenever it replans, and Now,
the plan, the car view and the watch redraw from that push. The chargers
are read when someone starts looking and the last read is over a minute
old, after a command, when the stream says the car started or stopped
drawing, when FTW Cloud or a pushed plan says a cable went in or out, and
when a pushed plan says a goal changed elsewhere.
A box whose plan comes without chargers pushes none: its chargers are also
read each quarter while a car is plugged in and every 15 s while its plan
is being made or for a minute after a cable moved, and its shortfall
when the car view opens and when the car's plan changes.
- **Ask by the data's age.** `LiveAsk` names what it wants by how old the
data is (the five minutes, the quarter, the day) and keeps an answer
while that name holds. A view appearing or a redial asks only when the
Expand Down
67 changes: 63 additions & 4 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ public final class SimulatedBox {
/// `time_of_day_min` on the household clock. Off, it is a Core that
/// knows only `time_of_day_min_utc`.
public var readsHouseholdClock = true
/// A Core with srcfl/ftw#1643 puts each charger's share on the plan and
/// pushes the plan when it replans. Off, it is a Core that does neither.
public var pushesPlan = true
/// How long the box takes to replan after a cable, a goal, a level or
/// solar-only charging: its checks, the solve and the push's settle. The
/// plan is pending meanwhile. A hold replans nothing, as in Core.
public var replanMs: Double = 300
private var replanning: Cancellable?
/// How long an API answer waits to leave after the box made it: a slow
/// box, whose answer can land after a newer push.
public var answerDelayMs: Double = 0
/// The last schedule body the app sent, as sent.
public private(set) var lastScheduleBody: JSON?
/// The box the demo is showing, for the demo's own controls (plug the
Expand Down Expand Up @@ -398,9 +409,11 @@ public final class SimulatedBox {
house.carSocSource = "inferred"
house.carReportedAtMs = house.carReportedAtMs.map { min($0, scheduler.nowMs - 1) }
result(cmdId, "applied", observed: house.carSocFraction)
replan()
case Contract.opLoadpointSurplusOnlySet:
house.surplusOnly = args["surplus_only"]?.bool ?? false
result(cmdId, "applied", observed: house.surplusOnly ? 1 : 0)
replan()
default:
result(cmdId, "rejected", error: ("E_UNKNOWN_OP", []))
}
Expand Down Expand Up @@ -482,7 +495,11 @@ public final class SimulatedBox {
break
}
let (status, json) = answer(method, route.pattern, route.id, query, requestBody)
respond(id, status: status, json: json)
guard answerDelayMs > 0 else {
respond(id, status: status, json: json)
return
}
scheduler.after(answerDelayMs) { [weak self] in self?.respond(id, status: status, json: json) }
}

/// Core's answer to "Are we in control?" for the demo's battery and
Expand Down Expand Up @@ -607,9 +624,11 @@ public final class SimulatedBox {
Int(body?["days"]?.number ?? 0),
body?["surplus_unlock_bat_soc"]?.number ?? 0
)
replan()
return (200, ["ok": true])
case ("DELETE", "/api/loadpoints/{id}/schedule"):
house.schedule = nil
replan()
return (200, ["ok": true])
default:
return (404, [:])
Expand Down Expand Up @@ -642,8 +661,8 @@ public final class SimulatedBox {
"manual_charge_w": h.manualHoldW.map { .number($0) } ?? .null,
"surplus_only": .bool(h.surplusOnly),
"charger": ["known": true, "available": true, "updated_at_ms": .number(scheduler.nowMs - 2_000)],
"plan_pending": false,
"plan_outdated": false,
"plan_pending": .bool(replanning != nil),
"plan_outdated": .bool(replanning != nil),
"commanded_known": true,
"commanded_w": .number(h.evWatts),
"commanded_reason": "",
Expand Down Expand Up @@ -695,7 +714,7 @@ public final class SimulatedBox {
lp = lp.setting("target_time", .string(ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: deadline / 1000))))
}
}
if let p = h.carPlan(now), p.endMs > p.startMs, p.endMs > now {
if replanning == nil, let p = h.carPlan(now), p.endMs > p.startMs, p.endMs > now {
lp = lp.setting("plan_windows", [["start_ms": .number(p.startMs.rounded()), "end_ms": .number(p.endMs.rounded()), "wh": .number(p.wh)]])
lp = lp.setting("plan_next_start_ms", .number(p.startMs.rounded()))
lp = lp.setting("plan_next_end_ms", .number(p.endMs.rounded()))
Expand Down Expand Up @@ -854,6 +873,46 @@ extension SimulatedBox {
/// Plug the demo car in or out.
public func setCarPluggedIn(_ on: Bool) {
if on { house.plugIn() } else { house.unplug() }
replan()
}

/// The charger's share of the plan, as Core puts it on the plan it
/// sends. A box that keeps no cars names none.
func planCharger(nowMs: Double) -> PlanCharger {
let pending = replanning != nil
var car = PlanCharger(id: "garage", pluggedIn: house.carPluggedIn, pending: pending, outdated: pending, goalRev: goalRev, windows: [], totalWh: 0)
if let p = house.carPlan(nowMs), p.endMs > p.startMs, p.endMs > nowMs {
car.windows = [Loadpoint.Window(fromMs: p.startMs.rounded(), toMs: p.endMs.rounded(), peakW: nil, energyWh: p.wh)]
car.totalWh = p.wh
car.shortfallWh = pending ? nil : p.shortfallWh
}
return car
}

/// Moves when the goal or solar-only choice does, as Core's does.
var goalRev: UInt32 {
let s = house.schedule
let text = "\(s.map { "\($0.socFraction) \($0.clockMin) \($0.recurring) \($0.days)" } ?? "-") \(house.surplusOnly)"
return text.utf8.reduce(2_166_136_261) { ($0 ^ UInt32($1)) &* 16_777_619 }
}

/// Something the plan depends on changed: the box replans, saying so
/// meanwhile. A box that pushes sends the plan when the replan starts,
/// pending, and again when the new one is made.
public func replan() {
replanning?.cancel()
replanning = scheduler.after(replanMs) { [weak self] in
guard let self else { return }
self.replanning = nil
self.house.planRev += 1
self.pushPlan()
}
pushPlan()
}

private func pushPlan() {
guard pushesPlan, subscription != nil else { return }
sendBulk(Envelope(t: "plan", b: sessionPlan().wire))
}
}

Expand Down
27 changes: 17 additions & 10 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,16 +216,23 @@ extension SimulatedBox {
/// or the preferences change, and the same plan until then.
func sessionPlan() -> Plan {
let quarter = (scheduler.nowMs / 900_000).rounded(.down)
if let made = madePlan, made.quarter == quarter, made.rev == house.planRev { return made.plan }
let steps = house.plan(nowMs: scheduler.nowMs)
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, socPermille: ($0.soc * 1000).rounded()) },
stale: false,
ceilingW: nil
)
madePlan = (quarter, house.planRev, plan)
var plan: Plan
if let made = madePlan, made.quarter == quarter, made.rev == house.planRev {
plan = made.plan
} else {
let steps = house.plan(nowMs: scheduler.nowMs)
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, socPermille: ($0.soc * 1000).rounded()) },
stale: false,
ceilingW: nil
)
madePlan = (quarter, house.planRev, plan)
}
// The charger's share as it stands, pending or not, as Core reads it
// each time it sends.
plan.chargers = pushesPlan ? [planCharger(nowMs: scheduler.nowMs)] : nil
return plan
}

Expand Down
7 changes: 7 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/Format/EV.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ public struct Loadpoint: Equatable, Sendable, Identifiable, Codable {
public var peakW: Double?
public var energyWh: Double?
public var id: Double { fromMs }

public init(fromMs: Double, toMs: Double, peakW: Double?, energyWh: Double?) {
self.fromMs = fromMs
self.toMs = toMs
self.peakW = peakW
self.energyWh = energyWh
}
}

public var id: String
Expand Down
77 changes: 76 additions & 1 deletion appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,17 @@ public struct Plan: Equatable, Sendable, Codable {
/// different sentences.
public var stale: Bool
public var ceilingW: Double?
/// Each charger's share of the plan. Nil from a box that sends none,
/// which also pushes no plan when it replans.
public var chargers: [PlanCharger]?

public init(rev: UInt64, uptimeMs: Double, slots: [PlanSlot], stale: Bool, ceilingW: Double?) {
public init(rev: UInt64, uptimeMs: Double, slots: [PlanSlot], stale: Bool, ceilingW: Double?, chargers: [PlanCharger]? = nil) {
self.rev = rev
self.uptimeMs = uptimeMs
self.slots = slots
self.stale = stale
self.ceilingW = ceilingW
self.chargers = chargers
}

init(_ c: CBOR) {
Expand All @@ -190,6 +194,7 @@ public struct Plan: Equatable, Sendable, Codable {
}
stale = c["stale"]?.bool ?? false
ceilingW = c["ceilingW"]?.double
chargers = c["chargers"]?.array?.compactMap { PlanCharger($0) }
}

var cbor: CBOR {
Expand All @@ -207,6 +212,76 @@ public struct Plan: Equatable, Sendable, Codable {
("stale", .bool(stale)),
]
pairs.append(("ceilingW", ceilingW.map { .ms($0) } ?? .null))
if let chargers { pairs.append(("chargers", .array(chargers.map(\.cbor)))) }
return .map(pairs)
}
}

/// One charger's share of the plan, as `GET /api/loadpoints` gives it, so
/// a plan the box pushes redraws the charger without a read. Keyed by the
/// charger: a car visits chargers.
public struct PlanCharger: Equatable, Sendable, Codable {
/// The charger's loadpoint id.
public var id: String
/// The session's car, nil while the box does not know which, and from
/// a box that keeps no cars.
public var carId: String?
public var pluggedIn: Bool
/// A newer plan is being made: the windows are the plan the box goes on
/// acting on until the new one lands. Outdated comes with it, and alone
/// when the plan is not one the box acts on; then there are no windows.
public var pending: Bool
public var outdated: Bool
/// Grid-funded charging waits for prices that reach the deadline.
public var gridDeferred: Bool
/// Moves when the charger's goal, ready time, schedule or solar-only
/// choice changes, wherever that was done. Nil from a box without it.
public var goalRev: UInt32?
public var windows: [Loadpoint.Window]
public var totalWh: Double
/// How far short of the goal the plan leaves the car, in battery
/// watt-hours. Nil when the plan has no goal for it.
public var shortfallWh: Double?

public init(id: String, carId: String? = nil, pluggedIn: Bool, pending: Bool = false, outdated: Bool = false, gridDeferred: Bool = false, goalRev: UInt32? = nil, windows: [Loadpoint.Window], totalWh: Double, shortfallWh: Double? = nil) {
self.id = id
self.carId = carId
self.pluggedIn = pluggedIn
self.pending = pending
self.outdated = outdated
self.gridDeferred = gridDeferred
self.goalRev = goalRev
self.windows = windows
self.totalWh = totalWh
self.shortfallWh = shortfallWh
}

init?(_ c: CBOR) {
guard let id = c["id"]?.string else { return nil }
self.id = id
carId = c["carId"]?.string.flatMap { $0.isEmpty ? nil : $0 }
pluggedIn = c["pluggedIn"]?.bool ?? false
pending = c["pending"]?.bool ?? false
outdated = c["outdated"]?.bool ?? false
gridDeferred = c["gridDeferred"]?.bool ?? false
goalRev = c["goalRev"]?.int64.flatMap { UInt32(exactly: $0) }
windows = (c["windows"]?.array ?? []).compactMap { w in
guard let s = w["startMs"]?.double, let e = w["endMs"]?.double, e > s, let wh = w["wh"]?.double, wh >= 0 else { return nil }
return Loadpoint.Window(fromMs: s, toMs: e, peakW: nil, energyWh: wh)
}
totalWh = c["totalWh"]?.double ?? 0
shortfallWh = c["shortfallWh"]?.double.map { max(0, $0) }
}

var cbor: CBOR {
var pairs: [(String, CBOR)] = [
("id", .text(id)), ("pluggedIn", .bool(pluggedIn)), ("pending", .bool(pending)), ("outdated", .bool(outdated)), ("gridDeferred", .bool(gridDeferred)),
("windows", .array(windows.map { .map([("startMs", .ms($0.fromMs)), ("endMs", .ms($0.toMs)), ("wh", .ms($0.energyWh ?? 0))]) })),
("totalWh", .ms(totalWh)),
]
if let carId { pairs.append(("carId", .text(carId))) }
if let goalRev { pairs.append(("goalRev", .unsigned(UInt64(goalRev)))) }
if let shortfallWh { pairs.append(("shortfallWh", .ms(shortfallWh))) }
return .map(pairs)
}
}
Expand Down
Loading
Loading