diff --git a/Apps/MetaWear/MetaWear/Export/LiveBufferCSVExporter.swift b/Apps/MetaWear/MetaWear/Export/LiveBufferCSVExporter.swift index 53c6063..888e8ea 100644 --- a/Apps/MetaWear/MetaWear/Export/LiveBufferCSVExporter.swift +++ b/Apps/MetaWear/MetaWear/Export/LiveBufferCSVExporter.swift @@ -1,4 +1,5 @@ import Foundation +import MetaWear nonisolated enum LiveBufferCSVExporter { @@ -50,15 +51,25 @@ nonisolated enum LiveBufferCSVExporter { private static func makeCSV(snapshot: ChannelSnapshot) -> String { let labels = snapshot.channelLabels let channelCount = labels.count + // Quaternion buffers get the same host-derived heading/pitch/roll + // columns as logged-session exports, via the SDK's derivation. + // Pattern match, not ==: the synthesized Equatable is MainActor- + // isolated under the app's default isolation and this exporter isn't. + let isQuaternion: Bool = if case .sensorFusion(.quaternion) = snapshot.key { true } else { false } + var header = ["time"] + labels + if isQuaternion { header += Quaternion.derivedColumnHeaders } var lines: [String] = [] lines.reserveCapacity(snapshot.samples.count + 1) - lines.append((["time"] + labels).joined(separator: ",")) + lines.append(header.joined(separator: ",")) for s in snapshot.samples { var fields: [String] = [iso(s.time)] if channelCount > 0 { fields.append(format(s.f0)) } if channelCount > 1 { fields.append(format(s.f1)) } if channelCount > 2 { fields.append(format(s.f2)) } if channelCount > 3 { fields.append(format(s.f3)) } + if isQuaternion { + fields += Quaternion(w: s.f0, x: s.f1, y: s.f2, z: s.f3).derivedColumnValues + } lines.append(fields.joined(separator: ",")) } return lines.joined(separator: "\n") + "\n" diff --git a/Apps/MetaWear/MetaWear/Features/LiveStream/FusionCalibrationBadge.swift b/Apps/MetaWear/MetaWear/Features/LiveStream/FusionCalibrationBadge.swift new file mode 100644 index 0000000..0205ed1 --- /dev/null +++ b/Apps/MetaWear/MetaWear/Features/LiveStream/FusionCalibrationBadge.swift @@ -0,0 +1,135 @@ +import SwiftUI +import MetaWear + +/// Compact readout of the fusion algorithm's per-sensor calibration accuracy, +/// with targeted coaching for whichever sensor is actually lagging. +/// +/// Bosch's fusion outputs (especially heading) are unreliable until the +/// algorithm has calibrated itself against real motion, and nothing else in +/// the UI ever said so. Three chips — A(ccelerometer), G(yroscope), +/// M(agnetometer) — show the chip's own 0–3 accuracy report. +/// +/// The bar is MEDIUM (≥ 2), not HIGH: Bosch's own guidance is that medium +/// accuracy is good enough to record. HIGH is a live trust score the +/// algorithm keeps re-evaluating — the magnetometer in particular gets +/// demoted the moment it detects field distortion (any desk full of +/// electronics), so "all green, forever" is not an achievable ask indoors +/// and the badge must not nag users toward it. +struct FusionCalibrationBadge: View { + let calibration: MWSensorFusionCalibration + + /// Bosch's usability threshold — accuracy 2 (MEDIUM) of 0–3. + private static let usableLevel: UInt8 = 2 + + private enum Readiness { + /// At least one sensor is below MEDIUM — orientation data is suspect. + case calibrating + /// Every sensor is at least MEDIUM — good enough to record. + case ready + /// Every sensor reads HIGH. + case fullyCalibrated + } + + private var readiness: Readiness { + let levels = [calibration.accelerometer, calibration.gyroscope, calibration.magnetometer] + if levels.allSatisfy({ $0 == 3 }) { return .fullyCalibrated } + if levels.allSatisfy({ $0 >= Self.usableLevel }) { return .ready } + return .calibrating + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + switch readiness { + case .calibrating: + Image(systemName: "scope").foregroundStyle(Palette.warning) + Text("Calibrating…").font(.subheadline.weight(.medium)) + case .ready: + Image(systemName: "checkmark.circle.fill").foregroundStyle(Palette.info) + Text("Ready To Record").font(.subheadline.weight(.medium)) + case .fullyCalibrated: + Image(systemName: "checkmark.seal.fill").foregroundStyle(Palette.success) + Text("Fully Calibrated").font(.subheadline.weight(.medium)) + } + Spacer() + chip("A", level: calibration.accelerometer) + chip("G", level: calibration.gyroscope) + chip("M", level: calibration.magnetometer) + } + switch readiness { + case .calibrating: + // Coach only the sensors that are actually below the bar — + // each one calibrates with a DIFFERENT motion, so a generic + // "rotate the board" line can't get a user to green. + if calibration.accelerometer < Self.usableLevel { + tip("A", "Rest the board on each of its faces for a few seconds, like rolling a die.") + } + if calibration.gyroscope < Self.usableLevel { + tip("G", "Set the board down and keep it still for a moment.") + } + if calibration.magnetometer < Self.usableLevel { + tip("M", "Trace a slow figure-8 in the air — away from metal, chargers, and other electronics.") + } + case .ready: + Text("Accuracy is good enough to record. Nearby electronics can lower the magnetometer rating — that's normal indoors.") + .font(.caption) + .foregroundStyle(.secondary) + case .fullyCalibrated: + EmptyView() + } + } + .glassCard() + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilitySummary) + } + + private func chip(_ label: String, level: UInt8) -> some View { + Text(label) + .font(.caption.weight(.bold)) + .foregroundStyle(.white) + .frame(width: 26, height: 22) + .background(Capsule().fill(color(for: level))) + } + + private func tip(_ label: String, _ text: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(label) + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + .frame(width: 14, alignment: .center) + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func color(for level: UInt8) -> Color { + switch level { + case 0: Palette.danger + case 1: Palette.warning + case 2: Palette.info + default: Palette.success + } + } + + private func levelName(_ level: UInt8) -> String { + switch level { + case 0: "unreliable" + case 1: "low" + case 2: "medium" + default: "high" + } + } + + private var accessibilitySummary: String { + let status = switch readiness { + case .calibrating: "calibrating" + case .ready: "ready to record" + case .fullyCalibrated: "fully calibrated" + } + return "Fusion calibration \(status): accelerometer \(levelName(calibration.accelerometer)), " + + "gyroscope \(levelName(calibration.gyroscope)), " + + "magnetometer \(levelName(calibration.magnetometer))" + } +} diff --git a/Apps/MetaWear/MetaWear/Features/LiveStream/LiveStreamView.swift b/Apps/MetaWear/MetaWear/Features/LiveStream/LiveStreamView.swift index d61aa28..8db96ff 100644 --- a/Apps/MetaWear/MetaWear/Features/LiveStream/LiveStreamView.swift +++ b/Apps/MetaWear/MetaWear/Features/LiveStream/LiveStreamView.swift @@ -21,6 +21,9 @@ struct LiveStreamView: View { if let viewModel, let startedAt = viewModel.startedAt { SessionStatsBar(startedAt: startedAt, totalSamples: viewModel.totalSamples) } + if let calibration = viewModel?.calibration { + FusionCalibrationBadge(calibration: calibration) + } GlassEffectContainer { if let viewModel { ForEach(viewModel.channels) { channel in diff --git a/Apps/MetaWear/MetaWear/Features/LiveStream/QuaternionRealityView.swift b/Apps/MetaWear/MetaWear/Features/LiveStream/QuaternionRealityView.swift index 4d42438..081ea59 100644 --- a/Apps/MetaWear/MetaWear/Features/LiveStream/QuaternionRealityView.swift +++ b/Apps/MetaWear/MetaWear/Features/LiveStream/QuaternionRealityView.swift @@ -34,7 +34,13 @@ struct QuaternionRealityView: View { content.camera = .virtual } update: { content in guard let latest, let entity = content.entities.first else { return } - let q = simd_quatf(ix: latest.f1, iy: latest.f2, iz: latest.f3, r: latest.f0) + let raw = simd_quatf(ix: latest.f1, iy: latest.f2, iz: latest.f3, r: latest.f0) + guard raw.length > 0.5 else { return } // malformed/zero sample + // Change of basis, NOT a pre-multiply: M·q·M⁻¹ re-expresses the + // rotation in the scene's frame while leaving the rest pose + // untouched (identity conjugates to identity) — hardware-verified + // that the rest pose was right while the axes were switched. + let q = Self.sensorToScene * simd_normalize(raw) * Self.sensorToScene.inverse if reduceMotion { entity.orientation = q } else { @@ -47,12 +53,21 @@ struct QuaternionRealityView: View { } } .containerRelativeFrame(.vertical, alignment: .center) { length, _ in - max(280, length * (isCompact ? 0.55 : 0.45)) + max(240, length * (isCompact ? 0.42 : 0.35)) } .glassCard() .accessibilityLabel("3D orientation of the device") } + /// Maps the sensor's world frame onto RealityKit's. The fusion quaternion + /// lives in a Z-up world (Bosch NDoF: Z = gravity-up, X/Y magnetic- + /// referenced) with body axes matching the CAD case (X width, Y length, + /// Z out the top face). RealityKit is Y-up with Z toward the camera. + /// Rotating −90° about X sends Earth-up (Z) to screen-up (Y) and north + /// (Y) into the screen, so physical yaw spins the model about the + /// screen's vertical and pitch/roll follow the axes you'd expect. + private static let sensorToScene = simd_quatf(angle: -.pi / 2, axis: [1, 0, 0]) + /// Build the entity placed at scene origin. If a `MetaMotion.usdz` resource /// ships in the bundle it's loaded and re-centered; otherwise we build a /// procedural MetaMotion-style rectangular board. diff --git a/Apps/MetaWear/MetaWear/Features/Sessions/SessionAxisStyle.swift b/Apps/MetaWear/MetaWear/Features/Sessions/SessionAxisStyle.swift new file mode 100644 index 0000000..2618d07 --- /dev/null +++ b/Apps/MetaWear/MetaWear/Features/Sessions/SessionAxisStyle.swift @@ -0,0 +1,61 @@ +import SwiftUI +import MetaWear +import MetaWearPersistence + +extension SensorAxisStyle { + /// Resolve the chart style for a SAVED session from its persisted + /// discriminators. The old `.generic(channelCount:)` fallback actively + /// mislabeled fusion sessions — a quaternion's first channel is `w`, not + /// `x`, and Euler channels are heading/pitch/roll/yaw — so fusion kinds + /// resolve from the type discriminator alone, and everything else tries + /// to recover the real sensor (units, colors, captured ± range) from the + /// rich label stamped at capture time. + static func forSession(sensorKind: String, label: String?, channelCount: Int) -> SensorAxisStyle { + if sensorKind == Quaternion.persistenceKind { + return SensorKey.sensorFusion(.quaternion).axisStyle + } + if sensorKind == EulerAngles.persistenceKind { + return SensorKey.sensorFusion(.eulerAngles).axisStyle + } + if let style = styleFromLabel(label) { + return style + } + return .generic(channelCount: channelCount) + } + + /// Map a capture-time label ("Gyroscope · ±500 dps · 25 Hz", + /// "Fusion · Gravity · 100 Hz") back to its sensor's style. Returns nil + /// for legacy records without a label or with an unrecognized head. + private static func styleFromLabel(_ label: String?) -> SensorAxisStyle? { + guard let parts = label?.components(separatedBy: " · "), let head = parts.first else { + return nil + } + let key: SensorKey? + switch head { + case "Accelerometer": key = .accelerometer + case "Gyroscope": key = .gyroscope + case "Magnetometer": key = .magnetometer + case "Barometer": key = .barometer + case "Temperature": key = .temperature + case "Humidity": key = .humidity + case "Ambient Light": key = .ambientLight + case "Fusion": + let outputName = parts.count > 1 ? parts[1] : "" + key = SensorFusionOutput.allCases + .first { $0.displayName == outputName } + .map(SensorKey.sensorFusion) + default: key = nil + } + guard let key else { return nil } + let base = key.axisStyle + // Restore the full-scale range the session was captured at ("±500 dps" + // → y-range −500…500) so the trace sits in its real frame instead of + // the sensor's default. + if let rangePart = parts.first(where: { $0.hasPrefix("±") }), + let magnitude = rangePart.dropFirst().components(separatedBy: " ").first, + let value = Double(magnitude) { + return SensorAxisStyle(unit: base.unit, yRange: -value...value, channels: base.channels) + } + return base + } +} diff --git a/Apps/MetaWear/MetaWear/Features/Sessions/SessionDetailView.swift b/Apps/MetaWear/MetaWear/Features/Sessions/SessionDetailView.swift index 380f39e..eeb6c3e 100644 --- a/Apps/MetaWear/MetaWear/Features/Sessions/SessionDetailView.swift +++ b/Apps/MetaWear/MetaWear/Features/Sessions/SessionDetailView.swift @@ -7,6 +7,10 @@ struct SessionDetailView: View { let snapshot: MWSessionSnapshot @Environment(AppStore.self) private var appStore @State private var preview: [AnyChartSample] = [] + /// Full-resolution samples + board-tick timeline for the 3D replay. + /// Only populated for quaternion sessions with enough samples to scrub. + @State private var replaySamples: [AnyChartSample] = [] + @State private var replayTimeline: ReplayTimeline? @State private var lastError: AppError? @State private var exportResult: ExportResult? @@ -14,6 +18,9 @@ struct SessionDetailView: View { ScrollView { VStack(spacing: 16) { statsCard + if let replayTimeline, !replaySamples.isEmpty { + SessionReplayView(samples: replaySamples, timeline: replayTimeline) + } if !preview.isEmpty { SensorChartView( title: snapshot.label ?? snapshot.sensorKind.capitalized, @@ -21,7 +28,11 @@ struct SessionDetailView: View { samples: preview, latest: preview.last, effectiveHz: 0, - axisStyle: .generic(channelCount: Int(preview.first?.channelCount ?? 1)) + axisStyle: .forSession( + sensorKind: snapshot.sensorKind, + label: snapshot.label, + channelCount: Int(preview.first?.channelCount ?? 1) + ) ) } Button("Export CSV", systemImage: "square.and.arrow.up") { @@ -65,6 +76,10 @@ struct SessionDetailView: View { case Quaternion.persistenceKind: let samples = try await appStore.persistence.fetchSamples(sessionID: snapshot.id, as: Quaternion.self) preview = samples.suffix(600).map(AnyChartSample.from) + if samples.count >= 2 { + replaySamples = samples.map(AnyChartSample.from) + replayTimeline = ReplayTimeline(ticksMs: samples.map(\.tickMs)) + } case EulerAngles.persistenceKind: let samples = try await appStore.persistence.fetchSamples(sessionID: snapshot.id, as: EulerAngles.self) preview = samples.suffix(600).map(AnyChartSample.from) diff --git a/Apps/MetaWear/MetaWear/Features/Sessions/SessionReplayView.swift b/Apps/MetaWear/MetaWear/Features/Sessions/SessionReplayView.swift new file mode 100644 index 0000000..e825b7b --- /dev/null +++ b/Apps/MetaWear/MetaWear/Features/Sessions/SessionReplayView.swift @@ -0,0 +1,143 @@ +import SwiftUI + +/// Maps a scrub position (seconds since session start) to the sample index +/// that was current at that moment. Times come from the board's own tick +/// clock, normalized to a 0-based, non-decreasing series — logged ticks count +/// from the board's last reset, stream-archived ticks are already 0-based. +struct ReplayTimeline: Sendable { + /// Seconds offsets from the first sample, non-decreasing. + let times: [Double] + let duration: Double + + init(ticksMs: [Double]) { + guard let first = ticksMs.first else { + times = [] + duration = 0 + return + } + var normalized: [Double] = [] + normalized.reserveCapacity(ticksMs.count) + // Clamp to non-decreasing so a stray out-of-order tick can't make the + // binary search lie mid-session. + var floor = 0.0 + for tick in ticksMs { + floor = max(floor, (tick - first) / 1000) + normalized.append(floor) + } + times = normalized + duration = floor + } + + /// Index of the sample playing at `seconds` — the last sample whose time + /// is ≤ the position, clamped to the ends. Nil only for an empty timeline. + func index(at seconds: Double) -> Int? { + guard !times.isEmpty else { return nil } + if seconds <= times[0] { return 0 } + if seconds >= duration { return times.count - 1 } + var lo = 0 + var hi = times.count - 1 + while lo < hi { + let mid = (lo + hi + 1) / 2 + if times[mid] <= seconds { lo = mid } else { hi = mid - 1 } + } + return lo + } +} + +/// Replays a saved quaternion session in 3D: the same board view the live +/// stream uses, driven by a playback clock instead of BLE, with a scrubber +/// and a speed toggle. `QuaternionRealityView` is a pure function of "latest +/// sample", so replay is just choosing which sample is latest. +struct SessionReplayView: View { + let samples: [AnyChartSample] + let timeline: ReplayTimeline + + @State private var position: Double = 0 + @State private var isPlaying = false + @State private var wasPlayingBeforeScrub = false + @State private var speed: Double = 1 + + private var current: AnyChartSample? { + timeline.index(at: position).map { samples[$0] } + } + + var body: some View { + VStack(spacing: 12) { + QuaternionRealityView(latest: current) + controls + } + .task(id: isPlaying) { + await runPlayback() + } + } + + private var controls: some View { + VStack(spacing: 8) { + HStack(spacing: 12) { + Button(isPlaying ? "Pause" : "Play", + systemImage: isPlaying ? "pause.fill" : "play.fill") { + isPlaying.toggle() + } + .buttonStyle(.glassProminent) + .labelStyle(.iconOnly) + Slider(value: $position, in: 0...max(timeline.duration, 0.001)) { editing in + // Scrubbing pauses the clock so the board tracks the + // finger; release resumes if the user was playing. + if editing { + wasPlayingBeforeScrub = isPlaying + isPlaying = false + } else if wasPlayingBeforeScrub { + wasPlayingBeforeScrub = false + isPlaying = true + } + } + Button(speedLabel) { + speed = speed >= 4 ? 1 : speed * 2 + } + .buttonStyle(.glass) + .font(.caption.weight(.semibold)) + .monospacedDigit() + } + HStack { + Text(timeString(position)) + Spacer() + Text(timeString(timeline.duration)) + } + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + } + .glassCard() + } + + private var speedLabel: String { + "\(speed.formatted(.number.precision(.fractionLength(0))))×" + } + + private func timeString(_ seconds: Double) -> String { + let total = Int(seconds.rounded(.down)) + return String(format: "%d:%02d", total / 60, total % 60) + } + + /// Advance the scrub position on a ~30 Hz wall clock while playing. The + /// step comes from measured elapsed time (not the nominal sleep) so sleep + /// jitter can't slow the replay below real time. + private func runPlayback() async { + guard isPlaying else { return } + if position >= timeline.duration { position = 0 } + let clock = ContinuousClock() + var last = clock.now + while isPlaying, !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(33)) + let now = clock.now + let elapsed = last.duration(to: now) + let dt = Double(elapsed.components.seconds) + + Double(elapsed.components.attoseconds) / 1e18 + last = now + position = min(timeline.duration, position + dt * speed) + if position >= timeline.duration { + isPlaying = false + } + } + } +} diff --git a/Apps/MetaWear/MetaWear/MetaMotion.usdz b/Apps/MetaWear/MetaWear/MetaMotion.usdz new file mode 100644 index 0000000..017a100 Binary files /dev/null and b/Apps/MetaWear/MetaWear/MetaMotion.usdz differ diff --git a/Apps/MetaWear/MetaWear/ViewModels/SessionHistoryViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/SessionHistoryViewModel.swift deleted file mode 100644 index ac490af..0000000 --- a/Apps/MetaWear/MetaWear/ViewModels/SessionHistoryViewModel.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation -import Observation -import MetaWearPersistence - -/// Minimal view model for the session-history screen. -/// -/// Listing is driven directly by SwiftData queries in the view; this type owns -/// actions that need async persistence calls and error presentation. -@Observable -@MainActor -final class SessionHistoryViewModel { - let store: MWPersistenceStore - - var lastError: AppError? - - init(store: MWPersistenceStore) { - self.store = store - } - - func deleteSession(id: UUID) async { - do { - try await store.deleteSession(id: id) - } catch { - lastError = AppError(error: error) - } - } -} diff --git a/Apps/MetaWear/MetaWear/ViewModels/StreamSessionViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/StreamSessionViewModel.swift index d2dcf21..e89ab24 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/StreamSessionViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/StreamSessionViewModel.swift @@ -33,8 +33,15 @@ final class StreamSessionViewModel { /// loop so the summary readout doesn't refresh on every individual /// sample append. var totalSamples: Int = 0 + /// Latest fusion calibration accuracy, polled every 2 s while a fusion + /// channel streams. The read is only valid while fusion is RUNNING, which + /// is exactly when the badge shows. `nil` before the first response, on + /// non-fusion sessions, and on firmware without the calibration read + /// (sensor-fusion revision < 1). + private(set) var calibration: MWSensorFusionCalibration? private var streamTasks: [SensorKey: Task] = [:] + private var calibrationTask: Task? private var stopHandlers: [SensorKey: @Sendable () async throws -> Void] = [:] private var throttleTask: Task? /// Selections last passed to `start(_:)`. Held so `resume()` can re-spawn @@ -72,6 +79,7 @@ final class StreamSessionViewModel { await spawnStream(for: selection) } startThrottle() + startCalibrationPolling() } func stop() async { @@ -81,6 +89,7 @@ final class StreamSessionViewModel { throttleTask = nil await tearDownStreams() + calibration = nil // Persist the captured buffers as part of Stop — not only in // `onDisappear`. Otherwise tapping Stop and then backgrounding or // killing the app (instead of navigating back) silently loses the @@ -124,6 +133,7 @@ final class StreamSessionViewModel { for selection in selections { await spawnStream(for: selection) } + startCalibrationPolling() } func togglePause() { @@ -150,6 +160,10 @@ final class StreamSessionViewModel { /// feeding the consume task with samples before cancellation actually /// took effect — symptom seen on the barometer. private func tearDownStreams() async { + // Calibration reads are meaningless once fusion stops sampling — + // end the poll with the streams (pause and resume respawn it). + calibrationTask?.cancel() + calibrationTask = nil for (_, stop) in stopHandlers { try? await stop() } @@ -160,6 +174,35 @@ final class StreamSessionViewModel { streamTasks.removeAll() } + /// Poll the fusion calibration state while a fusion channel streams. The + /// badge this feeds is a courtesy readout: any failure just stops the + /// updates — it must never tear down the streams themselves. + private func startCalibrationPolling() { + calibrationTask?.cancel() + calibrationTask = nil + let streamsFusion = selections.contains { + if case .sensorFusion = $0.id { return true } + return false + } + guard streamsFusion else { return } + calibrationTask = Task { @MainActor [weak self] in + guard let device = self?.device else { return } + // The read exists from sensor-fusion revision 1 (C++ + // CALIBRATION_REVISION); older firmware would let it time out. + guard let info = await device.modules[.sensorFusion], + info.isPresent, info.revision >= 1 else { return } + do { + for try await sample in device.poll(MWSensorFusionCalibrationState(), + every: .seconds(2)) { + guard let self, self.isStreaming, !self.isPaused else { continue } + self.calibration = sample.value + } + } catch { + // Silence is deliberate — see doc comment. + } + } + } + /// Terminate the session after a stream error (most commonly an /// unexpected BLE disconnect, which fails every active stream at once). /// Without this, `isStreaming` stayed true with dead streams — a zombie diff --git a/Apps/MetaWear/MetaWearTests/ReplayTimelineTests.swift b/Apps/MetaWear/MetaWearTests/ReplayTimelineTests.swift new file mode 100644 index 0000000..f382b00 --- /dev/null +++ b/Apps/MetaWear/MetaWearTests/ReplayTimelineTests.swift @@ -0,0 +1,55 @@ +import Testing +import Foundation +@testable import MetaWearApp + +/// The replay scrubber maps a seconds position to "the sample that was +/// current at that moment" via binary search over the board's own tick +/// clock. Off-by-ones here read as the 3D board jumping ahead of (or +/// lagging) the scrubber, so the boundary behavior is pinned down exactly. +@MainActor +struct ReplayTimelineTests { + + @Test func emptyTimeline_hasNoIndexAndZeroDuration() { + let t = ReplayTimeline(ticksMs: []) + #expect(t.index(at: 0) == nil) + #expect(t.duration == 0) + } + + @Test func singleSample_alwaysIndexZero() { + let t = ReplayTimeline(ticksMs: [5000]) + #expect(t.duration == 0) + #expect(t.index(at: 0) == 0) + #expect(t.index(at: 99) == 0) + } + + @Test func ticksNormalizeToZeroBasedSeconds() { + // Logged ticks count from the board's last RESET, not from the + // session start — a session recorded an hour after boot starts at + // tick ~3.6M. The timeline must subtract the first tick. + let t = ReplayTimeline(ticksMs: [3_600_000, 3_600_500, 3_601_000]) + #expect(t.times == [0, 0.5, 1.0]) + #expect(t.duration == 1.0) + } + + @Test func indexReturnsLastSampleAtOrBeforePosition() { + let t = ReplayTimeline(ticksMs: [0, 100, 200, 300]) // 0, 0.1, 0.2, 0.3 s + #expect(t.index(at: 0) == 0) + #expect(t.index(at: 0.1) == 1) + #expect(t.index(at: 0.15) == 1) // between samples → the earlier one + #expect(t.index(at: 0.299) == 2) + } + + @Test func positionsBeyondEndsClampToFirstAndLast() { + let t = ReplayTimeline(ticksMs: [0, 1000]) + #expect(t.index(at: -5) == 0) + #expect(t.index(at: 999) == 1) + } + + @Test func outOfOrderTicksClampMonotonic() { + // A stray out-of-order tick must not break the binary search's + // sortedness assumption — the timeline floors it to its predecessor. + let t = ReplayTimeline(ticksMs: [0, 500, 400, 900]) + #expect(t.times == [0, 0.5, 0.5, 0.9]) + #expect(t.index(at: 0.6) == 2) + } +} diff --git a/Apps/MetaWear/MetaWearTests/SessionAxisStyleTests.swift b/Apps/MetaWear/MetaWearTests/SessionAxisStyleTests.swift new file mode 100644 index 0000000..7e2654d --- /dev/null +++ b/Apps/MetaWear/MetaWearTests/SessionAxisStyleTests.swift @@ -0,0 +1,70 @@ +import Testing +import Foundation +import MetaWear +import MetaWearPersistence +@testable import MetaWearApp + +/// Saved-session charts used to fall back to generic x/y/z/w labels for +/// everything — which MISLABELED fusion data (a quaternion's first channel +/// is w, Euler's are heading/pitch/roll/yaw). These pin the recovery of the +/// real sensor style from the persisted kind + capture-time label. +@MainActor +struct SessionAxisStyleTests { + + @Test func quaternionKind_labelsWxyzInOrder() { + let style = SensorAxisStyle.forSession( + sensorKind: Quaternion.persistenceKind, label: nil, channelCount: 4) + #expect(style.channels.map(\.id) == ["w", "x", "y", "z"]) + } + + @Test func eulerKind_labelsEulerChannels() { + let style = SensorAxisStyle.forSession( + sensorKind: EulerAngles.persistenceKind, label: nil, channelCount: 4) + #expect(style.channels.map(\.id) == ["heading", "pitch", "roll", "yaw"]) + #expect(style.unit == "°") + } + + @Test func gyroLabel_restoresUnitAndCapturedRange() { + let style = SensorAxisStyle.forSession( + sensorKind: CartesianFloat.persistenceKind, + label: "Gyroscope · ±500 dps · 25 Hz", channelCount: 3) + #expect(style.unit == "dps") + #expect(style.yRange == -500...500) + #expect(style.channels.map(\.id) == ["x", "y", "z"]) + } + + @Test func accelLabel_withoutRangeChunk_usesSensorDefault() { + let style = SensorAxisStyle.forSession( + sensorKind: CartesianFloat.persistenceKind, + label: "Accelerometer · 50 Hz", channelCount: 3) + #expect(style.unit == "g") + } + + @Test func fusionGravityLabel_resolvesFusionOutput() { + let style = SensorAxisStyle.forSession( + sensorKind: CartesianFloat.persistenceKind, + label: "Fusion · Gravity · 100 Hz", channelCount: 3) + #expect(style.unit == "g") + #expect(style.yRange == -1...1) + } + + @Test func temperatureLabel_resolvesScalarStyle() { + let style = SensorAxisStyle.forSession( + sensorKind: Float.persistenceKind, + label: "Temperature · 1 Hz", channelCount: 1) + #expect(style.unit == "°C") + } + + @Test func unknownLabelAndKind_fallsBackToGeneric() { + let style = SensorAxisStyle.forSession( + sensorKind: "cartesian", label: "Mystery Sensor · 1 Hz", channelCount: 3) + #expect(style.channels.map(\.id) == ["x", "y", "z"]) + #expect(style.unit.isEmpty) + } + + @Test func legacyRecordWithNilLabel_fallsBackToGeneric() { + let style = SensorAxisStyle.forSession( + sensorKind: "cartesian", label: nil, channelCount: 3) + #expect(style.channels.count == 3) + } +} diff --git a/Sources/MetaWear/Models/MWDataTable.swift b/Sources/MetaWear/Models/MWDataTable.swift index c11f755..7663408 100644 --- a/Sources/MetaWear/Models/MWDataTable.swift +++ b/Sources/MetaWear/Models/MWDataTable.swift @@ -30,6 +30,18 @@ public protocol MWDataConvertible: Sendable { static var columnHeaders: [String] { get } /// String representation of each sensor-specific field, in the same order as `columnHeaders`. var columnValues: [String] { get } + /// Headers for host-computed convenience columns appended AFTER the raw + /// fields (e.g. Euler angles derived from a quaternion). Deriving at + /// export keeps the stored samples raw while sparing spreadsheet users + /// the quaternion math. Empty for types with nothing to derive. + static var derivedColumnHeaders: [String] { get } + /// Values parallel to `derivedColumnHeaders`. + var derivedColumnValues: [String] { get } +} + +public extension MWDataConvertible { + static var derivedColumnHeaders: [String] { [] } + var derivedColumnValues: [String] { [] } } // MARK: - Conformances @@ -47,6 +59,35 @@ extension Quaternion: MWDataConvertible { [w.formatted(csv6Float), x.formatted(csv6Float), y.formatted(csv6Float), z.formatted(csv6Float)] } + public static var derivedColumnHeaders: [String] { ["heading", "pitch", "roll"] } + public var derivedColumnValues: [String] { + let e = derivedEulerAngles + return [e.heading.formatted(csv4Float), + e.pitch.formatted(csv4Float), + e.roll.formatted(csv4Float)] + } +} + +public extension Quaternion { + /// Euler angles computed from this quaternion (Tait-Bryan Z-Y-X, degrees): + /// heading normalized to 0..360°, pitch −90..+90°, roll −180..+180°. + /// + /// `yaw` is set equal to `heading`: the firmware's separate yaw channel is + /// gyroscope-INTEGRATED (unbounded, drifts) and cannot be reconstructed + /// from a single orientation quaternion. No derived-yaw CSV column is + /// emitted for the same reason — it would just duplicate heading. + var derivedEulerAngles: EulerAngles { + let w = Double(w), x = Double(x), y = Double(y), z = Double(z) + let roll = atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)) + let pitch = asin(min(1, max(-1, 2 * (w * y - z * x)))) + let yaw = atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) + var heading = yaw * 180 / .pi + if heading < 0 { heading += 360 } + return EulerAngles(heading: Float(heading), + pitch: Float(pitch * 180 / .pi), + roll: Float(roll * 180 / .pi), + yaw: Float(heading)) + } } extension EulerAngles: MWDataConvertible { @@ -104,9 +145,10 @@ public extension MWDataTable { name: String ) -> MWDataTable { let iso = ISO8601DateFormatter() - let columns = ["epoch", "elapsed_ms"] + S.columnHeaders + let columns = ["epoch", "elapsed_ms"] + S.columnHeaders + S.derivedColumnHeaders let rows = samples.map { s -> [String] in - [iso.string(from: s.date), s.tickMs.formatted(csv3Double)] + s.value.columnValues + [iso.string(from: s.date), s.tickMs.formatted(csv3Double)] + + s.value.columnValues + s.value.derivedColumnValues } return MWDataTable(name: name, columns: columns, rows: rows) } @@ -118,9 +160,9 @@ public extension MWDataTable { name: String ) -> MWDataTable { let iso = ISO8601DateFormatter() - let columns = ["epoch"] + S.columnHeaders + let columns = ["epoch"] + S.columnHeaders + S.derivedColumnHeaders let rows = samples.map { s -> [String] in - [iso.string(from: s.time)] + s.value.columnValues + [iso.string(from: s.time)] + s.value.columnValues + s.value.derivedColumnValues } return MWDataTable(name: name, columns: columns, rows: rows) } diff --git a/Tests/MetaWearPersistenceTests/MWSessionExportTests.swift b/Tests/MetaWearPersistenceTests/MWSessionExportTests.swift index 6c291f7..992978f 100644 --- a/Tests/MetaWearPersistenceTests/MWSessionExportTests.swift +++ b/Tests/MetaWearPersistenceTests/MWSessionExportTests.swift @@ -36,7 +36,9 @@ struct MWSessionExportTests { let snap = try await store.saveSession(deviceID: UUID(), deviceInfo: makeDeviceInfo(), sensorKind: Quaternion.persistenceKind, samples: s) let table = try await store.exportTable(sessionID: snap.id, as: Quaternion.self) - #expect(table.columns == ["epoch", "elapsed_ms", "w", "x", "y", "z"]) + // Raw components first, then the host-derived Euler convenience columns. + #expect(table.columns == ["epoch", "elapsed_ms", "w", "x", "y", "z", + "heading", "pitch", "roll"]) } @Test func exportTable_rowCount_matchesSampleCount() async throws { diff --git a/Tests/MetaWearTests/MWDataTableTests.swift b/Tests/MetaWearTests/MWDataTableTests.swift index 5dacf52..bd5d1a4 100644 --- a/Tests/MetaWearTests/MWDataTableTests.swift +++ b/Tests/MetaWearTests/MWDataTableTests.swift @@ -36,6 +36,60 @@ struct MWDataConvertibleTests { #expect(vals[1] == "0.000000") } + // MARK: Quaternion → derived Euler columns + + @Test func quaternion_derivedHeaders() { + #expect(Quaternion.derivedColumnHeaders == ["heading", "pitch", "roll"]) + } + + @Test func quaternion_identity_derivesZeroAngles() { + let e = Quaternion(w: 1, x: 0, y: 0, z: 0).derivedEulerAngles + #expect(abs(e.heading) < 0.01) + #expect(abs(e.pitch) < 0.01) + #expect(abs(e.roll) < 0.01) + } + + @Test func quaternion_90degAboutZ_readsAsHeading90() { + // w = cos(45°), z = sin(45°) + let q = Quaternion(w: 0.7071068, x: 0, y: 0, z: 0.7071068) + let e = q.derivedEulerAngles + #expect(abs(e.heading - 90) < 0.01) + #expect(abs(e.pitch) < 0.01) + #expect(abs(e.roll) < 0.01) + } + + @Test func quaternion_90degAboutX_readsAsRoll90() { + let q = Quaternion(w: 0.7071068, x: 0.7071068, y: 0, z: 0) + let e = q.derivedEulerAngles + #expect(abs(e.roll - 90) < 0.01) + #expect(abs(e.pitch) < 0.01) + } + + @Test func quaternion_negativeYaw_normalizesHeadingInto0To360() { + // −90° about Z: w = cos(−45°), z = sin(−45°) + let q = Quaternion(w: 0.7071068, x: 0, y: 0, z: -0.7071068) + #expect(abs(q.derivedEulerAngles.heading - 270) < 0.01) + } + + @Test func quaternion_gimbalPole_pitchClampsWithoutNaN() { + // 90° about Y (pitch pole): sin(pitch) argument hits ±1 exactly. + let q = Quaternion(w: 0.7071068, x: 0, y: 0.7071068, z: 0) + let e = q.derivedEulerAngles + #expect(abs(e.pitch - 90) < 0.01) + #expect(!e.heading.isNaN && !e.roll.isNaN) + } + + @Test func quaternion_derivedValues_useFourDecimalEulerFormat() { + let vals = Quaternion(w: 1, x: 0, y: 0, z: 0).derivedColumnValues + #expect(vals == ["0.0000", "0.0000", "0.0000"]) + } + + @Test func nonQuaternion_hasNoDerivedColumns() { + #expect(CartesianFloat.derivedColumnHeaders.isEmpty) + #expect(EulerAngles.derivedColumnHeaders.isEmpty) + #expect(CartesianFloat(x: 1, y: 2, z: 3).derivedColumnValues.isEmpty) + } + // MARK: EulerAngles @Test func eulerAngles_headers() { @@ -145,6 +199,22 @@ struct MWDataTableFactoryTests { let table = MWDataTable.from(logged: [s], name: "t") #expect(table.rows[0].count == table.columns.count) } + + @Test func logged_quaternion_appendsDerivedEulerColumns() { + let s = MWLoggedSample(date: Date(timeIntervalSince1970: 0), tickMs: 0, + value: Quaternion(w: 0.7071068, x: 0, y: 0, z: 0.7071068)) + let table = MWDataTable.from(logged: [s], name: "t") + #expect(table.columns == ["epoch", "elapsed_ms", "w", "x", "y", "z", "heading", "pitch", "roll"]) + #expect(table.rows[0][6] == "90.0000") + } + + @Test func streamed_quaternion_appendsDerivedEulerColumns() { + let samples = [Timestamped(time: Date(timeIntervalSince1970: 0), + value: Quaternion(w: 1, x: 0, y: 0, z: 0))] + let table = MWDataTable.from(streamed: samples, name: "t") + #expect(table.columns == ["epoch", "w", "x", "y", "z", "heading", "pitch", "roll"]) + #expect(table.rows[0].count == table.columns.count) + } } // MARK: - MWDataTable CSV