The Problem
A freediving watch records everything and explains nothing. After a session you get a FIT file, a binary blob of depth and heart rate samples at one reading a second, and the watch’s own app shows you a count of dives and a max depth. That is it. The interesting questions are all left on the table.
How long was each dive, actually, not rounded to the second the watch felt like displaying. Which dive was my best, and was it deeper or longer than last week’s? How deep did I go on each one? And the question every freediver is told matters but almost no consumer tool will answer, what did my heart do down there? Did the mammalian dive reflex, the bradycardia that drops your heart rate when your face hits cold water and you hold your breath, actually trigger? At what point in the dive did it kick in?
That last one is the whole point of training. The dive reflex is the body’s oxygen conservation mode, and a diver who triggers it earlier and harder is a diver who is adapting. But it is invisible in every mainstream dive log. The data to see it is sitting right there in the FIT file, the heart rate channel, and nobody was reading it.
I wanted a dive log I would actually open again, one that read the file properly and showed me whether I was getting better.
The Insight
The watch already captured the signal. The product was not more sensors, it was reading the file honestly.
Two things followed from that. First, the truth lives in the per-second track, not in the watch’s summary numbers. The watch rounds, smooths, and counts dives by rules it does not tell you. If I interpolated the moment depth crossed a fixed threshold instead of trusting the nearest whole-second sample, I could recover sub-second dive times that the watch throws away. The honest number, not the prettied-up one.
Second, the dive reflex is not a number you read off a sensor, it is a shape you find in the heart rate curve. Heart rate falls during a good dive and rebounds on the surface. The reflex “triggering” means the fall is real and not noise, and the “onset” is the moment the fall steepens, the body flipping into conservation mode. Both of those are signal processing problems on a one-hertz series, not hardware problems. If I could find that shape reliably, I could show a diver something their thousand-dollar watch could not.
So the job was to take one file and answer six plain questions, dive time, best dive time, depth, heart rate through the dive, did the reflex fire, and when, and then stack those answers across sessions so progress became visible.
The Approach
freediver.space is a Phoenix LiveView app in Elixir over Postgres, with the FIT parsing done by a small Python script the app shells out to. Three decisions shaped it.
Lean on a mature FIT decoder, own the dive analysis. The FIT binary format is a swamp, and reimplementing it in Elixir would have been weeks of work to reproduce a solved problem. So the app writes the uploaded bytes to a temp file and runs a Python script (fitdecode) under a bounded Task with a ten second timeout, which streams back JSON on stdout. The decoder is borrowed. Every line of the dive detection and biometric analysis, the part that is actually the product, is hand written and lives in that one script so I can hold it in my head.
Detect dives the way the diver experienced them. A dive starts when depth passes 1.0 m and counts only if it reaches at least 1.5 m, so surface bobbing never registers as a dive. Adjacent segments are merged into one dive when they are within fifteen seconds and the diver never actually surfaced between them, a sensor blip below the threshold is not a new dive, but a real recovery breath at the surface is. The watch pauses recording at the surface, so a gap longer than thirty seconds closes the dive. The thresholds were calibrated against Zepp’s own dive count on Garmin T-Rex 3 Pro recordings, so the dive list matches what the diver remembers doing.
Store the computed truth, keep the raw file forever. Each upload is deduplicated by SHA-256, the original FIT bytes go to blob storage (local or S3), and the parsed summary and per-dive dives are persisted as JSON in Postgres. The raw file is never discarded, so the whole library can be re-parsed after an analysis improvement, and a lossless gzipped export round-trips every byte, including the original FIT, base64 encoded, so the data is always the diver’s to take.
The Build
Every dive in the file is reduced to the answers a freediver actually asks.
- Dive time, measured not rounded. Records are one per second, so the duration is computed by interpolating the exact moment depth crosses the 1.0 m line on the way down and again on the way back up. That recovers sub-second precision the watch discards. The 1.0 m boundary is deliberate, the diver hovers at 0.3 to 0.5 m between dives, so any shallower boundary catches that hover and produces garbage. It is the most exact value the track reliably supports, and it is explicitly not tuned to flatter the watch’s display.
- How deep, and how long at the bottom. Each dive reports its max depth, plus a “hang” time, how long the diver stayed within 0.3 m of the deepest point, which only counts once it lasts two seconds so a fast turn is not mistaken for a bottom hang. Descent and ascent speeds fall out of the depth and the time to and from that hang.
- Heart rate through the whole dive. The full per-second heart rate series is kept and plotted directly under the depth curve, so the diver sees the two move together, heart rate sliding down as depth increases. Three reference points are pulled out, the starting heart rate at the top of the dive, the minimum reached during it, and the recovery peak, the highest heart rate in the thirty seconds after surfacing as the body pays back its oxygen debt.
- Did the dive reflex trigger. Bradycardia is scored as the percentage drop from starting heart rate to the dive’s minimum. It is only recorded as a real dive reflex when that drop is at least five percent, below that it is noise, not adaptation. So the diver gets a clear yes-or-no with a magnitude behind it, not a hopeful guess.
- When it kicked in, the onset. This is the piece nothing else does. The script walks the heart rate series in a three-sample sliding window, computes the slope of each window, and finds the steepest fall. Then it walks backward from that steepest point to the moment the decline first became real, where the slope first reaches forty percent of the steepest, and calls that the onset, reported in seconds from the start of the dive. That is the instant the body flipped into conservation mode, found from the curve’s shape rather than a magic constant.
- Best dive, and progress across sessions. The session summary carries the best depth and best dive time, and personal bests are tracked chronologically across the whole history. A dive earns a PB chip only if it is the first dive ever to reach the current all-time max for that field, so a record is marked once, where it was actually set, and later ties do not steal the credit. The dashboard rolls this up into a progress chart with depth and breath-hold on one timeline, an activity heatmap of which weeks were dived and which were skipped, no streaks and no guilt, and headline stats like total dives and dives per week.
Beyond the single diver, sessions can be compared. A “dive together” view overlays several divers’ depth curves on one chart, with a consent model so a private diver’s curve only appears once they opt in, and a timestamp based suggestion of who you might have dived with that day, since the watch carries no location. There is a guest mode that parses a file and hands back a private shareable link with no account at all, and per-dive, per-session, and combined share links each render their own social preview image.
Where My Judgment Showed Up
The decisive calls were all about being honest with the data rather than impressive with it.
- Interpolated, unrounded dive time over matching the watch. It would have been easy to fudge the numbers until they agreed with Zepp’s display. I chose the more exact value the one-hertz track actually supports and documented that it is not tuned to match the watch, because a dive log that quietly lies to agree with the device it replaces is worthless.
- A real five percent gate on the dive reflex. Calling every small heart rate wobble a “dive reflex” would make the feature feel powerful and mean nothing. Gating it at a genuine five percent drop means a yes is a yes, and that is what makes the onset worth showing.
- Onset from the curve’s shape, not a hardcoded second. The steepest-slope-then-walk-back method is more work than picking an arbitrary threshold, but it adapts to each diver and each dive, which is the only way the onset is trustworthy across a whole library.
- Borrow the FIT decoder, own the analysis. Reproducing the FIT binary format in Elixir would have been effort spent re-solving a solved problem. Hand-writing the dive detection and biometrics, the part that is the product, was where the time belonged.
The Outcome
freediver.space is live at freediver.space. Drop in a FIT file from a Garmin, Suunto, or Zepp watch, with or without an account, and it answers the questions the watch leaves unanswered.
| The question a freediver asks | What the app reads from one FIT file |
|---|---|
| How long was the dive? | Interpolated 1.0 m threshold crossings, sub-second precision from a 1 Hz track |
| What is my best dive time? | Best duration per session, with PB chips marked at the dive that first set the record |
| How deep did I go? | Max depth per dive, plus bottom-hang time and descent/ascent speed |
| How did my heart rate evolve? | Full per-second HR curve under the depth profile, with start, minimum, and recovery peak |
| Did the dive reflex trigger? | Bradycardia scored as percent drop, gated at 5% so a yes is real |
| When did it kick in? | Onset second, found from the steepest fall in the HR curve |
| Am I making progress? | Depth and breath-hold on one timeline, PB tracking, and an activity heatmap |
It is the tool I wanted for my own training, run against my own dive files, and it sits alongside STAmaster, the static-apnea trainer, under the same roof, even feeding it a JSON API. Both come from the same belief, that the data to get better is already being recorded, and the work is reading it honestly.
What I Learned
The recurring lesson was that the valuable product was hiding inside a file everyone already had and nobody was reading. The watch was not missing a sensor. It was missing the willingness to interpret what its sensors already wrote down. The dive reflex onset, the single most training-relevant thing in the file, was three lines of slope analysis away from being visible, and it had been sitting unread in every diver’s FIT export the whole time.
The second lesson was that honesty in the numbers is a feature, not a constraint. The temptation with a tool that replaces a device is to make your numbers agree with the device so users trust you. The better move was the opposite, compute the most exact value the data supports, say plainly where it differs from the watch and why, and gate the soft signals like the dive reflex behind a real threshold so a positive means something. A dive log that tells you the comfortable number is just the watch again. One that tells you the true number is why you would open it twice.