flux branch T-1495/dashboard-simulation commits 4 (ahead of origin/main) files 40 changed lines +4448 / -59

Pre-push review: T-1495 Dashboard Simulation

Four unpushed commits implementing the Dashboard Simulation feature (T-1495), reviewed across reuse, quality, efficiency, and spec adherence.

At a glance

  • Adds a Dashboard "Simulate" control that applies a saved load preset (e.g. Charge car at 1700 W) as a clearly-labelled, read-only what-if on iOS and macOS — no device is ever touched.

  • The what-if is computed server-side via a simulateLoadWatts parameter on GET /status, so simulated and real figures share one calculation and cannot diverge (Decision 6).

  • Added load is allocated by a priority waterfall — reduce grid export, then battery up to the 5 kW inverter ceiling, then grid import — keeping the live trio energy-balanced (Decision 14).

  • Presets are a new system-wide CRUD resource (/simulation-presets, flux-simulation-presets table) modelled on /pricing, synced across devices, with a 20-preset cap and 1..20000 W validation on both client and server.

  • A distinct simulation accent + banner, suppressed off-peak indicator, skipped widget cache, and transient (cold-launch-reset) active state make the simulation unmistakable and self-contained. 40 files; backend + client + tests, verified on Go, iOS, and macOS.

Verdict

Ready to push (after this review's fixes)

No blockers. Four review findings were fixed and committed; the waterfall load-allocation math was independently verified correct. The branch was rebased cleanly onto origin/main (picking up #65) and is 4 linear commits ahead. Verified: Go build + API tests, full iOS suite, scoped macOS tests for the changed view models (incl. the one that flaked under load, green in isolation) plus a macOS app compile, and SwiftLint --strict.

One environment caveat (not a code regression): the full macOS suite exceeds the 10-minute sandbox cap, and KeychainAccessibilityMigratorTests fails on the headless macOS host (passes on the iOS simulator; this branch touches no keychain code).

Review findings

8 raised · 4 fixed · 4 skipped

Jump to findings →

Commits

Three-level explanation

The Dashboard normally shows your battery's real numbers — how much the house is using, how fast the battery is draining, and roughly when it will be empty. Dashboard Simulation lets you ask a what-if — "what if I charged the car right now?" — and see the answer on the same screen without turning anything on.

You save scenarios called presets in Settings: a label plus a wattage, like Charge car at 1700 W. On the Dashboard a new Simulate wand button lets you pick one; the figures change to show the picture as if that load were running, a coloured banner appears across the top, and the affected numbers change colour so you never mistake them for real readings. Tap Stop (or relaunch the app) and everything returns to reality.

  • Read-only: nothing is ever sent to the battery; only what you see changes.
  • Synced: presets live on the server, so they appear on every device and survive a reinstall.
  • One source of truth: the what-if numbers are computed by the same backend as the real ones, so the two can never calculate a value differently.

The change has three parts. Go backend: a new simulateLoadWatts query parameter on GET /status returns a what-if status, plus a system-wide presets CRUD resource (/simulation-presets) on a new flux-simulation-presets table. FluxCore: a SimulationPreset model + SimulationPresetDraft, five new FluxAPIClient methods (with default no-op fallbacks), a URLSessionAPIClient+Simulation extension, and an @Observable SimulationPresetsService. UI: a Settings Simulation section, a Dashboard SimulationBanner + DashboardSimulateMenu, and tint/accessibility changes in DashboardViewModel/DashboardView/LiveTrioPanel/DashboardHeroPanel.

The defining choice is to compute the simulated status server-side (Decision 6): the "empty by" estimate depends on handler policy (90s freshness gate, off-peak suppression, 15-min rolling average), so re-deriving it in Swift would create a second source of truth. The client sends an added-load parameter and renders the response. The backend allocates the added load W by a priority waterfall — reduce grid export, then discharge the battery up to the inverter ceiling, then import the rest — keeping the live trio energy-balanced.

Presets reuse two precedents (Decision 10): the backend mirrors /pricing (id-only key, list-all Scan, no partition); the client service/UI mirror SoC Alerts (server-confirmed-then-apply). The simulated call is a separate fetchStatus(simulateLoadWatts:) method so the widget and settings-validation paths can never simulate.

  • Active simulation is in-memory only — nil on cold launch, survives refresh/tab nav (Decision 5).
  • Each refresh re-resolves the preset's current watts; an absent id turns simulation off ([2.4]).
  • Trade-offs: unavailable offline/stale; one preset at a time; off-peak indicator suppressed while simulating; simulated status never cached for widgets.

Waterfall (status_simulate.go, sign: pbat>0 discharge, pgrid>0 import): exportReduction = min(W, max(0,-pgrid)); wBattery = W − exportReduction; headroom(p) = max(0, maxDischargeW − p) reusing the existing 5 kW maxDischargeKW; simDischarge(p,wBattery) = p + min(wBattery, headroom(p)); overflow spills to grid import. The cap is applied to the added portion via headroom, not min(p+wBattery, ceiling) — the naïve form would lower a real reading already at/above the ceiling and break zero-load equivalence at W=0. This guarantees simDischarge(p,0)==p for every p and simDischarge ≥ p always ([3.4]). headroom is evaluated per series (live pbat, rolling avgPbat).

Off-peak ([4.3], Decision 11): computeCantEmptyBeforeOffpeak is pbat-independent and rendered instead of the "empty by" line, so the handler returns CantEmptyBeforeOffpeak = nil whenever W>0, forcing the hero onto the simulated cutoff. liveFresh and the ct.Before(nextOpWindowStart) boundary are untouched ([4.1]/[4.5]).

Validation: parseSimulateLoad accepts empty (→0), integer (0,20000], else 400 before any I/O ([4.6]). 0 is rejected on the wire, so zero-load equivalence ([4.2]) is exercised only at the compute layer; status_simulate_property_test.go (pgregory.net/rapid) asserts equivalence, monotonicity (plateauing at saturation), and simDischarge ≥ pbat across boundaries including pbat ≥ ceiling.

  • Presets: presetId HASH-only, serialised json:"id"; server assigns id/timestamps, preserves createdAt on PUT, 404 unknown, idempotent 204 DELETE, 409 at the 20 cap, 400 on validation; no sentinel/transactional machinery.
  • Client: refresh() records resolved watts/name into activeSimulationDeltaWatts before fetch so the banner survives a failed/stale fetch; on simulated success it returns early, skipping the widget cache write + reload (Decision 13).
  • Watch items: maxDischargeKW is a fixed 5 kW constant (not per-system); self-consumption priority assumed; forced grid-charging not modelled; length cap uses untrimmed label.count (consistent client/server, but trailing whitespace counts toward 40). Banner +delta (added load) intentionally differs from the hero's net battery figure — [2.7] is about the watts sent matching the displayed status.

Important changes — detailed

Server-side simulated status via a single query parameter

internal/api/status.go

Why it matters. The core data-consistency mechanism: the client renders one server-authored picture instead of re-deriving the cutoff, so simulated and real values cannot diverge.

What to look at. handleStatus reads parseSimulateLoad, threads simLoadW through Live, the live cutoff, and the rolling cutoff

Takeaway. Verify every battery-derived field uses the allocated value and that W=0 is a true no-op.
Rationale. Decision 6 — the empty-by estimate is handler policy (freshness gate, off-peak suppression, rolling average), not a pure formula; reimplementing it in Swift would create a second source of truth.

Priority-waterfall load allocation with inverter-ceiling cap

internal/api/status_simulate.go

Why it matters. The most error-prone logic: it must stay energy-balanced, cap the battery at the ceiling, never lower a real reading, and be a true no-op at W=0.

What to look at. allocateSimLoad / headroom / simDischarge — cap applied to the added portion via headroom, not min(p+w, ceiling)

Takeaway. The headroom form is what makes zero-load equivalence hold even when pbat is already at/above the ceiling.
Rationale. Decision 14 — reduce export, then battery to its ceiling, then grid import, matching self-consumption hardware; supersedes the unclamped model in Decision 8 because car charging exceeds the 5 kW ceiling.

Off-peak indicator suppressed while simulating

internal/api/status.go

Why it matters. Prevents a real-data reassurance appearing beside a simulated empty-by (the contradiction [4.3] forbids); easy to regress.

What to look at. CantEmptyBeforeOffpeak only computed when simLoadW == 0

Takeaway. Confirm the hero always takes the simulated statusLine path while W>0.
Rationale. Decision 11 — the pbat-independent worst-case indicator is meaningless under an added-load what-if and is rendered instead of the empty-by line, so leaving it set could hide/contradict the simulated estimate.

Simulated status never cached for widgets

Flux/Flux/Dashboard/DashboardViewModel.swift

Why it matters. A leak path: refresh() normally writes every status to the shared widget cache; a simulated status must not reach widgets.

What to look at. Early return in refresh() skipping widgetCache.writeIfNewer and the reload trigger when simulating

Takeaway. Check the early return sits after status assignment but before the cache write; tested both ways.
Rationale. Decision 13 — widgets and Control Center must always show real data (a Non-Goal); not writing is the minimal correct behaviour.

Separate simulated-status method, not a change to fetchStatus()

Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift

Why it matters. Keeps the widget timeline and settings-validation call sites param-free so they can never accidentally simulate; default extensions keep ~30 conformers compiling.

What to look at. fetchStatus(simulateLoadWatts:) added with a default delegating to fetchStatus(); only URLSessionAPIClient overrides it

Takeaway. Confirm only DashboardViewModel calls the new method and the widget path still uses the no-arg form.
Rationale. Design pattern-extension audit — isolating the simulation parameter to one method is what guarantees non-Dashboard readers stay real.

Presets CRUD resource modelled on /pricing, client on SoC Alerts

internal/api/simulationpresets_handler.go

Why it matters. New table + endpoints + IAM; reuses two precedents, so the review should confirm the deviations (no sentinel, no transactions) are correct.

What to look at. handleCreate/Update/Delete/List with id-only key, server-assigned id/timestamps, 409 at the 20 cap, idempotent DELETE

Takeaway. Validation parity (label 1..40 runes, watts 1..20000) means a stored preset can never produce a rejected status request.
Rationale. Decision 10 — /pricing is already a system-wide id-only CRUD, the exact shape sync needs, with no partition-scoping decision; SoC Alerts is the closer client UI shape.

Key decisions

Decision 6: Compute the simulated status server-side

The backend computes the full simulated status (adjusted live values, recomputed "empty by", off-peak indicator) for an added-load parameter on /status; the client renders it and re-derives nothing.

The "empty by" estimate is handler policy — a freshness gate, off-peak suppression, the 15-minute rolling average, and a pbat-independent indicator — not a pure formula. Reimplementing it in Swift would create a second source of truth to keep in lockstep with Go forever, the exact cross-screen divergence the project forbids. Cost is one parameterised call on a path already polled every 10s. The trade-off: simulation is unavailable offline or when data is stale.

Decision 14: Priority-waterfall allocation (export → battery capped → grid)

Added W is allocated by a priority waterfall: cut current export first, then discharge the battery up to its headroom below the 5 kW inverter ceiling, then import the remainder. Capping only the added portion via headroom keeps W=0 a true no-op even when a reading is already at/above the ceiling.

This supersedes the unclamped "battery absorbs everything" model (Decision 8): car charging (the primary use case) pushes simulated discharge past 5 kW, where the old model showed an impossibly fast drain and an optimistic empty-by. The waterfall matches self-consumption hardware (PV → load → battery → grid), keeps the trio energy-balanced, and makes the Grid tile reveal whether charging cuts export or pulls peak import.

Decision 11: Suppress the off-peak indicator while simulating

While W>0 the backend returns cantEmptyBeforeOffpeak = nil, so the hero always shows the simulated "empty by" line.

That indicator is computed pbat-independently against the 5 kW ceiling — a worst-case reassurance that is meaningless under an added-load what-if and is rendered instead of the empty-by line. Leaving it set could hide or contradict the simulated estimate ([4.3]). It returns the instant simulation is turned off.

Decision 13: Do not cache a simulated status for widgets

While simulating, refresh() skips the widget-cache write and the reload trigger.

Widgets and the Control Center widget must always show real data (a Non-Goal). A simulated status written to the shared cache would leak what-if values into widgets; simply not writing is the minimal correct behaviour. The cache resumes updating as soon as simulation is off, and only ever held real data.

Decision 10: Model presets on /pricing (system-wide, id-only key)

Presets are a flux-simulation-presets table keyed by presetId only (no device/user partition), endpoints /simulation-presets with no {deviceId} segment, list-all via Scan — mirroring /pricing but without its singleton-sentinel and transactional machinery. The client service/UI mirror SoC Alerts, the closer UI shape.

/pricing is already a system-wide id-only CRUD, exactly the shape sync needs ([1.4]), with no partition-scoping decision to make. A serial partition was rejected because the backend is single-system and the serial stores one value forever.

Decision 5: Active simulation is transient session state

Presets persist server-side, but the active simulation lives only in DashboardViewModel.activeSimulationPresetIDnil on cold launch, surviving auto-refresh and tab navigation within a session.

A what-if is a momentary exploration; silently restoring it on launch risks the user mistaking simulated values for real ones after forgetting it was on. The cost is having to re-enable a simulation after restarting the app.

Review findings

SeverityAreaFindingResolution
majorreuse · status_simulate.goHand-rolled minFloat helper duplicates Go's builtin min (violates the project Go rule; modernize linter would flag).Replaced both call sites with the builtin min and removed minFloat.
majordocs · CHANGELOG.mdCHANGELOG entry sat under 'Documentation' and read 'Planning only — not yet implemented', but the feature is fully implemented.Moved to a shipped 'Added' feature entry; dropped the planning-only clause.
majorspec · DashboardViewModel ([4.5]/[5.1])Banner state was set only after a successful fetch, so a failed first-activation fetch would leave the banner hidden — contradicting 'banner stays up independent of data availability'.Set activeSimulationDeltaWatts/Name from the resolved preset before the fetch; figures fall back to the error/unavailable path.
majorspec · SimulationBanner ([2.3], macOS)The empty-state 'Add a preset…' called an onSettingsTap that is a no-op on macOS, so AC 2.3 was only partially met there.macOS now uses SettingsLink (matching the staleness banner) to open the Settings scene.
minorefficiency · simulationpresets_handler.goList endpoint sorts in both the store and the handler (double sort).Attempted to drop the handler sort but it broke a handler-contract test that asserts the handler sorts an unsorted fake store; reverted as not worth weakening the test (≤20 rows).
minorreuse · client + dynamoerrorBanner, view-model error props, EmptyResponse decode marker, and the Scan-all loop are each duplicated from pricing/SoC-alerts.Skipped — this is the project's deliberate copy-per-feature convention; extracting would ripple into pricing and SoC-alerts (out of scope).
minorefficiency · simulationpresets_handler.gohandleUpdatePreset Scans the whole table to fetch one row's createdAt / 404, where a GetItem would be O(1).Skipped — mirrors the established /pricing update pattern (handleUpdatePricing also lists); table capped at 20.
nitquality · SimulationPreset.swiftDraft validate() checks emptiness on the trimmed label but length on the untrimmed label.count; server counts untrimmed runes.Skipped — harmless (server is the authority; both reject the same realistic inputs).

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 5bce327..4a47342 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added  - **Week-to-date and month-to-date History ranges** (T-1361). The History range control adds two calendar-anchored options — **Wk** (this week so far) and **Mo** (this month so far) — alongside the fixed 7/14/30-day ranges, giving a five-segment control in the order 7d / 14d / 30d / Wk / Mo with 7d still the default. Boundaries are computed against the same Sydney calendar that keys the stored data, with the week's first day taken from the device locale, and are recomputed on every load (range switch, pull-to-refresh, screen appearance). The `/history` endpoint now accepts any range from 1 to 31 days, so a full month-to-date on the 31st of a 31-day month is no longer clipped to 30. The offline cache fallback is bounded by the computed week/month start, so a gappy cache never surfaces a day before the period start, and it now auto-selects the newest cached day (matching the online behaviour) instead of the oldest.+- **Dashboard Simulation** (T-1495). A new **Simulate** control on the Dashboard applies a saved load preset — e.g. "Charge car" at 1.7 kW — as a clearly-labelled what-if, showing how house load, battery flow, and the "empty by" estimate would change, without touching any device. Manage presets (label + watts) in **Settings → Simulation**. The simulation is computed on the backend so its figures stay consistent with the real Dashboard: the battery is shown discharging up to its limit with any excess drawn from the grid, and on a sunny day the added load is taken from your solar export first — so the picture is accurate whether you're on grid power in the evening or soaking up solar at midday. A distinct banner and tinted values make it obvious the numbers are simulated, and it clears when you turn it off or relaunch the app. iOS + macOS.  ### Changed 
Flux/Flux/Dashboard/DashboardHeroPanel.swift Modified +40 / -6
diff --git a/Flux/Flux/Dashboard/DashboardHeroPanel.swift b/Flux/Flux/Dashboard/DashboardHeroPanel.swiftindex b2aef0b..afc9f79 100644--- a/Flux/Flux/Dashboard/DashboardHeroPanel.swift+++ b/Flux/Flux/Dashboard/DashboardHeroPanel.swift@@ -9,17 +9,34 @@ struct DashboardHeroPanel: View {     let rolling15min: RollingAvg?     let battery: BatteryInfo?     let offpeakWindowStart: String?+    /// When true the discharge rate and "empty by" reflect a simulated added+    /// load, so they render in the simulation accent and are announced as+    /// simulated (Req 5.3/5.4).+    let isSimulating: Bool      init(         live: LiveData?,         rolling15min: RollingAvg?,         battery: BatteryInfo? = nil,-        offpeakWindowStart: String? = nil+        offpeakWindowStart: String? = nil,+        isSimulating: Bool = false     ) {         self.live = live         self.rolling15min = rolling15min         self.battery = battery         self.offpeakWindowStart = offpeakWindowStart+        self.isSimulating = isSimulating+    }++    /// Colour for the discharge/charge rate and "empty by" time in the+    /// subline. The empty-by time is normally amber; while simulating the+    /// whole simulated subline takes the simulation accent.+    private var sublineAccent: Color {+        isSimulating ? FluxTheme.Palette.simulation : FluxTheme.Palette.secondaryText+    }++    private var cutoffAccent: Color {+        isSimulating ? FluxTheme.Palette.simulation : FluxTheme.Palette.amber     }      var body: some View {@@ -141,22 +158,24 @@ struct DashboardHeroPanel: View {             HStack(spacing: 4) {                 if let cutoff {                     Text("\(Self.dischargingLabel(watts)) · ")-                        .foregroundStyle(FluxTheme.Palette.secondaryText)+                        .foregroundStyle(sublineAccent)                     Text("empty by ")-                        .foregroundStyle(FluxTheme.Palette.secondaryText)+                        .foregroundStyle(sublineAccent)                     Text(DateFormatting.clockTime(from: cutoff))-                        .foregroundStyle(FluxTheme.Palette.amber)+                        .foregroundStyle(cutoffAccent)                         .monospacedDigit()                 } else {                     Text(Self.dischargingLabel(watts))-                        .foregroundStyle(FluxTheme.Palette.secondaryText)+                        .foregroundStyle(sublineAccent)                 }             }             .appFont(FluxTheme.Typography.heroSubline)+            .modifier(SimulatedSublineAccessibility(isSimulating: isSimulating))         case .charging(let watts):             Text(Self.chargingLabel(watts))                 .appFont(FluxTheme.Typography.heroSubline)-                .foregroundStyle(FluxTheme.Palette.secondaryText)+                .foregroundStyle(sublineAccent)+                .modifier(SimulatedSublineAccessibility(isSimulating: isSimulating))         case .idle:             Text("Idle · battery holding")                 .appFont(FluxTheme.Typography.heroSubline)@@ -188,6 +207,21 @@ struct DashboardHeroPanel: View {     } } +/// Appends a ", simulated" suffix to the subline's VoiceOver announcement+/// while a simulation is active (Req 5.4), leaving the visible text untouched.+/// A no-op when not simulating so the default reading is unchanged.+private struct SimulatedSublineAccessibility: ViewModifier {+    let isSimulating: Bool++    func body(content: Content) -> some View {+        if isSimulating {+            content.accessibilityLabel("Simulated battery flow")+        } else {+            content+        }+    }+}+ #if DEBUG #Preview("Default + can't-empty indicator") {     ZStack {
Flux/Flux/Dashboard/DashboardView.swift Modified +61 / -14
diff --git a/Flux/Flux/Dashboard/DashboardView.swift b/Flux/Flux/Dashboard/DashboardView.swiftindex 374571f..1135c29 100644--- a/Flux/Flux/Dashboard/DashboardView.swift+++ b/Flux/Flux/Dashboard/DashboardView.swift@@ -10,6 +10,11 @@ struct DashboardView: View {     @Environment(\.horizontalSizeClass) private var hSizeClass     @State private var viewModel: DashboardViewModel     @State private var showingSettings = false+    /// Source of selectable presets for the Simulate menu. The shared service+    /// is bound at app startup (AppNavigationView); reading it here keeps the+    /// menu in sync with edits/deletes made in Settings or via cross-device+    /// sync.+    private var simulationService: SimulationPresetsService { .shared }      private var tabBinding: Binding<FluxTab>?     private var onSettingsTap: (() -> Void)?@@ -113,8 +118,9 @@ struct DashboardView: View {     private var dashboardContent: some View {         VStack(alignment: .leading, spacing: FluxTheme.Metrics.panelGap) {             headerSection+            simulationBanner             if viewModel.error != nil {-                stalenessBanner+                DashboardStalenessBanner(viewModel: viewModel, onSettingsTap: openSettings)             }             heroPanel             trioPanel@@ -131,8 +137,9 @@ struct DashboardView: View {         // and the settings toolbar gear, so skip both the FluxScreenHeader         // tab bar and the legacyHeader eyebrow/title block.         VStack(alignment: .leading, spacing: FluxTheme.Metrics.panelGap) {+            simulationBanner             if viewModel.error != nil {-                stalenessBanner+                DashboardStalenessBanner(viewModel: viewModel, onSettingsTap: openSettings)             }             AdaptiveColumnsLayout {                 heroPanel@@ -156,10 +163,44 @@ struct DashboardView: View {             FluxScreenHeader(                 selection: tabBinding,                 onSettingsTap: onSettingsTap,-                onTabActivate: onTabActivate+                onTabActivate: onTabActivate,+                trailingAccessory: AnyView(simulateMenu)             )         } else {-            legacyHeader+            HStack(alignment: .top) {+                legacyHeader+                Spacer()+                simulateMenu+            }+        }+    }++    private var simulateMenu: some View {+        DashboardSimulateMenu(+            viewModel: viewModel,+            presets: simulationService.presets,+            onAddPreset: openSettings+        )+    }++    @ViewBuilder+    private var simulationBanner: some View {+        if viewModel.isSimulating,+           let name = viewModel.activeSimulationName,+           let delta = viewModel.activeSimulationDeltaWatts {+            SimulationBanner(presetName: name, deltaWatts: delta) {+                Task { await viewModel.stopSimulation() }+            }+        }+    }++    private func openSettings() {+        if let onSettingsTap {+            onSettingsTap()+        } else {+            #if !os(macOS)+            showingSettings = true+            #endif         }     } @@ -169,13 +210,14 @@ struct DashboardView: View {             live: viewModel.status?.live,             rolling15min: viewModel.status?.rolling15min,             battery: viewModel.status?.battery,-            offpeakWindowStart: viewModel.status?.offpeak?.windowStart+            offpeakWindowStart: viewModel.status?.offpeak?.windowStart,+            isSimulating: viewModel.isSimulating         )     }      @ViewBuilder     private var trioPanel: some View {-        LiveTrioPanel(live: viewModel.status?.live)+        LiveTrioPanel(live: viewModel.status?.live, isSimulating: viewModel.isSimulating)     }      @ViewBuilder@@ -243,11 +285,18 @@ struct DashboardView: View {         .padding(.top, 6)     } -    @ViewBuilder-    private var stalenessBanner: some View {+}++/// The Dashboard's stale/error banner. Extracted from `DashboardView` so that+/// view stays under the type-body-length limit.+private struct DashboardStalenessBanner: View {+    let viewModel: DashboardViewModel+    let onSettingsTap: () -> Void++    var body: some View {         FluxPanel {             VStack(alignment: .leading, spacing: 8) {-                Label(stalenessTitle, systemImage: "exclamationmark.triangle.fill")+                Label(title, systemImage: "exclamationmark.triangle.fill")                     .appFont(.subheadline, weight: .semibold)                     .foregroundStyle(.orange) @@ -275,17 +324,15 @@ struct DashboardView: View {                     }                     .buttonStyle(.bordered)                     #else-                    Button("Settings") {-                        showingSettings = true-                    }-                    .buttonStyle(.bordered)+                    Button("Settings", action: onSettingsTap)+                        .buttonStyle(.bordered)                     #endif                 }             }         }     } -    private var stalenessTitle: String {+    private var title: String {         if case .some(.unauthorized) = viewModel.error {             return "Authentication required"         }
Flux/Flux/Dashboard/DashboardViewModel.swift Modified +76 / -1
diff --git a/Flux/Flux/Dashboard/DashboardViewModel.swift b/Flux/Flux/Dashboard/DashboardViewModel.swiftindex 04f7907..ab3c12a 100644--- a/Flux/Flux/Dashboard/DashboardViewModel.swift+++ b/Flux/Flux/Dashboard/DashboardViewModel.swift@@ -16,7 +16,20 @@ final class DashboardViewModel {     private(set) var isLoading = false     private(set) var activityTier: ActivityTier = .active +    /// Active simulation preset id, or nil when not simulating. In-memory only+    /// (transient session state, Decision 5): nil on cold launch, survives+    /// auto-refresh and tab navigation, never persisted.+    private(set) var activeSimulationPresetID: String?++    /// The watts that produced the *currently displayed* simulated status.+    /// The banner sources its name/delta from this (not from the live presets+    /// list) so the banner and the figures always describe the same watts even+    /// across a cross-device edit ([2.7]).+    private(set) var activeSimulationDeltaWatts: Int?+    private(set) var activeSimulationName: String?+     private let apiClient: any FluxAPIClient+    private let simulationService: SimulationPresetsService     private let nowProvider: @Sendable () -> Date     private let sleep: @Sendable (Duration) async throws -> Void     private let widgetCache: WidgetSnapshotCache@@ -30,6 +43,7 @@ final class DashboardViewModel {      init(         apiClient: any FluxAPIClient,+        simulationService: SimulationPresetsService = .shared,         widgetCache: WidgetSnapshotCache = WidgetSnapshotCache(),         widgetReloadTrigger: @escaping @Sendable () -> Void = {             WidgetCenter.shared.reloadTimelines(ofKind: WidgetKinds.battery)@@ -48,6 +62,7 @@ final class DashboardViewModel {         }     ) {         self.apiClient = apiClient+        self.simulationService = simulationService         self.widgetCache = widgetCache         self.widgetReloadTrigger = widgetReloadTrigger         self.widgetReloadDebounce = widgetReloadDebounce@@ -117,19 +132,79 @@ final class DashboardViewModel {         }     } +    /// True while a simulation is active. Drives the banner and value tinting;+    /// independent of data availability (a stale/failed simulated fetch keeps+    /// the banner up while the affected values fall back to the error path,+    /// per [4.5]).+    var isSimulating: Bool { activeSimulationPresetID != nil }++    /// Activate (or switch to) a preset. Triggers an immediate refresh so the+    /// on-screen figures and tint change at once ([2.2] replace-on-switch,+    /// immediacy in the design).+    func activateSimulation(presetID: String) async {+        activeSimulationPresetID = presetID+        await refresh()+    }++    /// Turn simulation off. Clears the active id and immediately re-fetches the+    /// real status so the values and all simulated markings drop in the same+    /// cycle ([5.5]).+    func stopSimulation() async {+        activeSimulationPresetID = nil+        activeSimulationDeltaWatts = nil+        activeSimulationName = nil+        await refresh()+    }+     func refresh() async {         guard !isLoading else { return }          isLoading = true         defer { isLoading = false } +        // Resolve the active preset's *current* watts from the presets list+        // each cycle ([2.7]). If the active id is absent (deleted locally or+        // removed via sync), simulation turns off ([2.4]).+        var simulateWatts: Int?+        var simulateName: String?+        if let activeID = activeSimulationPresetID {+            if let preset = simulationService.presets.first(where: { $0.id == activeID }) {+                simulateWatts = preset.watts+                simulateName = preset.label+            } else {+                // Active preset gone (deleted locally or removed via sync) → off ([2.4]).+                activeSimulationPresetID = nil+            }+        }++        // Set the banner state from the resolved preset *before* fetching, so the+        // banner shows immediately and stays up even if this fetch fails ([4.5],+        // [5.1]); the figures then fall back to the error/unavailable treatment.+        // Banner and request use the same resolved watts, so they never disagree+        // about which preset is active ([2.7]).+        activeSimulationDeltaWatts = simulateWatts+        activeSimulationName = simulateName+         do {-            let response = try await apiClient.fetchStatus()+            let simulating = simulateWatts != nil+            let response: StatusResponse+            if let watts = simulateWatts {+                response = try await apiClient.fetchStatus(simulateLoadWatts: watts)+            } else {+                response = try await apiClient.fetchStatus()+            }             let fetchedAt = nowProvider()             status = response             lastSuccessfulFetch = fetchedAt             error = nil +            if simulating {+                // A simulated status must never leak into the shared widget+                // cache — widgets always show real data (Decision 13). Skip the+                // cache write and the widget-reload trigger while simulating.+                return+            }+             let envelope = StatusSnapshotEnvelope(fetchedAt: fetchedAt, status: response)             let wrote = widgetCache.writeIfNewer(envelope) 
Flux/Flux/Dashboard/LiveTrioPanel.swift Modified +21 / -3
diff --git a/Flux/Flux/Dashboard/LiveTrioPanel.swift b/Flux/Flux/Dashboard/LiveTrioPanel.swiftindex fcb5bbf..f0fda78 100644--- a/Flux/Flux/Dashboard/LiveTrioPanel.swift+++ b/Flux/Flux/Dashboard/LiveTrioPanel.swift@@ -6,6 +6,9 @@ import SwiftUI /// show a minus sign — the verb does the work. struct LiveTrioPanel: View {     let live: LiveData?+    /// When true the House value reflects an added simulated load, so it is+    /// tinted in the simulation accent and announced as simulated (Req 5.3/5.4).+    var isSimulating = false      var body: some View {         FluxPanel(padding: 0) {@@ -20,9 +23,10 @@ struct LiveTrioPanel: View {                 column(                     label: "House",                     watts: live?.pload,-                    valueColor: FluxTheme.Palette.primaryText,+                    valueColor: isSimulating ? FluxTheme.Palette.simulation : FluxTheme.Palette.primaryText,                     sub: "using",-                    showsLeftDivider: true+                    showsLeftDivider: true,+                    simulated: isSimulating                 )                 column(                     label: "Grid",@@ -55,7 +59,8 @@ struct LiveTrioPanel: View {         watts: Double?,         valueColor: Color,         sub: String,-        showsLeftDivider: Bool+        showsLeftDivider: Bool,+        simulated: Bool = false     ) -> some View {         let parts = watts.map(PowerFormatting.split)         // Drop to a slightly smaller value font once the numeric portion runs@@ -91,8 +96,21 @@ struct LiveTrioPanel: View {             }             .padding(16)             .frame(maxWidth: .infinity, alignment: .leading)+            .accessibilityElement(children: .combine)+            .accessibilityLabel(columnAccessibilityLabel(label: label, parts: parts, sub: sub, simulated: simulated))         }     }++    private func columnAccessibilityLabel(+        label: String,+        parts: (value: String, unit: String)?,+        sub: String,+        simulated: Bool+    ) -> String {+        let value = parts.map { "\($0.value) \($0.unit)" } ?? "unavailable"+        let base = "\(label), \(value), \(sub)"+        return simulated ? "\(base), simulated" : base+    } }  #if DEBUG
Flux/Flux/Dashboard/SimulationBanner.swift Added +125 / -0
diff --git a/Flux/Flux/Dashboard/SimulationBanner.swift b/Flux/Flux/Dashboard/SimulationBanner.swiftnew file mode 100644index 0000000..b480065--- /dev/null+++ b/Flux/Flux/Dashboard/SimulationBanner.swift@@ -0,0 +1,125 @@+import FluxCore+import SwiftUI++/// Persistent Dashboard indicator shown while a simulation is active (Req 5.1).+/// Placement mirrors the staleness banner (a full-width Dashboard-level banner+/// at the top of content), but the treatment is deliberately distinct from the+/// calm hero off-peak line (Req 5.2): a dedicated `FluxTheme.Palette.simulation`+/// accent, an SF Symbol, the active preset name + signed added-load delta, and+/// a Stop control. The `+delta` is the preset's *added* load (always positive),+/// which is intentionally a different figure from the hero's *net* battery+/// number.+struct SimulationBanner: View {+    let presetName: String+    let deltaWatts: Int+    let onStop: () -> Void++    private var deltaText: String {+        "+\(PowerFormatting.format(Double(deltaWatts)))"+    }++    var body: some View {+        FluxPanel {+            HStack(spacing: 12) {+                Image(systemName: "wand.and.stars")+                    .foregroundStyle(FluxTheme.Palette.simulation)+                    .accessibilityHidden(true)+                VStack(alignment: .leading, spacing: 2) {+                    Text("Simulation · \(presetName) · \(deltaText)")+                        .appFont(.subheadline, weight: .semibold)+                        .foregroundStyle(FluxTheme.Palette.simulation)+                    Text("Showing a what-if. Real values return when you stop.")+                        .appFont(.caption)+                        .foregroundStyle(FluxTheme.Palette.secondaryText)+                }+                Spacer()+                Button("Stop", action: onStop)+                    .buttonStyle(.bordered)+                    .tint(FluxTheme.Palette.simulation)+            }+        }+        .overlay(+            RoundedRectangle(cornerRadius: FluxTheme.Metrics.panelCornerRadius, style: .continuous)+                .strokeBorder(FluxTheme.Palette.simulation.opacity(0.5), lineWidth: 1)+        )+        .accessibilityElement(children: .combine)+        .accessibilityLabel(+            "Simulated. \(presetName), added load \(deltaText). Double tap Stop to return to real values."+        )+    }+}++/// Menu that activates a preset or turns simulation off ([2.1]). Lists each+/// preset (label + watts) plus an Off entry while simulating; with no presets+/// it offers a path to create one in Settings ([2.3]) rather than an empty+/// selection. Extracted from DashboardView so that view stays under the+/// type-body-length limit.+struct DashboardSimulateMenu: View {+    let viewModel: DashboardViewModel+    let presets: [SimulationPreset]+    let onAddPreset: () -> Void++    var body: some View {+        Menu {+            if presets.isEmpty {+                #if os(macOS)+                // onAddPreset is a no-op on macOS (no settings sheet); open the+                // Settings scene so the empty state still offers a path ([2.3]),+                // matching the staleness banner's macOS treatment.+                SettingsLink {+                    Label("Add a preset…", systemImage: "plus")+                }+                #else+                Button {+                    onAddPreset()+                } label: {+                    Label("Add a preset…", systemImage: "plus")+                }+                #endif+            } else {+                ForEach(presets) { preset in+                    presetButton(preset)+                }+                if viewModel.isSimulating {+                    Divider()+                    Button("Off", systemImage: "xmark") {+                        Task { await viewModel.stopSimulation() }+                    }+                }+            }+        } label: {+            Label("Simulate", systemImage: "wand.and.stars")+                .labelStyle(.iconOnly)+                .foregroundStyle(+                    viewModel.isSimulating ? FluxTheme.Palette.simulation : FluxTheme.Palette.secondaryText+                )+        }+        .accessibilityLabel(+            viewModel.isSimulating ? "Simulation active. Change or stop simulation" : "Simulate"+        )+    }++    @ViewBuilder+    private func presetButton(_ preset: SimulationPreset) -> some View {+        let title = "\(preset.label) · \(PowerFormatting.format(Double(preset.watts)))"+        Button {+            Task { await viewModel.activateSimulation(presetID: preset.id) }+        } label: {+            if viewModel.activeSimulationPresetID == preset.id {+                Label(title, systemImage: "checkmark")+            } else {+                Text(title)+            }+        }+    }+}++#if DEBUG+#Preview {+    ZStack {+        FluxTheme.Palette.background.ignoresSafeArea()+        SimulationBanner(presetName: "Charge car", deltaWatts: 1700, onStop: {})+            .padding()+    }+}+#endif
Flux/Flux/Helpers/FluxTheme.swift Modified +5 / -0
diff --git a/Flux/Flux/Helpers/FluxTheme.swift b/Flux/Flux/Helpers/FluxTheme.swiftindex eba175d..6d56d28 100644--- a/Flux/Flux/Helpers/FluxTheme.swift+++ b/Flux/Flux/Helpers/FluxTheme.swift@@ -55,6 +55,11 @@ enum FluxTheme {         static let soc = Color(red: 1.0, green: 168 / 255, blue: 102 / 255)         static let load = Color(red: 245 / 255, green: 233 / 255, blue: 216 / 255)         static let night = Color(red: 91 / 255, green: 108 / 255, blue: 1.0)+        /// Dashboard-simulation accent. Deliberately distinct from amber /+        /// secondary so simulated values and the simulation banner never read+        /// as real data (Req 5.2). A cyan-teal that fits the V5 palette while+        /// standing apart from the amber hero, red grid, and purple battery.+        static let simulation = Color(red: 64 / 255, green: 209 / 255, blue: 196 / 255)     }      enum Metrics {
Flux/Flux/Helpers/FluxV5Components.swift Modified +8 / -0
diff --git a/Flux/Flux/Helpers/FluxV5Components.swift b/Flux/Flux/Helpers/FluxV5Components.swiftindex 144f374..bd1a1fb 100644--- a/Flux/Flux/Helpers/FluxV5Components.swift+++ b/Flux/Flux/Helpers/FluxV5Components.swift@@ -201,11 +201,19 @@ struct FluxScreenHeader: View {     @Binding var selection: FluxTab     var onSettingsTap: (() -> Void)?     var onTabActivate: ((FluxTab) -> Void)?+    /// Optional accessory rendered on the trailing side of the tab-bar row,+    /// before the settings affordance. The Dashboard uses it for the Simulate+    /// menu so the control lives in the scroll content (visible on compact+    /// iPhone where the nav bar is hidden), not the toolbar chrome.+    var trailingAccessory: AnyView?      var body: some View {         VStack(alignment: .leading, spacing: 14) {             HStack(spacing: 8) {                 FluxTabBar(selection: $selection, onActivate: onTabActivate)+                if let trailingAccessory {+                    trailingAccessory+                }                 if let onSettingsTap {                     FluxTabBarSettingsButton(action: onSettingsTap)                 }
Flux/Flux/Navigation/AppNavigationView.swift Modified +3 / -2
diff --git a/Flux/Flux/Navigation/AppNavigationView.swift b/Flux/Flux/Navigation/AppNavigationView.swiftindex dc22473..78655b1 100644--- a/Flux/Flux/Navigation/AppNavigationView.swift+++ b/Flux/Flux/Navigation/AppNavigationView.swift@@ -248,11 +248,12 @@ struct AppNavigationView: View {             }         } -        // Also binds SoCAlertsService / PricingService so their CRUD calls-        // don't throw .notConfigured.+        // Also binds SoCAlertsService / PricingService / SimulationPresetsService+        // so their CRUD calls don't throw .notConfigured.         if let apiClient {             SoCAlertsService.shared.bind(apiClient: apiClient)             PricingService.shared.bind(apiClient: apiClient)+            SimulationPresetsService.shared.bind(apiClient: apiClient)         }         selectedScreen = apiClient == nil ? .settings : (selectedScreen ?? .dashboard)     }
Flux/Flux/Settings/SettingsView.swift Modified +38 / -15
diff --git a/Flux/Flux/Settings/SettingsView.swift b/Flux/Flux/Settings/SettingsView.swiftindex 09153bf..0924070 100644--- a/Flux/Flux/Settings/SettingsView.swift+++ b/Flux/Flux/Settings/SettingsView.swift@@ -118,21 +118,7 @@ struct SettingsView: View {                 }             } -            Section("Pricing") {-                NavigationLink {-                    PricingPeriodsView()-                } label: {-                    Label("Pricing periods", systemImage: "dollarsign.circle")-                }-            }--            Section("Alerts") {-                NavigationLink {-                    SoCAlertsView()-                } label: {-                    Label("Battery alerts", systemImage: "bell.badge")-                }-            }+            SettingsFeatureSections()              if manualWhatsNewRelease != nil {                 Section("About") {@@ -254,6 +240,10 @@ struct SettingsView: View {                     SoCAlertsView()                 } +                macOSNavSection(title: "Simulation", rowLabel: "Load presets", icon: "bolt.badge.clock") {+                    SimulationPresetsView()+                }+                 if manualWhatsNewRelease != nil {                     LiquidGlassSection(title: "About") {                         Grid(alignment: .leadingFirstTextBaseline,@@ -329,6 +319,39 @@ struct LiquidGlassSection<Content: View>: View { #endif  #if !os(macOS)+/// The Pricing / Alerts / Simulation navigation sections of the iOS settings+/// form. Extracted to a standalone view so `SettingsView` stays under the+/// type-body-length limit.+private struct SettingsFeatureSections: View {+    var body: some View {+        Group {+            Section("Pricing") {+                NavigationLink {+                    PricingPeriodsView()+                } label: {+                    Label("Pricing periods", systemImage: "dollarsign.circle")+                }+            }++            Section("Alerts") {+                NavigationLink {+                    SoCAlertsView()+                } label: {+                    Label("Battery alerts", systemImage: "bell.badge")+                }+            }++            Section("Simulation") {+                NavigationLink {+                    SimulationPresetsView()+                } label: {+                    Label("Load presets", systemImage: "bolt.badge.clock")+                }+            }+        }+    }+}+ /// Caps the Settings form to a comfortable reading width on iPad regular /// size class. The double-frame trick centers the capped content inside the /// full sheet width. At compact size class the modifier is a no-op so iPhone
Flux/Flux/Settings/Simulation/SimulationPresetEditor.swift Added +90 / -0
diff --git a/Flux/Flux/Settings/Simulation/SimulationPresetEditor.swift b/Flux/Flux/Settings/Simulation/SimulationPresetEditor.swiftnew file mode 100644index 0000000..b452b8b--- /dev/null+++ b/Flux/Flux/Settings/Simulation/SimulationPresetEditor.swift@@ -0,0 +1,90 @@+import FluxCore+import SwiftUI++/// Sheet for creating or editing a single simulation preset. Validation runs+/// on every field edit and again on save; the Save button disables when the+/// draft doesn't validate. Mirrors SoCAlertEditor.+@MainActor+struct SimulationPresetEditor: View {+    @Bindable var viewModel: SimulationPresetsViewModel+    @Environment(\.dismiss) private var dismiss++    var body: some View {+        NavigationStack {+            Form {+                Section("Label") {+                    TextField("e.g. Charge car", text: $viewModel.draft.label)+                    #if os(iOS)+                        .textInputAutocapitalization(.sentences)+                    #endif+                }+                Section("Load") {+                    HStack {+                        Text("Added load")+                        Spacer()+                        TextField(+                            "Watts",+                            value: $viewModel.draft.watts,+                            format: .number.precision(.fractionLength(0))+                        )+                        #if os(iOS)+                        .keyboardType(.numberPad)+                        #endif+                        .multilineTextAlignment(.trailing)+                        .frame(maxWidth: 120)+                        Text("W")+                            .foregroundStyle(.secondary)+                    }+                }+                if let message = validationMessage {+                    Section {+                        Text(message)+                            .font(.footnote)+                            .foregroundStyle(.red)+                    }+                }+            }+            .navigationTitle(viewModel.editorMode == .create ? "New preset" : "Edit preset")+            #if os(iOS)+            .navigationBarTitleDisplayMode(.inline)+            #endif+            .toolbar {+                ToolbarItem(placement: .cancellationAction) {+                    Button("Cancel") {+                        viewModel.setEditorPresented(false)+                        dismiss()+                    }+                }+                ToolbarItem(placement: .confirmationAction) {+                    Button("Save") {+                        Task {+                            // Only dismiss on success; on failure the view+                            // model surfaces lastError as a banner and we keep+                            // the sheet open so the user's edits aren't lost.+                            do {+                                _ = try await viewModel.save()+                                dismiss()+                            } catch {+                                // viewModel.lastError is already set.+                            }+                        }+                    }+                    .disabled(!viewModel.canSave)+                }+            }+        }+    }++    private var validationMessage: String? {+        switch viewModel.draft.validate() {+        case .none:+            return nil+        case .emptyLabel:+            return "Label must not be empty."+        case .labelTooLong:+            return "Label is too long. Use 40 characters or fewer."+        case .wattsOutOfRange:+            return "Added load must be between 1 and 20000 W."+        }+    }+}
Flux/Flux/Settings/Simulation/SimulationPresetsView.swift Added +169 / -0
diff --git a/Flux/Flux/Settings/Simulation/SimulationPresetsView.swift b/Flux/Flux/Settings/Simulation/SimulationPresetsView.swiftnew file mode 100644index 0000000..0a0c892--- /dev/null+++ b/Flux/Flux/Settings/Simulation/SimulationPresetsView.swift@@ -0,0 +1,169 @@+import FluxCore+import SwiftUI++/// Shown from Settings → "Simulation" → "Load presets". Lists existing+/// simulation presets and lets the user add, edit, and delete. Mirrors+/// SoCAlertsView.+@MainActor+struct SimulationPresetsView: View {+    @State private var viewModel: SimulationPresetsViewModel++    init(service: SimulationPresetsService? = nil) {+        let resolved = service ?? SimulationPresetsService.shared+        _viewModel = State(initialValue: SimulationPresetsViewModel(service: resolved))+    }++    var body: some View {+        Group {+            #if os(macOS)+            macOSContent+            #else+            iOSContent+            #endif+        }+        .navigationTitle("Load presets")+        .task {+            await viewModel.refresh()+        }+        .sheet(+            isPresented: Binding(+                get: { viewModel.isEditorPresented },+                set: { viewModel.setEditorPresented($0) }+            )+        ) {+            SimulationPresetEditor(viewModel: viewModel)+        }+    }++    private var iOSContent: some View {+        Form {+            if viewModel.showsErrorBanner {+                Section {+                    errorBanner+                }+            }+            Section {+                if viewModel.presets.isEmpty {+                    emptyStateRow+                } else {+                    ForEach(viewModel.presets) { preset in+                        presetRow(preset)+                    }+                }+            } header: {+                Text("Presets")+            } footer: {+                if !viewModel.addAffordanceEnabled {+                    Text("You've reached the \(SimulationPresetsViewModel.presetCap)-preset limit. " ++                         "Delete a preset to add a new one.")+                        .font(.footnote)+                        .foregroundStyle(.secondary)+                } else {+                    Text("Presets let you simulate an added load on the Dashboard without changing anything.")+                        .font(.footnote)+                        .foregroundStyle(.secondary)+                }+            }+            Section {+                Button {+                    viewModel.beginCreate()+                } label: {+                    Label("Add preset", systemImage: "plus")+                }+                .disabled(!viewModel.addAffordanceEnabled)+            }+        }+    }++    #if os(macOS)+    private var macOSContent: some View {+        ScrollView {+            VStack(alignment: .leading, spacing: 12) {+                if viewModel.showsErrorBanner {+                    errorBanner+                }+                LiquidGlassSection(title: "Presets") {+                    VStack(alignment: .leading, spacing: 8) {+                        if viewModel.presets.isEmpty {+                            emptyStateRow+                        } else {+                            ForEach(viewModel.presets) { preset in+                                presetRow(preset)+                                if preset.id != viewModel.presets.last?.id {+                                    Divider()+                                }+                            }+                        }+                        Divider()+                        Button {+                            viewModel.beginCreate()+                        } label: {+                            Label("Add preset", systemImage: "plus")+                        }+                        .disabled(!viewModel.addAffordanceEnabled)+                    }+                    .padding(8)+                }+            }+            .padding()+        }+    }+    #endif++    private var errorBanner: some View {+        HStack {+            Image(systemName: "exclamationmark.triangle.fill")+                .foregroundStyle(.red)+            VStack(alignment: .leading) {+                Text(viewModel.lastErrorMessage ?? "Couldn't reach the backend")+                    .font(.footnote)+                    .foregroundStyle(.red)+            }+            Spacer()+            Button("Dismiss") { viewModel.clearError() }+                .buttonStyle(.borderless)+        }+    }++    private var emptyStateRow: some View {+        VStack(alignment: .leading, spacing: 4) {+            Text("No presets yet")+                .font(.body.weight(.semibold))+            Text("Add a preset to simulate a what-if load, like charging the car, from the Dashboard.")+                .font(.footnote)+                .foregroundStyle(.secondary)+        }+    }++    @ViewBuilder+    private func presetRow(_ preset: SimulationPreset) -> some View {+        Button {+            viewModel.beginEdit(preset)+        } label: {+            HStack {+                VStack(alignment: .leading, spacing: 2) {+                    Text(preset.label)+                        .font(.body)+                        .foregroundStyle(.primary)+                    Text(PowerFormatting.format(Double(preset.watts)))+                        .font(.caption)+                        .foregroundStyle(.secondary)+                }+                Spacer()+                Image(systemName: "chevron.right")+                    .font(.caption)+                    .foregroundStyle(.tertiary)+            }+        }+        .buttonStyle(.plain)+        #if os(iOS)+        .swipeActions(edge: .trailing) {+            Button(role: .destructive) {+                Task { try? await viewModel.delete(preset) }+            } label: {+                Label("Delete", systemImage: "trash")+            }+        }+        #endif+    }+}
Flux/Flux/Settings/Simulation/SimulationPresetsViewModel.swift Added +108 / -0
diff --git a/Flux/Flux/Settings/Simulation/SimulationPresetsViewModel.swift b/Flux/Flux/Settings/Simulation/SimulationPresetsViewModel.swiftnew file mode 100644index 0000000..ac491b6--- /dev/null+++ b/Flux/Flux/Settings/Simulation/SimulationPresetsViewModel.swift@@ -0,0 +1,108 @@+import FluxCore+import Foundation++/// Drives SimulationPresetsView and SimulationPresetEditor. Wraps+/// SimulationPresetsService so the view layer never reaches into the service+/// directly. Mirrors SoCAlertsViewModel.+@MainActor+@Observable+final class SimulationPresetsViewModel {+    /// Mode of the editor sheet.+    enum EditorMode: Equatable {+        case create+        case edit(SimulationPreset)+    }++    /// Editor draft. Mutate freely from the editor view; `canSave` reflects+    /// whether the current draft passes validation.+    var draft = SimulationPresetDraft()++    /// `nil` when no sheet is showing. The view binds `isPresented` to+    /// `editorMode != nil`.+    private(set) var editorMode: EditorMode?++    private let service: SimulationPresetsService+    /// Defensive cap (Decision 12); kept in lockstep with the backend's 409.+    static let presetCap = 20++    init(service: SimulationPresetsService) {+        self.service = service+    }++    // MARK: - Derived view state++    var presets: [SimulationPreset] { service.presets }++    var addAffordanceEnabled: Bool { presets.count < Self.presetCap }++    var showsErrorBanner: Bool { service.lastError != nil }++    var lastErrorMessage: String? {+        guard let err = service.lastError else { return nil }+        if let api = err as? FluxAPIError { return api.message }+        return err.localizedDescription+    }++    /// Save is disabled until the draft validates and (in create mode) the+    /// cap is not reached.+    var canSave: Bool {+        guard draft.validate() == nil else { return false }+        if case .create = editorMode, !addAffordanceEnabled { return false }+        return true+    }++    var isEditorPresented: Bool { editorMode != nil }++    /// SwiftUI binding-friendly accessor for sheet presentation. Setting to+    /// false dismisses the editor.+    func setEditorPresented(_ presented: Bool) {+        if !presented { editorMode = nil }+    }++    // MARK: - Lifecycle++    func refresh() async {+        try? await service.refresh()+    }++    func beginCreate() {+        draft = SimulationPresetDraft()+        editorMode = .create+    }++    func beginEdit(_ preset: SimulationPreset) {+        draft = SimulationPresetDraft(preset: preset)+        editorMode = .edit(preset)+    }++    @discardableResult+    func save() async throws -> SimulationPreset? {+        guard let mode = editorMode else { return nil }+        guard canSave else { return nil }+        switch mode {+        case .create:+            let created = try await service.create(draft)+            editorMode = nil+            return created+        case .edit(let original):+            // The backend stamps its own `updatedAt`; the local timestamp is+            // overwritten server-side.+            let updated = SimulationPreset(+                id: original.id,+                label: draft.label,+                watts: draft.watts,+                createdAt: original.createdAt,+                updatedAt: Date()+            )+            let result = try await service.update(updated)+            editorMode = nil+            return result+        }+    }++    func delete(_ preset: SimulationPreset) async throws {+        try await service.delete(preset.id)+    }++    func clearError() { service.clearError() }+}
Flux/FluxTests/DashboardViewModelSimulationTests.swift Added +265 / -0
diff --git a/Flux/FluxTests/DashboardViewModelSimulationTests.swift b/Flux/FluxTests/DashboardViewModelSimulationTests.swiftnew file mode 100644index 0000000..0022d4e--- /dev/null+++ b/Flux/FluxTests/DashboardViewModelSimulationTests.swift@@ -0,0 +1,265 @@+import FluxCore+import Foundation+import Testing+@testable import Flux++@MainActor+@Suite(.serialized)+struct DashboardViewModelSimulationTests {+    // MARK: - Activation resolves watts and sends one simulated request++    @Test+    func activatingPresetSendsSimulatedRequestWithItsWatts() async {+        let ctx = makeContext()+        ctx.api.simulatedStatus = makeStatusResponse(soc: 50)+        await ctx.seedPresets([("p1", "Charge car", 1700)])++        await ctx.viewModel.activateSimulation(presetID: "p1")++        #expect(ctx.viewModel.isSimulating)+        #expect(ctx.api.lastSimulateWatts == 1700)+        #expect(ctx.api.simulateCallCount == 1)+        #expect(ctx.viewModel.status?.live?.soc == 50)+    }++    @Test+    func unsimulatedRefreshUsesPlainFetch() async {+        let ctx = makeContext()+        ctx.api.plainStatus = makeStatusResponse(soc: 80)+        await ctx.viewModel.refresh()+        #expect(ctx.viewModel.isSimulating == false)+        #expect(ctx.api.simulateCallCount == 0)+        #expect(ctx.api.plainCallCount == 1)+        #expect(ctx.viewModel.status?.live?.soc == 80)+    }++    // MARK: - Single active preset (replace on switch)++    @Test+    func activatingSecondPresetReplacesFirst() async {+        let ctx = makeContext()+        ctx.api.simulatedStatus = makeStatusResponse(soc: 40)+        await ctx.seedPresets([("p1", "Charge car", 1700), ("p2", "Heat pump", 3200)])++        await ctx.viewModel.activateSimulation(presetID: "p1")+        await ctx.viewModel.activateSimulation(presetID: "p2")++        #expect(ctx.viewModel.activeSimulationPresetID == "p2")+        #expect(ctx.api.lastSimulateWatts == 3200)+    }++    // MARK: - Watts re-resolved each refresh (sync edit)++    @Test+    func editedWattsFlowToNextSimulatedFetch() async {+        let ctx = makeContext()+        ctx.api.simulatedStatus = makeStatusResponse(soc: 40)+        await ctx.seedPresets([("p1", "Charge car", 1700)])++        await ctx.viewModel.activateSimulation(presetID: "p1")+        #expect(ctx.api.lastSimulateWatts == 1700)++        // Simulate a cross-device edit: the preset's watts change in the list.+        await ctx.updatePresetWatts("p1", 5000)+        await ctx.viewModel.refresh()+        #expect(ctx.api.lastSimulateWatts == 5000)+    }++    // MARK: - Deleted/absent active preset clears simulation++    @Test+    func deletedActivePresetClearsSimulation() async {+        let ctx = makeContext()+        ctx.api.simulatedStatus = makeStatusResponse(soc: 40)+        ctx.api.plainStatus = makeStatusResponse(soc: 90)+        await ctx.seedPresets([("p1", "Charge car", 1700)])++        await ctx.viewModel.activateSimulation(presetID: "p1")+        #expect(ctx.viewModel.isSimulating)++        // The preset disappears from the list (deleted here or via sync).+        await ctx.removeAllPresets()+        await ctx.viewModel.refresh()++        #expect(ctx.viewModel.isSimulating == false)+        #expect(ctx.viewModel.activeSimulationPresetID == nil)+        // Falls back to the plain (real) status.+        #expect(ctx.viewModel.status?.live?.soc == 90)+    }++    // MARK: - Stop++    @Test+    func stopSimulationFetchesRealStatusImmediately() async {+        let ctx = makeContext()+        ctx.api.simulatedStatus = makeStatusResponse(soc: 40)+        ctx.api.plainStatus = makeStatusResponse(soc: 88)+        await ctx.seedPresets([("p1", "Charge car", 1700)])++        await ctx.viewModel.activateSimulation(presetID: "p1")+        let plainCallsBeforeStop = ctx.api.plainCallCount+        await ctx.viewModel.stopSimulation()++        #expect(ctx.viewModel.isSimulating == false)+        #expect(ctx.api.plainCallCount == plainCallsBeforeStop + 1, "stop must fetch real status immediately")+        #expect(ctx.viewModel.status?.live?.soc == 88)+    }++    // MARK: - Widget cache skipped while simulating++    @Test+    func widgetCacheNotWrittenWhileSimulating() async {+        let ctx = makeContext()+        ctx.api.simulatedStatus = makeStatusResponse(soc: 33)+        await ctx.seedPresets([("p1", "Charge car", 1700)])++        await ctx.viewModel.activateSimulation(presetID: "p1")++        #expect(ctx.cache.read() == nil, "a simulated status must never be cached for widgets")+        #expect(ctx.reloadCounter.count == 0)+        ctx.cleanUp()+    }++    @Test+    func widgetCacheWrittenWhenNotSimulating() async {+        let ctx = makeContext()+        ctx.api.plainStatus = makeStatusResponse(soc: 64)+        await ctx.viewModel.refresh()+        #expect(ctx.cache.read()?.status.live?.soc == 64)+        ctx.cleanUp()+    }++    // MARK: - Banner presentation values++    @Test+    func bannerExposesActivePresetNameAndDelta() async {+        let ctx = makeContext()+        ctx.api.simulatedStatus = makeStatusResponse(soc: 40)+        await ctx.seedPresets([("p1", "Charge car", 1700)])++        await ctx.viewModel.activateSimulation(presetID: "p1")+        #expect(ctx.viewModel.activeSimulationName == "Charge car")+        // Delta is the preset's added load (always positive), sourced from the+        // watts that produced the displayed status.+        #expect(ctx.viewModel.activeSimulationDeltaWatts == 1700)+    }++    // MARK: - helpers++    private struct Context {+        let viewModel: DashboardViewModel+        let api: SimDashboardAPIClient+        let service: SimulationPresetsService+        let cache: WidgetSnapshotCache+        let reloadCounter: SimReloadCounter+        let suiteName: String+        let seedPresets: ([(String, String, Int)]) async -> Void+        let updatePresetWatts: (String, Int) async -> Void+        let removeAllPresets: () async -> Void+        let cleanUp: () -> Void+    }++    private func makeContext() -> Context {+        let api = SimDashboardAPIClient()+        let service = SimulationPresetsService()+        service.bind(apiClient: api)+        let suiteName = "DashboardViewModelSimulationTests.\(UUID().uuidString)"+        let cache = WidgetSnapshotCache(suiteName: suiteName)+        let reloadCounter = SimReloadCounter()+        let fixedNow = Date(timeIntervalSince1970: 3_000)++        let viewModel = DashboardViewModel(+            apiClient: api,+            simulationService: service,+            widgetCache: cache,+            widgetReloadTrigger: { reloadCounter.increment() },+            nowProvider: { fixedNow }+        )++        return Context(+            viewModel: viewModel,+            api: api,+            service: service,+            cache: cache,+            reloadCounter: reloadCounter,+            suiteName: suiteName,+            seedPresets: { specs in+                api.storedPresets = specs.map { id, label, watts in+                    SimulationPreset(+                        id: id, label: label, watts: watts,+                        createdAt: Date(timeIntervalSince1970: 1), updatedAt: Date(timeIntervalSince1970: 1)+                    )+                }+                try? await service.refresh()+            },+            updatePresetWatts: { id, watts in+                api.storedPresets = api.storedPresets.map {+                    guard $0.id == id else { return $0 }+                    var copy = $0+                    copy.watts = watts+                    return copy+                }+                try? await service.refresh()+            },+            removeAllPresets: {+                api.storedPresets = []+                try? await service.refresh()+            },+            cleanUp: {+                cache.clear()+                UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName)+            }+        )+    }+}++private func makeStatusResponse(soc: Double) -> StatusResponse {+    StatusResponse(+        live: LiveData(+            ppv: 1000, pload: 700, pbat: 250, pgrid: 0,+            pgridSustained: false, soc: soc, timestamp: "2026-06-09T10:00:00Z"+        ),+        battery: nil,+        rolling15min: nil,+        offpeak: nil,+        todayEnergy: nil+    )+}++private final class SimReloadCounter: @unchecked Sendable {+    private let lock = NSLock()+    private var _count = 0+    var count: Int { lock.lock(); defer { lock.unlock() }; return _count }+    func increment() { lock.lock(); defer { lock.unlock() }; _count += 1 }+}++@MainActor+private final class SimDashboardAPIClient: FluxAPIClient, @unchecked Sendable {+    var storedPresets: [SimulationPreset] = []+    var plainStatus = StatusResponse(live: nil, battery: nil, rolling15min: nil, offpeak: nil, todayEnergy: nil)+    var simulatedStatus = StatusResponse(live: nil, battery: nil, rolling15min: nil, offpeak: nil, todayEnergy: nil)+    var plainCallCount = 0+    var simulateCallCount = 0+    var lastSimulateWatts: Int?++    nonisolated func fetchHistory(days _: Int) async throws -> HistoryResponse { HistoryResponse(days: []) }+    nonisolated func fetchDay(date: String) async throws -> DayDetailResponse {+        DayDetailResponse(date: date, readings: [], summary: nil, peakPeriods: nil, dailyUsage: nil, note: nil)+    }+    nonisolated func saveNote(date: String, text _: String) async throws -> NoteResponse {+        NoteResponse(date: date, text: "", updatedAt: nil)+    }++    func fetchStatus() async throws -> StatusResponse {+        plainCallCount += 1+        return plainStatus+    }++    func fetchStatus(simulateLoadWatts: Int) async throws -> StatusResponse {+        simulateCallCount += 1+        lastSimulateWatts = simulateLoadWatts+        return simulatedStatus+    }++    func fetchPresets() async throws -> [SimulationPreset] { storedPresets }+}
Flux/FluxTests/Settings/SimulationPresetsViewModelTests.swift Added +190 / -0
diff --git a/Flux/FluxTests/Settings/SimulationPresetsViewModelTests.swift b/Flux/FluxTests/Settings/SimulationPresetsViewModelTests.swiftnew file mode 100644index 0000000..2d6090a--- /dev/null+++ b/Flux/FluxTests/Settings/SimulationPresetsViewModelTests.swift@@ -0,0 +1,190 @@+import FluxCore+import Foundation+import Testing+@testable import Flux++@MainActor+@Suite(.serialized)+struct SimulationPresetsViewModelTests {+    @Test+    func draftStartsInvalid() {+        let vm = makeViewModel()+        vm.beginCreate()+        #expect(vm.canSave == false, "fresh draft (empty label, 0 W) must not be savable")+    }++    @Test+    func canSaveReflectsValidDraft() {+        let vm = makeViewModel()+        vm.beginCreate()+        vm.draft.label = "Charge car"+        vm.draft.watts = 1700+        #expect(vm.canSave)+    }++    @Test+    func saveDisabledWhenWattsOutOfRange() {+        let vm = makeViewModel()+        vm.beginCreate()+        vm.draft.label = "Charge car"+        vm.draft.watts = 0+        #expect(vm.canSave == false)+        vm.draft.watts = 20001+        #expect(vm.canSave == false)+    }++    @Test+    func saveDisabledWhenLabelEmpty() {+        let vm = makeViewModel()+        vm.beginCreate()+        vm.draft.label = ""+        vm.draft.watts = 1700+        #expect(vm.canSave == false)+    }++    @Test+    func addAffordanceDisabledAtCap() async throws {+        let (service, _) = makeServiceAndAPI()+        let vm = SimulationPresetsViewModel(service: service)+        for index in 0 ..< 20 {+            vm.beginCreate()+            vm.draft.label = "Preset \(index)"+            vm.draft.watts = 1000+            _ = try await vm.save()+        }+        #expect(vm.presets.count == 20)+        #expect(vm.addAffordanceEnabled == false, "the 21st add affordance must be disabled (cap 20)")+        // canSave must also be false in create mode at the cap.+        vm.beginCreate()+        vm.draft.label = "Preset 21"+        vm.draft.watts = 1000+        #expect(vm.canSave == false)+    }++    @Test+    func saveCreatesNewPresetWhenEditorOpenedForCreate() async throws {+        let vm = makeViewModel()+        vm.beginCreate()+        vm.draft.label = "Charge car"+        vm.draft.watts = 1700+        try await vm.save()+        #expect(vm.presets.count == 1)+        #expect(vm.presets.first?.watts == 1700)+    }++    @Test+    func saveUpdatesExistingPresetWhenEditorOpenedForEdit() async throws {+        let vm = makeViewModel()+        vm.beginCreate()+        vm.draft.label = "Charge car"+        vm.draft.watts = 1700+        try await vm.save()+        let existing = try #require(vm.presets.first)+        vm.beginEdit(existing)+        vm.draft.watts = 7000+        try await vm.save()+        #expect(vm.presets.first?.watts == 7000)+        #expect(vm.presets.count == 1)+    }++    @Test+    func deleteRemovesPresetLocally() async throws {+        let vm = makeViewModel()+        vm.beginCreate()+        vm.draft.label = "Charge car"+        vm.draft.watts = 1700+        try await vm.save()+        let preset = try #require(vm.presets.first)+        try await vm.delete(preset)+        #expect(vm.presets.isEmpty)+    }++    @Test+    func showsErrorBannerWhenServiceReportsLastError() async {+        let (service, _) = makeServiceAndAPI(failOnRefresh: FluxAPIError.serverError)+        try? await service.refresh()+        let vm = SimulationPresetsViewModel(service: service)+        #expect(vm.showsErrorBanner == true)+    }++    @Test+    func clearErrorDismissesBanner() async {+        let (service, _) = makeServiceAndAPI(failOnRefresh: FluxAPIError.serverError)+        try? await service.refresh()+        let vm = SimulationPresetsViewModel(service: service)+        #expect(vm.showsErrorBanner)+        vm.clearError()+        #expect(vm.showsErrorBanner == false)+    }++    // MARK: - helpers++    private func makeViewModel() -> SimulationPresetsViewModel {+        let (service, _) = makeServiceAndAPI()+        return SimulationPresetsViewModel(service: service)+    }++    private func makeServiceAndAPI(+        failOnRefresh error: Error? = nil+    ) -> (SimulationPresetsService, PresetsViewModelTestAPIClient) {+        let service = SimulationPresetsService()+        let api = PresetsViewModelTestAPIClient(refreshError: error)+        service.bind(apiClient: api)+        return (service, api)+    }+}++@MainActor+final class PresetsViewModelTestAPIClient: FluxAPIClient, @unchecked Sendable {+    private var presets: [SimulationPreset] = []+    private var nextID = 1+    private let refreshError: Error?+    private static let cap = 20++    init(refreshError: Error? = nil) { self.refreshError = refreshError }++    nonisolated func fetchStatus() async throws -> StatusResponse {+        StatusResponse(live: nil, battery: nil, rolling15min: nil, offpeak: nil, todayEnergy: nil)+    }+    nonisolated func fetchHistory(days _: Int) async throws -> HistoryResponse { HistoryResponse(days: []) }+    nonisolated func fetchDay(date: String) async throws -> DayDetailResponse {+        DayDetailResponse(date: date, readings: [], summary: nil, peakPeriods: nil, dailyUsage: nil, note: nil)+    }+    nonisolated func saveNote(date: String, text _: String) async throws -> NoteResponse {+        NoteResponse(date: date, text: "", updatedAt: nil)+    }++    func fetchPresets() async throws -> [SimulationPreset] {+        if let refreshError { throw refreshError }+        return presets+    }++    func createPreset(_ draft: SimulationPresetDraft) async throws -> SimulationPreset {+        if presets.count >= Self.cap { throw FluxAPIError.ruleCapReached }+        let now = Date(timeIntervalSince1970: TimeInterval(nextID))+        let created = SimulationPreset(+            id: "test-\(nextID)",+            label: draft.label,+            watts: draft.watts,+            createdAt: now,+            updatedAt: now+        )+        nextID += 1+        presets.append(created)+        return created+    }++    func updatePreset(_ preset: SimulationPreset) async throws -> SimulationPreset {+        guard let idx = presets.firstIndex(where: { $0.id == preset.id }) else {+            throw FluxAPIError.notFound+        }+        var updated = preset+        updated.updatedAt = Date()+        presets[idx] = updated+        return updated+    }++    func deletePreset(id: String) async throws {+        presets.removeAll { $0.id == id }+    }+}
Flux/Packages/FluxCore/Sources/FluxCore/Models/SimulationPreset.swift Added +81 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Models/SimulationPreset.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Models/SimulationPreset.swiftnew file mode 100644index 0000000..6d78b5c--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Models/SimulationPreset.swift@@ -0,0 +1,81 @@+import Foundation++/// Server-assigned simulation preset (a named added-load scenario). The id,+/// createdAt, and updatedAt fields are populated by the backend; the client+/// treats them as opaque and never mutates them locally. Mirrors the wire+/// shape of `/simulation-presets` (`presetId` → `id`, `label`, `watts`,+/// `createdAt`, `updatedAt`).+public struct SimulationPreset: Identifiable, Codable, Sendable, Equatable {+    public let id: String+    public var label: String+    public var watts: Int+    public let createdAt: Date+    public var updatedAt: Date++    public init(+        id: String,+        label: String,+        watts: Int,+        createdAt: Date,+        updatedAt: Date+    ) {+        self.id = id+        self.label = label+        self.watts = watts+        self.createdAt = createdAt+        self.updatedAt = updatedAt+    }+}++/// The editable shape used by the preset editor sheet. The backend assigns+/// id / createdAt / updatedAt on POST, so the draft only carries the writable+/// fields and a local validation method. `watts` defaults to 0 so a fresh+/// draft starts invalid, forcing a deliberate entry (mirrors the empty-label+/// rule).+public struct SimulationPresetDraft: Sendable, Equatable {+    public var label: String+    public var watts: Int++    public init(label: String = "", watts: Int = 0) {+        self.label = label+        self.watts = watts+    }++    /// Convenience: seed a draft from an existing preset for the edit sheet.+    public init(preset: SimulationPreset) {+        self.label = preset.label+        self.watts = preset.watts+    }++    /// Reasons a draft can fail validation. Map back to user-visible text in+    /// the view model so this enum can stay localisation-agnostic.+    public enum ValidationError: Error, Equatable, Sendable {+        case emptyLabel+        case labelTooLong+        case wattsOutOfRange+    }++    /// Inclusive bound on a preset's watt value (Req 1.3). Mirrors the+    /// server-side `presetWattsMax` so a stored preset can never produce a+    /// rejected status request.+    public static let wattsRange = 1 ... 20000+    /// Maximum label length in characters (Req 1.3), matching the server cap.+    public static let labelMaxChars = 40++    /// Local pre-flight validation matching AC 1.3. Returns nil when the+    /// draft is valid. The backend re-validates server-side; this is purely+    /// for early feedback in the editor.+    public func validate() -> ValidationError? {+        let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)+        if trimmed.isEmpty {+            return .emptyLabel+        }+        if label.count > Self.labelMaxChars {+            return .labelTooLong+        }+        if !Self.wattsRange.contains(watts) {+            return .wattsOutOfRange+        }+        return nil+    }+}
Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift Modified +35 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swiftindex 558b31f..9d60041 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift@@ -4,6 +4,16 @@ public protocol FluxAPIClient: Sendable {     func fetchDay(date: String) async throws -> DayDetailResponse     func saveNote(date: String, text: String) async throws -> NoteResponse +    // Dashboard simulation — dashboard-simulation spec. A SEPARATE method from+    // `fetchStatus()` so the widget timeline and settings-validation call+    // sites stay param-free and can never simulate. Only DashboardViewModel+    // calls this, and only while a simulation is active.+    func fetchStatus(simulateLoadWatts: Int) async throws -> StatusResponse+    func fetchPresets() async throws -> [SimulationPreset]+    func createPreset(_ draft: SimulationPresetDraft) async throws -> SimulationPreset+    func updatePreset(_ preset: SimulationPreset) async throws -> SimulationPreset+    func deletePreset(id: String) async throws+     // SoC alerts — soc-alerts spec.     func registerDevice(_ registration: DeviceRegistration) async throws -> DeviceItemResponse     func fetchRules(deviceId: String) async throws -> [SoCAlertRule]@@ -69,4 +79,29 @@ public extension FluxAPIClient {     ) async throws -> ReplaceOpenEndedResult {         throw FluxAPIError.notConfigured     }++    // Default so existing conformers (~30 test mocks, the widget timeline, the+    // settings-validation client) need no change. The non-simulating fallback+    // delegates to `fetchStatus()`, so any conformer that hasn't opted into+    // simulation simply returns its real status. Only URLSessionAPIClient+    // overrides this to actually send the parameter.+    func fetchStatus(simulateLoadWatts _: Int) async throws -> StatusResponse {+        try await fetchStatus()+    }++    func fetchPresets() async throws -> [SimulationPreset] {+        throw FluxAPIError.notConfigured+    }++    func createPreset(_: SimulationPresetDraft) async throws -> SimulationPreset {+        throw FluxAPIError.notConfigured+    }++    func updatePreset(_: SimulationPreset) async throws -> SimulationPreset {+        throw FluxAPIError.notConfigured+    }++    func deletePreset(id _: String) async throws {+        throw FluxAPIError.notConfigured+    } }
Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient+Simulation.swift Added +75 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient+Simulation.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient+Simulation.swiftnew file mode 100644index 0000000..c155c8b--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient+Simulation.swift@@ -0,0 +1,75 @@+import Foundation++// MARK: - Simulation (dashboard-simulation spec)++extension URLSessionAPIClient {+    /// Simulated status. Sends the added load as a single `simulateLoadWatts`+    /// query item; the backend returns a what-if status. Kept separate from+    /// `fetchStatus()` so non-Dashboard call sites (widget timeline, settings+    /// validation) can never accidentally simulate.+    public func fetchStatus(simulateLoadWatts: Int) async throws -> StatusResponse {+        try await performRequest(+            path: "status",+            queryItems: [URLQueryItem(name: "simulateLoadWatts", value: String(simulateLoadWatts))]+        )+    }++    public func fetchPresets() async throws -> [SimulationPreset] {+        let response: SimulationPresetListResponse = try await performRequest(+            path: "simulation-presets",+            queryItems: []+        )+        return response.presets+    }++    public func createPreset(_ draft: SimulationPresetDraft) async throws -> SimulationPreset {+        let body = try encoder.encode(SimulationPresetPayload(draft: draft))+        return try await performRequest(+            path: "simulation-presets",+            queryItems: [],+            method: "POST",+            body: body+        )+    }++    public func updatePreset(_ preset: SimulationPreset) async throws -> SimulationPreset {+        let body = try encoder.encode(SimulationPresetPayload(preset: preset))+        return try await performRequest(+            path: "simulation-presets/\(preset.id)",+            queryItems: [],+            method: "PUT",+            body: body+        )+    }++    public func deletePreset(id: String) async throws {+        let _: EmptySimulationPresetResponse = try await performRequest(+            path: "simulation-presets/\(id)",+            queryItems: [],+            method: "DELETE"+        )+    }++    private struct SimulationPresetPayload: Encodable {+        let label: String+        let watts: Int++        init(draft: SimulationPresetDraft) {+            self.label = draft.label+            self.watts = draft.watts+        }++        init(preset: SimulationPreset) {+            self.label = preset.label+            self.watts = preset.watts+        }+    }++    private struct SimulationPresetListResponse: Decodable {+        let presets: [SimulationPreset]+    }++    private struct EmptySimulationPresetResponse: Decodable {+        init(from _: Decoder) throws {}+    }+}
Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift Modified +2 / -2
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swiftindex 412c910..41ea37b 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift@@ -5,7 +5,7 @@ public final class URLSessionAPIClient: FluxAPIClient, Sendable {     private let baseURL: URL     private let tokenProvider: @Sendable () -> String?     private let decoder: JSONDecoder-    private let encoder: JSONEncoder+    let encoder: JSONEncoder  // internal: used by the simulation extension's own file      private static let noCacheSession: URLSession = {         let config = URLSessionConfiguration.default@@ -159,7 +159,7 @@ public final class URLSessionAPIClient: FluxAPIClient, Sendable {         init(from _: Decoder) throws {}     } -    private func performRequest<T: Decodable>(+    func performRequest<T: Decodable>(  // internal: used by the simulation extension's file         path: String,         queryItems: [URLQueryItem],         method: String = "GET",
Flux/Packages/FluxCore/Sources/FluxCore/Simulation/SimulationPresetsService.swift Added +112 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Simulation/SimulationPresetsService.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Simulation/SimulationPresetsService.swiftnew file mode 100644index 0000000..85a0e92--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Simulation/SimulationPresetsService.swift@@ -0,0 +1,112 @@+import Foundation++/// Owns the simulation-presets cache and the server-confirmed-then-apply CRUD+/// path. View models read `presets` directly. Mirrors `SoCAlertsService`'s+/// failure model (Decision 9): a change is applied locally only after the+/// server confirms it; on failure the list is left unchanged and `lastError`+/// is surfaced for a banner.+@MainActor+@Observable+public final class SimulationPresetsService {+    public static let shared = SimulationPresetsService()++    public private(set) var presets: [SimulationPreset] = []+    public private(set) var lastError: Error?++    private var apiClient: (any FluxAPIClient)?++    public init() {}++    /// Wires the API client. Call once from the app's startup path.+    public func bind(apiClient: any FluxAPIClient) {+        self.apiClient = apiClient+    }++    /// Drops the in-memory lastError; the editor sheet calls this on dismiss+    /// so the banner clears.+    public func clearError() {+        lastError = nil+    }++    public func refresh() async throws {+        guard let apiClient else {+            lastError = FluxAPIError.notConfigured+            throw FluxAPIError.notConfigured+        }+        do {+            let remote = try await apiClient.fetchPresets()+            // Bail if a fresher refresh has already overtaken us.+            try Task.checkCancellation()+            presets = remote.sorted { $0.createdAt < $1.createdAt }+            lastError = nil+        } catch is CancellationError {+            // Cancelled refreshes are not failures; leave state alone.+            return+        } catch {+            lastError = error+            throw error+        }+    }++    @discardableResult+    public func create(_ draft: SimulationPresetDraft) async throws -> SimulationPreset {+        guard let apiClient else {+            lastError = FluxAPIError.notConfigured+            throw FluxAPIError.notConfigured+        }+        do {+            let created = try await apiClient.createPreset(draft)+            foldInsert(created)+            lastError = nil+            return created+        } catch {+            lastError = error+            throw error+        }+    }++    @discardableResult+    public func update(_ preset: SimulationPreset) async throws -> SimulationPreset {+        guard let apiClient else {+            lastError = FluxAPIError.notConfigured+            throw FluxAPIError.notConfigured+        }+        do {+            let updated = try await apiClient.updatePreset(preset)+            if let idx = presets.firstIndex(where: { $0.id == updated.id }) {+                presets[idx] = updated+            } else {+                foldInsert(updated)+            }+            lastError = nil+            return updated+        } catch {+            lastError = error+            throw error+        }+    }++    public func delete(_ presetId: String) async throws {+        guard let apiClient else {+            lastError = FluxAPIError.notConfigured+            throw FluxAPIError.notConfigured+        }+        do {+            try await apiClient.deletePreset(id: presetId)+            presets.removeAll { $0.id == presetId }+            lastError = nil+        } catch {+            lastError = error+            throw error+        }+    }++    private func foldInsert(_ preset: SimulationPreset) {+        if let idx = presets.firstIndex(where: { $0.id == preset.id }) {+            presets[idx] = preset+        } else {+            presets.append(preset)+        }+        presets.sort { $0.createdAt < $1.createdAt }+    }+}
Flux/Packages/FluxCore/Tests/FluxCoreTests/SimulationMockURLProtocol.swift Added +81 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/SimulationMockURLProtocol.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/SimulationMockURLProtocol.swiftnew file mode 100644index 0000000..94dae84--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/SimulationMockURLProtocol.swift@@ -0,0 +1,81 @@+import Foundation++/// Test double for URLProtocol used by URLSessionAPIClientSimulationTests.+/// Captures the most recent request + body and dispatches to a handler.+/// A dedicated class (not shared with the pricing protocol) so the two+/// serialized suites cannot clobber each other's captured request state.+final class SimulationMockURLProtocol: URLProtocol {+    private static let lock = NSLock()+    private static var _requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?+    private static var _lastRequest: URLRequest?+    private static var _lastRequestBody: Data?++    static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? {+        get {+            lock.lock(); defer { lock.unlock() }+            return _requestHandler+        }+        set {+            lock.lock()+            _requestHandler = newValue+            _lastRequest = nil+            _lastRequestBody = nil+            lock.unlock()+        }+    }++    static var lastRequest: URLRequest? {+        get { lock.lock(); defer { lock.unlock() }; return _lastRequest }+        set { lock.lock(); _lastRequest = newValue; lock.unlock() }+    }++    static var lastRequestBody: Data? {+        lock.lock(); defer { lock.unlock() }+        return _lastRequestBody+    }++    // swiftlint:disable:next static_over_final_class+    override class func canInit(with _: URLRequest) -> Bool { true }+    // swiftlint:disable:next static_over_final_class+    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }++    override func startLoading() {+        guard let handler = Self.requestHandler else {+            client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))+            return+        }+        Self.lastRequest = request+        if let stream = request.httpBodyStream {+            Self.lock.lock()+            Self._lastRequestBody = Self.readAll(from: stream)+            Self.lock.unlock()+        } else if let body = request.httpBody {+            Self.lock.lock()+            Self._lastRequestBody = body+            Self.lock.unlock()+        }+        do {+            let (response, data) = try handler(request)+            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)+            client?.urlProtocol(self, didLoad: data)+            client?.urlProtocolDidFinishLoading(self)+        } catch {+            client?.urlProtocol(self, didFailWithError: error)+        }+    }++    private static func readAll(from stream: InputStream) -> Data {+        stream.open()+        defer { stream.close() }+        var buffer = [UInt8](repeating: 0, count: 4096)+        var data = Data()+        while stream.hasBytesAvailable {+            let read = stream.read(&buffer, maxLength: buffer.count)+            if read <= 0 { break }+            data.append(buffer, count: read)+        }+        return data+    }++    override func stopLoading() {}+}
Flux/Packages/FluxCore/Tests/FluxCoreTests/SimulationPresetTests.swift Added +139 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/SimulationPresetTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/SimulationPresetTests.swiftnew file mode 100644index 0000000..05845ce--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/SimulationPresetTests.swift@@ -0,0 +1,139 @@+import Foundation+import Testing+@testable import FluxCore++@Suite+struct SimulationPresetTests {+    // MARK: - Decode / encode++    @Test+    func decodeFromBackendShape() throws {+        let json = """+        {+          "id": "preset-uuid",+          "label": "Charge car",+          "watts": 1700,+          "createdAt": "2026-06-09T08:00:00Z",+          "updatedAt": "2026-06-09T08:00:00Z"+        }+        """.data(using: .utf8)!++        let preset = try jsonDecoder().decode(SimulationPreset.self, from: json)+        #expect(preset.id == "preset-uuid")+        #expect(preset.label == "Charge car")+        #expect(preset.watts == 1700)+    }++    @Test+    func encodeRoundTrip() throws {+        let preset = SimulationPreset(+            id: "p1",+            label: "Heat pump",+            watts: 3200,+            createdAt: Date(timeIntervalSince1970: 1_715_000_000),+            updatedAt: Date(timeIntervalSince1970: 1_715_100_000)+        )+        let data = try jsonEncoder().encode(preset)+        let back = try jsonDecoder().decode(SimulationPreset.self, from: data)+        #expect(back.id == preset.id)+        #expect(back.label == preset.label)+        #expect(back.watts == preset.watts)+    }++    // MARK: - Draft validation (boundary cases — AC 1.3)++    @Test+    func draftDefaultStartsInvalid() {+        // Empty label + watts 0 so the editor forces a deliberate entry. The+        // empty-label check is reported first.+        let draft = SimulationPresetDraft()+        #expect(draft.validate() != nil)+        #expect(draft.validate() == .emptyLabel)+    }++    @Test+    func draftWithLabelButZeroWattsStartsInvalid() {+        // watts defaults to 0 so even with a label the draft is invalid until+        // a deliberate watt value is entered.+        let draft = SimulationPresetDraft(label: "Charge car")+        #expect(draft.validate() == .wattsOutOfRange)+    }++    @Test+    func draftValidWithLabelAndWatts() {+        let draft = SimulationPresetDraft(label: "Charge car", watts: 1700)+        #expect(draft.validate() == nil)+    }++    @Test+    func draftRejectsEmptyLabel() {+        let draft = SimulationPresetDraft(label: "", watts: 1700)+        #expect(draft.validate() == .emptyLabel)+    }++    @Test+    func draftRejectsLabelOf41Chars() {+        let draft = SimulationPresetDraft(label: String(repeating: "a", count: 41), watts: 1700)+        #expect(draft.validate() == .labelTooLong)+    }++    @Test+    func draftAcceptsLabelOf40Chars() {+        let draft = SimulationPresetDraft(label: String(repeating: "a", count: 40), watts: 1700)+        #expect(draft.validate() == nil)+    }++    @Test+    func draftRejectsZeroWatts() {+        let draft = SimulationPresetDraft(label: "Charge car", watts: 0)+        #expect(draft.validate() == .wattsOutOfRange)+    }++    @Test+    func draftRejectsWattsOf20001() {+        let draft = SimulationPresetDraft(label: "Charge car", watts: 20001)+        #expect(draft.validate() == .wattsOutOfRange)+    }++    @Test+    func draftAcceptsWattsOf20000() {+        let draft = SimulationPresetDraft(label: "Charge car", watts: 20000)+        #expect(draft.validate() == nil)+    }++    @Test+    func draftAcceptsWattsOf1() {+        let draft = SimulationPresetDraft(label: "Charge car", watts: 1)+        #expect(draft.validate() == nil)+    }++    // MARK: - Draft from preset++    @Test+    func draftSeededFromPresetCarriesWritableFields() {+        let preset = SimulationPreset(+            id: "p1",+            label: "Oven",+            watts: 2400,+            createdAt: Date(),+            updatedAt: Date()+        )+        let draft = SimulationPresetDraft(preset: preset)+        #expect(draft.label == "Oven")+        #expect(draft.watts == 2400)+    }++    // MARK: - helpers++    private func jsonEncoder() -> JSONEncoder {+        let enc = JSONEncoder()+        enc.dateEncodingStrategy = .iso8601+        return enc+    }++    private func jsonDecoder() -> JSONDecoder {+        let dec = JSONDecoder()+        dec.dateDecodingStrategy = .iso8601+        return dec+    }+}
Flux/Packages/FluxCore/Tests/FluxCoreTests/SimulationPresetsServiceTests.swift Added +186 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/SimulationPresetsServiceTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/SimulationPresetsServiceTests.swiftnew file mode 100644index 0000000..2336717--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/SimulationPresetsServiceTests.swift@@ -0,0 +1,186 @@+import Foundation+import Testing+@testable import FluxCore++@MainActor+@Suite(.serialized)+struct SimulationPresetsServiceTests {+    @Test+    func refreshLoadsPresetsFromBackend() async throws {+        let api = SimulationTestAPIClient()+        let now = Date()+        api.presets = [+            SimulationPreset(id: "p1", label: "Charge car", watts: 1700, createdAt: now, updatedAt: now)+        ]+        let svc = makeService(api: api)+        try await svc.refresh()+        #expect(svc.presets.count == 1)+        #expect(svc.presets.first?.id == "p1")+        #expect(svc.lastError == nil)+    }++    @Test+    func refreshFailureSetsLastErrorAndLeavesListUnchanged() async {+        let api = SimulationTestAPIClient()+        api.refreshError = FluxAPIError.serverError+        let svc = makeService(api: api)+        try? await svc.refresh()+        #expect(svc.presets.isEmpty)+        #expect(svc.lastError != nil)+    }++    @Test+    func createAppliesAfterServerConfirms() async throws {+        let api = SimulationTestAPIClient()+        let svc = makeService(api: api)+        let created = try await svc.create(SimulationPresetDraft(label: "Charge car", watts: 1700))+        #expect(svc.presets.contains { $0.id == created.id })+        #expect(svc.lastError == nil)+    }++    @Test+    func createFailureSetsLastErrorAndLeavesListUnchanged() async {+        let api = SimulationTestAPIClient()+        api.createError = FluxAPIError.ruleCapReached+        let svc = makeService(api: api)+        do {+            _ = try await svc.create(SimulationPresetDraft(label: "x", watts: 100))+            Issue.record("expected create to throw")+        } catch {+            #expect(svc.presets.isEmpty)+            #expect(svc.lastError != nil)+        }+    }++    @Test+    func updateReplacesAfterServerConfirms() async throws {+        let api = SimulationTestAPIClient()+        let svc = makeService(api: api)+        let created = try await svc.create(SimulationPresetDraft(label: "Charge car", watts: 1700))+        var edited = created+        edited.watts = 7000+        let updated = try await svc.update(edited)+        #expect(updated.watts == 7000)+        #expect(svc.presets.first { $0.id == created.id }?.watts == 7000)+    }++    @Test+    func updateFailureSetsLastErrorAndLeavesListUnchanged() async throws {+        let api = SimulationTestAPIClient()+        let svc = makeService(api: api)+        let created = try await svc.create(SimulationPresetDraft(label: "Charge car", watts: 1700))+        api.updateError = FluxAPIError.serverError+        var edited = created+        edited.watts = 9999+        do {+            _ = try await svc.update(edited)+            Issue.record("expected update to throw")+        } catch {+            #expect(svc.presets.first { $0.id == created.id }?.watts == 1700)+            #expect(svc.lastError != nil)+        }+    }++    @Test+    func deleteRemovesAfterServerConfirms() async throws {+        let api = SimulationTestAPIClient()+        let svc = makeService(api: api)+        let created = try await svc.create(SimulationPresetDraft(label: "Charge car", watts: 1700))+        try await svc.delete(created.id)+        #expect(svc.presets.isEmpty)+        #expect(svc.lastError == nil)+    }++    @Test+    func deleteFailureSetsLastErrorAndLeavesListUnchanged() async throws {+        let api = SimulationTestAPIClient()+        let svc = makeService(api: api)+        let created = try await svc.create(SimulationPresetDraft(label: "Charge car", watts: 1700))+        api.deleteError = FluxAPIError.serverError+        do {+            try await svc.delete(created.id)+            Issue.record("expected delete to throw")+        } catch {+            #expect(svc.presets.contains { $0.id == created.id })+            #expect(svc.lastError != nil)+        }+    }++    @Test+    func clearErrorResetsLastError() async {+        let api = SimulationTestAPIClient()+        api.refreshError = FluxAPIError.serverError+        let svc = makeService(api: api)+        try? await svc.refresh()+        #expect(svc.lastError != nil)+        svc.clearError()+        #expect(svc.lastError == nil)+    }++    // MARK: - helpers++    private func makeService(api: SimulationTestAPIClient) -> SimulationPresetsService {+        let service = SimulationPresetsService()+        service.bind(apiClient: api)+        return service+    }+}++// MARK: - Test double++@MainActor+final class SimulationTestAPIClient: FluxAPIClient, @unchecked Sendable {+    var presets: [SimulationPreset] = []+    var refreshError: Error?+    var createError: Error?+    var updateError: Error?+    var deleteError: Error?+    private var nextSeq = 1++    nonisolated func fetchStatus() async throws -> StatusResponse {+        StatusResponse(live: nil, battery: nil, rolling15min: nil, offpeak: nil, todayEnergy: nil)+    }+    nonisolated func fetchHistory(days _: Int) async throws -> HistoryResponse { HistoryResponse(days: []) }+    nonisolated func fetchDay(date: String) async throws -> DayDetailResponse {+        DayDetailResponse(date: date, readings: [], summary: nil, peakPeriods: nil, dailyUsage: nil, note: nil)+    }+    nonisolated func saveNote(date: String, text _: String) async throws -> NoteResponse {+        NoteResponse(date: date, text: "", updatedAt: nil)+    }++    func fetchPresets() async throws -> [SimulationPreset] {+        if let refreshError { throw refreshError }+        return presets+    }++    func createPreset(_ draft: SimulationPresetDraft) async throws -> SimulationPreset {+        if let createError { throw createError }+        let now = Date()+        let created = SimulationPreset(+            id: "test-\(nextSeq)",+            label: draft.label,+            watts: draft.watts,+            createdAt: now,+            updatedAt: now+        )+        nextSeq += 1+        presets.append(created)+        return created+    }++    func updatePreset(_ preset: SimulationPreset) async throws -> SimulationPreset {+        if let updateError { throw updateError }+        guard let idx = presets.firstIndex(where: { $0.id == preset.id }) else {+            throw FluxAPIError.notFound+        }+        var updated = preset+        updated.updatedAt = Date()+        presets[idx] = updated+        return updated+    }++    func deletePreset(id: String) async throws {+        if let deleteError { throw deleteError }+        presets.removeAll { $0.id == id }+    }+}
Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientSimulationTests.swift Added +251 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientSimulationTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientSimulationTests.swiftnew file mode 100644index 0000000..633c2ef--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientSimulationTests.swift@@ -0,0 +1,251 @@+import Foundation+import Testing+@testable import FluxCore++// swiftlint:disable file_length type_body_length+@MainActor @Suite(.serialized)+struct URLSessionAPIClientSimulationTests {+    // MARK: - Simulated status request++    @Test+    func fetchStatusWithSimulateLoadAddsQueryItem() async throws {+        let session = makeSession()+        SimulationMockURLProtocol.requestHandler = { request in+            let url = try #require(request.url)+            let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!+            return (response, Data(Self.statusBody.utf8))+        }+        let client = makeClient(session: session)+        _ = try await client.fetchStatus(simulateLoadWatts: 1700)++        let request = try #require(SimulationMockURLProtocol.lastRequest)+        let requestURL = try #require(request.url)+        let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))+        #expect(components.path == "/status")+        #expect(request.httpMethod == "GET")+        let item = components.queryItems?.first { $0.name == "simulateLoadWatts" }+        #expect(item?.value == "1700")+    }++    @Test+    func fetchStatusWithoutSimulateLoadStaysParamFree() async throws {+        let session = makeSession()+        SimulationMockURLProtocol.requestHandler = { request in+            let url = try #require(request.url)+            let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!+            return (response, Data(Self.statusBody.utf8))+        }+        let client = makeClient(session: session)+        _ = try await client.fetchStatus()++        let request = try #require(SimulationMockURLProtocol.lastRequest)+        let requestURL = try #require(request.url)+        let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))+        #expect(components.path == "/status")+        // The unsimulated call must carry no query items at all.+        #expect(components.queryItems == nil)+    }++    @Test+    func fetchStatusWithSimulateLoadDecodesStatus() async throws {+        let session = makeSession()+        SimulationMockURLProtocol.requestHandler = { request in+            let url = try #require(request.url)+            let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!+            return (response, Data(Self.statusBody.utf8))+        }+        let client = makeClient(session: session)+        let status = try await client.fetchStatus(simulateLoadWatts: 1700)+        #expect(status.live?.soc == 62)+    }++    // MARK: - Preset list++    @Test+    func fetchPresetsDecodesArray() async throws {+        let session = makeSession()+        SimulationMockURLProtocol.requestHandler = { request in+            let url = try #require(request.url)+            let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!+            let body = """+            {+              "presets": [+                {"id": "p1", "label": "Charge car", "watts": 1700,+                 "createdAt": "2026-06-09T08:00:00Z", "updatedAt": "2026-06-09T08:00:00Z"},+                {"id": "p2", "label": "Heat pump", "watts": 3200,+                 "createdAt": "2026-06-09T09:00:00Z", "updatedAt": "2026-06-09T09:00:00Z"}+              ]+            }+            """+            return (response, Data(body.utf8))+        }+        let client = makeClient(session: session)+        let presets = try await client.fetchPresets()++        #expect(presets.count == 2)+        #expect(presets[0].id == "p1")+        #expect(presets[1].watts == 3200)+        let request = try #require(SimulationMockURLProtocol.lastRequest)+        let requestURL = try #require(request.url)+        let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))+        #expect(components.path == "/simulation-presets")+        #expect(request.httpMethod == "GET")+        #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer token")+    }++    // MARK: - Create++    @Test+    func createPresetPostsDraftAndDecodesResponse() async throws {+        let session = makeSession()+        SimulationMockURLProtocol.requestHandler = { request in+            let url = try #require(request.url)+            let response = HTTPURLResponse(url: url, statusCode: 201, httpVersion: nil, headerFields: nil)!+            let body = """+            {"id": "p-new", "label": "Charge car", "watts": 1700,+             "createdAt": "2026-06-09T08:00:00Z", "updatedAt": "2026-06-09T08:00:00Z"}+            """+            return (response, Data(body.utf8))+        }+        let client = makeClient(session: session)+        let created = try await client.createPreset(SimulationPresetDraft(label: "Charge car", watts: 1700))++        #expect(created.id == "p-new")+        #expect(created.watts == 1700)+        let request = try #require(SimulationMockURLProtocol.lastRequest)+        #expect(request.httpMethod == "POST")+        let requestURL = try #require(request.url)+        let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))+        #expect(components.path == "/simulation-presets")+        #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")+        let bodyData = try #require(SimulationMockURLProtocol.lastRequestBody)+        let json = try #require(try JSONSerialization.jsonObject(with: bodyData) as? [String: Any])+        #expect(json["label"] as? String == "Charge car")+        #expect((json["watts"] as? NSNumber)?.intValue == 1700)+    }++    @Test+    func createPresetMaps409ToRuleCapReached() async throws {+        let session = makeSession()+        SimulationMockURLProtocol.requestHandler = { request in+            let url = try #require(request.url)+            let response = HTTPURLResponse(url: url, statusCode: 409, httpVersion: nil, headerFields: nil)!+            return (response, Data("{\"error\":\"preset cap reached\"}".utf8))+        }+        let client = makeClient(session: session)+        do {+            _ = try await client.createPreset(SimulationPresetDraft(label: "x", watts: 100))+            Issue.record("expected cap error")+        } catch let error as FluxAPIError {+            #expect(error == .ruleCapReached)+        }+    }++    @Test+    func createPresetMaps400ToBadRequest() async throws {+        let session = makeSession()+        SimulationMockURLProtocol.requestHandler = { request in+            let url = try #require(request.url)+            let response = HTTPURLResponse(url: url, statusCode: 400, httpVersion: nil, headerFields: nil)!+            return (response, Data("{\"error\":\"label must not be empty\"}".utf8))+        }+        let client = makeClient(session: session)+        do {+            _ = try await client.createPreset(SimulationPresetDraft(label: "", watts: 100))+            Issue.record("expected bad request")+        } catch let error as FluxAPIError {+            #expect(error == .badRequest("label must not be empty"))+        }+    }++    // MARK: - Update++    @Test+    func updatePresetPutsDraftAndDecodesResponse() async throws {+        let session = makeSession()+        SimulationMockURLProtocol.requestHandler = { request in+            let url = try #require(request.url)+            let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!+            let body = """+            {"id": "p1", "label": "Charge car fast", "watts": 7000,+             "createdAt": "2026-06-09T08:00:00Z", "updatedAt": "2026-06-09T10:00:00Z"}+            """+            return (response, Data(body.utf8))+        }+        let preset = SimulationPreset(+            id: "p1", label: "Charge car fast", watts: 7000,+            createdAt: Date(timeIntervalSince1970: 1), updatedAt: Date(timeIntervalSince1970: 1)+        )+        let client = makeClient(session: session)+        let updated = try await client.updatePreset(preset)+        #expect(updated.id == "p1")+        #expect(updated.watts == 7000)+        let request = try #require(SimulationMockURLProtocol.lastRequest)+        #expect(request.httpMethod == "PUT")+        let requestURL = try #require(request.url)+        let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))+        #expect(components.path == "/simulation-presets/p1")+        let bodyData = try #require(SimulationMockURLProtocol.lastRequestBody)+        let json = try #require(try JSONSerialization.jsonObject(with: bodyData) as? [String: Any])+        #expect(json["label"] as? String == "Charge car fast")+        #expect((json["watts"] as? NSNumber)?.intValue == 7000)+    }++    @Test+    func updatePresetMaps404ToNotFound() async throws {+        let session = makeSession()+        SimulationMockURLProtocol.requestHandler = { request in+            let url = try #require(request.url)+            let response = HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)!+            return (response, Data())+        }+        let preset = SimulationPreset(+            id: "missing", label: "x", watts: 100,+            createdAt: Date(timeIntervalSince1970: 1), updatedAt: Date(timeIntervalSince1970: 1)+        )+        let client = makeClient(session: session)+        do {+            _ = try await client.updatePreset(preset)+            Issue.record("expected notFound")+        } catch let error as FluxAPIError {+            #expect(error == .notFound)+        }+    }++    // MARK: - Delete++    @Test+    func deletePresetHits204AndReturns() async throws {+        let session = makeSession()+        SimulationMockURLProtocol.requestHandler = { request in+            let url = try #require(request.url)+            let response = HTTPURLResponse(url: url, statusCode: 204, httpVersion: nil, headerFields: nil)!+            return (response, Data())+        }+        let client = makeClient(session: session)+        try await client.deletePreset(id: "p1")+        let request = try #require(SimulationMockURLProtocol.lastRequest)+        #expect(request.httpMethod == "DELETE")+        let requestURL = try #require(request.url)+        let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))+        #expect(components.path == "/simulation-presets/p1")+    }++    // MARK: - Helpers++    private static let statusBody = """+    {"live": {"ppv": 1000, "pload": 700, "pbat": 250, "pgrid": 0,+              "pgridSustained": false, "soc": 62, "timestamp": "2026-06-09T10:00:00Z"}}+    """++    private func makeClient(session: URLSession) -> URLSessionAPIClient {+        URLSessionAPIClient(baseURL: URL(string: "https://example.com")!, token: "token", session: session)+    }++    private func makeSession() -> URLSession {+        let configuration = URLSessionConfiguration.ephemeral+        configuration.protocolClasses = [SimulationMockURLProtocol.self]+        return URLSession(configuration: configuration)+    }+}+// swiftlint:enable file_length type_body_length
cmd/api/main.go Modified +5 / -0
diff --git a/cmd/api/main.go b/cmd/api/main.goindex c4ed57d..99e074c 100644--- a/cmd/api/main.go+++ b/cmd/api/main.go@@ -26,6 +26,7 @@ type config struct { 	rules        api.SocRuleStore 	fireState    api.FireStateCleaner 	pricing      api.PricingStore+	presets      api.SimulationPresetStore 	apiToken     string 	serial       string 	offpeakStart string@@ -44,6 +45,7 @@ var requiredEnvVars = []string{ 	"TABLE_SOC_RULES", 	"TABLE_SOC_FIRESTATE", 	"TABLE_PRICING",+	"TABLE_SIMULATION_PRESETS", 	"OFFPEAK_START", 	"OFFPEAK_END", 	"API_TOKEN_PARAM",@@ -65,6 +67,7 @@ func main() { 	handler.SetSocRuleStore(cfg.rules) 	handler.SetFireStateCleaner(cfg.fireState) 	handler.SetPricingStore(cfg.pricing)+	handler.SetSimulationPresetStore(cfg.presets) 	lambda.Start(handler.Handle) } @@ -119,6 +122,7 @@ func loadConfig(ctx context.Context) (*config, error) { 	ruleWriter := dynamo.NewDynamoSocRuleWriter(ddbClient, os.Getenv("TABLE_SOC_RULES")) 	fireState := dynamo.NewDynamoSocFireStateWriter(ddbClient, os.Getenv("TABLE_SOC_FIRESTATE")) 	pricing := dynamo.NewDynamoPricingStore(ddbClient, os.Getenv("TABLE_PRICING"))+	presets := dynamo.NewDynamoSimulationPresetStore(ddbClient, os.Getenv("TABLE_SIMULATION_PRESETS"))  	return &config{ 		reader:       reader,@@ -127,6 +131,7 @@ func loadConfig(ctx context.Context) (*config, error) { 		rules:        socRuleStoreAdapter{reader: ruleReader, writer: ruleWriter}, 		fireState:    fireStateCleanerAdapter{store: fireState}, 		pricing:      pricingStoreAdapter{store: pricing},+		presets:      presets, 		apiToken:     apiToken, 		serial:       serial, 		offpeakStart: os.Getenv("OFFPEAK_START"),
infrastructure/template.yaml Modified +33 / -0
diff --git a/infrastructure/template.yaml b/infrastructure/template.yamlindex d4549d6..9cfd6c8 100644--- a/infrastructure/template.yaml+++ b/infrastructure/template.yaml@@ -309,6 +309,16 @@ Resources:                   - dynamodb:TransactWriteItems                 Resource:                   - !GetAtt PricingTable.Arn+              # Simulation presets table — Lambda is the sole writer. Scan+              # covers ListPresets (capped at 20 rows); no TransactWriteItems+              # because presets are independent rows (no sentinel).+              - Effect: Allow+                Action:+                  - dynamodb:Scan+                  - dynamodb:PutItem+                  - dynamodb:DeleteItem+                Resource:+                  - !GetAtt SimulationPresetsTable.Arn               - Effect: Allow                 Action:                   - ssm:GetParameter@@ -567,6 +577,28 @@ Resources:       PointInTimeRecoverySpecification:         PointInTimeRecoveryEnabled: true +  # --- Simulation presets table (dashboard-simulation spec) ---+  # User-authored load presets (label + watts), system-wide and synced across+  # devices. Mirrors flux-pricing: id-only key (no partition), low write+  # volume, PITR enabled because presets are user-authored with no other+  # recovery path. Unlike flux-pricing there is no sentinel row (Decision 10).++  SimulationPresetsTable:+    Type: AWS::DynamoDB::Table+    DeletionPolicy: Retain+    UpdateReplacePolicy: Retain+    Properties:+      TableName: flux-simulation-presets+      BillingMode: PAY_PER_REQUEST+      AttributeDefinitions:+        - AttributeName: presetId+          AttributeType: S+      KeySchema:+        - AttributeName: presetId+          KeyType: HASH+      PointInTimeRecoverySpecification:+        PointInTimeRecoveryEnabled: true+   # --- Lambda Function and Function URL ---    ApiFunction:@@ -600,6 +632,7 @@ Resources:           TABLE_SOC_RULES: !Ref SocRulesTable           TABLE_SOC_FIRESTATE: !Ref SocFireStateTable           TABLE_PRICING: !Ref PricingTable+          TABLE_SIMULATION_PRESETS: !Ref SimulationPresetsTable    ApiFunctionUrl:     Type: AWS::Lambda::Url
internal/api/handler.go Modified +5 / -0
diff --git a/internal/api/handler.go b/internal/api/handler.goindex e71cd4f..ebeb910 100644--- a/internal/api/handler.go+++ b/internal/api/handler.go@@ -27,6 +27,7 @@ type Handler struct { 	rules        SocRuleStore 	fireState    FireStateCleaner 	pricing      PricingStore+	presets      SimulationPresetStore 	serial       string 	apiToken     string 	offpeakStart string@@ -85,6 +86,10 @@ func (h *Handler) buildMux() http.Handler { 	mux.HandleFunc("PUT /pricing/{id}", h.handleUpdatePricing) 	mux.HandleFunc("DELETE /pricing/{id}", h.handleDeletePricing) 	mux.HandleFunc("POST /pricing/replace-open-ended", h.handleReplaceOpenEnded)+	mux.HandleFunc("GET /simulation-presets", h.handleListPresets)+	mux.HandleFunc("POST /simulation-presets", h.handleCreatePreset)+	mux.HandleFunc("PUT /simulation-presets/{id}", h.handleUpdatePreset)+	mux.HandleFunc("DELETE /simulation-presets/{id}", h.handleDeletePreset) 	return bearerTokenMiddleware(h.apiToken, jsonNotFound(jsonMethodNotAllowed(mux))) } 
internal/api/simulationpresets.go Added +60 / -0
diff --git a/internal/api/simulationpresets.go b/internal/api/simulationpresets.gonew file mode 100644index 0000000..4f10355--- /dev/null+++ b/internal/api/simulationpresets.go@@ -0,0 +1,60 @@+package api++import (+	"context"+	"unicode/utf8"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+)++// presetCap is the defensive maximum number of simulation presets (Decision+// 12). Create returns 409 when the cap is reached; chosen for selection-menu+// legibility on the Dashboard rather than any storage limit.+const presetCap = 20++// presetWattsMax is the inclusive upper bound on a preset's watt value+// (Req 1.3). Presets are validated to the same bound the /status+// simulateLoadWatts parameter accepts, so a stored preset can never produce a+// rejected status request.+const presetWattsMax = simLoadMaxWatts++// presetLabelMaxChars caps the preset label length, counted as Unicode code+// points to match the client-side cap (Req 1.3).+const presetLabelMaxChars = 40++// presetBodyMaxBytes caps inbound JSON on every preset mutation. A maxed-out+// payload (40-rune label plus the small watts field) fits well under 512+// bytes; 4096 leaves generous room for whitespace.+const presetBodyMaxBytes = 4096++// SimulationPresetStore is the api-package-local view of the preset store.+// Lambda wiring constructs a *dynamo.DynamoSimulationPresetStore and passes it+// in directly; tests substitute an in-memory fake.+type SimulationPresetStore interface {+	ListPresets(ctx context.Context) ([]dynamo.SimulationPresetItem, error)+	PutPreset(ctx context.Context, item dynamo.SimulationPresetItem) error+	DeletePreset(ctx context.Context, id string) error+}++// simulationPresetPayload is the wire shape of POST /simulation-presets and+// PUT /simulation-presets/{id}.+type simulationPresetPayload struct {+	Label string `json:"label"`+	Watts int    `json:"watts"`+}++// validate enforces Req 1.3 on the wire shape: label 1..40 Unicode code+// points, watts 1..20000. Returns a human-readable reason, or "" when valid.+func (p simulationPresetPayload) validate() string {+	n := utf8.RuneCountInString(p.Label)+	if n < 1 {+		return "label must not be empty"+	}+	if n > presetLabelMaxChars {+		return "label exceeds 40 characters"+	}+	if p.Watts < 1 || p.Watts > presetWattsMax {+		return "watts must be between 1 and 20000"+	}+	return ""+}
internal/api/simulationpresets_handler.go Added +175 / -0
diff --git a/internal/api/simulationpresets_handler.go b/internal/api/simulationpresets_handler.gonew file mode 100644index 0000000..4847236--- /dev/null+++ b/internal/api/simulationpresets_handler.go@@ -0,0 +1,175 @@+package api++import (+	"encoding/json"+	"io"+	"log/slog"+	"net/http"+	"sort"+	"time"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+)++// SetSimulationPresetStore wires the preset CRUD dependency. Called by+// cmd/api/main.go and rebuilds the mux so the routes pick up the store.+func (h *Handler) SetSimulationPresetStore(s SimulationPresetStore) {+	h.presets = s+	h.mux = h.buildMux()+}++// decodePresetPayload reads, size-limits, and JSON-decodes the request body.+// On failure the response is already written; callers return early.+func decodePresetPayload(w http.ResponseWriter, r *http.Request) (simulationPresetPayload, bool) {+	r.Body = http.MaxBytesReader(w, r.Body, presetBodyMaxBytes)+	body, err := io.ReadAll(r.Body)+	if err != nil {+		writeJSONError(w, http.StatusBadRequest, "malformed request body")+		return simulationPresetPayload{}, false+	}+	var payload simulationPresetPayload+	if err := json.Unmarshal(body, &payload); err != nil {+		writeJSONError(w, http.StatusBadRequest, "malformed request body")+		return simulationPresetPayload{}, false+	}+	return payload, true+}++// handleListPresets returns every preset sorted by createdAt ascending.+func (h *Handler) handleListPresets(w http.ResponseWriter, r *http.Request) {+	if h.presets == nil {+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	presets, err := h.presets.ListPresets(r.Context())+	if err != nil {+		slog.Error("list simulation presets failed", "error", err)+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	sort.SliceStable(presets, func(i, j int) bool {+		return presets[i].CreatedAt < presets[j].CreatedAt+	})+	writeJSON(w, http.StatusOK, struct {+		Presets []dynamo.SimulationPresetItem `json:"presets"`+	}{Presets: presets})+}++// handleCreatePreset validates the payload, enforces the 20-preset cap,+// assigns server-side id/timestamps, and writes the row.+func (h *Handler) handleCreatePreset(w http.ResponseWriter, r *http.Request) {+	if h.presets == nil {+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	payload, ok := decodePresetPayload(w, r)+	if !ok {+		return+	}+	if msg := payload.validate(); msg != "" {+		writeJSONError(w, http.StatusBadRequest, msg)+		return+	}++	existing, err := h.presets.ListPresets(r.Context())+	if err != nil {+		slog.Error("list simulation presets failed during create", "error", err)+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	if len(existing) >= presetCap {+		writeJSONError(w, http.StatusConflict, "preset cap reached")+		return+	}++	now := h.nowFunc().UTC().Format(time.RFC3339)+	item := dynamo.SimulationPresetItem{+		PresetID:  h.idFunc(),+		Label:     payload.Label,+		Watts:     payload.Watts,+		CreatedAt: now,+		UpdatedAt: now,+	}+	if err := h.presets.PutPreset(r.Context(), item); err != nil {+		slog.Error("put simulation preset failed", "error", err)+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	writeJSON(w, http.StatusCreated, item)+}++// handleUpdatePreset validates and overwrites an existing preset row. The+// createdAt is preserved; updatedAt is bumped to now. 404 on unknown id.+func (h *Handler) handleUpdatePreset(w http.ResponseWriter, r *http.Request) {+	if h.presets == nil {+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	id := r.PathValue("id")+	if id == "" {+		writeJSONError(w, http.StatusBadRequest, "id required")+		return+	}+	payload, ok := decodePresetPayload(w, r)+	if !ok {+		return+	}+	if msg := payload.validate(); msg != "" {+		writeJSONError(w, http.StatusBadRequest, msg)+		return+	}++	existing, err := h.presets.ListPresets(r.Context())+	if err != nil {+		slog.Error("list simulation presets failed during update", "error", err)+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	var current *dynamo.SimulationPresetItem+	for i := range existing {+		if existing[i].PresetID == id {+			current = &existing[i]+			break+		}+	}+	if current == nil {+		writeJSONError(w, http.StatusNotFound, "preset not found")+		return+	}++	now := h.nowFunc().UTC().Format(time.RFC3339)+	item := dynamo.SimulationPresetItem{+		PresetID:  id,+		Label:     payload.Label,+		Watts:     payload.Watts,+		CreatedAt: current.CreatedAt,+		UpdatedAt: now,+	}+	if err := h.presets.PutPreset(r.Context(), item); err != nil {+		slog.Error("put simulation preset failed", "preset_id", id, "error", err)+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	writeJSON(w, http.StatusOK, item)+}++// handleDeletePreset removes a preset by id. Idempotent — re-deleting a+// missing preset still returns 204.+func (h *Handler) handleDeletePreset(w http.ResponseWriter, r *http.Request) {+	if h.presets == nil {+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	id := r.PathValue("id")+	if id == "" {+		writeJSONError(w, http.StatusBadRequest, "id required")+		return+	}+	if err := h.presets.DeletePreset(r.Context(), id); err != nil {+		slog.Error("delete simulation preset failed", "preset_id", id, "error", err)+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	w.Header().Set("Content-Type", "application/json")+	w.WriteHeader(http.StatusNoContent)+}
internal/api/simulationpresets_test.go Added +263 / -0
diff --git a/internal/api/simulationpresets_test.go b/internal/api/simulationpresets_test.gonew file mode 100644index 0000000..985d921--- /dev/null+++ b/internal/api/simulationpresets_test.go@@ -0,0 +1,263 @@+package api++import (+	"context"+	"encoding/json"+	"fmt"+	"net/http"+	"strings"+	"sync"+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// fakeSimulationPresetStore implements the SimulationPresetStore interface+// in-memory so the CRUD handlers exercise the real validation + 20-preset cap+// path. Mirrors fakeSocRuleStore / the pricing fake store shape.+type fakeSimulationPresetStore struct {+	mu      sync.Mutex+	presets []dynamo.SimulationPresetItem+	err     error+}++func newFakeSimulationPresetStore() *fakeSimulationPresetStore {+	return &fakeSimulationPresetStore{}+}++func (s *fakeSimulationPresetStore) ListPresets(_ context.Context) ([]dynamo.SimulationPresetItem, error) {+	s.mu.Lock()+	defer s.mu.Unlock()+	if s.err != nil {+		return nil, s.err+	}+	out := make([]dynamo.SimulationPresetItem, len(s.presets))+	copy(out, s.presets)+	return out, nil+}++func (s *fakeSimulationPresetStore) PutPreset(_ context.Context, item dynamo.SimulationPresetItem) error {+	s.mu.Lock()+	defer s.mu.Unlock()+	if s.err != nil {+		return s.err+	}+	for i, p := range s.presets {+		if p.PresetID == item.PresetID {+			s.presets[i] = item+			return nil+		}+	}+	s.presets = append(s.presets, item)+	return nil+}++func (s *fakeSimulationPresetStore) DeletePreset(_ context.Context, id string) error {+	s.mu.Lock()+	defer s.mu.Unlock()+	if s.err != nil {+		return s.err+	}+	for i, p := range s.presets {+		if p.PresetID == id {+			s.presets = append(s.presets[:i], s.presets[i+1:]...)+			return nil+		}+	}+	return nil+}++// newPresetsTestHandler wires a fake preset store with a fixed clock and a+// deterministic id generator so create/list/PUT/DELETE assertions are exact.+func newPresetsTestHandler(store *fakeSimulationPresetStore) *Handler {+	h := NewHandler(&mockReader{}, nil, testSerial, testToken, "11:00", "14:00")+	h.SetSimulationPresetStore(store)+	fixed := time.Date(2026, 5, 19, 10, 0, 0, 0, time.UTC)+	h.nowFunc = func() time.Time { return fixed }+	counter := 0+	h.idFunc = func() string {+		counter+++		return fmt.Sprintf("preset-uuid-%d", counter)+	}+	h.mux = h.buildMux()+	return h+}++func presetBody(label string, watts int) string {+	b, _ := json.Marshal(map[string]any{"label": label, "watts": watts})+	return string(b)+}++func TestHandleCreatePreset_AssignsIDAndTimestamps(t *testing.T) {+	store := newFakeSimulationPresetStore()+	h := newPresetsTestHandler(store)++	req := makeRequest(http.MethodPost, "/simulation-presets", "Bearer "+testToken)+	req.Body = presetBody("Charge car", 1700)+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	require.Equal(t, http.StatusCreated, resp.StatusCode)++	var item dynamo.SimulationPresetItem+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &item))+	assert.Equal(t, "preset-uuid-1", item.PresetID)+	assert.Equal(t, "Charge car", item.Label)+	assert.Equal(t, 1700, item.Watts)+	assert.Equal(t, "2026-05-19T10:00:00Z", item.CreatedAt)+	assert.Equal(t, "2026-05-19T10:00:00Z", item.UpdatedAt)++	// Persisted in the store.+	require.Len(t, store.presets, 1)+}++func TestHandleListPresets_ReturnsAllSortedByCreatedAt(t *testing.T) {+	store := newFakeSimulationPresetStore()+	store.presets = []dynamo.SimulationPresetItem{+		{PresetID: "z", Label: "Z", Watts: 100, CreatedAt: "2026-05-19T11:00:00Z"},+		{PresetID: "a", Label: "A", Watts: 200, CreatedAt: "2026-05-19T09:00:00Z"},+		{PresetID: "m", Label: "M", Watts: 300, CreatedAt: "2026-05-19T10:00:00Z"},+	}+	h := newPresetsTestHandler(store)++	req := makeRequest(http.MethodGet, "/simulation-presets", "Bearer "+testToken)+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	require.Equal(t, http.StatusOK, resp.StatusCode)++	var body struct {+		Presets []dynamo.SimulationPresetItem `json:"presets"`+	}+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &body))+	require.Len(t, body.Presets, 3)+	assert.Equal(t, "a", body.Presets[0].PresetID)+	assert.Equal(t, "m", body.Presets[1].PresetID)+	assert.Equal(t, "z", body.Presets[2].PresetID)+}++func TestHandleUpdatePreset_BumpsUpdatedAtPreservesCreatedAt(t *testing.T) {+	store := newFakeSimulationPresetStore()+	store.presets = []dynamo.SimulationPresetItem{+		{PresetID: "p1", Label: "Old", Watts: 1000, CreatedAt: "2026-05-18T08:00:00Z", UpdatedAt: "2026-05-18T08:00:00Z"},+	}+	h := newPresetsTestHandler(store)++	req := makeRequest(http.MethodPut, "/simulation-presets/p1", "Bearer "+testToken)+	req.Body = presetBody("New", 2000)+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	require.Equal(t, http.StatusOK, resp.StatusCode)++	var item dynamo.SimulationPresetItem+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &item))+	assert.Equal(t, "p1", item.PresetID)+	assert.Equal(t, "New", item.Label)+	assert.Equal(t, 2000, item.Watts)+	assert.Equal(t, "2026-05-18T08:00:00Z", item.CreatedAt, "createdAt preserved")+	assert.Equal(t, "2026-05-19T10:00:00Z", item.UpdatedAt, "updatedAt bumped to now")+}++func TestHandleUpdatePreset_UnknownIDReturns404(t *testing.T) {+	store := newFakeSimulationPresetStore()+	h := newPresetsTestHandler(store)++	req := makeRequest(http.MethodPut, "/simulation-presets/missing", "Bearer "+testToken)+	req.Body = presetBody("X", 1000)+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	assert.Equal(t, http.StatusNotFound, resp.StatusCode)+}++func TestHandleDeletePreset_Idempotent(t *testing.T) {+	store := newFakeSimulationPresetStore()+	store.presets = []dynamo.SimulationPresetItem{+		{PresetID: "p1", Label: "X", Watts: 1000, CreatedAt: "2026-05-18T08:00:00Z"},+	}+	h := newPresetsTestHandler(store)++	// First delete: 204 and removes the row.+	req := makeRequest(http.MethodDelete, "/simulation-presets/p1", "Bearer "+testToken)+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	assert.Equal(t, http.StatusNoContent, resp.StatusCode)+	assert.Empty(t, store.presets)++	// Second delete of the same id: still 204 (idempotent).+	resp, err = h.Handle(context.Background(), req)+	require.NoError(t, err)+	assert.Equal(t, http.StatusNoContent, resp.StatusCode)+}++func TestHandlePresetValidation(t *testing.T) {+	cases := map[string]struct {+		label string+		watts int+	}{+		"empty label":      {label: "", watts: 1000},+		"label too long":   {label: strings.Repeat("x", 41), watts: 1000},+		"watts zero":       {label: "OK", watts: 0},+		"watts negative":   {label: "OK", watts: -5},+		"watts over 20000": {label: "OK", watts: 20001},+	}+	for name, tc := range cases {+		t.Run(name, func(t *testing.T) {+			store := newFakeSimulationPresetStore()+			h := newPresetsTestHandler(store)+			req := makeRequest(http.MethodPost, "/simulation-presets", "Bearer "+testToken)+			req.Body = presetBody(tc.label, tc.watts)+			resp, err := h.Handle(context.Background(), req)+			require.NoError(t, err)+			assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s must be 400", name)++			var body map[string]string+			require.NoError(t, json.Unmarshal([]byte(resp.Body), &body))+			assert.NotEmpty(t, body["error"], "400 carries a reason")++			// Nothing persisted on a rejected create.+			assert.Empty(t, store.presets)+		})+	}+}++func TestHandlePresetValidationBoundaryAccepted(t *testing.T) {+	cases := map[string]struct {+		label string+		watts int+	}{+		"label 1 char":  {label: "x", watts: 1},+		"label 40 char": {label: strings.Repeat("y", 40), watts: 20000},+	}+	for name, tc := range cases {+		t.Run(name, func(t *testing.T) {+			store := newFakeSimulationPresetStore()+			h := newPresetsTestHandler(store)+			req := makeRequest(http.MethodPost, "/simulation-presets", "Bearer "+testToken)+			req.Body = presetBody(tc.label, tc.watts)+			resp, err := h.Handle(context.Background(), req)+			require.NoError(t, err)+			assert.Equal(t, http.StatusCreated, resp.StatusCode, "%s must be accepted", name)+		})+	}+}++func TestHandleCreatePreset_CapReturns409(t *testing.T) {+	store := newFakeSimulationPresetStore()+	for i := 0; i < 20; i++ {+		store.presets = append(store.presets, dynamo.SimulationPresetItem{+			PresetID:  fmt.Sprintf("p%d", i),+			Label:     fmt.Sprintf("Preset %d", i),+			Watts:     1000,+			CreatedAt: "2026-05-19T10:00:00Z",+		})+	}+	h := newPresetsTestHandler(store)++	req := makeRequest(http.MethodPost, "/simulation-presets", "Bearer "+testToken)+	req.Body = presetBody("One too many", 1500)+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	assert.Equal(t, http.StatusConflict, resp.StatusCode, "21st preset must 409")+	assert.Len(t, store.presets, 20, "no preset added at the cap")+}
internal/api/status.go Modified +59 / -16
diff --git a/internal/api/status.go b/internal/api/status.goindex de72613..7df67a2 100644--- a/internal/api/status.go+++ b/internal/api/status.go@@ -29,11 +29,22 @@ const ( 	liveDataStalenessThreshold = 90 * time.Second ) -func (h *Handler) handleStatus(ctx context.Context, _ events.LambdaFunctionURLRequest) events.LambdaFunctionURLResponse {+func (h *Handler) handleStatus(ctx context.Context, req events.LambdaFunctionURLRequest) events.LambdaFunctionURLResponse { 	now := h.nowFunc().In(sydneyTZ) 	today := now.Format("2006-01-02") 	nowUnix := now.Unix() +	// simLoadW is the added simulated load in watts (0 when no simulation is+	// requested). An unparseable or out-of-range value 400s before any I/O so+	// the client treats it as simulation-unavailable rather than rendering+	// fabricated values ([4.6]). W > 0 also suppresses the off-peak indicator+	// (Decision 11) so a real reassurance never appears beside a simulated+	// "empty by".+	simLoadW, err := parseSimulateLoad(req.QueryStringParameters)+	if err != nil {+		return errorResponse(400, err.Error())+	}+ 	// Phase 1: concurrent DynamoDB queries via errgroup. 	// Any failure cancels remaining queries and returns 500. 	var (@@ -94,11 +105,16 @@ func (h *Handler) handleStatus(ctx context.Context, _ events.LambdaFunctionURLRe 		latest := allReadings[len(allReadings)-1] 		sixtySecReadings := filterReadings(allReadings, nowUnix-60, nowUnix) +		// Allocate the added simulated load across the live trio via the+		// priority waterfall. At simLoadW == 0 this is a true no-op, so the+		// live values are the real reading unchanged ([3.3], [4.2]).+		live := allocateSimLoad(latest.Pload, latest.Pbat, latest.Pgrid, simLoadW)+ 		resp.Live = &LiveData{ 			Ppv:            roundPower(latest.Ppv),-			Pload:          roundPower(latest.Pload),-			Pbat:           roundPower(latest.Pbat),-			Pgrid:          roundPower(latest.Pgrid),+			Pload:          roundPower(live.pload),+			Pbat:           roundPower(live.pbat),+			Pgrid:          roundPower(live.pgrid), 			PgridSustained: computePgridSustained(sixtySecReadings), 			Soc:            roundPower(latest.Soc), 			Timestamp:      time.Unix(latest.Timestamp, 0).UTC().Format(time.RFC3339),@@ -124,7 +140,16 @@ func (h *Handler) handleStatus(ctx context.Context, _ events.LambdaFunctionURLRe  	if liveFresh { 		latest := allReadings[len(allReadings)-1]-		if ct := computeCutoffTime(latest.Soc, latest.Pbat, capacity, cutoffPercent, now); ct != nil {+		// wBattery is the portion of the added load that reaches the battery+		// after current export is cut first. exportReduction is derived from+		// the live grid and threaded into both cutoff series: it only bites+		// while exporting (where the cutoff is nil anyway), so it never makes a+		// real "empty by" wrong; in the importing/zero-grid case it is 0 and+		// wBattery == simLoadW. Each cutoff series caps independently via its+		// own per-series headroom (latest.Pbat here, avgPbat below).+		wBattery := simLoadW - exportReductionFor(latest.Pgrid, simLoadW)+		simPbat := simDischarge(latest.Pbat, wBattery)+		if ct := computeCutoffTime(latest.Soc, simPbat, capacity, cutoffPercent, now); ct != nil { 			if !hasOffpeakBoundary || ct.Before(nextOpWindowStart) { 				s := ct.UTC().Format(time.RFC3339) 				battery.EstimatedCutoff = &s@@ -133,14 +158,20 @@ func (h *Handler) handleStatus(ctx context.Context, _ events.LambdaFunctionURLRe 		// T-1327: pbat-independent "can't empty before off-peak" indicator. 		// Computed only on the live branch — a stale SoC would produce a 		// misleading flag (Decision 7, mirrors EstimatedCutoff's gating).-		battery.CantEmptyBeforeOffpeak = computeCantEmptyBeforeOffpeak(cantEmptyInput{-			Soc:                 latest.Soc,-			CapacityKwh:         capacity,-			Now:                 now,-			NextOpStart:         nextOpWindowStart,-			HasBoundary:         hasOffpeakBoundary,-			WithinOffpeakWindow: withinOffpeakWindow(now, h.offpeakStart, h.offpeakEnd),-		})+		// Suppressed entirely while simulating (Decision 11): the worst-case+		// reassurance is meaningless under an added-load what-if and the hero+		// renders it instead of the simulated "empty by", so leaving it set+		// could hide or contradict the simulated estimate ([4.3]).+		if simLoadW == 0 {+			battery.CantEmptyBeforeOffpeak = computeCantEmptyBeforeOffpeak(cantEmptyInput{+				Soc:                 latest.Soc,+				CapacityKwh:         capacity,+				Now:                 now,+				NextOpStart:         nextOpWindowStart,+				HasBoundary:         hasOffpeakBoundary,+				WithinOffpeakWindow: withinOffpeakWindow(now, h.offpeakStart, h.offpeakEnd),+			})+		} 	}  	// Lowest SOC since 00:00 Sydney local on now's date — see Decision 4 in@@ -162,13 +193,25 @@ func (h *Handler) handleStatus(ctx context.Context, _ events.LambdaFunctionURLRe 	fifteenMinReadings := filterReadings(allReadings, nowUnix-900, nowUnix) 	if len(fifteenMinReadings) >= 2 { 		avgLoad, avgPbat := computeRollingAverages(fifteenMinReadings)+		// The rolling cutoff is the hero "empty by". Under simulation the+		// rolling battery power uses simDischarge with its own per-series+		// headroom against avgPbat (capping independently of the live tile),+		// and the returned averages carry the same adjustment so the response+		// stays internally coherent. At simLoadW == 0 every term is a no-op.+		simAvgLoad := avgLoad + simLoadW+		wBattery := simLoadW+		if liveFresh {+			latest := allReadings[len(allReadings)-1]+			wBattery = simLoadW - exportReductionFor(latest.Pgrid, simLoadW)+		}+		simAvgPbat := simDischarge(avgPbat, wBattery) 		rolling := &RollingAvg{-			AvgLoad: roundPower(avgLoad),-			AvgPbat: roundPower(avgPbat),+			AvgLoad: roundPower(simAvgLoad),+			AvgPbat: roundPower(simAvgPbat), 		} 		if liveFresh { 			latest := allReadings[len(allReadings)-1]-			if ct := computeCutoffTime(latest.Soc, avgPbat, capacity, cutoffPercent, now); ct != nil {+			if ct := computeCutoffTime(latest.Soc, simAvgPbat, capacity, cutoffPercent, now); ct != nil { 				if !hasOffpeakBoundary || ct.Before(nextOpWindowStart) { 					s := ct.UTC().Format(time.RFC3339) 					rolling.EstimatedCutoff = &s
internal/api/status_simulate.go Added +111 / -0
diff --git a/internal/api/status_simulate.go b/internal/api/status_simulate.gonew file mode 100644index 0000000..c1a754d--- /dev/null+++ b/internal/api/status_simulate.go@@ -0,0 +1,111 @@+package api++import (+	"errors"+	"strconv"+	"strings"+)++// maxDischargeW is the inverter's sustained discharge ceiling in watts,+// derived from the single maxDischargeKW constant so the ceiling has one+// definition (Decision 14). Simulated battery discharge is capped here; the+// surplus spills to grid import.+const maxDischargeW = maxDischargeKW * 1000++// simLoadMaxWatts is the inclusive upper bound on the simulateLoadWatts+// parameter (20 kW). Values above it are rejected (Req 1.3 / 4.6); presets are+// validated to the same bound so a stored preset can never produce a rejected+// request.+const simLoadMaxWatts = 20000++// errInvalidSimLoad is returned by parseSimulateLoad when the parameter is+// present but unparseable or outside (0, simLoadMaxWatts].+var errInvalidSimLoad = errors.New("simulateLoadWatts must be an integer between 1 and 20000")++// parseSimulateLoad reads the simulateLoadWatts query parameter. An absent+// (empty) value returns (0, nil) — no simulation. A present value must be an+// integer strictly greater than zero and at most simLoadMaxWatts; anything+// else returns errInvalidSimLoad so the handler can 400 ([4.6]). The+// zero-load-equivalence invariant ([4.2]) is an internal compute-path+// property, deliberately NOT reachable via the wire (0 is rejected here).+func parseSimulateLoad(q map[string]string) (float64, error) {+	raw, ok := q["simulateLoadWatts"]+	if !ok || strings.TrimSpace(raw) == "" {+		return 0, nil+	}+	// Integer-only: a float or any non-numeric value is rejected.+	v, err := strconv.Atoi(strings.TrimSpace(raw))+	if err != nil {+		return 0, errInvalidSimLoad+	}+	if v <= 0 || v > simLoadMaxWatts {+		return 0, errInvalidSimLoad+	}+	return float64(v), nil+}++// simAllocation holds the simulated live trio after the priority waterfall has+// allocated the added load. Sign convention: pbat > 0 discharge, pgrid > 0+// import (pgrid < 0 export).+type simAllocation struct {+	pload float64+	pbat  float64+	pgrid float64+}++// allocateSimLoad applies the priority waterfall (Decision 14) to the live+// trio: first reduce any grid export, then draw from the battery up to its+// remaining discharge headroom, then meet the remainder with grid import. The+// returned trio is energy-balanced (delta load == delta battery + delta grid)+// and never shows the battery discharging below its real value. At w == 0+// every step is a no-op, so the result equals the input exactly ([4.2]).+func allocateSimLoad(pload, pbat, pgrid, w float64) simAllocation {+	exportReduction := exportReductionFor(pgrid, w)+	wBattery := w - exportReduction+	absorbed := batteryAbsorbed(pbat, wBattery)+	overflow := wBattery - absorbed+	return simAllocation{+		pload: pload + w,+		pbat:  pbat + absorbed,+		pgrid: pgrid + exportReduction + overflow,+	}+}++// exportReductionFor returns the portion of the added load served by cutting+// current grid export toward zero. Only positive export (pgrid < 0) can be+// reduced; the result is never more than the added load.+func exportReductionFor(pgrid, w float64) float64 {+	export := -pgrid+	if export < 0 {+		export = 0+	}+	return min(w, export)+}++// headroom returns the additional discharge the battery can take on before+// reaching the inverter ceiling. Zero when p is already at or above the+// ceiling — the key to the no-op property: capping only the *added* portion+// (via headroom) rather than min(p+w, ceiling) leaves a real reading already+// at/above the ceiling untouched at w == 0.+func headroom(p float64) float64 {+	h := maxDischargeW - p+	if h < 0 {+		return 0+	}+	return h+}++// batteryAbsorbed returns how much of wBattery the battery takes on, bounded by+// its remaining headroom.+func batteryAbsorbed(p, wBattery float64) float64 {+	return min(wBattery, headroom(p))+}++// simDischarge returns the simulated discharge for a series power p given the+// watts reaching the battery. It caps only the added portion via headroom, so+// simDischarge(p, wBattery) >= p always (it never lowers a real reading) and+// simDischarge(p, 0) == p for every p, including p >= the ceiling ([3.4],+// [4.2]). Evaluated per series (live pbat, rolling avgPbat).+func simDischarge(p, wBattery float64) float64 {+	return p + batteryAbsorbed(p, wBattery)+}
internal/api/status_simulate_property_test.go Added +154 / -0
diff --git a/internal/api/status_simulate_property_test.go b/internal/api/status_simulate_property_test.gonew file mode 100644index 0000000..485b601--- /dev/null+++ b/internal/api/status_simulate_property_test.go@@ -0,0 +1,154 @@+package api++import (+	"testing"+	"time"++	"pgregory.net/rapid"+)++// maxDischargeWatts restates the inverter ceiling in watts for the property+// generators below; the production code derives the same value from+// maxDischargeKW.+const maxDischargeWatts = maxDischargeKW * 1000++// drawPower generates a battery/grid power value spanning charging through+// discharge and deliberately past the inverter ceiling, so the headroom form+// is exercised above maxDischargeW (where the naive min would clamp).+func drawPower(t *rapid.T, label string) float64 {+	return rapid.Float64Range(-8000, 12000).Draw(t, label)+}++// drawWatts generates an added-load value across the accepted range, including+// 0 (the zero-load-equivalence boundary) up to the 20 kW cap.+func drawWatts(t *rapid.T) float64 {+	return rapid.Float64Range(0, 20000).Draw(t, "watts")+}++// TestPropertySimDischargeNeverBelowReal asserts [3.4]: for any series power p+// and any added load w >= 0, simDischarge(p, wBattery) is never below the real+// p (adding load never lowers the shown discharge), and never exceeds the+// inverter ceiling unless the real p already did.+func TestPropertySimDischargeNeverBelowReal(t *testing.T) {+	rapid.Check(t, func(t *rapid.T) {+		p := drawPower(t, "pbat")+		w := drawWatts(t)+		got := simDischarge(p, w)+		if got < p {+			t.Fatalf("simDischarge(%v, %v) = %v < real pbat %v", p, w, got, p)+		}+		// The added portion never pushes discharge above the ceiling; if p+		// already exceeds the ceiling, got must equal p (headroom 0).+		ceiling := float64(maxDischargeWatts)+		if p >= ceiling && got != p {+			t.Fatalf("p already >= ceiling: simDischarge(%v, %v) = %v, want %v", p, w, got, p)+		}+		if p < ceiling && got > ceiling+1e-9 {+			t.Fatalf("simDischarge(%v, %v) = %v exceeds ceiling %v", p, w, got, ceiling)+		}+	})+}++// TestPropertySimDischargeZeroLoadNoOp asserts the headroom form is a true+// no-op at w = 0 for every p — including p at or above the ceiling, where the+// naive min(p+w, ceiling) form would wrongly clamp p down.+func TestPropertySimDischargeZeroLoadNoOp(t *testing.T) {+	rapid.Check(t, func(t *rapid.T) {+		p := drawPower(t, "pbat")+		if got := simDischarge(p, 0); got != p {+			t.Fatalf("simDischarge(%v, 0) = %v, want %v (zero-load must be a no-op)", p, got, p)+		}+	})+}++// TestPropertyAllocateZeroLoadEquivalence asserts the compute-layer+// zero-load-equivalence ([4.2]): allocateSimLoad at w = 0 returns the input+// pload/pbat/pgrid unchanged, field by field, for any starting state+// (charging, idle, discharging, exporting, importing, above the ceiling).+func TestPropertyAllocateZeroLoadEquivalence(t *testing.T) {+	rapid.Check(t, func(t *rapid.T) {+		pload := rapid.Float64Range(0, 15000).Draw(t, "pload")+		pbat := drawPower(t, "pbat")+		pgrid := rapid.Float64Range(-10000, 10000).Draw(t, "pgrid")++		alloc := allocateSimLoad(pload, pbat, pgrid, 0)+		if alloc.pload != pload {+			t.Fatalf("pload changed at w=0: got %v want %v", alloc.pload, pload)+		}+		if alloc.pbat != pbat {+			t.Fatalf("pbat changed at w=0: got %v want %v", alloc.pbat, pbat)+		}+		if alloc.pgrid != pgrid {+			t.Fatalf("pgrid changed at w=0: got %v want %v", alloc.pgrid, pgrid)+		}+	})+}++// TestPropertyAllocateEnergyBalance asserts [3.2]/[3.4]: in every state the+// trio stays energy-balanced -- delta load == delta battery + delta grid -- and+// delta load equals the added watts.+func TestPropertyAllocateEnergyBalance(t *testing.T) {+	rapid.Check(t, func(t *rapid.T) {+		pload := rapid.Float64Range(0, 15000).Draw(t, "pload")+		pbat := drawPower(t, "pbat")+		pgrid := rapid.Float64Range(-10000, 10000).Draw(t, "pgrid")+		w := drawWatts(t)++		alloc := allocateSimLoad(pload, pbat, pgrid, w)+		dLoad := alloc.pload - pload+		dBat := alloc.pbat - pbat+		dGrid := alloc.pgrid - pgrid+		if abs(dLoad-(dBat+dGrid)) > 1e-6 {+			t.Fatalf("energy imbalance: dLoad=%v dBat=%v dGrid=%v", dLoad, dBat, dGrid)+		}+		if abs(dLoad-w) > 1e-6 {+			t.Fatalf("dLoad %v != added watts %v", dLoad, w)+		}+		// Battery never shown discharging below its real value.+		if alloc.pbat < pbat {+			t.Fatalf("simulated pbat %v below real %v", alloc.pbat, pbat)+		}+	})+}++// TestPropertyCutoffMonotonicity asserts [Property B]: a larger added load+// never pushes the simulated cutoff later. The cutoff is computed from+// simDischarge(avgPbat, w); once the battery saturates at the ceiling the+// cutoff plateaus (extra w then moves grid, not battery), so "no later" holds+// with equality at the plateau.+func TestPropertyCutoffMonotonicity(t *testing.T) {+	now := time.Date(2026, 4, 15, 18, 0, 0, 0, time.UTC)+	rapid.Check(t, func(t *rapid.T) {+		soc := rapid.Float64Range(0, 100).Draw(t, "soc")+		avgPbat := drawPower(t, "avgPbat")+		capacity := rapid.Float64Range(1, 20).Draw(t, "capacity")++		w1 := drawWatts(t)+		w2 := drawWatts(t)+		if w2 < w1 {+			w1, w2 = w2, w1+		}++		// wBattery equals w when not exporting; the cutoff series uses the+		// per-series headroom against avgPbat, so feed w directly.+		ct1 := computeCutoffTime(soc, simDischarge(avgPbat, w1), capacity, cutoffPercent, now)+		ct2 := computeCutoffTime(soc, simDischarge(avgPbat, w2), capacity, cutoffPercent, now)++		// More load can only turn "no cutoff" into "a cutoff" or move it+		// earlier; it can never move it later, and never erase an existing one.+		if ct1 != nil && ct2 == nil {+			t.Fatalf("larger w erased a cutoff: w1=%v ct1=%v, w2=%v ct2=nil", w1, *ct1, w2)+		}+		if ct1 != nil && ct2 != nil && ct2.After(*ct1) {+			t.Fatalf("larger w moved cutoff later: w1=%v ct1=%v, w2=%v ct2=%v", w1, *ct1, w2, *ct2)+		}+	})+}++// abs is a tiny float helper for the property assertions.+func abs(v float64) float64 {+	if v < 0 {+		return -v+	}+	return v+}
internal/api/status_simulate_test.go Added +389 / -0
diff --git a/internal/api/status_simulate_test.go b/internal/api/status_simulate_test.gonew file mode 100644index 0000000..1d75b89--- /dev/null+++ b/internal/api/status_simulate_test.go@@ -0,0 +1,389 @@+package api++import (+	"context"+	"encoding/json"+	"strconv"+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/aws/aws-lambda-go/events"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// simulateStatusRequest builds an authenticated GET /status request carrying+// the simulateLoadWatts query parameter. The empty string omits the parameter+// entirely (no simulation), matching how a real Dashboard refresh without an+// active preset behaves.+func simulateStatusRequest(watts string) events.LambdaFunctionURLRequest {+	req := makeRequest("GET", "/status", "Bearer "+testToken)+	if watts != "" {+		req.QueryStringParameters = map[string]string{"simulateLoadWatts": watts}+	}+	return req+}++// TestHandleStatusSimulateWaterfall covers the load-allocation waterfall+// (reduce export -> battery capped at the ceiling -> grid import) for the live+// trio. Each case asserts energy balance -- delta load == delta battery ++// delta grid -- against the real reading, and that simDischarge is never below+// the real pbat.+func TestHandleStatusSimulateWaterfall(t *testing.T) {+	// Evening "now" well before the off-peak window so a discharge cutoff is+	// not suppressed; this keeps the focus on the trio allocation.+	now := time.Date(2026, 4, 15, 18, 0, 0, 0, sydneyTZ)+	nowUnix := now.Unix()++	cases := map[string]struct {+		// real live reading values+		pload, pbat, pgrid, soc float64+		watts                   int+		// expected simulated live values+		wantPload, wantPbat, wantPgrid float64+	}{+		// (a) importing / zero grid below ceiling -> battery takes all of W,+		// grid unchanged.+		"a) importing below ceiling, battery absorbs all": {+			pload: 2000, pbat: 1000, pgrid: 500, soc: 60,+			watts:     1000,+			wantPload: 3000, wantPbat: 2000, wantPgrid: 500,+		},+		// (b) 1.7 kW over a ~4 kW evening draw -> battery caps at the ceiling,+		// grid import absorbs the overflow.+		"b) evening draw + car caps at ceiling, grid takes overflow": {+			// pbat 4000, +1700 -> would be 5700 but caps at 5000 (absorbed+			// 1000), overflow 700 hits the grid.+			pload: 4200, pbat: 4000, pgrid: 200, soc: 60,+			watts:     1700,+			wantPload: 5900, wantPbat: 5000, wantPgrid: 900,+		},+		// (c) charging + exporting (full sun) -> export cut first, battery+		// charging unchanged.+		"c) charging and exporting, export cut first": {+			// pgrid -2500 (exporting 2.5 kW), pbat -2000 (charging 2.0 kW).+			// W=1700 < 2500 export -> exportReduction 1700, wBattery 0.+			// battery charging unchanged at -2000, grid -2500+1700 = -800.+			pload: 1000, pbat: -2000, pgrid: -2500, soc: 80,+			watts:     1700,+			wantPload: 2700, wantPbat: -2000, wantPgrid: -800,+		},+		// (d) export partially covers W -> remainder hits the battery.+		"d) export partially covers, remainder to battery": {+			// pgrid -500 (exporting 0.5 kW), pbat 1000. W=1500.+			// exportReduction 500, wBattery 1000 -> battery 1000+1000 = 2000.+			// grid -500 + 500 (export reduction) + 0 (no overflow) = 0.+			pload: 1500, pbat: 1000, pgrid: -500, soc: 60,+			watts:     1500,+			wantPload: 3000, wantPbat: 2000, wantPgrid: 0,+		},+		// (e) real pbat already at/above the ceiling -> battery left at the+		// real value, all of wBattery to grid (headroom is 0).+		"e) pbat already above ceiling, all to grid": {+			// pbat 5500 already above 5000 ceiling -> headroom 0, absorbed 0.+			// overflow = W = 1000 -> grid 100 + 1000 = 1100. battery unchanged.+			pload: 6000, pbat: 5500, pgrid: 100, soc: 60,+			watts:     1000,+			wantPload: 7000, wantPbat: 5500, wantPgrid: 1100,+		},+	}++	for name, tc := range cases {+		t.Run(name, func(t *testing.T) {+			mr := &mockReader{+				queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+					return []dynamo.ReadingItem{+						{Timestamp: nowUnix - 20, Ppv: 0, Pload: tc.pload, Pbat: tc.pbat, Pgrid: tc.pgrid, Soc: tc.soc},+						{Timestamp: nowUnix - 10, Ppv: 0, Pload: tc.pload, Pbat: tc.pbat, Pgrid: tc.pgrid, Soc: tc.soc},+					}, nil+				},+				getSystemFn: func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+					return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+				},+			}+			h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+			h.nowFunc = func() time.Time { return now }++			resp, err := h.Handle(context.Background(), simulateStatusRequest(strconv.Itoa(tc.watts)))+			require.NoError(t, err)+			require.Equal(t, 200, resp.StatusCode)++			sr := parseStatusResponse(t, resp)+			require.NotNil(t, sr.Live)+			assert.Equal(t, roundPower(tc.wantPload), sr.Live.Pload, "Pload")+			assert.Equal(t, roundPower(tc.wantPbat), sr.Live.Pbat, "Pbat (simDischarge)")+			assert.Equal(t, roundPower(tc.wantPgrid), sr.Live.Pgrid, "Pgrid")++			// simDischarge never below the real pbat.+			assert.GreaterOrEqual(t, sr.Live.Pbat, roundPower(tc.pbat),+				"adding load must never lower the shown discharge")++			// Energy balance: delta load == delta battery + delta grid.+			dLoad := sr.Live.Pload - roundPower(tc.pload)+			dBat := sr.Live.Pbat - roundPower(tc.pbat)+			dGrid := sr.Live.Pgrid - roundPower(tc.pgrid)+			assert.InDelta(t, dLoad, dBat+dGrid, 1e-6,+				"energy balance: dLoad (%v) == dBat (%v) + dGrid (%v)", dLoad, dBat, dGrid)+			// And delta load equals the added watts.+			assert.InDelta(t, float64(tc.watts), dLoad, 1e-6, "delta load must equal added watts")+		})+	}+}++// TestHandleStatusSimulateEmptyByEarlier verifies the simulated "empty by"+// (rolling15min cutoff) lands earlier than the real one, that the rolling+// averages reflect the simulated allocation, and the off-peak indicator is+// suppressed while simulating.+func TestHandleStatusSimulateEmptyByEarlier(t *testing.T) {+	// Evening so a discharge cutoff is not off-peak-suppressed.+	now := time.Date(2026, 4, 15, 18, 0, 0, 0, sydneyTZ)+	nowUnix := now.Unix()++	mkReader := func() *mockReader {+		return &mockReader{+			queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+				// Modest evening discharge well below the ceiling so +W stays+				// below 5 kW: rolling avg pbat == 1000 W.+				return []dynamo.ReadingItem{+					{Timestamp: nowUnix - 60, Ppv: 0, Pload: 1200, Pbat: 1000, Pgrid: 200, Soc: 50},+					{Timestamp: nowUnix - 10, Ppv: 0, Pload: 1200, Pbat: 1000, Pgrid: 200, Soc: 50},+				}, nil+			},+			getSystemFn: func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+				return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+			},+		}+	}++	// Real (no simulation) cutoff.+	hReal := NewHandler(mkReader(), nil, testSerial, testToken, "11:00", "14:00")+	hReal.nowFunc = func() time.Time { return now }+	realResp, err := hReal.Handle(context.Background(), simulateStatusRequest(""))+	require.NoError(t, err)+	realSR := parseStatusResponse(t, realResp)+	require.NotNil(t, realSR.Rolling15m)+	require.NotNil(t, realSR.Rolling15m.EstimatedCutoff, "precondition: real rolling cutoff present")++	// Simulated cutoff with +2000 W.+	hSim := NewHandler(mkReader(), nil, testSerial, testToken, "11:00", "14:00")+	hSim.nowFunc = func() time.Time { return now }+	simResp, err := hSim.Handle(context.Background(), simulateStatusRequest("2000"))+	require.NoError(t, err)+	simSR := parseStatusResponse(t, simResp)+	require.NotNil(t, simSR.Rolling15m)+	require.NotNil(t, simSR.Rolling15m.EstimatedCutoff, "simulated rolling cutoff present")++	realCutoff, perr := time.Parse(time.RFC3339, *realSR.Rolling15m.EstimatedCutoff)+	require.NoError(t, perr)+	simCutoff, perr := time.Parse(time.RFC3339, *simSR.Rolling15m.EstimatedCutoff)+	require.NoError(t, perr)+	assert.True(t, simCutoff.Before(realCutoff),+		"simulated empty-by (%s) must be earlier than real (%s)", simCutoff, realCutoff)++	// AvgPbat under simulation reflects simDischarge(avgPbat) = 1000 + 2000.+	assert.Equal(t, roundPower(3000), simSR.Rolling15m.AvgPbat, "rolling AvgPbat reflects simDischarge")+	// AvgLoad under simulation reflects AvgLoad + W = 1200 + 2000.+	assert.Equal(t, roundPower(3200), simSR.Rolling15m.AvgLoad, "rolling AvgLoad reflects +W")++	// Live Pbat reflects simDischarge(latest.Pbat) = 1000 + 2000.+	require.NotNil(t, simSR.Live)+	assert.Equal(t, roundPower(3000), simSR.Live.Pbat, "live Pbat reflects simDischarge")++	// Off-peak indicator suppressed while simulating.+	require.NotNil(t, simSR.Battery)+	assert.Nil(t, simSR.Battery.CantEmptyBeforeOffpeak,+		"cantEmptyBeforeOffpeak must be nil while W>0")+}++// TestHandleStatusSimulateNoEmptyByWhenCharging verifies [4.4] is inherited:+// when the simulated battery is still net charging (export fully absorbs W),+// no "empty by" estimate is shown.+func TestHandleStatusSimulateNoEmptyByWhenCharging(t *testing.T) {+	now := time.Date(2026, 4, 15, 9, 0, 0, 0, sydneyTZ)+	nowUnix := now.Unix()++	mr := &mockReader{+		queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+			// Charging 2 kW, exporting 3 kW. +1700 W fully consumed by export+			// reduction -> battery still charging, no cutoff.+			return []dynamo.ReadingItem{+				{Timestamp: nowUnix - 60, Ppv: 6000, Pload: 1000, Pbat: -2000, Pgrid: -3000, Soc: 70},+				{Timestamp: nowUnix - 10, Ppv: 6000, Pload: 1000, Pbat: -2000, Pgrid: -3000, Soc: 70},+			}, nil+		},+		getSystemFn: func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+			return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+		},+	}+	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), simulateStatusRequest("1700"))+	require.NoError(t, err)+	sr := parseStatusResponse(t, resp)+	require.NotNil(t, sr.Live)+	assert.Equal(t, roundPower(-2000), sr.Live.Pbat, "battery still charging after export reduction")+	require.NotNil(t, sr.Battery)+	assert.Nil(t, sr.Battery.EstimatedCutoff, "no empty-by while charging (inherited [4.4])")+	require.NotNil(t, sr.Rolling15m)+	assert.Nil(t, sr.Rolling15m.EstimatedCutoff, "no rolling empty-by while charging")+}++// TestHandleStatusSimulateOffpeakBoundaryGate verifies the off-peak boundary+// suppression still gates the simulated cutoff: an early-morning light+// discharge whose simulated cutoff lands inside/after the off-peak window is+// suppressed.+func TestHandleStatusSimulateOffpeakBoundaryGate(t *testing.T) {+	now := time.Date(2026, 4, 15, 7, 0, 0, 0, sydneyTZ)+	nowUnix := now.Unix()++	mr := &mockReader{+		queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+			// Light discharge: real cutoff is many hours out (after off-peak).+			// A modest +W still leaves the cutoff after 11:00, so it stays+			// suppressed.+			return []dynamo.ReadingItem{+				{Timestamp: nowUnix - 60, Ppv: 0, Pload: 100, Pbat: 100, Pgrid: 0, Soc: 50},+				{Timestamp: nowUnix - 10, Ppv: 0, Pload: 100, Pbat: 100, Pgrid: 0, Soc: 50},+			}, nil+		},+		getSystemFn: func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+			return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+		},+	}+	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), simulateStatusRequest("200"))+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)+	sr := parseStatusResponse(t, resp)+	require.NotNil(t, sr.Battery)+	assert.Nil(t, sr.Battery.EstimatedCutoff,+		"simulated cutoff still suppressed when it lands after the off-peak window")+	require.NotNil(t, sr.Rolling15m)+	assert.Nil(t, sr.Rolling15m.EstimatedCutoff,+		"simulated rolling cutoff still suppressed when after off-peak")+}++// TestHandleStatusSimulateStaleGate verifies that a stale latest reading omits+// Live even under simulation (the liveFresh gate is unchanged), so no+// fabricated simulated values are returned.+func TestHandleStatusSimulateStaleGate(t *testing.T) {+	now := fixedNow()+	nowUnix := now.Unix()+	mr := &mockReader{+		queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+			return []dynamo.ReadingItem{+				// 2 hours old -> past the 90s gate.+				{Timestamp: nowUnix - 2*3600, Ppv: 0, Pload: 200, Pbat: 250, Pgrid: 150, Soc: 65},+			}, nil+		},+	}+	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), simulateStatusRequest("1700"))+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)+	sr := parseStatusResponse(t, resp)+	assert.Nil(t, sr.Live, "live omitted when stale, even under simulation")+	require.NotNil(t, sr.Battery)+	assert.Nil(t, sr.Battery.EstimatedCutoff, "no fabricated cutoff when stale")+	assert.Nil(t, sr.Battery.CantEmptyBeforeOffpeak)+}++// TestHandleStatusSimulateInvalidParam verifies the 400 cases: zero,+// unparseable, negative, over the 20000 cap, whitespace, and float all reject+// with an error and do not return a status body.+func TestHandleStatusSimulateInvalidParam(t *testing.T) {+	now := fixedNow()+	nowUnix := now.Unix()++	cases := map[string]string{+		"zero":        "0",+		"unparseable": "abc",+		"negative":    "-100",+		"over cap":    "20001",+		"float":       "100.5",+	}++	for name, watts := range cases {+		t.Run(name, func(t *testing.T) {+			mr := &mockReader{+				queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+					return []dynamo.ReadingItem{+						{Timestamp: nowUnix - 10, Ppv: 0, Pload: 200, Pbat: 1000, Pgrid: 50, Soc: 50},+					}, nil+				},+			}+			h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+			h.nowFunc = func() time.Time { return now }++			resp, err := h.Handle(context.Background(), simulateStatusRequest(watts))+			require.NoError(t, err)+			assert.Equal(t, 400, resp.StatusCode, "watts=%q must reject", watts)++			var body map[string]string+			require.NoError(t, json.Unmarshal([]byte(resp.Body), &body))+			assert.NotEmpty(t, body["error"], "400 must carry an error reason")+		})+	}+}++// TestHandleStatusSimulateBoundaryAccepted verifies the inclusive upper bound+// (20000) and the lowest accepted value (1) are accepted, returning 200.+func TestHandleStatusSimulateBoundaryAccepted(t *testing.T) {+	now := time.Date(2026, 4, 15, 18, 0, 0, 0, sydneyTZ)+	nowUnix := now.Unix()++	for _, watts := range []string{"1", "20000"} {+		t.Run(watts, func(t *testing.T) {+			mr := &mockReader{+				queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+					return []dynamo.ReadingItem{+						{Timestamp: nowUnix - 10, Ppv: 0, Pload: 200, Pbat: 1000, Pgrid: 50, Soc: 50},+					}, nil+				},+				getSystemFn: func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+					return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+				},+			}+			h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+			h.nowFunc = func() time.Time { return now }++			resp, err := h.Handle(context.Background(), simulateStatusRequest(watts))+			require.NoError(t, err)+			assert.Equal(t, 200, resp.StatusCode, "watts=%s must be accepted", watts)+		})+	}+}++// TestHandleStatusNoParamUnchanged verifies that a /status request with no+// simulateLoadWatts parameter returns the actual values unchanged ([3.3]).+func TestHandleStatusNoParamUnchanged(t *testing.T) {+	now := time.Date(2026, 4, 15, 18, 0, 0, 0, sydneyTZ)+	nowUnix := now.Unix()++	mr := &mockReader{+		queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+			return []dynamo.ReadingItem{+				{Timestamp: nowUnix - 10, Ppv: 0, Pload: 2000, Pbat: 1000, Pgrid: 500, Soc: 60},+			}, nil+		},+		getSystemFn: func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+			return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+		},+	}+	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), simulateStatusRequest(""))+	require.NoError(t, err)+	sr := parseStatusResponse(t, resp)+	require.NotNil(t, sr.Live)+	assert.Equal(t, roundPower(2000), sr.Live.Pload)+	assert.Equal(t, roundPower(1000), sr.Live.Pbat)+	assert.Equal(t, roundPower(500), sr.Live.Pgrid)+}
internal/dynamo/simulationpresets.go Added +115 / -0
diff --git a/internal/dynamo/simulationpresets.go b/internal/dynamo/simulationpresets.gonew file mode 100644index 0000000..13ed141--- /dev/null+++ b/internal/dynamo/simulationpresets.go@@ -0,0 +1,115 @@+package dynamo++import (+	"context"+	"fmt"+	"sort"++	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+)++// SimulationPresetItem represents one row of the flux-simulation-presets+// table. PresetID is serialised as "id" so the Swift client decodes it through+// Identifiable. The table is system-wide and keyed by presetId only (no+// device/user partition), mirroring flux-pricing (Decision 10).+type SimulationPresetItem struct {+	PresetID  string `dynamodbav:"presetId" json:"id"`+	Label     string `dynamodbav:"label" json:"label"`+	Watts     int    `dynamodbav:"watts" json:"watts"`+	CreatedAt string `dynamodbav:"createdAt" json:"createdAt"` // RFC3339 UTC+	UpdatedAt string `dynamodbav:"updatedAt" json:"updatedAt"` // bumped on every PUT+}++// SimulationPresetAPI is the subset of the DynamoDB client used by the preset+// store. The live *dynamodb.Client satisfies every method. Unlike pricing,+// presets are independent rows, so there is no TransactWriteItems dependency.+type SimulationPresetAPI interface {+	PutItem(ctx context.Context, params *dynamodb.PutItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error)+	DeleteItem(ctx context.Context, params *dynamodb.DeleteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error)+	Scan(ctx context.Context, params *dynamodb.ScanInput, optFns ...func(*dynamodb.Options)) (*dynamodb.ScanOutput, error)+}++// DynamoSimulationPresetStore implements the preset read+write surface against+// a real DynamoDB table. It mirrors DynamoPricingStore minus the singleton+// sentinel and transactional machinery (Decision 10).+type DynamoSimulationPresetStore struct {+	client SimulationPresetAPI+	table  string+}++// NewDynamoSimulationPresetStore returns a store scoped to the given table.+func NewDynamoSimulationPresetStore(client SimulationPresetAPI, table string) *DynamoSimulationPresetStore {+	return &DynamoSimulationPresetStore{client: client, table: table}+}++// presetKey returns the DynamoDB key for a preset row.+func presetKey(id string) map[string]types.AttributeValue {+	return map[string]types.AttributeValue{+		"presetId": &types.AttributeValueMemberS{Value: id},+	}+}++// presetListPageLimit caps each Scan page. A defensive cap of 20 presets is+// enforced on create, so 50 is comfortably above realistic usage while still+// bounding a pathological scan.+const presetListPageLimit = 50++// ListPresets returns every preset row sorted by CreatedAt ascending.+func (s *DynamoSimulationPresetStore) ListPresets(ctx context.Context) ([]SimulationPresetItem, error) {+	items := make([]SimulationPresetItem, 0)+	limit := int32(presetListPageLimit)+	input := &dynamodb.ScanInput{TableName: &s.table, Limit: &limit}+	for {+		out, err := s.client.Scan(ctx, input)+		if err != nil {+			return nil, fmt.Errorf("scan simulation presets (table=%s): %w", s.table, err)+		}+		for _, av := range out.Items {+			var item SimulationPresetItem+			if err := attributevalue.UnmarshalMap(av, &item); err != nil {+				return nil, fmt.Errorf("unmarshal simulation preset (table=%s): %w", s.table, err)+			}+			items = append(items, item)+		}+		if out.LastEvaluatedKey == nil {+			break+		}+		input.ExclusiveStartKey = out.LastEvaluatedKey+	}+	sort.SliceStable(items, func(i, j int) bool {+		return items[i].CreatedAt < items[j].CreatedAt+	})+	return items, nil+}++// PutPreset inserts or overwrites a preset row. Create and update both route+// here; the handler is the authority for id assignment and timestamp bumping.+func (s *DynamoSimulationPresetStore) PutPreset(ctx context.Context, item SimulationPresetItem) error {+	av, err := attributevalue.MarshalMap(item)+	if err != nil {+		return fmt.Errorf("marshal simulation preset (presetId=%s): %w", item.PresetID, err)+	}+	_, err = s.client.PutItem(ctx, &dynamodb.PutItemInput{+		TableName: &s.table,+		Item:      av,+	})+	if err != nil {+		return fmt.Errorf("put simulation preset (table=%s, presetId=%s): %w", s.table, item.PresetID, err)+	}+	return nil+}++// DeletePreset removes a preset row by id. Idempotent — deleting an absent row+// is a no-op (DynamoDB DeleteItem does not error on a missing key).+func (s *DynamoSimulationPresetStore) DeletePreset(ctx context.Context, id string) error {+	_, err := s.client.DeleteItem(ctx, &dynamodb.DeleteItemInput{+		TableName: &s.table,+		Key:       presetKey(id),+	})+	if err != nil {+		return fmt.Errorf("delete simulation preset (table=%s, presetId=%s): %w", s.table, id, err)+	}+	return nil+}
specs/OVERVIEW.md Modified +10 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 3cf1abf..57d6d08 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -28,6 +28,7 @@ | [Better macOS Interface](#better-macos-interface) | 2026-05-25 | Done | Activate the iPad adaptive multi-column body (shipped T-1150) on macOS by flipping `IPadLayoutGate.isActive` to `true` on macOS — `DashboardView`, `DayDetailView`, and `HistoryView` automatically pick their existing `*Regular` branches; `legacyHeader` (Dashboard) and the legacy eyebrow `header` (Day Detail) drop on macOS as a consequence of the branch selection. Remove the in-content `DayNavigationHeader` mustache on Day Detail via `#if !os(macOS)` wrap and replace it with native chrome: a new `internal macPageTitle` (`DayDetailEyebrow.full` formatter — Decision 10 keeps the mustache's verbose-date character on macOS) wired to `.navigationTitle`, plus prev/next chevron buttons in a trailing `ToolbarItemGroup(.primaryAction)` with `accessibilityLabel` + `.help` and `disabled(viewModel.isToday)` on the next-day button. The existing `.onKeyPress(.leftArrow/.rightArrow)` handlers stay unchanged (AC 4.7). macOS scene clamps to `minWidth: 960, minHeight: 600` (window derivation: 700pt detail-column threshold + ~240pt sidebar + ~20pt chrome) via `.frame` on `AppNavigationView` combined with `.windowResizability(.contentSize)` on the `WindowGroup` — without the resizability modifier the `.frame` does not clamp the NSWindow. `.defaultSize(1200, 800)` opens windows with breathing room above the minimum. macOS adaptive bodies therefore start at the 2-column tier; the 1-column tier is reachable only via unit test, not via window resize. Existing `AppNavigationView` macOS shell unchanged (Decision 2 kept it instead of promoting `FluxiPadRoot`). Out of scope: sidebar visibility persistence, CommandMenu ⌘←/⌘→ entries, toolbar customization, calendar-popover date picker, gate rename, History date-range header, freshness indicator replacement for the removed Dashboard eyebrow. T-1342. | | [Off-Peak From Readings](#off-peak-from-readings) | 2026-05-25 | Done | Bug fix replacing the off-peak window's snapshot-diff computation with trapezoidal integration over the `flux-readings` table. AlphaESS's intraday `eInput` value lags physical reality by 15–30 minutes, so the 14:00 snapshot misses the tail of grid-charging; those kWh land in the EOD daily total and get misclassified as peak (~4× overcount on the History peak imports tile, confirmed on 2026-05-18: meter 0.17/20.75 vs Flux 1.74/18.95). Five integrated deltas (grid import, grid export, solar, battery charge, battery discharge) replace `endE* − startE*` in `internal/poller/offpeak.go::computeOffpeakDeltas` and the snapshot-projection branch in `internal/api/compute.go::offpeakDeltas`. New `derivedstats.IntegrateOffpeakDeltas` with a shared `integrate(readings, start, end, selector)` helper (per-sample clamping before integration; same 60s gap rule and boundary interpolation as the existing `integratePload`/`integratePpv`). Poller defers window-end finalisation until a reading at-or-after `offpeak-end` (30s budget) to avoid recreating the boundary-loss class of bug at the readings layer. Today's live `/status` and `/day` integrate readings up to `now`; past days served from the persisted row. Diagnostic snapshot fields retained on the row alongside three new provenance fields (sample count, skipped pairs, `integratedAt`). New `WriteOffpeakIfPendingOrAbsent` / `WriteOffpeakIfComplete` conditional writes guard against poller-vs-backfill races. One-shot `cmd/backfill-offpeak` CLI recomputes all retained `flux-offpeak` rows within the 30-day readings TTL. Drift logging on every write surfaces snapshot-vs-integration divergence in CloudWatch. T-1341. | | [History Month and Week to Date](#history-month-and-week-to-date) | 2026-06-04 | Done | Two calendar-anchored History ranges — week-to-date (Wk) and month-to-date (Mo) — added to the existing fixed 7/14/30-day segmented control. The app resolves each to an inclusive day-count `N` against the Sydney calendar (only the week's first weekday comes from the device locale) and reuses `GET /history?days=N`; the backend's sole change is widening its `{7,14,30}` allowlist to a `1–31` bounds check. Selection moves from a bare `Int` to a `HistoryRange` enum; new FluxCore `DateFormatting` boundary helpers; offline cache fallback switches from newest-N-by-count to date-bounded; downstream cards/charts/`DerivedState` unchanged (already data-adaptive). T-1361. |+| [Dashboard Simulation](#dashboard-simulation) | 2026-06-09 | Planned | Dashboard what-if toggle that applies a named predetermined load (watts) as a clearly-labelled simulation, showing the effect on house load, battery discharge, and the "empty by" estimate. Computed server-side via a `simulateLoadWatts` query param on `GET /status` (one source of truth — added load raises live `Pload`/`Pbat` and the rolling cutoff, suppresses `cantEmptyBeforeOffpeak`); added load is allocated by a priority waterfall (reduce grid export → battery, capped at the inverter's 5 kW ceiling → grid import) so car charging is accurate in every state — evening peak, mild sun, and full-sun export — validated 1–20000 W. Named presets are a system-wide CRUD resource mirroring `/pricing` (`flux-simulation-presets`, id-only key, synced across devices). Client adds a separate `fetchStatus(simulateLoadWatts:)` so widget/settings paths can't simulate; in-memory active state resets on cold launch; Settings ▸ Simulation editor + a Dashboard Simulate menu and distinct SimulationBanner. iOS + macOS. T-1495. |  --- @@ -278,3 +279,12 @@ Two calendar-anchored ranges — "week to date" (Wk) and "month to date" (Mo)  - [implementation.md](history-month-week-to-date/implementation.md) - [requirements.md](history-month-week-to-date/requirements.md) - [tasks.md](history-month-week-to-date/tasks.md)++## Dashboard Simulation++Dashboard "what-if" toggle that applies a named predetermined load (watts) as a clearly-labelled simulation, so the user can see what their power flow and battery life would look like (e.g. "Charge car" +1.7 kW). The simulated status is computed **server-side** via a `simulateLoadWatts` query parameter on the existing `GET /status` — keeping one source of truth rather than reimplementing the cutoff policy (freshness gate, off-peak suppression, 15-min rolling-average pbat) on the client. Added load `W` raises live `Pload` and `Pbat`, the returned rolling `AvgLoad`/`AvgPbat`, and both `computeCutoffTime` calls; `battery.cantEmptyBeforeOffpeak` is forced `nil` while simulating so the real off-peak indicator can't appear beside a simulated "empty by". Added load is allocated by a priority waterfall matching self-consumption-mode hardware (Decision 14): reduce current grid export first, then draw from the battery up to its sustained-discharge ceiling (`maxDischargeKW = 5 kW`, reused from `compute.go`), then import the remainder from the grid — so the trio stays energy-balanced (`Δload = Δbattery + Δgrid`) and the picture is right in every state. Evening peak: a 1.7 kW car on top of a high household draw caps the battery at 5 kW and shows the overflow as grid import, with an accurate "empty by". Full sun (battery charging + exporting): the car charge cuts export first, leaving the battery charging and dropping the export figure. The Grid tile thus answers "charge now or wait for off-peak?" directly. Watts are validated to 1–20000 and an out-of-range/`0`/unparseable param returns 400. Zero-load equivalence (`W=0` ≡ no-simulation) is an internal compute-path invariant covered by `pgregory.net/rapid` property tests plus a monotonicity property. Named presets (label + watts) are a new system-wide CRUD resource modelled on `/pricing` (new `flux-simulation-presets` table keyed by `presetId` only, no device/serial partition, list-all via Scan — so presets sync across devices) rather than the device-scoped SoC Alerts pattern; cap 20. Client adds a **separate** `fetchStatus(simulateLoadWatts:)` method with a default extension delegating to `fetchStatus()`, so the widget timeline and settings-validation fetches (and ~30 test mocks) can't accidentally simulate and need no change. A FluxCore `SimulationPresetsService` (mirrors `SoCAlertsService`, server-confirmed-then-apply) backs a Settings ▸ Simulation list/editor. `DashboardViewModel` holds an in-memory `activeSimulationPresetID` (resets on cold launch, survives auto-refresh/tab nav), resolves watts from the current presets each refresh, fires an immediate fetch on activate/switch/stop, and skips the widget-cache write while simulating. The Dashboard "Simulate" menu lives in `headerSection` (the nav-bar toolbar is hidden on compact iPhone) and a distinct `SimulationBanner` (placement baseline = the existing `stalenessBanner`) plus accent-tinted simulated values mark the what-if. iOS + macOS. Out of scope: controlling real devices, reducing load, stacking presets, simulating solar/tariff/off-peak, History/Day Detail, persisting the active simulation across restart, widget/Control-Center simulation, offline simulation. T-1495.++- [decision_log.md](dashboard-simulation/decision_log.md)+- [design.md](dashboard-simulation/design.md)+- [requirements.md](dashboard-simulation/requirements.md)+- [tasks.md](dashboard-simulation/tasks.md)
specs/dashboard-simulation/decision_log.md Added +452 / -0
diff --git a/specs/dashboard-simulation/decision_log.md b/specs/dashboard-simulation/decision_log.mdnew file mode 100644index 0000000..9d19c18--- /dev/null+++ b/specs/dashboard-simulation/decision_log.md@@ -0,0 +1,452 @@+# Decision Log: Dashboard Simulation++## Decision 1: Use the full spec workflow++**Date**: 2026-06-08+**Status**: accepted++### Context++T-1495 asks for a Dashboard toggle that applies a predetermined load as a what-if simulation, configured via user-level presets. The scope assessment estimated >80 LOC across ~7-9 files, a cross-cutting data-consistency concern (the client must recompute a metric currently produced server-side), and several open design questions.++### Decision++Run the full requirements → design → tasks workflow rather than smolspec.++### Rationale++The feature exceeds every smolspec threshold and carries a real consistency risk that benefits from explicit design.++### Alternatives Considered++- **Smolspec**: Lightweight single-doc spec - Rejected because the feature is larger and more cross-cutting than smolspec targets, risking under-specification of the consistency handling.++### Consequences++**Positive:**+- Design phase can resolve the client/server calculation parity explicitly.++**Negative:**+- More up-front effort than a smolspec.++---++## Decision 2: Store presets server-side, synced++**Date**: 2026-06-08+**Status**: accepted++### Context++Presets (label → watts) are described as managed "at the user level". Options were local UserDefaults, iCloud key-value sync, or server-side storage with Lambda CRUD (matching the existing SoC Alerts and Pricing features).++### Decision++Persist presets server-side with CRUD endpoints, mirroring the SoC Alerts pattern, so they survive reinstall and sync across devices.++### Rationale++The app already has an established server-side config pattern (SoC Alerts), giving sync and durability for free conceptually and keeping configuration consistent with how other user-level settings work.++### Alternatives Considered++- **Local per-device (UserDefaults)**: Simplest - Rejected because presets would not sync and would be lost on reinstall.+- **iCloud key-value sync**: No backend work - Rejected in favour of consistency with the existing server-side config pattern.++### Consequences++**Positive:**+- Presets sync across devices and survive reinstall; consistent with SoC Alerts.++**Negative:**+- Requires Go backend and DynamoDB changes (new table + endpoints).++---++## Decision 3: One simulation active at a time++**Date**: 2026-06-08+**Status**: accepted++### Context++Multiple presets could either stack (watts sum) or be mutually exclusive.++### Decision++At most one preset is active at a time; activating a preset replaces any currently active one.++### Rationale++Matches the motivating example (a single "charge car" scenario), and keeps both the toggle UI and the power-flow model simple.++### Alternatives Considered++- **Stack multiple presets**: Sum watts of all active presets - Rejected as unnecessary for the use case and more complex to present and reason about.++### Consequences++**Positive:**+- Simple selection model and indicator.++**Negative:**+- Cannot model two simultaneous appliances without a combined preset.++---++## Decision 4: Simulation adjusts load, battery flow, and battery life++**Date**: 2026-06-08+**Status**: accepted++### Context++The simulation could change only the house-load figure and the "empty by" estimate, or also adjust the battery/grid tiles so the live trio panel stays internally balanced.++### Decision++Added load W increases displayed house load by W, increases displayed battery discharge by W (battery modelled as absorbing the extra draw) while the discharge stays within the battery's sustained-discharge ceiling, leaves grid unchanged below that ceiling, and recomputes the "empty by" from the simulated battery power. Decision 14 refines this: discharge is capped at the ceiling and the excess is routed to grid import.++### Rationale++Keeping the trio panel balanced avoids showing load rising while the battery tile stays put, which would look broken. Attributing the extra draw to the battery is the simplest balanced model and directly drives the battery-life change the user wants to see.++### Alternatives Considered++- **Load + battery-life only**: Leave grid and battery tiles at real values - Rejected because the trio panel would not balance (load up, battery unchanged).++### Consequences++**Positive:**+- Internally consistent panel; one clear attribution rule.++**Negative:**+- The plain battery-absorbs rule is inaccurate once simulated discharge exceeds the inverter's discharge ceiling; Decision 14 corrects this by capping at the ceiling and routing the excess to the grid.++---++## Decision 5: Active simulation is transient session state++**Date**: 2026-06-08+**Status**: accepted++### Context++When the app restarts, the active simulation could either be restored or reset.++### Decision++Presets persist (server-side), but the *active* simulation resets to off on a cold launch. It persists across auto-refresh and tab navigation within a session.++### Rationale++A what-if is a momentary exploration; silently restoring it on launch risks the user mistaking simulated values for real ones after they have forgotten it was on.++### Alternatives Considered++- **Restore active simulation on launch**: Persist the toggle - Rejected because of the risk of confusing simulated values for real state.++### Consequences++**Positive:**+- No risk of a forgotten simulation persisting into a fresh session.++**Negative:**+- The user must re-enable a simulation after restarting the app.++---++## Decision 6: Compute the simulated status server-side++**Date**: 2026-06-09+**Status**: accepted++### Context++The battery "empty by" estimate is not a pure formula: the value the Dashboard shows is the result of server handler policy — a freshness gate, off-peak-window suppression, use of the 15-minute rolling-average battery power, and a separate pbat-independent "won't empty before off-peak" indicator that hides the "empty by" line when set. The client today does not recompute the cutoff; it renders the server-formatted value. Supporting simulation requires the estimate to reflect added load. The options were to reimplement this policy in Swift (verified by a Go↔Swift parity test) or to compute the simulated status server-side via a load parameter on `/status`. A design-critic review and external validation (Codex and Gemini, independently) both favoured the server-side approach.++### Decision++The backend computes the full simulated status — adjusted live values plus the recomputed "empty by" and off-peak indicator — for a given added-load parameter on `/status`. The client renders the returned simulated status and does not re-derive any shared metric.++### Rationale++The project mandates that a metric shown on more than one screen be computed once, server-side where possible. Reimplementing the handler policy in Swift would create a second source of truth that must be kept in lockstep with the Go handler forever, which is the exact cross-screen divergence the mandate forbids. A load parameter reuses the one existing implementation, and the app already round-trips `/status` every 10s, so the cost is one parameterised call.++### Alternatives Considered++- **Client-side recompute in Swift, verified by a Go↔Swift parity test**: Works offline - Rejected because it duplicates non-trivial handler policy (freshness gate, off-peak suppression, rolling-average series, off-peak indicator) and carries ongoing drift risk; the parity test verifies a copy rather than removing the second source of truth.++### Consequences++**Positive:**+- One source of truth; simulated and real values cannot diverge.+- Cleanly resolves the off-peak-indicator contradiction and the stale-data case (simulation is simply unavailable when data is stale).++**Negative:**+- Simulation requires a live connection; it is unavailable offline or when live data is stale.+- Adds a parameter and simulation path to the Go `/status` handler.++---++## Decision 7: Available on both iOS and macOS++**Date**: 2026-06-09+**Status**: accepted++### Context++The app ships on iOS and macOS sharing the Dashboard hero panel, FluxCore, and a Settings scene. The initial requirements named only "the app" without stating platform scope; review flagged the omission.++### Decision++The feature — preset management, the Dashboard activation control, and the simulation indicator — is available on both iOS and macOS.++### Rationale++The affected surfaces are shared code; scoping to one platform would either leave the other broken or require platform-gating that the shared architecture does not otherwise need.++### Alternatives Considered++- **iOS only**: Smaller initial scope - Rejected because the shared hero panel and Settings scene would otherwise behave inconsistently across platforms.++### Consequences++**Positive:**+- Consistent behaviour across platforms from the shared implementation.++**Negative:**+- macOS layout and interaction need explicit verification, not just iOS.++---++## Decision 8: Keep the battery-absorbs model; bound the watt value instead++**Date**: 2026-06-09+**Status**: superseded by Decision 14++> **Superseded:** the 20 kW watt-value bound (Req 1.3) stays, but the choice *not* to model the inverter ceiling is reversed by Decision 14 — car charging (the primary use case) is a hard requirement and the unclamped model produced optimistic "empty by" times once simulated discharge exceeded the ceiling.++### Context++The "battery absorbs the added load, grid unchanged" model (Decision 4) is physically inaccurate near the inverter discharge ceiling, near cutoff, or while solar is charging. A review suggested clamping simulated discharge at the inverter ceiling and routing overflow to the grid.++### Decision++Keep the documented first-order approximation and instead reject preset watt values above 20 kW, rather than modelling the inverter ceiling and grid overflow.++### Rationale++A what-if estimate does not need a faithful power-electronics model; building one is disproportionate to the feature. The 20 kW cap is only an input-sanity guard against typos and absurd values — it deliberately does **not** keep simulated discharge within the inverter's ~5 kW sustained ceiling. Simulated battery discharge (`real pbat + W`) can and often will exceed 5 kW in the feature's primary use case (high-watt car charging on top of existing load); under the battery-absorbs model the resulting "empty by" is then optimistic relative to what the real system, falling back to the grid at the ceiling, would do. This is the accepted limit of the approximation, recorded as a non-goal rather than corrected with a power-flow model.++### Alternatives Considered++- **Clamp discharge at the inverter ceiling with grid overflow**: More physically faithful - Rejected as over-engineering for a what-if; adds a power-flow model the feature does not need.+- **A 5 kW cap to match the inverter ceiling**: Appears safer - Rejected because added load stacks on existing load, so even a small `W` can push simulated discharge past 5 kW; a low cap would not make the model physically exact and would block legitimately large appliance presets.++### Consequences++**Positive:**+- Simple, predictable model; inputs bounded against nonsense values.++**Negative:**+- When simulated discharge exceeds the inverter ceiling (likely for car charging), the "empty by" is optimistic and carries no on-screen caveat. If this proves misleading in use, a follow-up could add a "beyond battery output" hint without changing the model.++---++## Decision 9: Mirror SoC Alerts for preset CRUD failure handling++**Date**: 2026-06-09+**Status**: accepted++### Context++Preset CRUD is server-side; the draft requirements specified only validation, not transport-failure or offline behaviour. The existing SoC Alerts feature already has an established error model.++### Decision++Preset CRUD follows the SoC Alerts pattern: apply a change locally only after the server confirms it, leave the list unchanged on failure, and surface a visible error.++### Rationale++Reusing the established pattern is near-zero marginal cost and consistent with the feature it is modelled on; inventing a lighter bespoke error path would diverge from precedent for no benefit.++### Alternatives Considered++- **Optimistic update with rollback**: Snappier UI - Rejected because it does not match the existing server-confirmed-then-apply pattern and adds rollback complexity.+- **No explicit error surface (silent/toast only)**: Less UI work - Rejected as inconsistent with SoC Alerts and likely to hide real failures.++### Consequences++**Positive:**+- Consistent, predictable failure behaviour matching the rest of the app.++**Negative:**+- A change is not reflected until the server confirms it (a brief delay on slow connections).++---++## Decision 10: Model presets on the `/pricing` precedent (system-wide, id-only key)++**Date**: 2026-06-09+**Status**: accepted++### Context++Presets must sync across the user's devices ([requirement 1.4](requirements.md#1.4)). The first draft mirrored the SoC Alert rules feature, which is partitioned by `deviceId` (because alert notifications target a registered device) — and so needed a deliberate deviation to a serial partition to achieve sync. Review pointed out a better existing precedent: `/pricing` is already a system-wide CRUD resource. `flux-pricing` is keyed by `pricingId` (HASH only, no device/user/serial partition) and `ListPricing(ctx)` scans the whole table. There is no per-user identity in the backend and the API token is a single shared secret, so a single-system, id-only table is the natural fit.++### Decision++Model the presets CRUD on `/pricing`: a `flux-simulation-presets` table keyed by `presetId` only (no partition), endpoints `/simulation-presets` with no `{deviceId}` segment, list-all via Scan. Omit pricing's singleton-sentinel and transactional machinery, which exist only for its open-ended-period coupling. Mirror SoC Alerts for the *client* service and editor UI, which is the closer UI shape.++### Rationale++Following an existing system-wide precedent removes the need for any partition-scoping decision: every device that talks to the backend sees the same list, satisfying sync, with no deviceId siloing and no serial partition that would hold exactly one value forever (the backend is single-system; `/status` itself is hard-wired to one serial, so a serial key enables nothing today). This is strictly simpler than the serial-partition draft.++### Alternatives Considered++- **Device-scoped (mirror SoC rules exactly)**: Maximum consistency with the notifications feature - Rejected because presets would silo per device, violating 1.4.+- **Serial-partitioned table**: Aligns the key with `/status` - Rejected as needless complexity: the serial is a single configured value with no multi-system path, so the partition stores one value forever; `/pricing` shows the keyless single-system model is the established pattern.++### Consequences++**Positive:**+- Presets sync across devices with no extra machinery and no deviation to document.+- Reuses an existing single-system CRUD shape end to end (handler, store, table, IAM).++**Negative:**+- Two precedents in play (pricing for the backend, SoC Alerts for the client UI); the design states which applies where.++---++## Decision 11: Suppress the off-peak indicator while simulating++**Date**: 2026-06-09+**Status**: accepted++### Context++The hero panel shows either the real-data "won't empty before off-peak" indicator or the "empty by" line, never both (`DashboardHeroPanel.subline`). That indicator is computed pbat-independently against the 5 kW inverter ceiling — a worst-case reassurance. Under an added-load simulation it would still reflect real data and could hide the simulated "empty by", producing the contradiction [requirement 4.3](requirements.md#4.3) forbids.++### Decision++While a non-zero load is simulated, the backend returns `cantEmptyBeforeOffpeak = nil`, so the hero always shows the simulated "empty by" line (or just the simulated discharge rate when the cutoff lands after off-peak under the existing suppression).++### Rationale++The worst-case "won't empty" guarantee is not meaningful for a what-if that deliberately raises load; the informative value under simulation is the recomputed "empty by", whose presence already encodes whether the battery empties before off-peak. Suppressing the real indicator removes the only path by which a real and a simulated statement could appear together.++### Alternatives Considered++- **Recompute the indicator from the simulated discharge**: Keeps the affordance - Rejected because it mixes the simulated load with the indicator's fixed 5 kW-ceiling model, muddying its meaning; the "empty by" line already conveys the same information.+- **Leave the real indicator as-is**: No backend change - Rejected because it can hide the simulated "empty by" and contradict it (4.3).++### Consequences++**Positive:**+- No real/simulated contradiction; the hero's behaviour under simulation is simple to reason about.++**Negative:**+- The off-peak reassurance disappears while simulating; it returns the instant simulation is turned off.++---++## Decision 12: Cap presets at 20++**Date**: 2026-06-09+**Status**: accepted++### Context++The requirements do not bound the number of presets. The SoC rules feature caps rules at 10 to keep the list and the backend bounded.++### Decision++Enforce a defensive cap of 20 presets on create (HTTP 409 when exceeded), and disable the add affordance in the editor at the cap.++### Rationale++A cap keeps the Dashboard selection control usable and the stored list bounded, consistent with the precedent feature. Twenty is generous for personal use while still bounding the menu.++### Alternatives Considered++- **No cap**: Less code - Rejected as inconsistent with the SoC rules precedent and leaves the Dashboard menu unbounded.++### Consequences++**Positive:**+- Bounded list and selection menu; matches existing precedent.++**Negative:**+- A user wanting more than 20 presets cannot, which is implausible for this use case.++---++## Decision 13: Do not cache a simulated status for widgets++**Date**: 2026-06-09+**Status**: accepted++### Context++`DashboardViewModel.refresh()` writes every fetched status into the shared widget cache and may trigger a widget reload. Widgets and the Control Center widget must always show real data (a Non-Goal of this feature).++### Decision++While a simulation is active, skip the widget-cache write and the widget-reload trigger in `refresh()`.++### Rationale++A simulated status written to the shared cache would leak what-if values into widgets, which are out of scope and must stay real. Skipping the write is the minimal correct behaviour.++### Alternatives Considered++- **Tag cached entries as simulated and have widgets ignore them**: More flexible - Rejected as unnecessary work; widgets have no simulation feature, so simply not writing is sufficient.++### Consequences++**Positive:**+- Widgets never display simulated values.++**Negative:**+- The widget cache is not refreshed while a simulation runs; it resumes updating as soon as simulation is off (and the cache only ever held real data).++---++## Decision 14: Allocate added load by a priority waterfall — reduce export → battery (capped) → grid import++**Date**: 2026-06-09+**Status**: accepted (supersedes the no-clamp part of Decision 8)++### Context++Charging the car (the 1.7 kW example) is the primary use case and a hard requirement to model correctly across the states it's actually used in — both the evening peak and the sunny "soak up my solar" midday. The plain battery-absorbs model (Decisions 4/8) attributes the entire added load to the battery and leaves grid unchanged. Two physical realities break that:+1. The inverter has a sustained-discharge ceiling (`maxDischargeKW = 5.0 kW`, already a constant in `compute.go`); beyond it the battery cannot supply more and the surplus comes from the grid. Adding 1.7 kW on top of a ~4 kW evening household draw exceeds 5 kW, so the unclamped model showed the battery draining faster than possible and an "empty by" that was too early.+2. When the battery is charging *and* exporting surplus solar (full sun), self-consumption-mode hardware serves new load by cutting export first, keeping the battery charging — the plain model instead reduced battery charging and left export untouched, mis-attributing where the energy comes from.++### Decision++Allocate the added W by a priority waterfall (sign convention `pbat > 0` discharge, `pgrid < 0` export): (1) `exportReduction = min(W, max(0, -pgrid))` cuts current export toward zero; (2) the remainder `wBattery = W − exportReduction` is taken on by the battery up to its remaining headroom — `batteryAbsorbed = min(wBattery, max(0, maxDischargeW − pbat))`, giving `simDischarge = pbat + batteryAbsorbed` (never below the real `pbat`); (3) the leftover `overflow = wBattery − batteryAbsorbed` becomes grid import, `simPgrid = pgrid + exportReduction + overflow`. Headroom is evaluated per series (live `pbat`, rolling `avgPbat`). Capping only the *added* portion — not `min(pbat + wBattery, maxDischargeW)` — keeps `W = 0` a true no-op even when a real reading already sits at/above the ceiling.++### Rationale++One rule covers every starting state correctly with ~6 lines server-side, reusing the existing ceiling constant so the inverter limit has a single definition, and keeps the trio energy-balanced (`Δload = Δbattery + Δgrid`) throughout. It matches AlphaESS's default self-consumption priority (PV → load → battery → grid). As a bonus the Grid tile now conveys the real answer to the decision the feature exists for — charging the car reduces your export (you self-consume) or, once the battery saturates, pulls peak import — which the "grid unchanged" model could not show. The export step only changes outcomes while exporting (battery charging), where no "empty by" is shown, so it never affects the battery-life estimate.++### Alternatives Considered++- **Keep the unclamped battery-absorbs model (Decision 8)**: Simplest - Rejected because it gives optimistic "empty by" times in the evening case and mis-attributes the sunny case, both of which the user requires to be correct.+- **Cap discharge but skip the export-reduction step**: Fewer lines - Rejected because in the charging+exporting state it shows battery charging dropping while export stays flat, contradicting how the hardware actually responds.+- **Cap the discharge for the "empty by" only, leave the grid tile unchanged**: Slightly less code - Rejected because it re-breaks the panel's energy balance — the exact inconsistency Decision 4 exists to avoid.+- **Show an on-screen "beyond battery output" caveat instead of computing it correctly**: No model change - Rejected because computing the correct value is cheap and strictly better than flagging a known-wrong one.++### Consequences++**Positive:**+- The simulated power split and "empty by" are accurate in every state — evening peak, mild sun, and full-sun export — and the trio always balances.+- The Grid tile reveals whether charging would cut export or import peak power.++**Negative:**+- The grid value is no longer always unchanged under simulation (it moves when exporting or when the battery saturates) — intended, but a reader of the older non-goal must re-learn it.+- Assumes self-consumption priority (reduce export before reducing battery charge); a feed-in-priority configuration would behave differently. The 5 kW ceiling is a fixed constant — if the real inverter limit differs it updates in one place (`maxDischargeKW`).++---
specs/dashboard-simulation/design.md Added +229 / -0
diff --git a/specs/dashboard-simulation/design.md b/specs/dashboard-simulation/design.mdnew file mode 100644index 0000000..5038ab0--- /dev/null+++ b/specs/dashboard-simulation/design.md@@ -0,0 +1,229 @@+# Design: Dashboard Simulation++## Overview++A `simulateLoadWatts` query parameter on `GET /status` makes the Go handler return a what-if status (added load, increased discharge, recomputed "empty by") so the client renders one server-authored picture and never re-derives a shared metric. Named presets (label + watts) are a new server-side CRUD resource modelled on the existing system-wide `/pricing` resource, so they sync across the user's devices, surfaced in Settings and selectable from the Dashboard.++## Architecture++Two independent server additions plus client wiring:++1. **Simulated status** — a parameter on the existing `/status` path. No new endpoint, no new storage.+2. **Simulation presets** — a new system-wide CRUD resource. The backend mirrors `/pricing` (single table keyed by row id only, no device/user partition, list-all); the client service and editor mirror SoC Alerts (closer UI shape).++### Simulated status — injection sites++The added load `W` is applied at every point where battery power feeds a value the Dashboard renders. `internal/api/status.go` reads `simulateLoadWatts` from `req.QueryStringParameters` (same access pattern as `history.go`/`day.go`), validates it, and threads it through. The load is allocated by a **priority waterfall** matching self-consumption-mode hardware: first reduce any grid export, then draw from the battery up to its sustained-discharge ceiling `maxDischargeW = maxDischargeKW * 1000` (the existing 5 kW constant in `compute.go`, reused so the ceiling has one definition), then import the rest from the grid. Sign convention: `pbat > 0` discharge, `pgrid > 0` import. The derived quantities (computed from the live snapshot):++- `exportReduction = min(W, max(0, -latest.Pgrid))` — added load served by cutting current export first+- `wBattery = W - exportReduction` — the remainder that reaches the battery+- `headroom(p) = max(0, maxDischargeW - p)` — discharge the battery can still take on (0 if `p` is already at/above the ceiling)+- `batteryAbsorbed(p) = min(wBattery, headroom(p))`; `simDischarge(p) = p + batteryAbsorbed(p)` — caps only the *added* discharge, so `simDischarge(p) ≥ p` always (it never reduces a real reading)+- `overflow = wBattery - batteryAbsorbed(latest.Pbat)` — what the battery can't take on, met by grid import++Capping the added portion via `headroom` (rather than `min(pbat + wBattery, maxDischargeW)`) matters when a real reading is already at/above the ceiling: the naïve `min` would *lower* that reading and, at `W = 0`, return `maxDischargeW ≠ pbat`, breaking zero-load equivalence ([4.2](requirements.md#4.2)). The headroom form is identical whenever `pbat ≤ maxDischargeW` and is a true no-op at `W = 0`.++| Site (`status.go`) | Source value | Under simulation | Renders as |+|---|---|---|---|+| `Live.Pload` | `latest.Pload` | `+ W` | Trio "House" |+| `Live.Pbat` | `latest.Pbat` | `simDischarge(latest.Pbat)` | Hero subline "Discharging · X kW", hero mode |+| `Live.Pgrid` | `latest.Pgrid` | `+ exportReduction + overflow` (both 0 when not exporting and below the ceiling → unchanged) | Trio "Grid" |+| `battery.EstimatedCutoff` | `computeCutoffTime(latest.Soc, latest.Pbat, …)` | `simDischarge(latest.Pbat)` | not read by client; same inline math keeps the whole response coherent for any API/debug consumer |+| `rolling15m.EstimatedCutoff` | `computeCutoffTime(latest.Soc, avgPbat, …)` | `simDischarge(avgPbat)` | **Hero "empty by HH:MM"** |+| `rolling15m.AvgLoad` / `AvgPbat` | `computeRollingAverages(…)` | `AvgLoad + W`; `AvgPbat → simDischarge(avgPbat)` | not rendered today; carry the same adjustment so the returned averages stay coherent |+| `battery.CantEmptyBeforeOffpeak` | `computeCantEmptyBeforeOffpeak(…)` | forced `nil` when `W > 0` | suppressed (see below) |+| `Live.Ppv` | unchanged | unchanged | Trio "Solar" |++`wBattery` (not `W`) is the amount that reaches the battery, so `simDischarge` and the cutoffs use it; `headroom` is evaluated per-series (against `latest.Pbat` for the live tile, `avgPbat` for the rolling cutoff), so each series caps independently. `exportReduction` is computed from the live grid and threaded into both cutoff series — intentionally: it only bites while you're exporting (sunny, battery charging), where the cutoff is `nil` anyway, so it never makes the "empty by" wrong; in the importing/zero-grid evening case `exportReduction = 0` and `wBattery = W`, exactly the simple form. One state is deliberately not modelled: a battery *charging while importing* (e.g. a scheduled off-peak grid charge) — added load there is shown shifting the battery toward discharge with grid unchanged, which is acceptable for a what-if since the feature targets solar/evening states, not forced grid-charging.++The waterfall handles every starting state with one rule (`pbat > 0` discharge, `pgrid < 0` export):++- **Importing / zero grid (evening peak):** `exportReduction = 0`, so `wBattery = W` shifts the battery toward discharge; above the 5 kW ceiling the remainder becomes grid import — the load-bearing fix for the car-on-top-of-evening-draw case ([3.4](requirements.md#3.4)), where the battery is shown at the ceiling and the "empty by" reflects the real (capped) drain rather than an impossibly fast one.+- **Charging, zero grid (mild sun):** `wBattery = W` against a negative `pbat` crosses charge→discharge naturally (charging 1.0 kW + 1.7 kW → 0.7 kW discharge).+- **Charging *and* exporting (full sun):** `exportReduction` consumes the export first, so the battery keeps charging and only the export figure drops (charging 2.0 kW / exporting 2.5 kW + 1.7 kW → still charging 2.0 kW, exporting 0.8 kW). This is what self-consumption-mode hardware does, and it's the case the plain "battery absorbs everything" model got wrong.++In all cases load increase `W` equals battery increase + grid increase, so the trio stays energy-balanced. `computeCutoffTime` already returns `nil` when the (allocated, capped) `pbat <= 0` or `soc <= cutoffPercent`, so [4.4](requirements.md#4.4) is inherited unchanged. The off-peak boundary suppression (`ct.Before(nextOpWindowStart)`) and the 90s `liveFresh` gate are likewise unchanged and apply identically to the simulated cutoff — satisfying [4.1](requirements.md#4.1) and [4.5](requirements.md#4.5). When `W = 0` every step is a no-op, so the simulated response equals the real one ([4.2](requirements.md#4.2)).++**Off-peak indicator (resolves [4.3](requirements.md#4.3)).** `computeCantEmptyBeforeOffpeak` is pbat-independent — it asks "even at the 5 kW inverter ceiling, can the battery reach cutoff before off-peak?" That worst-case reassurance is meaningless under an added-load what-if, and the hero renders it *instead of* the "empty by" line (`DashboardHeroPanel.subline`, mutually exclusive). So while `W > 0` the handler returns `CantEmptyBeforeOffpeak = nil`; the hero then always takes the `statusLine` path and shows the simulated "empty by" (or just the discharge rate when the simulated cutoff lands after off-peak). The real indicator and a simulated "empty by" can never appear together.++### Simulation presets — CRUD, system-wide (mirrors `/pricing`)++The `/pricing` resource is already a system-wide, single-table CRUD with no device or user partition: `flux-pricing` is keyed by `pricingId` (HASH only) and `ListPricing(ctx)` scans the whole table. That is exactly the shape presets need — one monitored system, list-all, synced across every device that talks to the backend ([1.4](requirements.md#1.4)) — with **no** device path segment and **no** partition-scoping decision to make. Presets therefore mirror `/pricing`'s handler/store shape, **minus** its singleton-sentinel and transactional machinery (those exist only for pricing's open-ended-period coupling; presets are independent rows).++Endpoints (bearer-token auth, same global middleware — identical to `/pricing`):++```+GET    /simulation-presets+POST   /simulation-presets+PUT    /simulation-presets/{id}+DELETE /simulation-presets/{id}+```++A defensive cap of 20 presets is enforced on create (returns 409) — chosen for selection-menu legibility on the Dashboard rather than any storage limit.++### Client data flow++```+SimulationPresetsService (FluxCore, @Observable)  ──CRUD──▶  /simulation-presets+        │ presets: [SimulationPreset]+        ▼+SettingsView ▸ "Simulation" section ▸ SimulationPresetsView (list + editor sheet)++DashboardViewModel+  • activeSimulationPresetID: String?   (in-memory; nil on cold launch → [2.5])+  • resolves watts from presets list each refresh → fetchStatus(simulateLoadWatts:)+  • if active ID absent from list → clears it ([2.4]); watts re-read each cycle ([2.7])+        ▼+DashboardView passes isSimulating + active preset name to panels+  • SimulationBanner (new, distinct treatment)+  • Trio "House" / hero subline values tinted as simulated+```++The active simulation is view-model state, not persisted, so it resets on cold launch ([2.5]) and survives auto-refresh/tab changes within a session.++**Immediacy.** Activating a preset, switching presets, or turning simulation off does not wait for the next auto-refresh tick — each triggers an immediate `refresh()` so the on-screen figures and tint change at once. Turning off clears `activeSimulationPresetID` and immediately re-fetches without the parameter, so the real values and the removal of all simulated markings ([5.5](requirements.md#5.5)) happen in the same cycle rather than lingering until the next tick.++**Banner/figure agreement ([2.7]).** The banner's preset name and signed delta are sourced from the watt value that produced the *currently displayed* status (the view model records the watts it last sent), not from the live presets list. So even across a cross-device watts edit, the banner and the figures always describe the same watts; the next refresh re-resolves the watts from the updated list and both move together.++**Stale while simulating ([4.5], Req 5).** Whether the banner shows is driven by the toggle state (simulation active), independent of data availability. If live data goes stale or the fetch fails mid-simulation, the banner stays up but the affected values fall back to the existing "Awaiting live data" / error treatment — no simulated figures are fabricated.++### Pattern-extension audit — every reader of the injected values++| Consumer | File:symbol | Needs simulation handling? |+|---|---|---|+| Trio House | `LiveTrioPanel` `live?.pload` | No code change — reads simulated `live` from response; add "simulated" tint via `isSimulating` |+| Trio Solar/Grid | `LiveTrioPanel` `live?.ppv/pgrid` | No — values unchanged by sim |+| Hero discharge/charge line | `DashboardHeroPanel.mode` (`live.pbat`) | No code change — reads simulated `pbat`; tint via `isSimulating` |+| Hero "empty by" | `DashboardHeroPanel` `rolling15min?.estimatedCutoffTime` | No code change — server returns simulated value |+| Hero off-peak indicator | `DashboardHeroPanel` `battery.cantEmptyBeforeOffpeak` | Server returns `nil` under sim; no client change |+| Widget cache | `DashboardViewModel.refresh` `widgetCache.writeIfNewer` | **Yes — must NOT cache simulated status** (widgets show real data; Non-Goal). Skip the cache write while simulating |+| Widget timeline fetch | `Widget/StatusTimelineLogic.swift:90` `client.fetchStatus()` | **Yes — must keep the unsimulated call.** It uses the no-arg `fetchStatus()`; the simulation parameter is a *separate* method only the Dashboard calls (see API section) so this path can never simulate |+| Settings validation fetch | `SettingsViewModel.swift:75` `validationClient.fetchStatus()` | Keeps the no-arg `fetchStatus()`; never simulates |+| `battery.estimatedCutoffTime` | unused by client | Kept consistent server-side; no client impact |++The widget-cache row is the one non-obvious consumer: `refresh()` currently writes every fetched status to the shared widget cache. A simulated status must not leak into widgets, so the cache write (and widget reload trigger) is skipped while `activeSimulationPresetID != nil`.++## Components and Interfaces++### Backend (Go)++```go+// internal/dynamo/simulationpresets.go  (mirrors PricingItem — id-only key, no partition)+type SimulationPresetItem struct {+    PresetID  string `dynamodbav:"presetId"  json:"id"`+    Label     string `dynamodbav:"label"     json:"label"`+    Watts     int    `dynamodbav:"watts"     json:"watts"`+    CreatedAt string `dynamodbav:"createdAt" json:"createdAt"`+    UpdatedAt string `dynamodbav:"updatedAt" json:"updatedAt"`+}+// Store.ListPresets(ctx) (Scan) / PutPreset(ctx, item) / DeletePreset(ctx, presetId) — same shape as DynamoPricingStore++// internal/api/simulationpresets.go+type simulationPresetPayload struct { Label string `json:"label"`; Watts int `json:"watts"` }+func (p simulationPresetPayload) validate() error // label 1..40 chars; watts 1..20000++// internal/api/compute.go — no new function needed; callers pass pbat+W inline.+// handleStatus parses/validates the param:+func parseSimulateLoad(q map[string]string) (float64, error) // "", or 0<v<=20000, else error → 400+```++Handler behaviour: an out-of-range or unparseable `simulateLoadWatts` returns 400 with `{"error": …}` ([4.6](requirements.md#4.6)); presets are validated identically on write, so a valid preset can never produce a rejected status request.++### Client (Swift / FluxCore)++```swift+// FluxCore/Models/SimulationPreset.swift+public struct SimulationPreset: Identifiable, Codable, Sendable, Equatable {+    public let id: String+    public var label: String+    public var watts: Int+    public let createdAt: Date+    public var updatedAt: Date+}+public struct SimulationPresetDraft: Sendable, Equatable {+    public var label: String = ""+    public var watts: Int = 0      // starts invalid (validation requires >0) → forces a deliberate entry, like the empty-label rule+    public func validate() -> ValidationError?   // emptyLabel, labelTooLong(40), wattsOutOfRange(1...20000)+}++// FluxCore/Networking/FluxAPIClient.swift — additions (existing fetchStatus() is UNCHANGED)+func fetchStatus(simulateLoadWatts: Int) async throws -> StatusResponse   // NEW, separate method+func fetchPresets() async throws -> [SimulationPreset]+func createPreset(_ draft: SimulationPresetDraft) async throws -> SimulationPreset+func updatePreset(_ preset: SimulationPreset) async throws -> SimulationPreset+func deletePreset(id: String) async throws++// Default so existing conformers (~30 test mocks, widget, settings) need no change:+extension FluxAPIClient {+    func fetchStatus(simulateLoadWatts: Int) async throws -> StatusResponse {+        try await fetchStatus()        // non-simulating fallback+    }+}+// URLSessionAPIClient overrides it to actually send the parameter.++// FluxCore/Simulation/SimulationPresetsService.swift  (@MainActor @Observable)+//   presets: [SimulationPreset]; lastError: Error?+//   refresh()/create()/update()/delete() — server-confirmed-then-apply, mirroring SoCAlertsService+```++The simulation parameter is a **separate** method, not a change to `fetchStatus()`, so the widget timeline and settings-validation call sites are untouched and cannot accidentally simulate (audit rows above). Only `DashboardViewModel` calls the new method, and only while a simulation is active; `URLSessionAPIClient` adds one `URLQueryItem` via the existing `performRequest(path:queryItems:)`. Exactly one status request per refresh cycle ([2.6](requirements.md#2.6)).++### UI++| Element | File (new unless noted) | Baseline to match |+|---|---|---|+| Presets list + error banner | `Settings/Simulation/SimulationPresetsView.swift` | `SoCAlertsView` |+| Editor sheet (label, watts stepper/field, validation) | `Settings/Simulation/SimulationPresetEditor.swift` | `SoCAlertEditor` |+| List view model | `Settings/Simulation/SimulationPresetsViewModel.swift` | `SoCAlertsViewModel` |+| Settings entry | `Settings/SettingsView.swift` (edit) | add `NavigationLink` next to the "Alerts" section, iOS + macOS |+| Dashboard control | `Dashboard/DashboardView.swift` (edit) | `Menu` listing presets + "Off"; empty → "Add in Settings" deep link ([2.3]) |+| Simulation indicator | `Dashboard/SimulationBanner.swift` | **Deliberately distinct** from the hero `cantEmptyBeforeOffpeak` line |++**Where the control lives ([2.1], [2.3]).** The control must sit in the Dashboard's *scroll content*, not the navigation toolbar: on compact iPhone the nav bar is hidden (`DashboardView` line 51, `.toolbar(... .hidden, for: .navigationBar)`), so a toolbar item would be invisible there. Placement:++- **Trigger (inactive):** a `Menu` in `headerSection` (trailing side of the existing `FluxScreenHeader`, alongside the settings affordance) labelled "Simulate" with an SF Symbol. Visible on iPhone, iPad, and macOS because it is part of the content, not the chrome. The menu lists each preset (label + watts) and an "Off" entry; with no presets it shows a single "Add a preset…" item that opens Settings ▸ Simulation ([2.3]).+- **Active state:** the `SimulationBanner` renders at the top of `dashboardContent`, in the same slot and following the placement of the existing `stalenessBanner` (line 247) — a full-width banner above the panels — but with the distinct simulation treatment below. It shows the active preset + delta and a **Stop** control; the "Simulate" menu remains available to switch presets.++`stalenessBanner` is the *placement/structure* baseline (a Dashboard-level banner at the top of content); the simulation banner's visual treatment is deliberately distinct (next paragraph).++**Indicator treatment ([5.1]–[5.4]).** The existing hero off-peak indicator is calm secondary-text inside the hero subline. The simulation indicator must read as *not real data*, so it is a separate persistent banner above the panels with a distinct accent (a new `FluxTheme.Palette.simulation` accent, not amber/secondary), an SF Symbol, the active preset name and signed delta ("Simulation · Charge car · +1.7 kW"), and a Stop action. The values the simulation changes (Trio "House", hero discharge/empty-by) render in the same simulation accent while active, so the figures alone reveal the simulation ([5.3]). Banner and tinted values carry `.accessibilityLabel`s announcing "simulated", matching how the hero indicator already labels itself ([5.4]). Note the banner's `+1.7 kW` is the preset's *added load* (always positive), which is intentionally a different number from the hero's *net* battery figure (e.g. 1.0 kW charging + a 1.7 kW preset shows as 0.7 kW discharging) — [2.7]'s "agreement" is about the watt value sent matching the displayed status, not the banner delta equalling the hero's net figure.++## Data Models++New DynamoDB table `flux-simulation-presets` (mirrors `flux-pricing`): `BillingMode: PAY_PER_REQUEST`, `DeletionPolicy: Retain`, PITR enabled, key id-only (no partition by device/user):++| | Partition (HASH) | Sort (RANGE) |+|---|---|---|+| `flux-pricing` | `pricingId` | — |+| `flux-simulation-presets` | `presetId` | — |++`presetId` is a server-assigned 128-bit hex id; `createdAt`/`updatedAt` are RFC3339 UTC, `updatedAt` bumped on every PUT. Lambda IAM gains `Scan/PutItem/DeleteItem` on the new table (matching `flux-pricing`'s grant); `TABLE_SIMULATION_PRESETS` env var added. Unlike `flux-pricing`, there is **no** sentinel row — presets are independent.++## Error Handling++| Condition | Behaviour |+|---|---|+| Invalid/over-range `simulateLoadWatts` | 400 `{"error":…}`; client treats as simulation-unavailable, no fabricated values ([4.6]) |+| Live data stale (>90s) or fetch fails while simulating | server omits `live` (existing `liveFresh` gate) / client keeps existing error path → "Awaiting live data"; no simulated values fabricated ([4.5]) |+| Preset CRUD failure / offline | server-confirmed-then-apply: list unchanged, `lastError` surfaced in a banner; editor sheet stays open ([1.6]) — mirrors `SoCAlertsService` |+| Preset cap (20) exceeded | 409; create disabled in editor when at cap |+| Active preset deleted (locally or via sync) | absent from refreshed list → `DashboardViewModel` clears `activeSimulationPresetID`, simulation turns off ([2.4]) |+| Active preset's watts edited on another device | watts re-resolved from the refreshed list each cycle, next request uses the new value ([2.7]) |++## Testing Strategy++**Backend (Go, table-driven, mock reader + fake store — existing style):**+- `compute`/`status`: `W > 0` shifts the live `pbat`/`pload` and the rolling cutoff earlier by the expected amount; off-peak suppression and `liveFresh` still gate the simulated cutoff ([4.1], [4.5]); `CantEmptyBeforeOffpeak` is `nil` whenever `W > 0` ([4.3]); a request with `?simulateLoadWatts=0` (or unparseable / >20000) → 400 ([4.6]).+- Load-allocation waterfall ([3.2]/[3.4]) — assert `Δload == Δbattery + Δgrid` (energy balance) in every case: (a) importing/zero grid below ceiling → battery takes all of `W`, grid unchanged; (b) the 1.7 kW-over-evening-draw case → battery caps at `maxDischargeW`, `pgrid += overflow`, "empty by" reflects the capped rate; (c) charging + exporting (full-sun) → export reduced first, battery charging unchanged, no "empty by"; (d) export partially covers `W` with the remainder hitting the battery.+- Presets handler (fake store, fixed clock + deterministic id): create assigns id+timestamps, list sorted by `createdAt`, PUT bumps `updatedAt`, DELETE idempotent, validation parity (label 1..40, watts 1..20000), cap at 20 → 409. (No partition-isolation test — the table is system-wide like `flux-pricing`, so "list returns all stored presets" is the relevant assertion.)++**Property-based ([4.2], the load-bearing invariant).** Zero-load equivalence is an *internal compute-path* invariant: `status` computed with `W = 0` equals `status` computed with no simulation, field-by-field, for the same readings and clock. It is exercised at the compute layer with `W = 0` — **not** via `?simulateLoadWatts=0`, which is correctly a 400 ([4.6]'s "greater than zero" rule). Use `pgregory.net/rapid` (the repo's standard PBT framework) to generate `soc`/`pbat`/`avgPbat`/`pgrid`/`timestamp` across the boundary conditions the static cases miss — the charging→discharging crossover (`pbat == -W`), `soc == cutoffPercent`, the off-peak equality case (`ct == nextOpWindowStart`), the discharge-ceiling crossover (`pbat + W == maxDischargeW`), and **`pbat`/`avgPbat` already at or above `maxDischargeW`** (the case the headroom form must leave untouched at `W = 0`). Generation ranges must include `pbat > maxDischargeW` so the equivalence isn't accidentally only tested below the ceiling. Two further `rapid` properties: monotonicity, `W↑ ⇒ cutoff no later` (plateauing once the battery saturates, since extra `W` then moves grid, not the battery); and `simDischarge ≥ actual pbat` for all `W ≥ 0` ([3.4] — adding load never lowers the shown discharge).++**Client (Swift):**+- `SimulationPresetsService`: refresh/create/update/delete against a stubbed `FluxAPIClient`; failure sets `lastError` and leaves the list unchanged.+- `DashboardViewModel`: active id resolves to the current preset's watts; deleted active id clears simulation; edited watts flow to the next simulated fetch; widget cache write is skipped while simulating; an immediate fetch fires on activate/switch/stop.+- Widget non-regression: `StatusTimelineLogic` calls `fetchStatus()` (the unsimulated form) — assert the simulation parameter is never sent on the widget path.+- `SimulationPresetDraft.validate()`: boundary cases (empty label, 41 chars, 0 W, 20001 W).++## Decisions Logged+The pricing-precedent choice (Decision 10), the `CantEmptyBeforeOffpeak` suppression (11), the 20-preset cap (12), skipping the widget-cache write while simulating (13), and the inverter-ceiling clamp with grid overflow (14, superseding the no-clamp part of 8) are recorded in `decision_log.md`.
specs/dashboard-simulation/requirements.md Added +81 / -0
diff --git a/specs/dashboard-simulation/requirements.md b/specs/dashboard-simulation/requirements.mdnew file mode 100644index 0000000..615fcb2--- /dev/null+++ b/specs/dashboard-simulation/requirements.md@@ -0,0 +1,81 @@+# Requirements: Dashboard Simulation++## Introduction++Dashboard Simulation lets a user toggle a named, predetermined load (in watts) on the Dashboard to see — as a clearly-labelled what-if — how their power flow and battery life would change. For example, activating a "Charge car" preset of 1700W shows house load 1.7kW higher and a correspondingly earlier "empty by" time, without changing any real device. The simulated state is produced by the same backend that serves the real status, so the simulated and real values never use divergent logic. Presets (a label paired with a watt value) are managed at the user level, sync across the user's devices, and the feature is available on both iOS and macOS.++## Non-Goals++- Controlling or sending commands to the real battery, inverter, or any AlphaESS device — the feature is read-only and computes a hypothetical only.+- Simulating a *reduction* in load (turning appliances off); only added load is supported.+- Running more than one preset at the same time (no stacking/summing of presets).+- Simulating changes to solar generation, grid tariff, or off-peak windows.+- Changing the History or Day Detail screens; simulation affects the Dashboard only.+- Persisting an active simulation across an app restart; it is a transient session state.+- Reflecting simulated values in widgets or the Control Center widget; those always show real data.+- Producing a simulated estimate while offline or while live data is stale; simulation requires a fresh server response.++## Requirements++### 1. Manage Simulation Presets++**User Story:** As a user, I want to create, edit, and delete named load presets, so that I can reuse what-if scenarios like charging the car.++**Acceptance Criteria:**++1. <a name="1.1"></a>The system SHALL allow a user to create a simulation preset consisting of a text label and a load value in watts.  +2. <a name="1.2"></a>The system SHALL allow a user to edit the label and watt value of an existing preset, and to delete a preset.  +3. <a name="1.3"></a>The system SHALL reject a preset whose label is empty, whose watt value is not greater than zero, or whose watt value exceeds 20000 (20 kW), and SHALL inform the user why it was rejected.  +4. <a name="1.4"></a>The system SHALL persist presets server-side so that the same presets are available after reinstalling the app and on the user's other devices.  +5. <a name="1.5"></a>The system SHALL present the list of presets within the Settings screen for management on both iOS and macOS.  +6. <a name="1.6"></a>WHEN a create, edit, or delete operation fails or the device is offline, the system SHALL apply the change only after the server confirms it, leave the displayed list unchanged on failure, and surface a visible error, matching the existing SoC Alerts behaviour.  ++### 2. Activate a Simulation From the Dashboard++**User Story:** As a user, I want to switch a simulation on or off from the Dashboard, so that I can quickly see a what-if without leaving the main screen.++**Acceptance Criteria:**++1. <a name="2.1"></a>The Dashboard SHALL provide a control to activate one preset or to turn simulation off, on both iOS and macOS.  +2. <a name="2.2"></a>WHEN a preset is activated while another preset is active, the system SHALL replace the active preset so that at most one simulation is active at any time.  +3. <a name="2.3"></a>IF no presets are configured THEN the Dashboard control SHALL offer a path to create one rather than presenting an empty selection.  +4. <a name="2.4"></a>WHEN the currently active preset is deleted — whether deleted on this device or removed via sync from another device — the system SHALL turn simulation off.  +5. <a name="2.5"></a>The active simulation SHALL remain active across the Dashboard's auto-refresh and across navigation between tabs within a session, and SHALL reset to off on a cold app launch.  +6. <a name="2.6"></a>WHILE a simulation is active, each periodic refresh SHALL issue exactly one status request, carrying the active preset's watt value.  +7. <a name="2.7"></a>The watt value simulated SHALL match the active preset's current stored value; WHEN that preset's watts change via sync while it is active, the Dashboard SHALL re-request the simulated status with the updated value so the indicator and the figures never reflect different watts.  ++### 3. Simulated Power Flow++**User Story:** As a user, I want the Dashboard's live values to reflect the added load consistently, so that the simulated picture is believable and internally balanced.++**Acceptance Criteria:**++1. <a name="3.1"></a>WHEN a preset of W watts is active, the backend SHALL return a status whose house load is the actual load plus W.  +2. <a name="3.2"></a>WHEN a preset of W watts is active, the backend SHALL allocate the added load in priority order — first reducing any grid export toward zero, then increasing battery discharge (reducing charge) up to the battery's maximum sustained discharge rate — so that solar surplus currently being exported is consumed before the battery is drawn down, and the displayed load, battery, and grid remain energy-balanced.  +3. <a name="3.3"></a>WHEN no simulation is active, the Dashboard SHALL display the actual server-provided values unchanged.  +4. <a name="3.4"></a>WHEN the added load exceeds what reducing export and discharging the battery at its maximum sustained rate can supply, the backend SHALL meet the remainder with grid import. The simulated battery discharge SHALL never be shown faster than that maximum rate nor slower than its actual rate (adding load never reduces the displayed discharge).  ++### 4. Simulated Battery Life++**User Story:** As a user, I want the "empty by" estimate to update under simulation, so that I can judge how much sooner the battery would run down.++**Acceptance Criteria:**++1. <a name="4.1"></a>WHEN a simulation of W watts is active, the "empty by" estimate SHALL be derived from the battery value *after* the load is allocated per [3.2](#3.2)/[3.4](#3.4) — i.e. after any export is reduced and capped at the battery's maximum sustained discharge rate — and the live discharge shown in the power panel SHALL reflect the same allocated value, so the displayed discharge and the "empty by" are one consistent figure that is never sooner than the battery's real discharge limit allows, computed with the same handler policy (freshness gating, off-peak suppression) as the real estimate.  +2. <a name="4.2"></a>WHEN the added load is zero, the simulated status SHALL equal the non-simulated status computed from the same reading data and time, so the two never diverge.  +3. <a name="4.3"></a>WHEN a simulation is active, the off-peak ("won't empty before off-peak") indicator and the simulated "empty by" SHALL be consistent with each other: the Dashboard SHALL NOT show the real-data off-peak indicator alongside a simulated "empty by", and any indicator shown SHALL be derived from the same simulated state.  +4. <a name="4.4"></a>IF the simulated battery power is not a net discharge, or the state of charge is already at or below the cutoff, THEN no "empty by" estimate SHALL be shown, matching how the real estimate behaves in those conditions.  +5. <a name="4.5"></a>IF the underlying live data is stale (older than the server's freshness threshold) or the simulated-status request fails, THEN the Dashboard SHALL NOT display fabricated simulated values and SHALL indicate the data is unavailable, as it does for the non-simulated stale case.  +6. <a name="4.6"></a>IF a status request carries a load parameter that is unparseable or outside the accepted range (greater than zero and at most 20000), THEN the backend SHALL reject it with a reason rather than return a status, and the Dashboard SHALL treat this as simulation-unavailable (per [4.5](#4.5)) rather than render fabricated values.  ++### 5. Clear Simulation Labelling++**User Story:** As a user, I want it to be obvious when I'm looking at a simulation, so that I never mistake a what-if for the real state.++**Acceptance Criteria:**++1. <a name="5.1"></a>WHILE a simulation is active, the Dashboard SHALL display a persistent indicator identifying that values are simulated and naming the active preset.  +2. <a name="5.2"></a>The simulation indicator SHALL be visually distinct from the existing hero status indicators (such as the "won't empty before off-peak" line) so it is not mistaken for a real-data status, while still fitting the Dashboard's visual language.  +3. <a name="5.3"></a>WHILE a simulation is active, the values changed by the simulation SHALL carry a visible marking distinguishing them from real values, so a glance at the figures alone reveals the simulation.  +4. <a name="5.4"></a>The simulation indicator and the simulated values SHALL be announced as simulated to assistive technologies, matching the accessibility treatment of the existing hero status indicator.  +5. <a name="5.5"></a>WHEN simulation is turned off, the indicator and value markings SHALL be removed and all displayed values SHALL return to their actual values.  
specs/dashboard-simulation/tasks.md Added +145 / -0
diff --git a/specs/dashboard-simulation/tasks.md b/specs/dashboard-simulation/tasks.mdnew file mode 100644index 0000000..72bd375--- /dev/null+++ b/specs/dashboard-simulation/tasks.md@@ -0,0 +1,145 @@+---+references:+    - specs/dashboard-simulation/requirements.md+    - specs/dashboard-simulation/design.md+    - specs/dashboard-simulation/decision_log.md+---+# Dashboard Simulation++## Backend++- [x] 1. Write Go unit tests for simulated /status (RED) <!-- id:0plbrzn -->+  - internal/api/status_test.go + compute_test.go; existing table-driven + mockReader style, h.nowFunc clock.+  - Load-allocation waterfall (reduce export -> battery capped at 5 kW -> grid import): assert delta load = delta battery + delta grid (energy balance) in every case, and that simDischarge is never below the real pbat.+  - Cases: (a) importing/zero grid below ceiling -> battery takes all W, grid unchanged; (b) 1.7 kW over a ~4 kW evening draw -> battery caps at maxDischargeW, grid import = overflow, empty-by from the capped rate; (c) charging + exporting (full sun) -> export cut first, battery charging unchanged, no empty-by; (d) export partially covers W -> remainder hits the battery; (e) real pbat already at/above the ceiling -> battery left at the real value, all of wBattery to grid.+  - Off-peak suppression (ct.Before(nextOpWindowStart)) and the 90s liveFresh gate still gate the simulated cutoff; CantEmptyBeforeOffpeak is nil whenever W>0.+  - Request with simulateLoadWatts=0, unparseable, or >20000 returns 400.+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.4](requirements.md#3.4), [4.1](requirements.md#4.1), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.6](requirements.md#4.6)++- [x] 2. Write rapid property tests for the simulated compute path (RED) <!-- id:0plbrzo -->+  - Use pgregory.net/rapid.+  - Property A: zero-load equivalence at the compute layer (W=0 equals no-simulation, field-by-field, same readings+clock); exercised internally, NOT via simulateLoadWatts=0.+  - Property B: monotonicity (larger W gives a cutoff no later), plateauing once discharge saturates at the ceiling. Property C: simDischarge >= actual pbat for all W>=0 (adding load never lowers shown discharge).+  - Generate soc/pbat/avgPbat/pgrid/timestamp across boundaries: pbat == -W crossover, soc == cutoffPercent, ct == nextOpWindowStart, pbat+W == maxDischargeW (ceiling crossover), and pbat/avgPbat already >= maxDischargeW (the headroom form must leave these untouched at W=0).+  - Stream: 1+  - Requirements: [4.2](requirements.md#4.2)++- [x] 3. Implement simulateLoadWatts on handleStatus (GREEN) <!-- id:0plbrzp -->+  - Parse+validate in internal/api/status.go (parseSimulateLoad: empty allowed; else 0<v<=20000 or 400).+  - Allocate W by waterfall (sign: pbat>0 discharge, pgrid<0 export): exportReduction = min(W, max(0,-pgrid)); wBattery = W - exportReduction; headroom(p)=max(0, maxDischargeW-p); batteryAbsorbed(p)=min(wBattery, headroom(p)); simDischarge(p)=p+batteryAbsorbed(p) (never below p); overflow = wBattery - batteryAbsorbed(latest.Pbat).+  - Apply: Pload+W; live Pbat = simDischarge(latest.Pbat); both computeCutoffTime inputs and the returned rolling AvgPbat use simDischarge with per-series headroom; AvgLoad+W; Pgrid = pgrid + exportReduction + overflow. Reuse the existing maxDischargeKW constant.+  - Force battery.CantEmptyBeforeOffpeak=nil when W>0; W=0 path is a true no-op (headroom form does not clamp a real reading already at/above the ceiling); a small allocation helper, no new cutoff function.+  - Blocked-by: 0plbrzn (Write Go unit tests for simulated /status (RED)), 0plbrzo (Write rapid property tests for the simulated compute path (RED)), compute, compute, compute, compute, compute, compute, compute, compute, compute, compute, compute, compute, compute, compute, compute, compute, compute, compute, compute, compute+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [4.1](requirements.md#4.1), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.6](requirements.md#4.6)++- [x] 4. Write Go tests for simulation-presets store + handler (RED) <!-- id:0plbrzq -->+  - New internal/api/simulationpresets_test.go with a fake store (mirror pricing_test.go / socrules_test.go style, fixed clock + deterministic id).+  - Create assigns id+createdAt/updatedAt; list returns all stored presets; PUT bumps updatedAt; DELETE idempotent.+  - Validation (label 1..40 chars, watts 1..20000) returns 400 with a reason; cap 20 returns 409.+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.6](requirements.md#1.6)++- [x] 5. Implement simulation-presets CRUD (GREEN) <!-- id:0plbrzr -->+  - internal/dynamo/simulationpresets.go: SimulationPresetItem keyed by presetId only (no partition); Store with ListPresets(ctx) Scan / PutPreset / DeletePreset, mirroring DynamoPricingStore minus sentinel/transactional bits.+  - internal/api/simulationpresets.go + _handler.go: payload+validate, handlers, error-JSON bodies, 200/201/204/400/409.+  - Register GET/POST/PUT/DELETE /simulation-presets in internal/api/handler.go; wire the store in cmd/api/main.go (TABLE_SIMULATION_PRESETS).+  - Blocked-by: 0plbrzq (Write Go tests for simulation-presets store + handler (RED)), presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler, presets, handler+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.6](requirements.md#1.6)++- [x] 6. Add infrastructure for the presets table (config) <!-- id:0plbrzs -->+  - infrastructure/template.yaml: add SimulationPresetsTable (flux-simulation-presets, key presetId HASH only, PAY_PER_REQUEST, DeletionPolicy/UpdateReplacePolicy Retain, PITR on), copied from PricingTable.+  - Add Scan/PutItem/DeleteItem IAM on the Lambda role for the new table; add TABLE_SIMULATION_PRESETS env var to ApiFunction.+  - Blocked-by: 0plbrzr (Implement simulation-presets CRUD (GREEN)), presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets+  - Stream: 1+  - Requirements: [1.4](requirements.md#1.4)++## Client++- [x] 7. Write tests for SimulationPresetDraft.validate() (RED) <!-- id:0plbrzt -->+  - FluxCore tests; boundary cases: empty label, 41-char label, 0 W, 20001 W, and a valid case.+  - Stream: 2+  - Requirements: [1.3](requirements.md#1.3)++- [x] 8. Implement SimulationPreset model + draft (GREEN) <!-- id:0plbrzu -->+  - FluxCore/Models/SimulationPreset.swift: Identifiable/Codable/Sendable/Equatable (id,label,watts,createdAt,updatedAt).+  - SimulationPresetDraft: label empty default, watts=0 so it starts invalid; validate() -> ValidationError emptyLabel/labelTooLong(40)/wattsOutOfRange(1...20000).+  - Blocked-by: 0plbrzt (Write tests for SimulationPresetDraft.validate() (RED))+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.3](requirements.md#1.3)++- [x] 9. Write tests for API client preset CRUD + simulated status request (RED) <!-- id:0plbrzv -->+  - FluxCore URLSessionAPIClient tests.+  - Assert path/method/body for fetchPresets/createPreset/updatePreset/deletePreset on /simulation-presets, and that fetchStatus(simulateLoadWatts:) adds the query item while the existing fetchStatus() stays param-free.+  - Blocked-by: 0plbrzu (Implement SimulationPreset model + draft (GREEN))+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [3.1](requirements.md#3.1)++- [x] 10. Implement FluxAPIClient additions (GREEN) <!-- id:0plbrzw -->+  - Add protocol methods fetchPresets/createPreset/updatePreset/deletePreset and fetchStatus(simulateLoadWatts: Int).+  - Add a default extension fetchStatus(simulateLoadWatts:) delegating to fetchStatus() so the widget, settings, and ~30 test mocks compile unchanged.+  - URLSessionAPIClient overrides it (one URLQueryItem via performRequest) and implements the preset CRUD.+  - Blocked-by: 0plbrzv (Write tests for API client preset CRUD + simulated status request (RED)), request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [3.1](requirements.md#3.1), [3.3](requirements.md#3.3)++- [x] 11. Write tests for SimulationPresetsService (RED) <!-- id:0plbrzx -->+  - FluxCore; refresh/create/update/delete against a stubbed FluxAPIClient.+  - Server-confirmed-then-apply; on failure lastError is set and the list is unchanged (mirror SoCAlertsServiceTests).+  - Blocked-by: 0plbrzu (Implement SimulationPreset model + draft (GREEN))+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [1.6](requirements.md#1.6)++- [x] 12. Implement SimulationPresetsService (GREEN) <!-- id:0plbrzy -->+  - FluxCore/Simulation/SimulationPresetsService.swift: @MainActor @Observable; presets, lastError; bind(apiClient:); refresh/create/update/delete; mirroring SoCAlertsService.+  - Blocked-by: 0plbrzw (Implement FluxAPIClient additions (GREEN)), 0plbrzx (Write tests for SimulationPresetsService (RED))+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [1.6](requirements.md#1.6)++- [x] 13. Write tests for SimulationPresetsViewModel (RED) <!-- id:0plbrzz -->+  - canSave reflects draft.validate(); the add affordance is disabled at the 20 cap; save() handles create vs edit; service errors are surfaced.+  - Blocked-by: 0plbrzu (Implement SimulationPreset model + draft (GREEN))+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.6](requirements.md#1.6)++- [x] 14. Implement Settings Simulation list + editor (GREEN) <!-- id:0plbs00 -->+  - Settings/Simulation/SimulationPresetsView.swift + SimulationPresetEditor.swift + SimulationPresetsViewModel.swift (mirror SoCAlerts views/editor/vm, error banner, cap 20).+  - Add a NavigationLink in Settings/SettingsView.swift near the Alerts section for iOS and macOS.+  - Blocked-by: 0plbrzy (Implement SimulationPresetsService (GREEN)), 0plbrzz (Write tests for SimulationPresetsViewModel (RED))+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6)++- [x] 15. Write tests for DashboardViewModel simulation logic (RED) <!-- id:0plbs01 -->+  - Flux/FluxTests: activeSimulationPresetID single (replace on switch); watts resolved from current presets each refresh; deleted/absent active id clears the simulation.+  - Edited watts flow to the next simulated fetch; one status request per cycle; immediate fetch on activate/switch/stop.+  - Widget-cache write skipped while simulating; widget non-regression (StatusTimelineLogic stays on fetchStatus()); banner presentation values (preset name, +delta).+  - Blocked-by: 0plbrzu (Implement SimulationPreset model + draft (GREEN))+  - Stream: 2+  - Requirements: [2.2](requirements.md#2.2), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [4.5](requirements.md#4.5), [5.5](requirements.md#5.5)++- [x] 16. Implement DashboardViewModel simulation state + fetch wiring (GREEN) <!-- id:0plbs02 -->+  - Flux/Flux/Dashboard/DashboardViewModel.swift: add activeSimulationPresetID (in-memory, nil on cold launch), resolve watts from SimulationPresetsService each refresh.+  - Call fetchStatus(simulateLoadWatts:) when active else fetchStatus(); immediate refresh() on toggle change.+  - Skip widgetCache.writeIfNewer + reload trigger while simulating; expose isSimulating + active preset name/delta.+  - Blocked-by: 0plbrzw (Implement FluxAPIClient additions (GREEN)), 0plbrzy (Implement SimulationPresetsService (GREEN)), 0plbs01 (Write tests for DashboardViewModel simulation logic (RED))+  - Stream: 2+  - Requirements: [2.2](requirements.md#2.2), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [4.5](requirements.md#4.5), [5.5](requirements.md#5.5)++- [x] 17. Implement Dashboard simulation UI (view wiring) <!-- id:0plbs03 -->+  - Simulate menu in DashboardView headerSection (lists presets + Off; empty shows Add a preset deep-linking to Settings Simulation).+  - New Dashboard/SimulationBanner.swift at the top of dashboardContent (stalenessBanner placement baseline) with a distinct FluxTheme.Palette.simulation accent, preset+delta, and a Stop control.+  - Tint the simulated values (trio House, hero discharge/empty-by) in the accent while active; accessibility labels announce simulated; iOS + macOS.+  - Blocked-by: 0plbs00 (Implement Settings Simulation list + editor (GREEN)), 0plbs02 (Implement DashboardViewModel simulation state + fetch wiring (GREEN))+  - Stream: 2+  - Requirements: [2.1](requirements.md#2.1), [2.3](requirements.md#2.3), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5)++## Verification++- [ ] 18. Run full Go + iOS + macOS test/lint and fix issues <!-- id:0plbs04 -->+  - Run the Makefile targets for Go tests and ios/macos build+test+lint.+  - Fix any build/lint/platform-parity breakages; confirm the feature compiles and tests pass on both iOS and macOS.+  - Blocked-by: 0plbrzs (Add infrastructure for the presets table (config)), presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, presets, 0plbs03 (Implement Dashboard simulation UI (view wiring))+  - Stream: 1+  - Requirements: [1.5](requirements.md#1.5), [2.1](requirements.md#2.1)

Things to double-check

Full macOS suite vs the 10-minute sandbox cap

The full macOS test run exceeds the 10-minute background-command timeout in this environment, so it can't complete in one call. The changed view models were verified scoped (green), the macOS app compiles, and the broader run was healthy before the cap. Consider running make macos-test to completion locally before merge.

KeychainAccessibilityMigratorTests on macOS

Fails on the headless macOS host (keychain access) but passes on the iOS simulator; this branch's 40-file diff contains no keychain code, so it is an environment artifact, not a regression. Confirm it passes in a normal local macOS test run.

maxDischargeKW is a fixed 5 kW constant

The inverter ceiling reuses the existing 5 kW maxDischargeKW constant rather than a per-system value. If the real inverter limit differs, the simulated cap and grid-overflow split would need that constant updated in one place.