19 HealthKit features into an on-device model
A personal project about making recovery something you can actually see. Alcyona builds 19 daily features out of raw HealthKit sleep, activity and heart data, runs 12 of them through a CoreML model on device, and turns the result into a Recovery Flow Score. That score then grows a SceneKit garden of USDZ plants. No account, no backend, nothing leaves the phone.
Overview
The data layer queries several HealthKit types in parallel: sleep at stage-level granularity, hourly step buckets, resting heart rate and HRV. From those it derives 19 engineered daily features, including a custom clustering pass that merges sleep segments less than 30 minutes apart, so the app can find the real overnight episode and tell it apart from naps.
Twelve of those features feed a compiled linear-regression model that ships inside the bundle. Behind it sits a three-step imputation chain: use the observed value, fall back to the user’s own 14-day rolling median, then to a fixed baseline. Nine missingness flags travel alongside the features so the model can discount anything that was filled in rather than measured. All of it is persisted through SwiftData stores isolated behind actors, because HealthKit delivers on its own schedule.
Tech stack
- SwiftUI
- HealthKit
- CoreML
- SceneKit
- SwiftData
- Swift Concurrency
- Actors
- USDZ
- Blender
Inside the app
From the daily score through the health data behind it, and out into the garden it feeds. Captured in both appearances, so these follow whichever one this site is set to.
The day's Recovery Flow score, with the factor driving it most. HealthKit sleep stages, efficiency and consistency over time. Movement distribution, not just a step total. Each plant is a USDZ model that advances through growth stages.
The product
I wanted to know how well I was actually recovering, and every app I tried either buried it in charts or made me feel bad about it. Alcyona is my attempt at the opposite.
Recovery Flow, framed so it never scolds you
Under the hood the model predicts a stress level. That is the honest technical description, and it is also exactly the wrong thing to put in front of someone at 7am. A number that goes up when you are struggling is a number people stop opening.
So the score is inverted and named for where you are rather than what is wrong: Flowing, Steady, Adjusting, Rebuilding. Even the worst state is phrased as something you are doing, not something you failed at. It is a small decision that shaped the whole app, because once the framing is positive the natural next question is “what would help” instead of “how bad is it”.
It also refuses to be a black box. Alongside the score the app shows the three features driving it most, so “Adjusting” arrives with “sleep duration is shaping today’s flow most” rather than leaving you to guess which habit moved it.
Sleep, in stages rather than hours
Eight hours in bed and eight hours asleep are different facts, and the gap between them is where most sleep problems live. The sleep view reads core, deep and REM separately, works out efficiency from the ratio of sleep to time in bed, counts wake-ups, and tracks regularity as the spread of the last seven nights’ midpoints.
Regularity is the quiet one. Two people can both average seven hours and be in completely different shape if one of them goes to bed at a consistent time, so consistency gets measured as its own signal instead of being folded into the total.
Steps, spread across the day
Ten thousand steps earned in one evening walk is not the same day as ten thousand spread across twelve hours, but a step counter reports them identically. Steps are bucketed hour by hour, which lets the app work out how many distinct hours actually had movement in them and show the shape of the day next to its total.
The garden, which is the whole point
A chart tells you about yesterday. I wanted something that made months legible at a glance, and that you would genuinely want to check on. Three plants map to three pillars: Luna Fern for sleep, Stride Bloom for activity, Calm Willow for recovery. Each one advances only after a rolling window of good days, five for the first stage, seven for the next, fourteen for the last. A fourth plant, the Harmony Orchid, unlocks once all three are established.
The engine behind it is fully deterministic. Same history, same garden, every time. That was deliberate: a plant that grows on a random roll is a slot machine, and the only version worth looking at is one where growth means something real happened. Reveals are paced to one per app open too, so coming back after a fortnight away is not a flood of animations.
What I built
Making an on-device model survive missing data
Real HealthKit data has holes in it. A night without the watch, a day the phone stayed on the desk. Feed a model zeros for those gaps and it reports a crisis. So every input runs a three-step chain: use the observed value, or the user’s own 14-day rolling median once there are at least five observations, or a fixed baseline.
Imputation alone would still let a guess masquerade as a measurement, so nine boolean missingness flags go into the model beside the features, letting it weight filled values differently from observed ones. Inputs are clamped to biologically plausible bounds before inference (sleep 0 to 16 hours, heart rate 30 to 140 bpm) so one corrupt sample can’t drag the score somewhere impossible.
Getting USDZ plants to sit right in a SceneKit scene
The four plants use models I didn’t make. They go through Blender and come out as .usdz for SceneKit to load directly. The correction that bites first is axes: Blender is Z-up, SceneKit is Y-up, so every model arrives on its side without a −90° rotation on X. The bounding box has to be measured before that rotation, while the Z extent still represents height, then each plant is uniformly scaled to a target height and bottom-aligned so its base sits exactly on the ground plane.
Everything that isn’t a plant is procedural. The ground, the pond ring, the centre island and the empty soil mounds for locked slots are SceneKit primitives built in code, which keeps the bundle down to four assets and lets the scene update incrementally instead of being rebuilt every time a stage advances.
Actor-isolated stores, because HealthKit is noisy
HealthKit delivers on its own terms: observer queries fire in the background, iOS batches step updates by up to an hour, and the user can be anywhere in the app when data lands. Routing every read and write through SwiftData stores isolated behind actors makes those concurrent updates safe by construction rather than by remembering to be careful.
Going backendless was the other half. There is no server to reconcile against, so the local store is the source of truth: scores are computed on device, journalled with the readings that produced them, and the garden is derived from that history rather than stored as its own state. That is what makes the reward engine reproducible.



