Night, oxygen and measurements
Night oxygen and sleep, everyday measurements and the usual range of each value.
Each algorithm is described with what it reads, its steps, the values it uses (with their place in the code), what it shows and its limits.
Sensor and algorithm bias: what Trévor says and the measures offered
Trévor explains that oximeters, watches, rings and phone cameras can show oxygen higher than reality on dark skin, and that its own calculations inherit this bias. It offers five practical measures. This text is displayed and is not a calculation.
Where you see it: Bot: button "Sensor bias, possible measures" in "My oxygen" and the end of the "My night" screen; app (GET /api/phone/nuit).
What the algorithm reads
- No personal data: same text for everyone (textes fixes)
The calculation, step by step
What is shown
Three fixed messages ("Sensor bias", "Algorithm bias", "Possible measures") shown with the night reminder. No value is corrected according to skin color: Trévor applies no numeric bias correction, neither for the watch nor for the camera.
A worked example
none
What this algorithm cannot do
- The text does not measure your device's bias: it cannot say by how much it is wrong for you.
- Personal camera calibration assumes your reference oximeter is accurate.
- Oxygen alerts stay at 92 and 88 percent for everyone, with no skin-based adjustment.
- The text cites Sjoding et al. (NEJM 2020) and the FDA (2021); the repository notes that the Sjoding figures were not re-read and that the exact date of the 2021 FDA communication is not verified.
- The text says the forehead oximeter would be less sensitive to skin color according to some studies, without citing them.
Code : data/i18n/fr.json (textBiasWhy, textBiasAlgo, textBiasMeasures, textBiasBtn, textNightDisclaimer), src/trevor/conversation/night_screen.py (blocks), src/trevor/conversation/spo2_screens.py (on_spo2), src/trevor/media/ppg_spo2.py (docstring)
Weather and air quality at your home location
Each day Trévor fetches the weather and air quality where you live and keeps a daily summary, to set it beside how you are doing. It invents nothing: with no location or no service response, the series stays empty.
Where you see it: Environment curves on the life dashboard, the fifteen-day moments summary, daily context.
What the algorithm reads
- Home position (rounded to about 1 km) (profil)
- Responses from Open-Meteo and Open-Meteo Air Quality (CAMS) (external services on the Internet)
The calculation, step by step
The values used
| Name in the code | Value | Role | Where |
|---|---|---|---|
MAX_PAST_DAYS | 92 | Open-Meteo ceiling for past days | src/trevor/navigator/environment.py:23 |
BACKFILL_DAYS | 60 | History requested on activation | src/trevor/navigator/environment.py:24 |
place_key | 2 decimals | Place rounded to about 1 km | src/trevor/navigator/environment.py:54 |
timeout | 10 s | Timeout of each call | src/trevor/navigator/environment.py:127 |
units | °C ; % ; hPa ; km/h ; mm ; µg/m³ ; grains/m³ ; m | Variable units | src/trevor/navigator/environment.py:28-48 |
evidence level | moderate, low, none | Evidence level noted for each variable from the October 7, 2026 research (min and max temperature, temperature drop, apparent temperature, wind, PM2.5: moderate; UV, dust, pollen: none) | src/trevor/navigator/environment.py:27-48 |
What is shown
A day-by-day table of the variables, with the attribution "Open-Meteo, CAMS (Copernicus)". Nothing says these variables cause a crisis.
A worked example
Computed by running the code: on November 4, max 12.0 C, min 4.0 C, pressure 1015.0 hPa; on November 5, max 7.5 C, min 1.0 C, pressure 1008.2 hPa. For November 5: temperature drop 4.5 C, pressure change -6.8 hPa, daily range 6.5 C.
What this algorithm cannot do
- The weather is that of your home point, not of where you went that day.
- Past values come from a refreshed forecast: they are model estimates, not station measurements.
- Pollen values are modeled and experimental, with about 2022 as the start of the history.
- Free use of Open-Meteo is reserved for non-commercial use: the code notes this is to be confirmed for this product.
- No causal explanation.
Code : src/trevor/navigator/environment.py (fetch_days, parse_forecast, parse_air, record, location_of)
Daily watch measurements: steps, pulse, HRV, temperature, stress, and detected signals
Trévor stores your watch or ring measurements as one value per day, drops impossible values, fills in the temperature deviation when the device gives only the temperature, then compares your last 7 days with the 30 days before. It also lists factual signals (low oxygen, high pulse, low HRV, temperature, breathing, short nights). This is not a validated medical device.
Where you see it: "Your measurements: average of the last 7 days" and "Detected signals" summary in the bot, journal curves, daily monitor, the watch "My health" screen, and coaching sentences without numbers.
What the algorithm reads
- Steps, resting and average pulse, HRV, sleep, oxygen, respiratory rate, skin temperature, stress, ambient noise, calories, distance, active minutes (Oura, Fitbit, Open Wearables, Samsung, Apple, Google Fit and Garmin exports, app webhook, entered oximeter reading)
- "Day in progress" flag (partial) (webhook: today or a later day)
The calculation, step by step
The values used
| Name in the code | Value | Role | Where |
|---|---|---|---|
SUMMARY_KEYS | steps, sleep_minutes, resting_hr, hrv, spo2, readiness, temp_deviation, skin_temp, respiratory_rate, noise_db | Measurements summarized | src/trevor/wearables/service.py:49 |
NIGHT_KEYS | night, sleep_minutes, hrv, skin_temp, temp_deviation | Night measurements kept in a partial day | src/trevor/wearables/service.py:48 |
difference threshold | 5 % | Difference displayed if it reaches 5 percent of the reference | src/trevor/wearables/service.py:399 |
TEMP_BASELINE_DAYS | 30 | Window for the temperature median | src/trevor/wearables/models.py:112 |
TEMP_BASELINE_MIN | 3 | Minimum nights for a temperature deviation | src/trevor/wearables/models.py:113 |
pulse signal | +10 bpm | Resting pulse vs median of the previous 30 days (5 days minimum) | src/trevor/wearables/service.py:488 |
HRV signal | 0.75 x reference | HRV at least 25 percent below the median | src/trevor/wearables/service.py:490 |
temperature signal | 0.5 °C | Skin temperature deviation | src/trevor/wearables/service.py:492 |
breathing signal | 20 /min | Respiratory rate | src/trevor/wearables/service.py:494 |
sleep signal | 300 min, 3 nights, 120 min minimum | Under 5 h three nights in a row; under 2 h the night does not count | src/trevor/wearables/service.py:497-505 |
ambient noise | dB(A) | Sound exposure read by the watch; no sound is kept | src/trevor/wearables/models.py:31 |
stress_score | /100 | Watch stress score (daily average for Samsung); not in the summary, read by the risk model | src/trevor/wearables/files.py:314 |
What is shown
For each measurement: average value over the last 7 days, difference from the previous 30 days if large enough, and, if interpretation is on, a list of factual signals with the date. Example line: "Resting heart rate: 58 bpm (+6 vs previous 30 days)". Signals are presented as facts to discuss with your care team.
A worked example
Computed by running the code, as of November 6, 2026: 24 days at 60 bpm resting pulse, then on October 30 a pulse of 71 bpm and oxygen of 91 percent. Result: two signals on October 30, "Night SpO2 91 percent" and "Resting HR 71 bpm (reference 60)". Another example: 7 nights of skin temperature alternating 33.1 and 32.9, then 34.0: computed deviation +0.9 C (median of earlier nights: 33.1).
What this algorithm cannot do
- Only one value per day: a pulse spike or oxygen drop of a few minutes does not appear in this summary.
- Priority of one source over another depends only on order (Oura and Fitbit last), not on the real quality of the measurements.
- When two sources give different values on the same day, one overwrites the other without you seeing it.
- The stress score is stored but does not appear in the summary; neither do the average and maximum pulse.
- Without interpretation turned on, the signal list stays empty.
- Trévor does no deduplication of sleep sessions: for Samsung, sessions on the same day are added up; for Oura, only the "long_sleep" type is read; for Fitbit, the main session (isMainSleep) supplies the detail.
- Watch measurements depend on the sensor and are less reliable with movement and on dark skin.
Code : src/trevor/wearables/service.py (_day_rows, series, summary, summary_text, qualitative_summary, grounded_values, signals, handle_webhook), src/trevor/wearables/models.py (clean_day, derive_temp_deviation, merge_days), src/trevor/storage/database.py (upsert_wearable_days, wearable_days), src/trevor/wearables/files.py, src/trevor/wearables/day_view.py
Fifteen days of "moments": summary of your hard moments and the environment
If you turn it on, for fifteen days Trévor asks twice a day how you feel right now (good, medium, not good). At the end it compares hard days with other days on the environment and writes at most two conditions that differ, without speaking of cause.
Where you see it: Bot questions at 10-11 am and 4-5 pm; summary sent the day after the period ends.
What the algorithm reads
- Answers "good", "medium", "not good" (one per slot) (self-report)
- Daily environment (see weather and air) (Open-Meteo, CAMS)
The calculation, step by step
The values used
| Name in the code | Value | Role | Where |
|---|---|---|---|
BURST_DAYS | 14 | Length of the period (today + 14 days = 15 days) | src/trevor/navigator/ema.py:20 |
SLOT_HOURS | am 10-11, pm 16-17 | Geneva hours at which a slot can fire | src/trevor/navigator/ema.py:21 |
MAX_LOG | 120 | Answers kept | src/trevor/navigator/ema.py:22 |
MIN_DAYS_PER_GROUP | 3 | Minimum days per group | src/trevor/navigator/ema.py:23 |
MIN_EFFECT | 0.5 | Minimum standardized difference (Cohen's d) | src/trevor/navigator/ema.py:24 |
MAX_CONDITIONS | 2 | Conditions cited at most | src/trevor/navigator/ema.py:25 |
SCORE | good 2, mid 1, bad 0 | Answer values | src/trevor/navigator/ema.py:26 |
slot gap | 0.25 | Difference in share of "not good" to name a slot | src/trevor/navigator/ema.py:122 |
What is shown
"Over N moments: X good, Y medium, Z not good", possibly the hardest slot, and at most two sentences like "On harder days, [condition]: mean A versus B". A last sentence recalls that these figures are fifteen-day markers and never indicate a cause. Answers are not used for crisis alerts.
A worked example
Computed by running the code: for three hard days with a maximum temperature of 2, 4 and 3 C and three other days at 8, 9 and 10 C, the standardized difference is -1.93; its absolute value exceeds 0.5, so this variable is kept (mean 3.0 versus 9.0).
What this algorithm cannot do
- With 3 days of each kind and twenty variables tested, differences appear by chance: the summary does not correct for multiple comparisons.
- Only the absolute value of the difference is used for ranking; the direction comes from the two means.
- A day with one answer counts as much as a day with two.
- No cause is established: a fifteen-day correlation is not proof.
- Environment variables come from your home location.
Code : src/trevor/navigator/ema.py (due_slot, answer, summary, _group_stats, summary_lines), src/trevor/navigator/environment.py (VARIABLES)
My night: your usual range, lower nights, time below 90 percent and alerts
Trévor compares each night's average oxygen to your own usual range from the previous 30 nights and flags nights that are clearly lower. It also reports time below 90 percent, a run of low nights and a very low measured oxygen, in which case safety comes first. This is not a diagnosis.
Where you see it: "My night" screen in the bot and GET /api/phone/nuit; an optional soft alert (off by default) sent at most once a day.
What the algorithm reads
- Nights of the last 14 days and the 31 days before (oxygen, sleep, source) (watch, ring, manual entry)
- Answer to "Did you sleep well?" and night sweats (0, 1, 2) (self-report in the daily check-in)
- Latest fingertip oximeter value entered yesterday or today (self-report (sorrce « oximeter »))
- Emergency thresholds 88 percent and 3 nights (data/emergency_thresholds.json)
The calculation, step by step
The values used
| Name in the code | Value | Role | Where |
|---|---|---|---|
NIGHT_TOO_FEW | 7 | Measured nights needed to speak of "your usual range" | src/trevor/wearables/service.py:1068 |
NIGHT_DROP_Z | -1.5 (crisis.DROP_Z) | A night is "low" at 1.5 deviations or more below the usual range | src/trevor/wearables/crisis.py:189 |
BASELINE_DAYS | 30 | Prior nights for the usual range | src/trevor/wearables/crisis.py:121 |
MIN_SD oxy_mean | 0.7 | Minimum spread of the oxygen usual range (points) | src/trevor/wearables/crisis.py:166 |
night_spo2_very_low | 88 % | Very low oxygen: emergency ("to be validated by HUG") | data/emergency_thresholds.json:11 |
night_spo2_repeated | 3 nights | Low nights in a row before the repeat sentence | data/emergency_thresholds.json:12 |
sweat n_both | 10 | Minimum nights for the sweat sentence | src/trevor/wearables/service.py:1136 |
sweat window | 90 days | Period for the sweat and low-night association | src/trevor/wearables/service.py:1155 |
days shown | 14 | Nights shown on screen | src/trevor/conversation/night_screen.py:102 |
What is shown
A two-message screen: safety first (emergency or reminder), your usual range ("average oxygen X percent, over N nights"), one line per night (date, average oxygen, sleep, flags ⚠️ 🔄 👍 👎 💦), then lower nights, time below 90 percent, the quality warning, watch-versus-answer divergence, sweats, the run of low nights, the sensor warning and general advice. The lower cutoff of your usual range is computed internally (low_cut = median - 1.5 x spread) and not written in the text.
A worked example
Computed by running the code: 8 nights at 95, 95.5, 96, 95, 96.5, 95.5, 95 and 96 percent give a median of 95.5 and a spread of 0.74. A night at 94.0 percent has z = (94 - 95.5) / 0.74 = -2.0, so it is flagged lower (lower cutoff 94.4 percent). A night at 95.0 percent has z = -0.7 and is not flagged.
What this algorithm cannot do
- Without 7 measured nights of non-low quality, no night is compared: the screen says the usual range is not yet known.
- The usual range follows your own nights: a slow drift over several weeks can become your "usual".
- Nights under 90 percent count every night with a positive p90, including low-quality nights, whereas lower-night flags exclude them (see the notes).
- The 88 percent and 3-night thresholds are Trévor rules; the file says "to be validated by HUG".
- The average of a manually entered night counts like a watch average.
Code : src/trevor/wearables/service.py (night_view, _night_pairs, _recent_oximeter, night_alert_due), src/trevor/conversation/night_screen.py (blocks, row, payload), src/trevor/wearables/crisis.py (_robust, _z, DROP_Z), data/emergency_thresholds.json, src/trevor/conversation/reminders.py (alerte douce, vers 1565)
Night oxygen: average, lowest point, time below threshold, desaturation index
When your watch or ring sends a series of oxygen readings during the night, Trévor boils it down to a few numbers: average, lowest point, share of the night below 92, 90 and 88 percent, and drops per hour. These numbers feed the "My night" screen and your usual range. This is not a validated medical device.
Where you see it: "My night" screen in the bot (one line per night with the average oxygen), the app (GET /api/phone/nuit), and the "My landmarks" curves (average oxygen, lowest point, time below 90 percent, drops per hour).
What the algorithm reads
- Night oxygen readings (time and SpO2 in percent), kept only between 50 and 100 (watch or ring via the app, Health Connect, Apple Health export, Health Auto Export)
- Night length to cover (time asleep, otherwise time in bed) (sleep of the same night)
- Share of the night spent moving (0 to 1), if the source provides it (watch or app)
The calculation, step by step
The values used
| Name in the code | Value | Role | Where |
|---|---|---|---|
THRESHOLDS | (92, 90, 88) | Thresholds for cumulative time below saturation | src/trevor/wearables/night.py:26 |
MAX_GAP_S | 300 | Beyond 5 minutes without a reading, the time is not counted as measured | src/trevor/wearables/night.py:27 |
ODI_MAX_STEP_S | 60 | Maximum typical gap to compute a desaturation index | src/trevor/wearables/night.py:28 |
ODI_MIN_VALID_MIN | 120 | Minimum valid minutes for an index | src/trevor/wearables/night.py:29 |
BASELINE_WINDOW_S | 120 | Reference level of an event: maximum of the previous 2 minutes | src/trevor/wearables/night.py:30 |
MAX_SAMPLES | 20000 | Maximum number of readings read | src/trevor/wearables/night.py:35 |
drop in _count_events | 3 and 4 points | An event is a drop of at least 3 (odi3) or 4 points (odi4) below the reference, held for two readings | src/trevor/wearables/night.py:93-94,110-123 |
What is shown
One summary per night: mean (1 decimal), min (lowest point), p92, p90, p88 (percent of valid time), odi3 and odi4 (per hour, only if the gap is 60 s or less and at least 2 hours are valid), valid_min, coverage, motion, quality. An event ends when saturation climbs back to within (threshold - 1) points of its reference; a single isolated low reading does not count.
A worked example
Computed by running the code: 480 readings, one per minute, at 96 percent, including 3 readings at 91 and 10 readings at 89, 480-minute night. Result: average 95.8; lowest 89; p92 2.7 percent; p90 2.1 percent; p88 0 percent; odi3 and odi4 0.2 per hour; coverage 1.0; quality "good".
What this algorithm cannot do
- Numbers depend on the device: a consumer sensor makes more mistakes when you move.
- A series spaced more than 5 minutes apart gives no time share and no index, only an average and a minimum.
- Gaps are not reconstructed: unmeasured time is not counted, which can hide a drop during a gap.
- A summary received as is (Oura: average only; Fitbit: average and minimum) has no time below threshold and no index.
Code : src/trevor/wearables/night.py (summarize_oxygen, _count_events, oxygen_from_aggregate), src/trevor/wearables/files.py (night_from_item), src/trevor/wearables/oura.py, src/trevor/wearables/fitbit.py
Night quality, ignored nights and source choice
Trévor rates each measured night "good", "medium" or "low" based on measured time, coverage and movement. A "low" night stays visible but is left out of every calculation (your usual range, lower nights, curves). When several sources describe the same night, Trévor keeps the best one.
Where you see it: "My night" screen (a quality warning when a night is low), "My landmarks" curves, and the risk model (which also ignores low nights).
What the algorithm reads
- Night oxygen summary (valid_min, coverage, movement, number of readings) (computed from the series or the sorrce summary)
- Night sleep summary (watch, ring, entry)
- Date and time of a reading (to assign the reading to a night) (watch)
The calculation, step by step
The values used
| Name in the code | Value | Role | Where |
|---|---|---|---|
MIN_VALID_MIN | 120 | Under 2 valid hours, low quality | src/trevor/wearables/night.py:31 |
MIN_COVERAGE | 0.5 | Under 50 percent of the night covered, low quality | src/trevor/wearables/night.py:32 |
MOTION_HIGH | 0.30 | More than 30 percent of the night in motion: low quality | src/trevor/wearables/night.py:33 |
MOTION_MID | 0.15 | More than 15 percent in motion: medium quality | src/trevor/wearables/night.py:34 |
average coverage | 0.8 | Series covering less than 80 percent: medium quality | src/trevor/wearables/night.py:131 |
number of measurements | 30 | Fewer than 30 readings: medium quality | src/trevor/wearables/night.py:131 |
night hours | 8 pm to 10 am | Window for assigning a reading to a night | src/trevor/wearables/night.py:50-58 |
What is shown
A quality label per night (good, medium, low). `oxy_usable` and `sleep_usable` return the night only if quality is not "low" (and, for oxygen, only if an average exists). A manual entry gets 480 valid minutes, so "medium".
A worked example
Computed by running the code: the 480-reading series from the previous example (coverage 1.0, 480 valid minutes, 480 readings) gives "good". A summary without a series (average and minimum only) gives "medium" at best.
What this algorithm cannot do
- Quality does not check whether the sensor was worn well or whether dark skin distorted the light: it sees only duration, coverage and movement.
- A summary received from a source says nothing about coverage: Trévor cannot judge it more finely.
- A "low" night drops out of calculations without a night-by-night notice; only a general warning shows when at least one of the last 14 nights is low.
- Sleep always gets "medium" or "good" (quality is recomputed on every read), so `sleep_usable` never sets a sleep night aside.
Code : src/trevor/wearables/night.py (assign_night, quality_of, oxy_usable, sleep_usable, scalars, by_date, _oxy_rank, _sleep_rank, clean_night), src/trevor/wearables/service.py (night_records)
Manual entry: night oxygen, fingertip oximeter, how you slept and bedtime
When your watch does not send the night's oxygen, you can type it yourself, along with a fingertip oximeter value, how you slept and an approximate bedtime. Trévor stores these entries as measurements without correcting them.
Where you see it: Bot: "My oxygen (SpO2)", "My night" (entry button), "My day" (night question).
What the algorithm reads
- Two or three numbers from 50 to 100 for the night (average, lowest, optional peak) (self-report in the bot)
- One oximeter value between 70 and 100 (self-report in the bot)
- Night feel (good, medium, bad) and bedtime range (before 10 pm, 10 pm, 11 pm, after midnight) (self-report in the bot)
The calculation, step by step
The values used
| Name in the code | Value | Role | Where |
|---|---|---|---|
night entry range | 50 to 100 | Each number in the night entry | src/trevor/conversation/night_screen.py:90 |
manual valid_min | 480 | Valid minutes assigned to a manual entry | src/trevor/conversation/night_screen.py:132 |
oximeter range | 70 to 100 | Accepted oximeter value | src/trevor/conversation/spo2_screens.py:41 |
LOW | 92.0 (ppg_spo2.LOW) | Below this, safety message on the entered value | src/trevor/conversation/spo2_screens.py:24 |
KEEP (oximeter) | 60 | Oximeter values kept | src/trevor/conversation/spo2_screens.py:25 |
KEEP_NIGHTS | 60 | Felt nights kept | src/trevor/navigator/sleep.py:22 |
BAD_NIGHTS_ADVICE / ADVICE_WINDOW_DAYS | 3 / 7 | "Bad" nights in 7 days before the advice | src/trevor/navigator/sleep.py:23-24 |
SELF_SLEEP_Z | good +1.0; average 0; bad -1.0 | Declared night seen as a deviation, on the watch measurement scale (used by the daily form) | src/trevor/navigator/sleep.py:26 |
What is shown
A saved oxygen night (average, lowest, optional peak) visible in "My night", an oximeter value in the curves and value alerts, a felt night stored in the profile. Below 92 percent: "Redo the measurement after two minutes of rest, seated, hands warm. If the value stays below 92 percent, call now."
A worked example
Based on the parsing code: "94 88 97" gives average 94, lowest 88, peak 97; "93.5 90" gives average 93.5, lowest 90, no peak; "96" alone is refused.
What this algorithm cannot do
- Nothing checks that the numbers come from a device: Trévor cannot tell a real average from a copied figure.
- Entry is always dated today; entering yesterday is not possible from this screen.
- With an oximeter entry and a watch value on the same day, the Oura ring or the Fitbit watch takes priority in the daily curves.
- A pulse oximeter can read too high on dark skin.
Code : src/trevor/conversation/night_screen.py (parse_night_values, on_night_enter), src/trevor/conversation/spo2_screens.py (parse_value, on_spo2_enter), src/trevor/navigator/sleep.py, src/trevor/conversation/daily_screen.py (screen_sleep_night, on_sleep_bed)
Night sleep: duration, efficiency, awakenings
Trévor takes what your device reports about your night (time asleep, time in bed, awakenings, stages) and computes only what can be computed without inventing anything, such as sleep efficiency. It then compares your night to your own usual range to spot a fragmented night.
Where you see it: "My night" screen (sleep duration for each night), "My landmarks" curves (efficiency, awakenings, deep sleep), and the daily form sentence.
What the algorithm reads
- Sleep start and end, minutes in bed, asleep, latency, awake time (WASO), number of awakenings, deep, light, REM stages (Oura, Fitbit, Health Connect, Apple Health, Health Auto Export, Open Wearables, files)
- Sleep segments (start, end, stage) when the device provides them (Health Connect, Apple Health)
The calculation, step by step
The values used
| Name in the code | Value | Role | Where |
|---|---|---|---|
watch_sleep good | 85 | Efficiency from which the watch reading is called "good" | src/trevor/wearables/service.py:1103 |
watch_sleep poor | 75 | Efficiency below which the watch reading is called "poor" | src/trevor/wearables/service.py:1103 |
fragmentation threshold | 1.5 | Deviations from your usual range (awake time up or efficiency down) | src/trevor/wearables/service.py:1106-1107,1109 |
MIN_SD sleep_eff / sleep_waso | 3.0 / 10.0 | Minimum standard deviations of the usual range for efficiency and awake time | src/trevor/wearables/crisis.py:164-165 |
BASELINE_DAYS | 30 | Prior nights used for the usual range | src/trevor/wearables/crisis.py:121 |
MIN_BASELINE | 7 | Measured nights needed for a usual range | src/trevor/wearables/crisis.py:122 |
What is shown
Per night: durations in minutes, efficiency in percent, number of awakenings, stages if known. On screen: time asleep as "7 h 00", a fragmentation flag (used in the view) and, with your answer to "Did you sleep well?", a 🔄 sign when the watch and your answer disagree (answer "yes" with efficiency under 75 percent, or "no" with efficiency of at least 85 percent).
A worked example
Computed by running the code: 480 minutes in bed and 420 minutes asleep, with no efficiency given, give an efficiency of 88 percent (100 x 420 / 480) and quality "good". 88 percent is above 85, so the watch reads "good".
What this algorithm cannot do
- A missing value stays missing: without an "awake" segment, Trévor shows no awakening rather than zero.
- Deep, light and REM stages depend on the device, and consumer watches estimate them with a margin of error.
- Open Wearables and Health Auto Export field names are marked in the code as plausible and not checked against a real export (openwearables.py:81; files.py:488).
- A nap does not replace the night for Oura (only the "long_sleep" type is read); for Samsung, durations of several sessions on the same day are added up.
Code : src/trevor/wearables/night.py (sleep_summary, sleep_from_segments, normalise_stage), src/trevor/wearables/service.py (night_view), src/trevor/conversation/night_screen.py (row), src/trevor/wearables/oura.py, src/trevor/wearables/fitbit.py
Oxygen estimate from the phone camera (experimental)
The phone has you rest your finger on the lens and flash, measures how the light pulses, and sends Trévor a ratio R, the signal quality and the pulse. Trévor converts R into a SpO2 estimate with a range, wider until you calibrate with a reference oximeter. This is experimental and not a validated medical device.
Where you see it: Coach Drépano app ("SpO2 by camera" card) and bot ("My oxygen": latest estimate, range and verdict sentence).
What the algorithm reads
- R = (red AC / red DC) / (blue AC / blue DC), computed on the phone (the image stays on the phone) (fingertip photo with flash)
- Signal quality from 0 to 1 and pulse (30 to 220) (app)
- Reference pulse oximeter value (70 to 100), optional, and reference color (blue or green) (self-report in the app)
The calculation, step by step
The values used
| Name in the code | Value | Role | Where |
|---|---|---|---|
R_RANGE | (0.2, 2.5) | Plausible range of R | src/trevor/media/ppg_spo2.py:24 |
DEFAULT_A, DEFAULT_B | 110.0, 25.0 | Universal line: SpO2 = 110 - 25 x R | src/trevor/media/ppg_spo2.py:25 |
DEFAULT_MARGIN | 8.0 | Half-width of the range without calibration (SpO2 points) | src/trevor/media/ppg_spo2.py:26 |
MIN_QUALITY | 0.5 | Minimum signal quality | src/trevor/media/ppg_spo2.py:27 |
MIN_PAIRS | 3 | (R, oximeter) pairs for a personal line | src/trevor/media/ppg_spo2.py:28 |
LOW | 92.0 | Threshold for verdict "low" | src/trevor/media/ppg_spo2.py:29 |
KEEP | 90 | Readings kept per person | src/trevor/media/ppg_spo2.py:30 |
calibrated margin | max(2, 2 x residual deviation) | Range with a personal line (residual spread at least 1) | src/trevor/media/ppg_spo2.py:48,58 |
pair range | R 0.2 to 2.5; SpO2 70 to 100 | Pairs kept for fitting | src/trevor/media/ppg_spo2.py:35 |
What is shown
A rounded value, a range (low and high), whether it is calibrated, and a verdict. Bot sentences: "Low value.", "The range is too wide to conclude." or "No alert. This estimate does not replace a pulse oximeter." The "normal" verdict requires a personal line and the bottom of the range to stay at or above 92. Below 92 or if you feel unwell, a pulse oximeter then your care team or emergency services come first.
A worked example
Computed by running the code: R = 0.6 and quality 0.8 with no calibration give 95 percent (110 - 25 x 0.6), range 87 to 100 percent, verdict "uncertain". With four pairs (0.5, 98), (0.7, 93), (0.9, 88), (0.6, 95.5), the line becomes 110.5 - 25 x R with a residual spread of 1; R = 0.6 gives 96 percent between 94 and 98, verdict "normal"; R = 0.95 gives 87 percent between 85 and 89, verdict "low".
What this algorithm cannot do
- A phone sensor is not designed to measure oxygen: cold fingers, nail polish, movement and ambient light distort the result.
- The universal line has not been validated, especially on dark skin; melanin absorbs blue, which is why personal calibration is the basis of the measurement.
- Calibration is distorted if your reference oximeter overestimates oxygen on dark skin: the estimate then inherits that bias.
- The range relies only on the residual spread of your pairs: with 3 pairs it can look too narrow by chance (floor at 2 points).
- No link to nights: this measurement does not enter "My night" or value alerts.
Code : src/trevor/media/ppg_spo2.py (fit, estimate, add_reading, view), src/trevor/signals.py (spo2_cam_reading, last_oximeter), src/trevor/conversation/spo2_screens.py (screen_spo2), docs/SPO2_CAMERA.md
Watch measurement quality: good, medium or insufficient
Trévor counts over 14 days how many days have enough useful measurements, and from that decides whether your watch gives "good", "medium" or "insufficient" measurements. This label is used to silence a fragile conclusion and to show reliability on the wrist and in the bot.
Where you see it: "measure_quality" field on the wrist "My health" screen and in the app.
What the algorithm reads
- Merged daily rows for the last 14 days (watch or ring)
The calculation, step by step
The values used
| Name in the code | Value | Role | Where |
|---|---|---|---|
WINDOW_DAYS | 14 | Window | src/trevor/wearables/mesure.py:16 |
MIN_NIGHT_COVERAGE_MIN | 120 | Minimum sleep for a usable day | src/trevor/wearables/mesure.py:17 |
GOOD_DAYS | 10 | Days for "good" | src/trevor/wearables/mesure.py:19 |
MEDIUM_DAYS | 6 | Days for "medium" | src/trevor/wearables/mesure.py:20 |
What is shown
A label (good, medium, insufficient), the number of usable days, the gaps. The label says nothing about the accuracy of the values, only about their regularity.
A worked example
none
What this algorithm cannot do
- The count measures the presence of data, not its accuracy.
- A badly worn watch that still produces two measurements is judged "good".
Code : src/trevor/wearables/mesure.py (usable, assess), src/trevor/wearables/watch_summary.py (_measure_quality)
Aligned series of the "dashboard of your life"
Trévor puts on the same day axis what you experience (pain, mood, form, fatigue, crisis days), your body (watch, nights, lab results), the environment, your care and event markers. It computes no averages and no interpolation: a day without a measurement stays empty.
Where you see it: Health view (lifecourse/health_view.py, up to 366 days), navigator recommendations (90 days), findings.py associations and longitudinal research (180 days); the exact screens that display them were not all re-read.
What the algorithm reads
- Pain, mood, fatigue, temperature feel (cold, hot, fever), measured temperature (daily check-in, observations rapides)
- Crisis episodes, hospital stays, transfusions (self-report, health record)
- Watch measurements, nights (average oxygen, lowest point, time below 90 percent, drops per hour, efficiency, awakenings, deep sleep) (watch, ring)
- Daily environment (Open-Meteo, CAMS)
- Lab results, eye yellow index, water, treatment intake, rescue pain medication, personal self-care actions (health record, self-report)
The calculation, step by step
The values used
| Name in the code | Value | Role | Where |
|---|---|---|---|
MAX_DAYS | 180 | Default maximum window | src/trevor/navigator/series.py:21 |
MIN_DAYS | 14 | Minimum window | src/trevor/navigator/series.py:22 |
pain threshold | 7 | A day with recorded pain of 7 or more counts as a crisis day | src/trevor/navigator/series.py:157 |
MAX_MISSED_LISTED | 300 | Missed doses listed | src/trevor/navigator/series.py:76 |
units | steps; min; bpm; ms; %; /min; °C; dB(A); /h | Units of watch and night series | src/trevor/navigator/series.py:29-47 |
What is shown
For each series, values per day (measured days only), labels with unit and group, events, the attribution "Open-Meteo, CAMS (Copernicus)". No composite number.
A worked example
none
What this algorithm cannot do
- A day with recorded pain of 7 or more becomes a "crisis day" even without a declared episode: this is a Trévor rule, not a diagnosis.
- Low-quality nights do not appear (see the quality algorithm).
- Watch values depend on sensor bias.
- All night reading sits in a block that silently ignores errors: a night reading failure leaves the curve empty without a message.
Code : src/trevor/navigator/series.py (build), src/trevor/wearables/day_view.py (series, monitor), src/trevor/lifecourse/health_view.py (_nav), src/trevor/navigator/reco_flow.py (vers 243), src/trevor/research_longitudinal.py (vers 589)
The low oxygen threshold (92 percent) and nearby thresholds: where they apply and how to set them
Trévor uses several oxygen thresholds in different places: 92 percent for a single value, 90 and 88 percent for time spent at night, and 88 percent for emergencies. The 92 percent cannot be changed; you can only set your own usual range, which changes when an alert is sent.
Where you see it: Oximeter entry messages, camera estimate, value alerts, watch signals, "My night".
What the algorithm reads
- An oxygen value (oximeter, camera, watch) or a night average (see the other algorithms)
- Your usual range (low, high) if you set one (“Value alerts” card)
The calculation, step by step
The values used
| Name in the code | Value | Role | Where |
|---|---|---|---|
LOW | 92.0 | Camera and oximeter message threshold | src/trevor/media/ppg_spo2.py:29 |
Metric spo2 low | 92 | General saturation norm in value alerts | src/trevor/value_alerts.py:68 |
spo2 signals | < 92 | "Night SpO2" signal | src/trevor/wearables/service.py:486 |
qualitative spo2 | < 92 | Trend sentence (recent average) | src/trevor/wearables/service.py:444 |
THRESHOLDS | (92, 90, 88) | Cumulative time below these thresholds each night | src/trevor/wearables/night.py:26 |
ABS_LOW_NIGHT | 92.0 | Night average below which the risk model counts a low night | src/trevor/wearables/crisis.py:190 |
night_spo2_very_low | 88 | Emergency: night average or oximeter at 88 percent or less | data/emergency_thresholds.json:11 |
What is shown
The same word "low" can come from different thresholds depending on the screen. No screen lets you change 92 or 88 percent. Your personal usual range (or your clinician's) only changes the level of the value alert.
A worked example
Computed by running the code: a value of 91 percent with no range set is an "alert"; with a lower limit set at 90 percent, the same value becomes "usual for you"; with a lower limit set at 94 percent, it stays an "alert". A value of 93 percent with a lower limit at 94 percent is "watch".
What this algorithm cannot do
- The number 92 is written in at least six places in the code, with no shared constant: a future change would have to be made everywhere.
- The 88 percent emergency threshold and the 3-night count are read from data/emergency_thresholds.json; the other 92 percent values are hard-coded.
- An oximeter can read too high on dark skin: a figure of 93 percent does not prove everything is fine.
Code : src/trevor/media/ppg_spo2.py, src/trevor/conversation/spo2_screens.py (on_spo2_enter), src/trevor/value_alerts.py, src/trevor/wearables/service.py (signals, qualitative_summary, night_view), src/trevor/wearables/night.py, src/trevor/wearables/crisis.py, data/emergency_thresholds.json
Usual range of each measurement and value alerts
Trévor reads your latest value against two references: the general adult norm and your usual range, learned from your own values or set by you or your clinician. Depending on whether the value is outside one, both or neither, it shows "alert", "watch", "usual for you" or "nothing to report". This is not a diagnosis.
Where you see it: "Value alerts" card on the dashboard (patient and clinician), bot push message for the "alert" level (once per measurement and date, can be turned off), dashboard alerts for fatigue and lab results.
What the algorithm reads
- One value per day from the watch: night oxygen, resting heart rate, respiratory rate, HRV, skin temperature deviation, sleep, steps, ambient noise (watch, ring, entered oximeter reading (read only if yor turned on connected device data))
- Declared fatigue out of 10 (daily check-in or quick action)
- Set limits (low, high, by patient or clinician) (dashboard card)
- Lab results (covered by other Trévor algorithms) (health record)
The calculation, step by step
The values used
| Name in the code | Value | Role | Where |
|---|---|---|---|
LEARN_K | 2.5 | Width of the learned range in robust spreads | src/trevor/value_alerts.py:41 |
MIN_WEAR, WEAR_DAYS | 10, 90 | Watch values needed to learn a range and window in days | src/trevor/value_alerts.py:42 |
spo2 | low norm 92; floor 0.7; freshness 3 d; alert if alone | Night saturation: under 92 percent is outside the norm; with no personal range the reading is already "alert" | src/trevor/value_alerts.py:68 |
resting_hr | 50 to 100 bpm, both directions, floor 3 | Resting heart rate | src/trevor/value_alerts.py:69 |
respiratory_rate | 12 to 20 /min, both directions, floor 1 | Respiratory rate | src/trevor/value_alerts.py:70 |
hrv | no norm, direction low, floor 5 ms | Heart rate variability: only the gap from your range counts | src/trevor/value_alerts.py:71 |
temp_deviation | high 0.5 °C, direction high, floor 0.2 | Skin temperature deviation | src/trevor/value_alerts.py:72 |
sleep_minutes | low 300 min, direction low, floor 30 | Sleep: under 5 h | src/trevor/value_alerts.py:73 |
steps | no norm, direction low, floor 1,500 | Steps per day | src/trevor/value_alerts.py:74 |
fatigue | high 7/10, direction high, floor 1 | Declared fatigue: above 7/10 (marker to be validated) | src/trevor/value_alerts.py:75 |
noise_db | high 70 dB(A), floor 3 | Daily average ambient noise | src/trevor/value_alerts.py:76 |
PLAUSIBLE | oxygen 50 to 100; pulse 25 to 250; sleep 0 to 960 min; steps 0 to 60,000 | Beyond these, the value does not enter the usual range | src/trevor/value_alerts.py:98-99 |
What is shown
One line per measurement with level (alert, watch, usual for you, nothing to report), direction (under or above), norm and usual range, sorted from most to least concerning. Example "alert" sentence: "Night oxygen saturation: 91 percent, below the norm (92 percent) and below your usual range (93.65 percent). Talk to your care team."
A worked example
Computed by running the code: 12 nights between 95.0 and 96.0 percent (median 95.5; learned range 93.65 to 97.35) then a value of 91 percent: "alert" level. If you set a lower limit of 90 percent yourself, the same 91 percent becomes "usual for you": it is below the 92 norm but above your limit, so no message is sent. With a single 91 percent value and no history: also "alert", because saturation is handled separately.
What this algorithm cannot do
- A limit you set replaces the learned range and can make an alert disappear: a lower oxygen limit of 90 percent turns 91 percent into "usual for you".
- The 92 percent norm threshold cannot be adjusted: only the personal range can (route /moi/.../limites for you, /soignant/patient/.../limites for the clinician).
- A slow drift over several weeks can settle into the learned range without an alert.
- Watch measurements are read only if you turned on reading your connected device data.
- The range comes from the median of your values and ignores the time of night; only one value per day.
Code : src/trevor/value_alerts.py (METRICS, learn, evaluate, set_limit, build, describe, unsent), src/trevor/dashboard/server.py (patient_limits, clinician_limits, vers 6939-6970), src/trevor/conversation/reminders.py (value_alerts)
