diff --git a/CLAUDE.md b/CLAUDE.md index 40e5965..60cf710 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -331,8 +331,10 @@ strip, with gold for the battery giving power. - **The app expresses intent; the box decides.** - **Positive watts flow into the site, negative out.** The UI never shows a raw minus sign. -- **Lane 0 frames are byte-identical in length and constant in cadence.** - A test enforces this. +- **Lane 0 frames are byte-identical in length.** A test enforces this. + Over the relay they also keep a constant cadence. Through FTW Cloud the + box sends each reading as it lands, and a beat once per subscribed + period when nothing moves, so nothing may assume one frame a second. - **The cache is a cache, never the original.** - **The cache key is not PRF-wrapped.** Cold start paints before Face ID. PRF gates enrollment and privileged commands, not reading. diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index 26d28fa..f6d4d6d 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -278,7 +278,7 @@ final class NodeTraceModel { let resized = size.height != self.size.height self.size = size let series = built - frame = TraceFrame(series: series, previous: resized ? nil : frame?.series, arrivedAt: now) + frame = resized ? TraceFrame(series: series, previous: nil, arrivedAt: now) : TraceFrame(series: series, after: frame, arrivedAt: now) if playing, let last = series.last, series.domain.span > 0 { AXLiveAudioGraph.updateValue((last.v - series.domain.lower) / series.domain.span) } diff --git a/appleApp/FTW/Watch/WatchBridge.swift b/appleApp/FTW/Watch/WatchBridge.swift index 83cd3ce..1816296 100644 --- a/appleApp/FTW/Watch/WatchBridge.swift +++ b/appleApp/FTW/Watch/WatchBridge.swift @@ -183,8 +183,9 @@ final class WatchBridge: NSObject { lookTime = nil } - /// Now's readings to the watch as each frame lands, until the watch - /// looks away, stops being reachable, or says nothing for `lookMs`. + /// Now's readings to the watch as what it shows moves, and once a second + /// otherwise, until the watch looks away, stops being reachable, or says + /// nothing for `lookMs`. private func forwardNow(_ home: HomeModels) async { Self.log.info("watch looks: sending Now") let frames = FrameWatch(site: home.site) @@ -194,18 +195,19 @@ final class WatchBridge: NSObject { frames.stop() done() } - var lastAt: Double? // The reply to the look: the last three minutes first, so the trace // opens whole, then Now as it stands. send(news: .recent(WatchShare.recent(home, nowMs: nowMs))) - send(news: WatchShare.now(home).map(WatchNews.now)) + var last = WatchShare.now(home) + send(news: last.map(WatchNews.now)) forwardedAtMs = nowMs // Ends when the look is let go; a watch out of reach meanwhile gets // nothing, and its reachability ends the look. for await _ in frames.stream { guard session?.isReachable == true else { continue } - guard let record = WatchShare.now(home), record.readAtMs != lastAt else { continue } - lastAt = record.readAtMs + // At most a message a second unless what the wrist shows moved. + guard let record = WatchShare.now(home), WatchShare.worthSending(record, after: last) else { continue } + last = record send(news: .now(record)) forwardedAtMs = nowMs } diff --git a/appleApp/FTWCharts/LiveTrace.swift b/appleApp/FTWCharts/LiveTrace.swift index ff79915..9ff92f1 100644 --- a/appleApp/FTWCharts/LiveTrace.swift +++ b/appleApp/FTWCharts/LiveTrace.swift @@ -44,6 +44,18 @@ struct TraceFrame: Equatable { var rest = Path() } + /// The frame after `old`. Its slide starts where `old` draws its head + /// now, so a reading that lands while the last one still slides carries + /// on from there rather than jumping to where that slide was headed. + init(series: LiveSeries, after old: TraceFrame?, arrivedAt: Date) { + var from = old?.series + if let old, let before = old.previous { + let progress = TraceInk.ease(arrivedAt.timeIntervalSince(old.arrivedAt), TraceInk.slide) + if progress < 1 { from = old.series.midSlide(from: before, progress: progress) } + } + self.init(series: series, previous: from, arrivedAt: arrivedAt) + } + init(series: LiveSeries, previous: LiveSeries?, arrivedAt: Date) { self.series = series self.previous = previous diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/TunnelCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/TunnelCarrier.swift index f882cd1..91d808b 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/TunnelCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/TunnelCarrier.swift @@ -7,7 +7,9 @@ import FTWShared /// Cloud hands it to the box under the device its own handshake proved. `d` /// is the box session byte for byte, Noise to the box's key, so FTW Cloud /// can drop or delay a frame and can neither read nor forge one. Lane 0 -/// keeps its padding and cadence inside it. +/// keeps its padding inside it. The box sends each reading as it lands +/// rather than on a one-second pulse, and a beat when nothing moves for +/// the subscribed period. /// /// One socket instead of two: the box session's message 1 waits for the app /// session's message 1 and rides inside it, so the box session is up one diff --git a/appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift b/appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift index 5e3634e..7da97ba 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift @@ -12,7 +12,10 @@ import Foundation public final class NoiseTransport { public static let seqBytes = 8 public static let overhead = seqBytes + Noise.tagBytes - /// At 1 Hz on lane 0, 64 frames is a minute of tolerance and one word. + /// One word. Over the relay lane 0 goes once a second, so 64 frames is + /// a minute; through FTW Cloud up to five a second, about 13 s, while + /// the box's link holds a paced bulk frame at most about 9 s (srcfl/ftw + /// `cloudlink.MaxBulkWait`). public static let replayWindow: UInt64 = 64 public let handshakeHash: Bytes diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift b/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift index 4a9a646..1208128 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift @@ -90,9 +90,16 @@ public final class LiveTrace { /// What a node's live curve shows. public nonisolated static let windowMs: Double = 5 * 60_000 - /// Ten minutes at 1 Hz. Hidden, at 0.2 Hz, the same room lasts longer, - /// and the window still shows only the last five minutes. - nonisolated static let capacity = 600 + /// Five minutes at the box's fastest: through FTW Cloud it sends each + /// reading as it lands, at most five frames a second. A slower stream + /// fills the same room more slowly, and the window still shows only the + /// last five minutes. + nonisolated static let capacity = 1_500 + /// A value the ring already holds, read again within this, adds no + /// point: a frame that carries another device's reading is not a new + /// reading of this one. A slow device still repeats once a second, as + /// it did when the box streamed at 1 Hz. + nonisolated static let repeatMs: Double = 1_000 /// Past this a silence in a 1 Hz stream is a gap: it misses a beat now /// and then, and three are a stream that stopped. Each source says its /// own (`SiteModel.readingGapMs`); this is the box's stream in front. @@ -105,8 +112,8 @@ public final class LiveTrace { public var toMs: Double? } - /// Moves once per recorded reading, so a curve redraws once a second - /// and nothing else does. + /// Moves once per recorded reading, so a curve redraws when a value + /// moves, or once a second while none does, and nothing else does. public private(set) var revision = 0 /// Nil when nothing was restored at launch. public private(set) var resumed: Resumed? @@ -157,6 +164,7 @@ public final class LiveTrace { var ring = rings[fid] ?? RingBuffer(capacity: Self.capacity) let last = ring.last if let last, atMs <= last.t { continue } + if let last, !last.restored, last.v == v, atMs - last.t < Self.repeatMs { continue } let run: Int if let last, !last.restored, atMs - last.t <= holds[fid, default: gapMs] { run = last.run @@ -256,7 +264,8 @@ public final class LiveTrace { /// The live rings as `ring-` keeps them: per field, times as steps in /// whole ms after the first, the values, and where each run starts. About -/// 15 KB for five minutes of Now's fields. +/// 15 KB for five minutes of Now's fields read once a second, and up to five +/// times that when a fast driver moves them five times a second. struct RingFile: Codable, Equatable, Sendable { static let version = 1 diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index ee734a4..cea550a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -29,8 +29,9 @@ private let askLog = Logger(subsystem: "energy.ftw.app", category: "asks") public final class SiteModel { /// Fields the Now screen draws. Their sources drive the freshness band. static let nowFids = [Contract.FID.gridW, Contract.FID.pvW, Contract.FID.batteryW, Contract.FID.batterySoc, Contract.FID.loadW, Contract.FID.evW] - /// A 1 Hz stream is always a moment behind and a dropped tick is normal - /// on a phone; past a couple of beats the silence is real. + /// The box beats at least once a second, and through FTW Cloud sends + /// each reading between beats as it lands. A dropped frame is normal on + /// a phone; past a couple of beats the silence is real. static let streamQuietAfterMs: Double = 3_000 /// How long a foreground stream gets to prove its socket still works. public static let foregroundFrameDeadlineMs: Double = 2_500 @@ -51,9 +52,9 @@ public final class SiteModel { /// FTW Cloud: the send time rule U gives. The live trace stamps by it. @ObservationIgnored public private(set) var readingStampMs: Double? /// How long the newest reading stands before a silence is a gap. The - /// box streams at 1 Hz in front and 0.2 Hz hidden, and misses a beat - /// now and then; FTW Cloud sends on change and a heartbeat at least - /// every H. + /// box beats at least once a second in front and every 5 s hidden, and + /// misses a beat now and then; FTW Cloud's copy sends on change and a + /// heartbeat at least every H. @ObservationIgnored public private(set) var readingGapMs: Double = LiveTrace.gapMs public private(set) var documentVisible = true /// The app itself is on screen; `documentVisible` also counts a watch. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift index cc857ad..4343d96 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift @@ -135,6 +135,21 @@ public enum WatchShare { return CacheRecord(siteId: key ?? homeKey(home), carrier: site.carrier, srcState: site.isLive ? .live : .stale, readAtMs: at, value: WatchNow(readings: readings, control: control, standsMs: site.readingGapMs)) } + /// Whether Now is worth a message to the watch after `last`: what the + /// wrist shows moved (a figure, a word, a level, the glow or the + /// answer to "in control"), or a second has passed. Through FTW Cloud + /// frames come as readings land, up to five a second; the watch draws + /// about a point a second, and its radio is the cost. + public static func worthSending(_ next: CacheRecord, after last: CacheRecord?) -> Bool { + guard let last else { return true } + guard next.readAtMs > last.readAtMs else { return false } + if next.carrier != last.carrier || next.srcState != last.srcState || next.value.control != last.value.control + || WatchGlance.figures(next.value, lit: true) != WatchGlance.figures(last.value, lit: true) { + return true + } + return next.readAtMs - last.readAtMs >= LiveSample.repeatMs + } + static func reading(_ r: NodeReading) -> WatchNow.Reading { let way: WatchNow.Way switch r.node { diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift index 2e3cb46..1146cac 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift @@ -15,6 +15,21 @@ public struct LiveSample: Equatable, Sendable { self.v = v self.seen = seen } + + /// Through FTW Cloud frames come as readings land, up to five a second, + /// and most carry another device's news. A value a trace holds, read + /// again within this, adds no point; a device that did not move still + /// repeats once a second, as at 1 Hz. The phone's ring keeps the same + /// rule (FTWKit `LiveTrace.repeatMs`). + public static let repeatMs: Double = 1_000 + + /// Whether `next` adds a point after `last`: it is newer, and its value + /// or sight moved, or a second has passed. + public static func adds(_ next: LiveSample, after last: LiveSample?) -> Bool { + guard let last else { return true } + guard next.t > last.t else { return false } + return next.v != last.v || next.seen != last.seen || next.t - last.t >= repeatMs + } } /// The live trace's geometry, built once per reading off the main actor. @@ -419,6 +434,16 @@ extension LiveSeries { return out } + /// This series as its slide from `old` draws it at `progress`: the last + /// run where the slide has it now. A reading that lands mid-slide slides + /// on from here, instead of jumping to where the last slide was headed. + public func midSlide(from old: LiveSeries, progress: Double) -> LiveSeries { + guard let curve = sliding(from: old, progress: progress), !curves.isEmpty else { return self } + var s = self + s.curves[s.curves.count - 1] = curve + return s + } + /// A curve as line and body paths in this geometry, for one frame of a /// slide: the same split by sight as a built run. public func paths(_ curve: [Point]) -> (line: ChartPath, lineUnseen: ChartPath, body: ChartPath, bodyUnseen: ChartPath) { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LiveLatencyTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LiveLatencyTests.swift new file mode 100644 index 0000000..1a90b3a --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/LiveLatencyTests.swift @@ -0,0 +1,114 @@ +import Foundation +import Security +import Testing +@testable import FTWKit + +/// How long a reading takes from the box's driver to this session's decode, +/// against a real `ftwcloudd` and the box srcfl/ftw runs as +/// `TestLiveBoxForTheiPhone` (go/internal/appuplink), whose grid reading is +/// the wall-clock millisecond it arrived. Off unless the environment names +/// them: +/// +/// FTW_LIVE_LATENCY_DIR=/path the box's directory: pair.url, +/// noise.pub and ca.pem; decode.log and +/// open.log are written here +/// FTW_LIVE_CLOUD_URL=wss://127.0.0.1:18843/v1/app +/// FTW_LIVE_LATENCY_SECONDS=120 per phase, visible and hidden +/// +/// Each decoded grid reading is logged with the wall clock in microseconds, +/// so a script pairs it with the box's own log of arrivals. +@MainActor +@Suite(.enabled(if: ProcessInfo.processInfo.environment["FTW_LIVE_LATENCY_DIR"] != nil), .serialized) +struct LiveLatencyTests { + let env = ProcessInfo.processInfo.environment + var dir: URL { URL(fileURLWithPath: env["FTW_LIVE_LATENCY_DIR"]!) } + + func read(_ name: String) throws -> String { + try String(contentsOf: dir.appendingPathComponent(name), encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// The grid readings this session decoded, and when. + @MainActor final class Decodes { + var phase = "pair" + var last: Double? + var lines: [String] = [] + + func watch(_ core: Session) { + let inner = core.onChange + core.onChange = { [self] s in + if s.carrier == .tunnel, let v = s.fields[2], v != last { + last = v + lines.append("\(Int64(Date().timeIntervalSince1970 * 1_000_000)) \(Int64(v)) \(phase)") + } + inner?(s) + } + } + } + + @Test func readingsFromTheDriverToTheDecode() async throws { + let pem = try read("ca.pem") + let der = try #require(Data(base64Encoded: pem.split(separator: "\n").filter { !$0.hasPrefix("-----") }.joined())) + let anchor = try #require(SecCertificateCreateWithData(nil, der as CFData)) + let endpoint = CloudEndpoint(url: URL(string: env["FTW_LIVE_CLOUD_URL"] ?? "wss://127.0.0.1:18843/v1/app")!, + serviceKeys: [try Base64url.decode(read("noise.pub"))]) + let relayURL = URL(string: "wss://127.0.0.1:9")! + let sockets: WebSocketFactory = { url, events in + url.port == relayURL.port + ? URLSessionWebSocket.factory()(url, events) + : PinnedWebSocket(url: url, events: events, anchor: anchor) + } + let store = MemoryStore() + let files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-live-latency-\(UUID().uuidString)"), store: store) + func model() -> AppModel { + AppModel(store: store, files: files, passkeys: FakePasskeys(), scheduler: LiveScheduler(), + build: "live-latency-test", ua: "test", relayURL: relayURL, cloudEndpoint: endpoint, makeSocket: sockets) + } + let app = model() + await PairModel(app: app).pair(try read("pair.url")) + let site = try #require(app.site) + _ = try await until { site.session.phase == .streaming && site.carrier == .tunnel } + + let seconds = UInt64(env["FTW_LIVE_LATENCY_SECONDS"] ?? "120") ?? 120 + let decodes = Decodes() + decodes.watch(site.core) + decodes.phase = "visible" + try await Task.sleep(nanoseconds: seconds * 1_000_000_000) + decodes.phase = "hidden" + site.setVisible(false) + try await Task.sleep(nanoseconds: seconds * 1_000_000_000) + try decodes.lines.joined(separator: "\n").write(to: dir.appendingPathComponent("decode.log"), atomically: true, encoding: .utf8) + print("latency: \(decodes.lines.count) grid readings decoded") + + // Cold opens: a new app over the same store, from launch to the + // first reading the box sent through FTW Cloud. + app.suspend() + var opens: [String] = [] + for i in 0..<10 { + try await Task.sleep(nanoseconds: 3_000_000_000) + let cold = model() + let t0 = Date() + cold.launch() + let s = try #require(cold.site) + let firstLive = Decodes() + firstLive.phase = "open" + firstLive.watch(s.core) + let streaming = try await until { s.session.phase == .streaming && s.carrier == .tunnel } + let live = try await until { !firstLive.lines.isEmpty } + opens.append("\(i) \(Int((streaming) * 1000)) \(Int((Date().timeIntervalSince(t0)) * 1000)) \(Int(live * 1000))") + cold.suspend() + } + try opens.joined(separator: "\n").write(to: dir.appendingPathComponent("open.log"), atomically: true, encoding: .utf8) + print("latency: cold open (i, to streaming ms, to first new reading ms):\n\(opens.joined(separator: "\n"))") + try? "done".write(to: dir.appendingPathComponent("stop"), atomically: true, encoding: .utf8) + } + + /// Wait for `ok`, polling every 1 ms, and return how long it took. + func until(_ seconds: Double = 20, _ ok: () -> Bool) async throws -> Double { + let start = Date() + while !ok() { + if Date().timeIntervalSince(start) > seconds { throw CancellationError() } + try await Task.sleep(nanoseconds: 1_000_000) + } + return Date().timeIntervalSince(start) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift index b2edd9a..d480684 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift @@ -52,13 +52,34 @@ import FTWShared } @MainActor - @Test func theRingHoldsTenMinutesAndNoMore() { + @Test func theRingHoldsFiveMinutesAtTheFastestAndNoMore() { let trace = LiveTrace() - for i in 0..<(LiveTrace.capacity + 50) { trace.record([Contract.FID.gridW: Double(i)], atMs: Double(i) * 1000) } + // Five frames a second, the most the box sends through FTW Cloud. + for i in 0..<(LiveTrace.capacity + 50) { trace.record([Contract.FID.gridW: Double(i)], atMs: Double(i) * 200) } let points = trace.points(Contract.FID.gridW, sinceMs: 0) #expect(points.count == LiveTrace.capacity) #expect(points.first?.v == 50) #expect(points.last?.v == Double(LiveTrace.capacity + 49)) + #expect(points.last!.t - points.first!.t >= LiveTrace.windowMs - 200) + } + + /// Frames come as readings land, so most carry another device's news. + /// A device whose value did not move repeats once a second, as it did + /// at 1 Hz, and its curve keeps one run. + @MainActor + @Test func aFrameThatCarriesAnotherDevicesReadingAddsNoPoint() { + let trace = LiveTrace() + let t0 = 1_000_000.0 + for i in 0..<50 { + trace.record([Contract.FID.gridW: Double(1000 + i), Contract.FID.pvW: -2000], atMs: t0 + Double(i) * 200) + } + #expect(trace.points(Contract.FID.gridW, sinceMs: 0).count == 50) + let pv = trace.points(Contract.FID.pvW, sinceMs: 0) + #expect(pv.count == 10) + #expect(Set(LiveTrace.runs(pv).map(\.run)).count == 1) + // A move goes in at once, whatever the last point's age. + trace.record([Contract.FID.pvW: -2100], atMs: t0 + 50 * 200) + #expect(trace.points(Contract.FID.pvW, sinceMs: 0).last?.v == -2100) } @MainActor diff --git a/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift index d883506..17a010a 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift @@ -193,4 +193,24 @@ import FTWShared await rig.run(6_000) #expect(await frames(over: 20_000) <= 6) } + + /// Through FTW Cloud frames come up to five a second. The watch hears + /// one when what it shows moves, and otherwise once a second. + @Test func theWatchHearsAMoveAtOnceAndOtherwiseOnceASecond() { + func now(_ watts: Double, at t: Double, live: Bool = true) -> CacheRecord { + CacheRecord(siteId: "s", carrier: .tunnel, srcState: .live, readAtMs: t, + value: WatchNow(readings: [.init(node: .grid, watts: watts, way: .buying, live: live)], control: .inControl)) + } + let last = now(1_234, at: 10_000) + // 1.2 kW either way: nothing the wrist shows moved. + #expect(!WatchShare.worthSending(now(1_236, at: 10_200), after: last)) + #expect(WatchShare.worthSending(now(1_236, at: 11_000), after: last)) + // 1.7 kW: a move goes at once. + #expect(WatchShare.worthSending(now(1_650, at: 10_200), after: last)) + // The glow going out is a move too. + #expect(WatchShare.worthSending(now(1_234, at: 10_200, live: false), after: last)) + #expect(!WatchShare.worthSending(now(1_650, at: 9_000), after: last)) + #expect(WatchShare.worthSending(last, after: nil)) + } + } diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/LiveOnArrivalTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/LiveOnArrivalTests.swift new file mode 100644 index 0000000..fa51b58 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/LiveOnArrivalTests.swift @@ -0,0 +1,41 @@ +import CoreGraphics +import FTWShared +import Testing + +/// Traces fed by frames that come as readings land through FTW Cloud, up to +/// five a second, most of them carrying another device's news. +@Suite struct LiveOnArrivalTests { + @Test func aRepeatedValueAddsAPointOnlyOnceASecond() { + let last = LiveSample(t: 10_000, v: 1_200) + #expect(!LiveSample.adds(LiveSample(t: 10_200, v: 1_200), after: last)) + #expect(LiveSample.adds(LiveSample(t: 10_200, v: 1_210), after: last)) + #expect(LiveSample.adds(LiveSample(t: 10_200, v: 1_200, seen: false), after: last)) + #expect(LiveSample.adds(LiveSample(t: 11_000, v: 1_200), after: last)) + #expect(!LiveSample.adds(LiveSample(t: 10_000, v: 1_300), after: last)) + #expect(LiveSample.adds(LiveSample(t: 0, v: 0), after: nil)) + } + + /// A reading that lands while the last one still slides starts its own + /// slide where the head is drawn, never where the last slide was going. + @Test func aReadingMidSlideSlidesOnFromWhereTheHeadIs() throws { + let t0 = 1_000_000.0 + var samples = (0..<10).map { LiveSample(t: t0 + Double($0) * 1_000, v: 1_000 + Double($0 % 2) * 300) } + func build(_ s: [LiveSample]) -> LiveSeries { + LiveSeries.build(s, sourceGapMs: 3_500, windowMs: 300_000, nowMs: s.last!.t, size: CGSize(width: 300, height: 100), floor: 3_000) + } + let a = build(samples) + samples.append(LiveSample(t: t0 + 10_000, v: 2_000)) + let b = build(samples) + samples.append(LiveSample(t: t0 + 10_200, v: 500)) + let c = build(samples) + try #require(a.domain == b.domain && b.domain == c.domain) + + let drawn = try #require(b.sliding(from: a, progress: 0.4)?.last) + let start = try #require(c.sliding(from: b.midSlide(from: a, progress: 0.4), progress: 0)?.last) + #expect(abs(start.t - drawn.t) < 0.001 && abs(start.v - drawn.v) < 0.001) + + // From the last slide's end instead, the head would jump. + let jump = try #require(c.sliding(from: b, progress: 0)?.last) + #expect(abs(jump.v - drawn.v) > 1) + } +} diff --git a/appleApp/FTWWatch/WatchTrace.swift b/appleApp/FTWWatch/WatchTrace.swift index 8146ae1..69b8103 100644 --- a/appleApp/FTWWatch/WatchTrace.swift +++ b/appleApp/FTWWatch/WatchTrace.swift @@ -38,15 +38,21 @@ final class WatchTrace { } /// One frame from the phone. `lit` says whether it came live just now. + /// A frame the trace already has, an older one, or one that only + /// repeats a value within a second adds nothing (`LiveSample.adds`), and + /// the trace is built again only when a point came or the glow changed. func add(_ record: CacheRecord, lit: Bool) { + var added = false for r in record.value.readings { guard let v = r.signedW else { continue } var run = samples[r.node, default: []] - // A frame the trace already has, or an older one, adds nothing. - if let last = run.last, last.t >= record.readAtMs { continue } - run.append(LiveSample(t: record.readAtMs, v: v, seen: lit && r.live)) + let sample = LiveSample(t: record.readAtMs, v: v, seen: lit && r.live) + guard LiveSample.adds(sample, after: run.last) else { continue } + run.append(sample) samples[r.node] = Self.cut(run, to: record.readAtMs) + added = true } + guard added || live != lit else { return } live = lit rebuild(keepScale: true) } @@ -123,7 +129,7 @@ final class WatchTrace { previous: keepScale ? frame?.series.domain : nil, floor: node.floorW ) - frame = TraceFrame(series: series, previous: keepScale ? frame?.series : nil, arrivedAt: now) + frame = keepScale ? TraceFrame(series: series, after: frame, arrivedAt: now) : TraceFrame(series: series, previous: nil, arrivedAt: now) } /// Each node's colours, as on the phone: one hue each side of zero; the