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
65 changes: 65 additions & 0 deletions appleApp/FTW/UI/GridPhasesRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import FTWKit
import SwiftUI

/// The grid's one extra row: L1, L2 and L3 as 10-cell meters against the
/// main fuse, each with its current in mono, and the phase nearest the fuse
/// in an inverse tag. Absent when the box sends no phases. While they are
/// not live the row keeps its room and shows no number.
struct GridPhasesRow: View {
let phases: GridPhases
@Environment(\.dynamicTypeSize) private var typeSize

var body: some View {
let layout = typeSize.isAccessibilitySize
? AnyLayout(VStackLayout(alignment: .leading, spacing: 10))
: AnyLayout(HStackLayout(alignment: .top, spacing: 16))
VStack(alignment: .leading, spacing: 6) {
Text(phases.kicker)
.font(Theme.mono(.caption2))
.tracking(0.5)
.foregroundStyle(Theme.ink3)
layout {
ForEach(Array(phases.phases.enumerated()), id: \.offset) { i, phase in
PhaseMeter(phase: phase, reading: phases.reading(phase), nearest: i == phases.nearest, live: phases.live)
}
}
}
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
.frame(maxWidth: .infinity, alignment: .leading)
.animation(Theme.spring, value: phases.nearest)
.accessibilityElement(children: .ignore)
.accessibilityLabel(phases.kicker)
.accessibilityValue(phases.spoken)
.accessibilityIdentifier("node.grid.phases")
}
}

/// One phase: `L2 6,0 A` over its meter, a cell for each tenth of the fuse.
private struct PhaseMeter: View {
let phase: GridPhases.Phase
let reading: String
let nearest: Bool
let live: Bool

var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 4) {
// Every name keeps the tag's inset, so the mark can move
// from phase to phase without moving a figure.
Text(verbatim: phase.name)
.padding(.horizontal, 3)
.foregroundStyle(nearest ? Theme.ground : Theme.ink2)
.background(nearest ? Theme.ink : .clear, in: RoundedRectangle(cornerRadius: 2))
Text(reading)
.foregroundStyle(live ? Theme.ink : Theme.ink3)
.contentTransition(.numericText(value: phase.amps ?? 0))
}
.font(Theme.mono(.footnote))
.lineLimit(1)
CellMeter(pct: (phase.ofFuse ?? 0) * 100, tint: Theme.grid, live: live)
.frame(maxWidth: 96, minHeight: 4, maxHeight: 4)
.padding(.leading, 3)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
4 changes: 4 additions & 0 deletions appleApp/FTW/UI/NodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@ struct NodeView: View {
CarRow(loadpoints: home.loadpoints, zoom: zoom)
.padding(.horizontal, 20)
.padding(.top, 4)
} else if node == .grid, let phases = site.gridPhases {
GridPhasesRow(phases: phases)
.padding(.horizontal, 20)
.padding(.top, 8)
}
if fill { Spacer(minLength: 8) }
NodeFooter(site: site, node: node, live: models.live)
Expand Down
18 changes: 18 additions & 0 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,20 @@ public final class SimulatedBox {
Contract.FID.loadW: house.baseLoadW,
]
f[Contract.FID.evW] = house.evWatts
f.merge(phases) { $1 }
return f
}

/// The grid's phases as Core sends them, in tenths of an ampere and
/// signed by the site convention, under a 20 A fuse. The inverter and
/// the car spread over all three; the house leans on L1.
var phases: [Int: Double] {
let even = (house.batteryW + house.pvW + house.evWatts) / 3
let house = [0.45, 0.33, 0.22].map { $0 * self.house.baseLoadW }
var f = [Contract.FID.gridFuseDA: 200.0]
for (fid, load) in zip(Contract.FID.gridPhases, house) {
f[fid] = ((load + even) / 230 * 10).rounded()
}
return f
}

Expand All @@ -223,6 +237,10 @@ public final class SimulatedBox {
("5", .map([("name", .text("battery_soc")), ("unit", .text("permille")), ("srcId", .text("sungrow"))])),
("6", .map([("name", .text("load_w")), ("unit", .text("W")), ("srcId", .text("sdm630"))])),
("10", .map([("name", .text("ev_w")), ("unit", .text("W")), ("srcId", .text("easee"))])),
("11", .map([("name", .text("grid_l1_da")), ("unit", .text("dA")), ("srcId", .text("sdm630"))])),
("12", .map([("name", .text("grid_l2_da")), ("unit", .text("dA")), ("srcId", .text("sdm630"))])),
("13", .map([("name", .text("grid_l3_da")), ("unit", .text("dA")), ("srcId", .text("sdm630"))])),
("14", .map([("name", .text("grid_fuse_da")), ("unit", .text("dA")), ("srcId", .null)])),
]
sendBulk(Envelope(t: "snap", b: .map([
("uptimeMs", .number(uptimeMs)),
Expand Down
10 changes: 10 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,16 @@ public enum Contract {
public static let srcBattery = 9
/// Present only on a site with a charger.
public static let evW = 10

/// The registry's extra fields: depth for a node's own view, sent
/// only while the box has the reading. The grid's current on L1,
/// L2 and L3 in tenths of an ampere, signed by the site convention,
/// and the main fuse per phase as their scale.
public static let gridL1DA = 11
public static let gridL2DA = 12
public static let gridL3DA = 13
public static let gridFuseDA = 14
public static let gridPhases = [gridL1DA, gridL2DA, gridL3DA]
}

/// Whether a code is worth retrying, from the registry's `errors` and
Expand Down
81 changes: 81 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/GridPhases.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Foundation
import FTWShared

/// The grid's phases against the main fuse, from the box's extra fields 11
/// to 14.
///
/// Nil when the box sends no phase: a meter that gives none shows nothing,
/// never three idle meters. While the phases are not live the view keeps
/// their room and shows no number, because an old phase current says
/// nothing about the fuse now.
public struct GridPhases: Equatable, Sendable {
public struct Phase: Equatable, Sendable {
/// "L1", "L2", "L3".
public let name: String
/// The current's size in amps, nil while not live. The breaker
/// trips on size, whichever way the current flows.
public let amps: Double?
/// The share of the fuse, nil while not live or with no fuse.
public let ofFuse: Double?
}

public let phases: [Phase]
/// The main fuse per phase, in amps.
public let fuseA: Double?
/// The phase nearest the fuse, by its place in `phases`, while live.
public let nearest: Int?
public let live: Bool

public static func read(fields: [Int: Double], live: Bool) -> GridPhases? {
let present = Contract.FID.gridPhases.enumerated().compactMap { i, fid in
fields[fid].map { (name: "L\(i + 1)", deciamps: $0) }
}
guard !present.isEmpty else { return nil }
let fuseA = fields[Contract.FID.gridFuseDA].flatMap { $0 > 0 ? $0 / 10 : nil }
let phases = present.map { p -> Phase in
guard live else { return Phase(name: p.name, amps: nil, ofFuse: nil) }
let amps = abs(p.deciamps) / 10
return Phase(name: p.name, amps: amps, ofFuse: fuseA.map { amps / $0 })
}
// The first of equals: max(by:) keeps the earliest maximum.
let nearest = live && fuseA != nil ? phases.indices.max { (phases[$0].ofFuse ?? 0) < (phases[$1].ofFuse ?? 0) } : nil
return GridPhases(phases: phases, fuseA: fuseA, nearest: nearest, live: live)
}

/// "PHASES · FUSE 20 A", the row's kicker.
public var kicker: String {
guard let fuseA else { return NodeWords.text("PHASES") }
return NodeWords.format("PHASES · FUSE %@", Self.amps(fuseA, decimals: 0))
}

/// "6,0 A", or a dash while not live.
public func reading(_ phase: Phase) -> String {
phase.amps.map { Self.amps($0, decimals: 1) } ?? "—"
}

/// One sentence for VoiceOver: each phase, which one is nearest the
/// fuse, and the fuse.
public var spoken: String {
guard live else { return NodeWords.text("The phases are not live.") }
let each = phases.enumerated().map { i, p in
var s = p.name + " " + reading(p)
if i == nearest { s += ", " + NodeWords.text("nearest the fuse") }
return s
}
return each.joined(separator: "; ")
}

static func amps(_ a: Double, decimals: Int) -> String {
AppWords.decimal(String(format: "%.\(decimals)f", a)) + "\u{00A0}A"
}
}

extension SiteModel {
/// The grid's phases as the node view draws them, live by the site
/// meter's own freshness: the phases are its readings.
public var gridPhases: GridPhases? {
let fields = session.fields
guard let fid = Contract.FID.gridPhases.first(where: { fields[$0] != nil }) else { return nil }
return GridPhases.read(fields: fields, live: freshness(fid: fid).live)
}
}
17 changes: 10 additions & 7 deletions appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,19 +141,19 @@ public final class LiveTrace {
lastFrameAtMs = at
guard let stamp = site.readingStampMs else { return }
var live = [Int: Double]()
for (fid, v) in site.session.fields where !Self.notReadings.contains(fid) && site.freshness(fid: fid).live {
for (fid, v) in site.session.fields where !Self.untraced.contains(fid) && site.freshness(fid: fid).live {
live[fid] = v
}
guard !live.isEmpty else { return }
record(live, atMs: stamp, gapMs: site.readingGapMs)
}

/// One reading's values, taken by the box at `atMs`. The mode and the
/// source references are not readings and are not kept. A value no
/// newer than what the ring holds is not a new reading.
/// One reading's values, taken by the box at `atMs`. Only what a trace
/// draws is kept (`untraced`). A value no newer than what the ring
/// holds is not a new reading.
func record(_ fields: [Int: Double], atMs: Double, gapMs: Double = LiveTrace.gapMs) {
var any = false
for (fid, v) in fields where !Self.notReadings.contains(fid) {
for (fid, v) in fields where !Self.untraced.contains(fid) {
var ring = rings[fid] ?? RingBuffer(capacity: Self.capacity)
let last = ring.last
if let last, atMs <= last.t { continue }
Expand Down Expand Up @@ -200,7 +200,7 @@ public final class LiveTrace {
func restore(_ file: RingFile) {
var newest: Double?
for (key, lane) in file.lanes {
guard let fid = Int(key), !Self.notReadings.contains(fid), lane.dt.count == lane.v.count, !lane.v.isEmpty else { continue }
guard let fid = Int(key), !Self.untraced.contains(fid), lane.dt.count == lane.v.count, !lane.v.isEmpty else { continue }
var ring = RingBuffer<Point>(capacity: Self.capacity)
var t = lane.t0
for i in lane.v.indices {
Expand All @@ -214,7 +214,10 @@ public final class LiveTrace {
if let newest { resumed = Resumed(fromMs: newest, toMs: nil) }
}

nonisolated static let notReadings: Set<Int> = [Contract.FID.mode, Contract.FID.srcGrid, Contract.FID.srcPV, Contract.FID.srcBattery]
/// Not kept: the mode and the source references are not readings, the
/// fuse is config, and the phases are drawn as meters of now, never as
/// a trace.
nonisolated static let untraced: Set<Int> = Set([Contract.FID.mode, Contract.FID.srcGrid, Contract.FID.srcPV, Contract.FID.srcBattery, Contract.FID.gridFuseDA] + Contract.FID.gridPhases)

/// The time the app missed, while restored points still show in the
/// window that ends at `nowMs`.
Expand Down
5 changes: 5 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ public enum NodeWords {
"VIA FTW CLOUD": "VIA FTW CLOUD",
"SAVED ON THIS PHONE": "SPARAD PÅ TELEFONEN",
"NOT CONNECTED": "INTE ANSLUTEN",
// The grid's phases against the main fuse.
"PHASES": "FASER",
"PHASES · FUSE %@": "FASER · SÄKRING %@",
"nearest the fuse": "närmast säkringen",
"The phases are not live.": "Faserna är inte live.",
"READS EVERY %@ S": "LÄSER VAR %@ S",
"LAST 5 MIN": "SENASTE 5 MIN",
"LIVE · LAST 5 MIN": "LIVE · SENASTE 5 MIN",
Expand Down
13 changes: 13 additions & 0 deletions appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ import FTWDemo
}
}

@Test func extraFieldIDsAreTheRegistrys() {
typealias F = Contract.FID
let ids: [(Int, String)] = [
(F.gridL1DA, "grid_l1_da"), (F.gridL2DA, "grid_l2_da"), (F.gridL3DA, "grid_l3_da"),
(F.gridFuseDA, "grid_fuse_da"),
]
let extra = section("extra_fields").filter { $0.contains("{ name:") }
#expect(extra.count == ids.count)
for (id, name) in ids {
#expect(extra.contains { $0.trimmingCharacters(in: .whitespaces).hasPrefix("\(id): { name: \(name),") }, "\(id) \(name)")
}
}

/// The demo box is not the box, but it may not invent the box's names.
@Test func theDemoBoxSpeaksOnlyRegistryNames() {
let modes = Set(Contract.modes.map(\.key))
Expand Down
77 changes: 77 additions & 0 deletions appleApp/FTWKit/Tests/FTWKitTests/GridPhasesTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import Foundation
import Testing
@testable import FTWKit
import FTWShared

/// The grid's phases against the main fuse: what the node view draws from
/// the box's extra fields.
@Suite struct GridPhasesTests {
typealias F = Contract.FID

/// The home box on 14 October 2026: L1 and L3 sold while L2 bought,
/// under a 20 A fuse.
let home: [Int: Double] = [F.gridW: 112, F.gridL1DA: -37, F.gridL2DA: 60, F.gridL3DA: -29, F.gridFuseDA: 200]

@Test func eachPhaseIsItsSizeAgainstTheFuse() throws {
let p = try #require(GridPhases.read(fields: home, live: true))
#expect(p.phases.map(\.name) == ["L1", "L2", "L3"])
#expect(p.phases.map(\.amps) == [3.7, 6.0, 2.9])
#expect(p.fuseA == 20)
#expect(abs((p.phases[1].ofFuse ?? 0) - 0.3) < 1e-9)
#expect(p.nearest == 1)
}

/// A meter that gives no phases shows nothing: not three idle meters,
/// and not the fuse on its own.
@Test func noPhasesNoRow() {
#expect(GridPhases.read(fields: [F.gridW: 1200], live: true) == nil)
#expect(GridPhases.read(fields: [F.gridW: 1200, F.gridFuseDA: 200], live: true) == nil)
}

/// While not live the row keeps its room but shows no number: an old
/// phase current says nothing about the fuse now.
@Test func notLiveShowsNoNumber() throws {
let p = try #require(GridPhases.read(fields: home, live: false))
#expect(p.phases.count == 3)
#expect(p.phases.allSatisfy { $0.amps == nil && $0.ofFuse == nil })
#expect(p.nearest == nil)
#expect(p.phases.map(p.reading) == ["—", "—", "—"])
}

/// A one-phase site sends L1 alone, and the row draws one meter.
@Test func onlyThePhasesTheBoxSends() throws {
let p = try #require(GridPhases.read(fields: [F.gridL1DA: 95, F.gridFuseDA: 250], live: true))
#expect(p.phases.map(\.name) == ["L1"])
#expect(p.nearest == 0)
}

/// Two phases equally near: the first is marked, so the mark does not
/// flicker between them.
@Test func theFirstOfEqualsIsNearest() throws {
let p = try #require(GridPhases.read(fields: [F.gridL1DA: 40, F.gridL2DA: -60, F.gridL3DA: 60, F.gridFuseDA: 160], live: true))
#expect(p.nearest == 1)
}

/// Without a fuse there is no scale, so nothing is marked.
@Test func noFuseNoMark() throws {
let p = try #require(GridPhases.read(fields: [F.gridL1DA: 40], live: true))
#expect(p.fuseA == nil)
#expect(p.nearest == nil)
#expect(p.phases[0].ofFuse == nil)
#expect(p.phases[0].amps == 4)
}

@Test func wordsInSwedishAndEnglish() throws {
let p = try #require(GridPhases.read(fields: home, live: true))
AppWords.$language.withValue(.sv) {
#expect(p.kicker == "FASER · SÄKRING 20\u{00A0}A")
#expect(p.reading(p.phases[1]) == "6,0\u{00A0}A")
#expect(p.spoken == "L1 3,7\u{00A0}A; L2 6,0\u{00A0}A, närmast säkringen; L3 2,9\u{00A0}A")
#expect(GridPhases.read(fields: home, live: false)?.spoken == "Faserna är inte live.")
}
AppWords.$language.withValue(.en) {
#expect(p.kicker == "PHASES · FUSE 20\u{00A0}A")
#expect(p.reading(p.phases[0]) == "3.7\u{00A0}A")
}
}
}
1 change: 1 addition & 0 deletions appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,7 @@ import FTWShared
"appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift", "appleApp/FTW/UI/PriceView.swift",
"appleApp/FTW/UI/StatusLine.swift", "appleApp/FTW/UI/ControlSheet.swift", "appleApp/FTW/UI/ModeMenu.swift",
"appleApp/FTWKit/Sources/FTWKit/State/StatusLine.swift",
"appleApp/FTWKit/Sources/FTWKit/State/GridPhases.swift", "appleApp/FTW/UI/GridPhasesRow.swift",
]
let pattern = #/(?:\bL|NodeWords\.(?:text|format)|AppWords\.(?:text|format))\("((?:[^"\\]|\\.)*)"/#
var english = Set<String>()
Expand Down
Loading
Loading