DEV Community

izetg
izetg

Posted on

Fusing watchOS Sensor Signals Into a Risk Decision — Without Coupling to HealthKit

I've been building Ember, a privacy app that
wipes an encrypted vault when an Apple Watch detects a real emergency —
a fall, a dangerous SpO2 drop, a manual panic trigger. Building the
detection logic surfaced a design problem I hadn't seen written about
much, so I pulled the general pattern out into a small open-source
package, SignalFusionKit, and
wanted to write up the actual problem and the shape of the solution.

The problem: combining signals that don't agree on a schedule

HealthKit gives you SpO2 and heart-rate-variability samples on its own
cadence — whenever a reading happens to land. CoreMotion gives you
accelerometer data continuously. Fall detection is a delegate callback
that fires whenever it fires. None of these arrive in lockstep, and none
of them are individually reliable enough to act on alone.

The naive approach is a weighted formula: multiply each signal by some
importance factor and sum them. I started there and it broke immediately
on the case that mattered most: a confirmed fall with calm vitals
averages out to "probably fine," because calm vitals are, numerically,
most of the score. But a confirmed fall isn't ambiguous — it should win
outright, not get diluted by an unrelated signal that happens to be
calm at that instant.

So the actual logic isn't a formula, it's a cascade of overrides, most
severe first:

private static func deriveLevel(
    _ snapshot: SignalSnapshot,
    config: RiskEngineConfig
) -> RiskLevel {
    if snapshot.motionAnomalyDetected { return .critical }
    if snapshot.fallDetected { return .critical }

    if let spo2 = snapshot.oxygenSaturation {
        if spo2 < config.criticalOxygenSaturation { return .critical }
        if spo2 < config.lowOxygenSaturation { return .high }
    }

    if let hrv = snapshot.heartRateVariability, hrv < config.lowHeartRateVariability {
        return .high
    }

    if let spo2 = snapshot.oxygenSaturation, spo2 < config.watchOxygenSaturation {
        return .elevated
    }

    return .normal
}
Enter fullscreen mode Exit fullscreen mode

This is RiskEngine.evaluate, and it's a pure function — no I/O, no
stored state, no singleton. Given a SignalSnapshot and a
RiskEngineConfig, it returns a RiskAssessment. That's the whole
contract. I did this deliberately, because I wanted this specific piece —
the actual decision — to be testable without HealthKit, without a Watch,
without mocking Apple frameworks. It's just Swift values in, Swift values
out.

Detecting a sudden-impact motion pattern

The other interesting piece is motion anomaly detection — reading
accelerometer magnitude and deciding "something sudden just happened."
There are two failure modes to avoid:

  • A threshold that's too twitchy fires on ordinary bumps (dropping your arm on a table, closing a car door).
  • A threshold that requires sustained force over time misses genuinely instantaneous impacts, because by the time you've confirmed "sustained," the event that mattered is already over.

MotionAnomalyDetector runs two independent checks to cover both:

public func ingest(
    magnitudeG: Double,
    at timestamp: Date,
    currentSpeedMetersPerSecond: Double? = nil
) -> Bool {
    // ... speed gate omitted for brevity ...

    // Sharp delta: instant jump within a short window
    if let prev = previous,
       timestamp.timeIntervalSince(prev.at) <= config.deltaTimeWindow,
       abs(magnitudeG - prev.magnitudeG) >= config.deltaThresholdG {
        previous = (magnitudeG, timestamp)
        sustainedStart = nil
        return true
    }
    previous = (magnitudeG, timestamp)

    // Sustained: magnitude held above threshold for a minimum duration
    guard magnitudeG >= config.sustainedThresholdG else {
        sustainedStart = nil
        return false
    }
    guard let start = sustainedStart else {
        sustainedStart = timestamp
        return false
    }
    guard timestamp.timeIntervalSince(start) >= config.sustainedMinDuration else {
        return false
    }
    sustainedStart = nil
    return true
}
Enter fullscreen mode Exit fullscreen mode

The delta check catches the instant, sharp case — a spike far above
the previous reading within a short window (say, 100ms) fires
immediately, no waiting. The sustained check catches gradual events —
force held above a threshold for a minimum duration filters out
single-frame noise a delta check alone would miss.

The thing I care about architecturally here: this type takes plain
Double and Date values. It has zero dependency on CoreMotion. You
can feed it synthetic samples in a unit test and assert on the boolean it
returns, with no simulator, no device, no mocked delegate. The actual
CMMotionManager wiring lives in a separate, thin adapter
(WatchKitAdapter) that isn't unit tested — it can't be, not without a
real HealthKit/CoreMotion environment — but it's also small enough that
there's not much surface for bugs to hide in once the core logic is
solid.

What I kept out, on purpose

The threshold values in the repo (RiskEngineConfig.example,
MotionAnomalyConfig.example) are round, illustrative numbers, not
Ember's actual production configuration. Real thresholds need real-world
recording and tuning against your target hardware and your tolerance for
false positives vs. false negatives — that's product-specific work, not
something a reference architecture should hand you as if it were
validated. The README says this explicitly. I'd rather someone read
.example and think "obviously a placeholder" than mistake it for
something I've clinically verified.

Honest limitation

I don't currently have a Mac in my setup. The pure-Swift core
(RiskEngine, MotionAnomalyDetector, CooldownGate) is covered by
swift test and I've run that. The WatchKitAdapter — the part that
actually calls HealthKit and CoreMotion — hasn't been exercised on real
Watch hardware yet. It's flagged in the README. If you have a watchOS dev
setup and try it, I'd genuinely like to know what breaks.

Where this lives

If you're solving a similar problem — combining unreliable, asynchronous
signals into one decision without a single ambiguous input drowning out
an unambiguous one — I'd like to hear how you approached it.

Top comments (0)