Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion Apps/MetaWear/MetaWear/Export/LiveBufferCSVExporter.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import MetaWear

nonisolated enum LiveBufferCSVExporter {

Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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))"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,28 @@ import MetaWear
/// Loads `MetaMotion.usdz` from the app bundle when present. Otherwise it uses
/// a procedural MetaMotion-style rectangular board so the live orientation view
/// still reads as real MetaWear hardware.
///
/// ## Why the display is TARED (relative to a reference pose)
/// Three hardware sessions proved the fusion quaternion's absolute frame
/// cannot be trusted as a fixed constant: axis-isolated motions landed on
/// DIFFERENT quaternion components across sessions, which no fixed remap can
/// explain (heading is magnetic-referenced and the world frame is not stable
/// session-to-session). So the view renders the rotation SINCE a reference
/// sample instead: `Δ = ref⁻¹ · q` is the board's motion expressed in the
/// reference pose's own body axes — magnetic north, the session's world
/// frame, and the convention ambiguity all cancel. The reference defaults to
/// the first sample (the board starts face-on), and the Zero button lets the
/// user re-anchor at any time — hold the board facing the screen, tap Zero,
/// and the model mirrors every motion from there.
struct QuaternionRealityView: View {
let latest: AnyChartSample?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.horizontalSizeClass) private var horizontalSizeClass

/// Tare pose: motion renders relative to this sample. Captured from the
/// first valid sample, replaced when the user taps Zero.
@State private var reference: simd_quatf?

private var isCompact: Bool { horizontalSizeClass == .compact }

var body: some View {
Expand All @@ -34,7 +51,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
// Motion since the tare pose, in the tare pose's body axes.
let delta = (reference ?? simd_normalize(raw)).inverse * simd_normalize(raw)
// Change of basis M·Δ·M⁻¹ re-expresses that motion in the
// scene's frame; at the tare pose Δ = identity → face-on.
let q = Self.bodyToScene * delta * Self.bodyToScene.inverse
if reduceMotion {
entity.orientation = q
} else {
Expand All @@ -47,12 +70,51 @@ 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))
}
.overlay(alignment: .bottomTrailing) {
if latest != nil {
Button("Zero", systemImage: "scope") {
if let latest {
let raw = simd_quatf(ix: latest.f1, iy: latest.f2, iz: latest.f3, r: latest.f0)
if raw.length > 0.5 { reference = simd_normalize(raw) }
}
}
.buttonStyle(.glass)
.font(.caption.weight(.medium))
.padding(10)
.accessibilityHint("Anchor the 3D board to the device's current orientation")
}
}
.onChange(of: latest?.id) {
// Default tare: the first valid sample of the stream/replay, so
// the board starts face-on without the user doing anything.
guard reference == nil, let latest else { return }
let raw = simd_quatf(ix: latest.f1, iy: latest.f2, iz: latest.f3, r: latest.f0)
if raw.length > 0.5 { reference = simd_normalize(raw) }
}
.glassCard()
.accessibilityLabel("3D orientation of the device")
}

/// Maps tare-body (sensor) axes onto the model's case axes. Body-axis
/// motions right-multiply the tared delta (Δ → Δ·Δb), which the display
/// composes as a rotation about the MODEL'S axis M·b in its local frame
/// — so with the right M, the model does exactly what the physical
/// board does, for ANY tare pose.
///
/// Hardware rounds 4–5 fixed M: rotation about the face normal tracks
/// flawlessly under any Z-preserving map, but pitch and roll rendered
/// SWAPPED with each other (front-back rock displayed as left-right
/// rock) — the IMU chip is mounted rotated 90° about the face normal
/// inside the case, so chip X lies along the case's length. M is the
/// 90°-about-Z rotation carrying chip axes onto the case axes the
/// model is authored in. The SIGN of the 90° is not derivable from
/// docs (no PCB axis diagram exists); if hardware shows front-back
/// and left-right on the correct axes but with inverted direction,
/// flip iz to −0.7071068 — that is the final free parameter.
private static let bodyToScene = simd_quatf(ix: 0, iy: 0, iz: 0.7071068, r: 0.7071068)

/// 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.
Expand Down
61 changes: 61 additions & 0 deletions Apps/MetaWear/MetaWear/Features/Sessions/SessionAxisStyle.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
17 changes: 16 additions & 1 deletion Apps/MetaWear/MetaWear/Features/Sessions/SessionDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,32 @@ 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?

var body: some 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,
systemImage: "chart.line.uptrend.xyaxis",
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") {
Expand Down Expand 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)
Expand Down
Loading
Loading