diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadShapeDraft.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadShapeDraft.swift new file mode 100644 index 0000000..da5685f --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadShapeDraft.swift @@ -0,0 +1,79 @@ +import Foundation + +/// A local study of pulling a future household-load curve. This value has +/// no transport or planner side effects. Each gesture starts from a copy; +/// every move applies its total displacement to that same copy. +public struct LoadShapeDraft: Equatable, Sendable { + public struct Slot: Equatable, Sendable { + public let startMs: Double + public let endMs: Double + public let baseWatts: Double + public private(set) var targetWatts: Double? + public var watts: Double { targetWatts ?? baseWatts } + + public init(startMs: Double, endMs: Double, baseWatts: Double) { + self.startMs = startMs + self.endMs = endMs + self.baseWatts = baseWatts + } + + fileprivate mutating func set(_ watts: Double) { + targetWatts = watts == baseWatts ? nil : watts + } + } + + public enum Invalid: Error { case slots, pull } + public private(set) var slots: [Slot] + + /// Missing intervals stay missing. Input must be in time order and + /// must contain finite, nonnegative interval means. + public init(slots: [Slot]) throws { + var previousEnd = -Double.infinity + for slot in slots { + guard slot.startMs.isFinite, slot.endMs.isFinite, + slot.endMs > slot.startMs, + (slot.endMs - slot.startMs).isFinite, + slot.startMs >= previousEnd, + slot.baseWatts.isFinite, slot.baseWatts >= 0 else { throw Invalid.slots } + previousEnd = slot.endMs + } + self.slots = slots + } + + /// Pull nearby interval means by a smooth weight with finite support. + /// `radiusMs` comes from the visible scale, frozen with the y scale + /// during the gesture. The current interval and all past ones stay put. + public func pulling(atMs: Double, byWatts: Double, radiusMs: Double, nowMs: Double) throws -> Self { + guard atMs.isFinite, byWatts.isFinite, radiusMs.isFinite, + radiusMs > 0, nowMs.isFinite, atMs >= nowMs else { throw Invalid.pull } + var result = self + for i in slots.indices where slots[i].startMs >= nowMs { + let middle = slots[i].startMs + (slots[i].endMs - slots[i].startMs) / 2 + let distance = abs(middle - atMs) / radiusMs + guard distance < 1 else { continue } + let edge = 1 - distance * distance + let watts = slots[i].watts + byWatts * edge * edge * edge + guard watts.isFinite else { throw Invalid.pull } + result.slots[i].set(max(0, watts)) + } + return result + } + + /// Only complete future intervals are submitted. A draft may have + /// stayed open while the box's clock moved into one of its intervals. + public func changes(nowMs: Double) -> [Slot] { + guard nowMs.isFinite else { return [] } + return slots.filter { $0.startMs >= nowMs && $0.targetWatts != nil } + } + + /// Reset the selected future intervals to this draft's base forecast. + /// The caller reads the current base from Core after a real reset. + public func resetting(in range: Range, nowMs: Double) -> Self { + guard range.lowerBound.isFinite, range.upperBound.isFinite, nowMs.isFinite else { return self } + var result = self + for i in slots.indices where slots[i].startMs >= nowMs && slots[i].startMs < range.upperBound && slots[i].endMs > range.lowerBound { + result.slots[i].set(slots[i].baseWatts) + } + return result + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LoadShapeDraftTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LoadShapeDraftTests.swift new file mode 100644 index 0000000..a1bab58 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/LoadShapeDraftTests.swift @@ -0,0 +1,125 @@ +import Foundation +import Testing +@testable import FTWKit + +@Suite struct LoadShapeDraftTests { + private let quarter = 900_000.0 + + private func draft(_ values: [Double], fromMs: Double = 0) throws -> LoadShapeDraft { + try LoadShapeDraft(slots: values.enumerated().map { i, watts in + .init(startMs: fromMs + Double(i) * quarter, + endMs: fromMs + Double(i + 1) * quarter, baseWatts: watts) + }) + } + + @Test func raisesAZeroColdStartInWatts() throws { + let base = try draft([0, 0, 0]) + let raised = try base.pulling(atMs: 1.5 * quarter, byWatts: 500, radiusMs: quarter, nowMs: 0) + #expect(raised.slots.map(\.watts) == [0, 500, 0]) + #expect(base.slots.map(\.watts) == [0, 0, 0]) + } + + @Test func nearbyIntervalsMoveLessAndDistantOnesStayPut() throws { + let base = try draft(Array(repeating: 1_000, count: 7)) + let changed = try base.pulling(atMs: 3.5 * quarter, byWatts: 800, radiusMs: 2 * quarter, nowMs: 0) + #expect(changed.slots.map(\.watts) == [1_000, 1_000, 1_337.5, 1_800, 1_337.5, 1_000, 1_000]) + } + + @Test func pastAndInFlightIntervalsStayPut() throws { + let base = try draft([700, 800, 900]) + let changed = try base.pulling(atMs: 1.5 * quarter, byWatts: -500, radiusMs: 3 * quarter, nowMs: quarter + 1) + #expect(changed.slots[0] == base.slots[0]) + #expect(changed.slots[1] == base.slots[1]) + #expect(changed.slots[2].watts < 900) + } + + @Test func aDownwardPullStopsAtZeroAndKeepsTheBase() throws { + let changed = try draft([200]).pulling(atMs: quarter / 2, byWatts: -600, radiusMs: quarter, nowMs: 0) + #expect(changed.slots[0].watts == 0) + #expect(changed.slots[0].baseWatts == 200) + #expect(changed.changes(nowMs: 0).count == 1) + } + + @Test func returningTheFingerToItsStartRestoresTheGestureSnapshot() throws { + let before = try draft([800, 1_000, 800]) + // The UI replaces its preview from `before` on every event. + for delta in [100.0, 350, -200] { + _ = try before.pulling(atMs: 1.5 * quarter, byWatts: delta, radiusMs: 2 * quarter, nowMs: 0) + } + let returned = try before.pulling(atMs: 1.5 * quarter, byWatts: 0, radiusMs: 2 * quarter, nowMs: 0) + #expect(returned == before) + #expect(returned.changes(nowMs: 0).isEmpty) + } + + @Test func aLaterGestureCanReshapeAnExistingEditAndUndoIsAValue() throws { + let base = try draft([800]) + let first = try base.pulling(atMs: quarter / 2, byWatts: 500, radiusMs: quarter, nowMs: 0) + let second = try first.pulling(atMs: quarter / 2, byWatts: -300, radiusMs: quarter, nowMs: 0) + #expect(second.slots[0].watts == 1_000) + #expect(first.slots[0].watts == 1_300) + #expect(base.slots[0].watts == 800) + } + + @Test func resetOnlyClearsEditsInTheSelectedFutureWindow() throws { + let base = try draft([500, 500, 500, 500]) + let changed = try base.pulling(atMs: 2 * quarter, byWatts: 400, radiusMs: 4 * quarter, nowMs: 0) + let reset = changed.resetting(in: quarter..<(3 * quarter), nowMs: quarter) + #expect(reset.slots[0] == changed.slots[0]) + #expect(reset.slots[1] == base.slots[1]) + #expect(reset.slots[2] == base.slots[2]) + #expect(reset.slots[3] == changed.slots[3]) + } + + @Test func anExpiredEditIsNeverSubmitted() throws { + let changed = try draft([500, 500]).pulling(atMs: quarter, byWatts: 400, radiusMs: 2 * quarter, nowMs: 0) + #expect(changed.changes(nowMs: 1).map(\.startMs) == [quarter]) + #expect(changed.changes(nowMs: 2 * quarter).isEmpty) + } + + @Test func gapsStayMissing() throws { + let base = try LoadShapeDraft(slots: [ + .init(startMs: 0, endMs: quarter, baseWatts: 500), + .init(startMs: 3 * quarter, endMs: 4 * quarter, baseWatts: 500), + ]) + let changed = try base.pulling(atMs: 2 * quarter, byWatts: 300, radiusMs: 4 * quarter, nowMs: 0) + #expect(changed.slots.count == 2) + #expect(changed.slots.map(\.startMs) == [0, 3 * quarter]) + } + + @Test func repeatedLocalHoursKeepDistinctUtcIntervals() throws { + let start = ISO8601DateFormatter().date(from: "2026-10-25T00:00:00Z")!.timeIntervalSince1970 * 1_000 + let base = try draft(Array(repeating: 500, count: 8), fromMs: start) + let changed = try base.pulling(atMs: start + 4.5 * quarter, byWatts: 300, radiusMs: quarter, nowMs: start) + #expect(changed.changes(nowMs: start).map(\.startMs) == [start + 4 * quarter]) + #expect(changed.slots[0].watts == 500) + #expect(changed.slots[4].watts == 800) + } + + @Test func invalidSlotsAreRejected() { + for slots: [LoadShapeDraft.Slot] in [ + [.init(startMs: 0, endMs: 1, baseWatts: -.infinity)], + [.init(startMs: 0, endMs: 1, baseWatts: -1)], + [.init(startMs: .nan, endMs: 1, baseWatts: 100)], + [.init(startMs: 1, endMs: 1, baseWatts: 100)], + [.init(startMs: 0, endMs: .infinity, baseWatts: 100)], + [.init(startMs: 0, endMs: 2, baseWatts: 100), .init(startMs: 1, endMs: 3, baseWatts: 100)], + ] { + #expect(throws: LoadShapeDraft.Invalid.slots) { try LoadShapeDraft(slots: slots) } + } + } + + @Test func invalidPullsAndOverflowAreRejectedWithoutChangingTheDraft() throws { + let base = try draft([Double.greatestFiniteMagnitude]) + for (at, delta, radius, now): (Double, Double, Double, Double) in [ + (.nan, 100, quarter, 0), (quarter / 2, .infinity, quarter, 0), + (quarter / 2, 100, 0, 0), (quarter / 2, 100, .infinity, 0), + (quarter / 2, 100, quarter, .nan), (-1, 100, quarter, 0), + (quarter / 2, .greatestFiniteMagnitude, quarter, 0), + ] { + #expect(throws: LoadShapeDraft.Invalid.pull) { + try base.pulling(atMs: at, byWatts: delta, radiusMs: radius, nowMs: now) + } + } + #expect(base.changes(nowMs: 0).isEmpty) + } +}