Skip to content
Open
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 @@ -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 {
Expand All @@ -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.
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