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
12 changes: 6 additions & 6 deletions appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ public enum Flow {
}

public struct Readings: Equatable, Sendable {
public var loadKw: Double
/// Core's qualified load, or nil when no load was sent.
public var loadKw: Double?
public var nodes: [Node]
public var selfPoweredPctToday: Double?
}
Expand Down Expand Up @@ -82,7 +83,7 @@ public enum Flow {
if let e = fields[Contract.FID.evW] {
nodes.append(Node(id: "ev", role: .ev, corner: .bottomRight, title: "EV CHARGER", kw: e / 1000, toHub: false, tone: tone(.ev, e), sub: idle(e) ? "idle" : "charging", dailyParts: [], tappable: true))
}
return Readings(loadKw: (fields[Contract.FID.loadW] ?? 0) / 1000, nodes: nodes, selfPoweredPctToday: nil)
return Readings(loadKw: fields[Contract.FID.loadW].map { $0 / 1000 }, nodes: nodes, selfPoweredPctToday: nil)
}

/// Nodes from GET /api/status: per-driver bubbles and energy today, the
Expand Down Expand Up @@ -117,22 +118,21 @@ public enum Flow {
}
}
let selfPowered: Double? = loadTotal > 0.001 ? max(0, min(100, (1 - imp / loadTotal) * 100)) : nil
return Readings(loadKw: (status["load_w"]?.number ?? 0) / 1000, nodes: nodes, selfPoweredPctToday: selfPowered)
return Readings(loadKw: status["load_w"]?.number.map { $0 / 1000 }, nodes: nodes, selfPoweredPctToday: selfPowered)
}

/// Charger watts the box's HTTP API knows, summed.
public static func loadpointChargeW(_ points: [Loadpoint]) -> Double {
points.reduce(0) { $0 + ($1.powerW > idleW ? $1.powerW : 0) }
}

/// Put the car on its own node when the 1 Hz stream folded it into the
/// house. A box that already sends field 10 is left alone.
/// Add the charger's reading when the stream has no active EV field.
/// Core owns the house load: keep it unchanged, including nil and zero.
public static func withLoadpointEV(_ fields: [Int: Double], evW: Double) -> [Int: Double] {
guard evW > idleW else { return fields }
if let wire = fields[Contract.FID.evW], abs(wire) > idleW { return fields }
var out = fields
out[Contract.FID.evW] = evW.rounded()
out[Contract.FID.loadW] = max(0, (fields[Contract.FID.loadW] ?? 0) - evW)
return out
}

Expand Down
13 changes: 6 additions & 7 deletions appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ public struct NodeReading: Equatable, Sendable, Identifiable {
}

public enum NowFlow {
/// The nodes Now draws, in its order. The grid is always there, because
/// a home that cannot see its own meter should see that gap; any other
/// node the box sends no field for is left out rather than drawn at zero,
/// The nodes Now draws, in its order. Grid and home stay visible so
/// a missing meter or qualified load reads as unknown. Any other node
/// the box sends no field for is left out rather than drawn at zero,
/// unless the home is `known` to have it.
public static func nodes(fields: [Int: Double], hasCar: Bool, known: [EnergyNode] = []) -> [EnergyNode] {
EnergyNode.allCases.filter { node in
node == .grid || fields[node.fid] != nil || (node == .car && hasCar) || known.contains(node)
node == .grid || node == .home || fields[node.fid] != nil || (node == .car && hasCar) || known.contains(node)
}
}

Expand Down Expand Up @@ -115,9 +115,8 @@ extension NowFlow {
let chargeW = Flow.loadpointChargeW(points)
var fields = site.session.fields
let wire = fields[Contract.FID.evW]
// The car comes off the house only when both are as old: a fresh
// read beside the stream, or both from the cache. An old read never
// changes a live figure.
// The charger can supply its own display reading when fresh, or
// alongside a cached stream. It never changes Core's house load.
if !loadpoints.stale || site.lastFrameAtMs == nil {
fields = Flow.withLoadpointEV(fields, evW: chargeW)
}
Expand Down
65 changes: 65 additions & 0 deletions appleApp/FTWKit/Tests/FTWKitTests/CoreLoadPreservationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation
import Testing
@testable import FTWKit

@MainActor
@Suite struct CoreLoadPreservationTests {
typealias F = Contract.FID

@Test func chargerDisplayKeepsTheCoreLoadUnchangedOrAbsent() {
for load: Double? in [nil, 0, 25.75, 9_000, -20] {
for wireEV: Double? in [nil, 0, 7_400] {
var original = [F.gridW: 3_700.0, F.pvW: -1_200]
original[F.loadW] = load
original[F.evW] = wireEV
let shown = Flow.withLoadpointEV(original, evW: 7_400)
#expect(shown[F.loadW] == load)
#expect(shown[F.evW] == 7_400)
#expect(shown[F.gridW] == original[F.gridW])
#expect(shown[F.pvW] == original[F.pvW])
}
}
}

@Test func bothInputFormatsKeepUnknownDistinctFromARecordedZero() throws {
let missingStream: Double? = Flow.readings(fields: [:]).loadKw
let missingStatus: Double? = Flow.readings(status: try JSON(parsing: Array("{}".utf8))).loadKw
#expect(missingStream == nil)
#expect(missingStatus == nil)
let nullStatus: Double? = Flow.readings(status: try JSON(parsing: Array("{\"load_w\":null}".utf8))).loadKw
#expect(nullStatus == nil)
#expect(Flow.readings(fields: [F.loadW: 0]).loadKw == 0)
#expect(Flow.readings(status: try JSON(parsing: Array("{\"load_w\":0}".utf8))).loadKw == 0)
#expect(Flow.readings(fields: [F.loadW: 1_250]).loadKw == 1.25)
}

@Test func aHomeWithNoCoreLoadStillHasAnUnknownReading() throws {
let readings = NowFlow.readings(fields: [F.gridW: 3_700], freshness: { _ in
NodeFreshness(carrier: .relay, srcState: .never, ageMs: nil, sourceName: nil)
})
let home = try #require(readings.first { $0.node == .home })
#expect(home.watts == nil)
#expect(!home.flowing)
let watch = WatchShare.reading(home)
#expect(watch.watts == nil)
#expect(watch.way == .noReading)
#expect(!watch.live)
}

@Test func nowAndWatchKeepCoreZeroAndNeverSubtractTheCharger() throws {
for load: Double? in [nil, 0, 1_600] {
var fields = [F.gridW: 9_000.0]
fields[F.loadW] = load
fields = Flow.withLoadpointEV(fields, evW: 7_400)
let readings = NowFlow.readings(fields: fields, freshness: { fid in
NodeFreshness(carrier: .relay, srcState: fields[fid] == nil ? .never : .live, ageMs: 0, sourceName: nil)
}, hasCar: true)
let home = try #require(readings.first { $0.node == .home })
#expect(home.watts == load)
let watch = WatchShare.reading(home)
#expect(watch.watts == load)
#expect(watch.way == (load == nil ? .noReading : .using))
#expect(watch.live == (load != nil))
}
}
}
2 changes: 1 addition & 1 deletion appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ import Testing
#expect(!r.nodes.contains { $0.role == .ev })
let overlaid = Flow.withLoadpointEV([F.loadW: 9000, F.evW: 0], evW: 7400)
#expect(overlaid[F.evW] == 7400)
#expect(overlaid[F.loadW] == 1600)
#expect(overlaid[F.loadW] == 9000)
}

}
Expand Down
Loading