<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: izetg</title>
    <description>The latest articles on DEV Community by izetg (@izetg).</description>
    <link>https://dev.to/izetg</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4071314%2F9d078cdc-1217-45d9-883c-d0e61aa4475b.png</url>
      <title>DEV Community: izetg</title>
      <link>https://dev.to/izetg</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/izetg"/>
    <language>en</language>
    <item>
      <title>Fusing watchOS Sensor Signals Into a Risk Decision — Without Coupling to HealthKit</title>
      <dc:creator>izetg</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:45:44 +0000</pubDate>
      <link>https://dev.to/izetg/fusing-watchos-sensor-signals-into-a-risk-decision-without-coupling-to-healthkit-38a0</link>
      <guid>https://dev.to/izetg/fusing-watchos-sensor-signals-into-a-risk-decision-without-coupling-to-healthkit-38a0</guid>
      <description>&lt;p&gt;I've been building &lt;a href="https://www.emberwipe.com" rel="noopener noreferrer"&gt;Ember&lt;/a&gt;, a privacy app that&lt;br&gt;
wipes an encrypted vault when an Apple Watch detects a real emergency —&lt;br&gt;
a fall, a dangerous SpO2 drop, a manual panic trigger. Building the&lt;br&gt;
detection logic surfaced a design problem I hadn't seen written about&lt;br&gt;
much, so I pulled the general pattern out into a small open-source&lt;br&gt;
package, &lt;a href="https://github.com/izetg/SignalFusionKit" rel="noopener noreferrer"&gt;SignalFusionKit&lt;/a&gt;, and&lt;br&gt;
wanted to write up the actual problem and the shape of the solution.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem: combining signals that don't agree on a schedule
&lt;/h2&gt;

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

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

&lt;p&gt;So the actual logic isn't a formula, it's a cascade of overrides, most&lt;br&gt;
severe first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;deriveLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SignalSnapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;RiskEngineConfig&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;RiskLevel&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;motionAnomalyDetected&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;critical&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fallDetected&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;critical&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;spo2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oxygenSaturation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;spo2&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;criticalOxygenSaturation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;critical&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;spo2&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lowOxygenSaturation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;high&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;hrv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heartRateVariability&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hrv&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lowHeartRateVariability&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;high&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;spo2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oxygenSaturation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spo2&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;watchOxygenSaturation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;elevated&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normal&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is &lt;code&gt;RiskEngine.evaluate&lt;/code&gt;, and it's a pure function — no I/O, no&lt;br&gt;
stored state, no singleton. Given a &lt;code&gt;SignalSnapshot&lt;/code&gt; and a&lt;br&gt;
&lt;code&gt;RiskEngineConfig&lt;/code&gt;, it returns a &lt;code&gt;RiskAssessment&lt;/code&gt;. That's the whole&lt;br&gt;
contract. I did this deliberately, because I wanted this specific piece —&lt;br&gt;
the actual decision — to be testable without HealthKit, without a Watch,&lt;br&gt;
without mocking Apple frameworks. It's just Swift values in, Swift values&lt;br&gt;
out.&lt;/p&gt;
&lt;h2&gt;
  
  
  Detecting a sudden-impact motion pattern
&lt;/h2&gt;

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

&lt;ul&gt;
&lt;li&gt;A threshold that's too twitchy fires on ordinary bumps (dropping your
arm on a table, closing a car door).&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;MotionAnomalyDetector&lt;/code&gt; runs two independent checks to cover both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;ingest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;magnitudeG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="nv"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;currentSpeedMetersPerSecond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... speed gate omitted for brevity ...&lt;/span&gt;

    &lt;span class="c1"&gt;// Sharp delta: instant jump within a short window&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeIntervalSince&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deltaTimeWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;magnitudeG&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;magnitudeG&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deltaThresholdG&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;magnitudeG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;sustainedStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;magnitudeG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// Sustained: magnitude held above threshold for a minimum duration&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;magnitudeG&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sustainedThresholdG&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;sustainedStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sustainedStart&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;sustainedStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeIntervalSince&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sustainedMinDuration&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;sustainedStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;h2&gt;
  
  
  What I kept out, on purpose
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  Honest limitation
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  Where this lives
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/izetg/SignalFusionKit" rel="noopener noreferrer"&gt;SignalFusionKit on GitHub&lt;/a&gt; — MIT licensed&lt;/li&gt;
&lt;li&gt;&lt;a href="https://swiftpackageindex.com/izetg/SignalFusionKit" rel="noopener noreferrer"&gt;On the Swift Package Index&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Extracted from &lt;a href="https://www.emberwipe.com" rel="noopener noreferrer"&gt;Ember&lt;/a&gt;, the app I'm building this for&lt;/li&gt;
&lt;/ul&gt;

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

</description>
      <category>swift</category>
      <category>watchos</category>
      <category>opensource</category>
      <category>ios</category>
    </item>
  </channel>
</rss>
