diff --git a/docs/tracking.md b/docs/tracking.md index 9347949..1bb7dfe 100644 --- a/docs/tracking.md +++ b/docs/tracking.md @@ -138,6 +138,115 @@ The optional CSV contains only `unix_seconds,bpm`. It is created privately path you specify. Nothing is logged by default, and heart-rate values are not printed to the terminal. Delete your session file when you no longer need it. +### Checking heart rate against a reference + +`scripts/heart-check.py` runs on your computer. `listen` shows our OSC +readings live as they arrive, so you can watch them next to another device: + +```sh +python3 scripts/heart-check.py listen --port 9000 --out ours.csv +``` + +Point the Frame at it with `--osc 9000`. `compare` lines +up two recordings by time and reports the mean difference, bias, the share +within ±5 BPM and the delay between them. It passes when the mean difference +is at most 5 BPM, at least 80% of reference readings are matched and nothing +was shown while the sensor reported lost skin contact: + +```sh +python3 scripts/heart-check.py compare ours.csv reference.csv +python3 scripts/heart-check.py compare ours.csv ~/Downloads/export.zip +``` + +The reference can be a CSV (`time,bpm[,flags]`, time in unix seconds or ISO +8601) or an Apple Health export (`export.zip` or `export.xml`). Only heart-rate +records within the recording's time range are read. Everything stays on your +computer. + +`scripts/heart-test-strap.swift` turns a Mac into a synthetic strap. It +advertises the standard Heart Rate Service and sends a fixed, known sequence +(8-bit and 16-bit values and a skin-contact loss), printing each sent value, so +`compare` can check that the Frame shows exactly what was sent. It needs +Bluetooth permission for the process that runs it. **Untested on 2026-09-29:** +it compiled, but on this Mac, launched from an agent session, macOS never +delivered a Bluetooth state and no permission prompt appeared, so it never +advertised. + +## Pulse from the eye cameras (experimental) + +The Frame has no heart-rate sensor. **Verified 2026-09-29** (SteamOS 0.4.1, +build `20260925.6191901`): its sensors are an ambient light/proximity sensor +(`vcnl4000`), a hall sensor (`als31300`), two passthrough cameras +(`arcimx616`), two tracking cameras (`og01a1b`) and two IR eye cameras +(`og0ve10`). There is no optical heart-rate (PPG) sensor. + +The experiment asks whether the eye cameras can see a pulse anyway. With each +heartbeat, the blood volume in the skin around the eye changes slightly and +its IR reflectance changes with it. This is camera-based photoplethysmography; +near-IR works, though the signal is weaker than in green light. + +```sh +python3 scripts/tracking-on-frame.py pulse --seconds 60 --show +``` + +How it works: + +- **Capture (verified).** SteamVR ships `eyetracking --calib N`, which saves + both eye cameras for N seconds as 400×400 8-bit IR PNGs with a monotonic + timestamp per frame. A 2-second test captured 177 stereo pairs, about 90 fps. + SteamVR's live eye tracker, part of `steamvr.service`, gets its frames from + the DSP and stops its cameras when the headset is off; the capture ran + alongside it without errors in its log. **Untested:** whether the capture + and the live tracker coexist while the headset is worn and tracking. +- **Privacy.** Each image is reduced to a 16×16 grid of patch averages as + soon as it is complete, then deleted. The capture directory is removed on + exit, even after errors. No image is kept or leaves the Frame. The estimate + is printed only with `--show`, and sent or saved only with `--osc` or + `--log`, as for the strap. +- **Estimate.** Patch traces are averaged down to 15 Hz and turned into + relative change. A 2-second moving median removes drift and blinks. + Patches with frequent spikes (the eyeball and eyelid) are dropped, as are + dark or saturated ones. The 20% of patches with the clearest rhythm between + 42 and 180 BPM are combined in the frequency domain. Output is an overall + estimate plus one estimate per second over 15-second windows. A result + counts as **clear** only when the top patches agree and the combined signal + stands out from the noise. Otherwise the command exits 3 and sends nothing. + The thresholds are provisional until checked on real wearers. + +**Verified on synthetic data** (unit tests): a 0.3% brightness pulse in a +third of the patches, with noise, drift, blinks and eye movement, is +recovered within 1.5 BPM at 58, 72 and 115 BPM; noise and blinks alone are +not reported as a pulse. **Not yet verified:** whether a real wearer's eye +images contain a usable pulse, and how accurate it is. That needs someone +wearing the headset and a reference, as below. + +### Comparing with an Apple Watch + +1. On the watch, start a workout (for example **Other**) so it measures heart + rate every few seconds rather than occasionally. +2. Put the Frame on, sit still and look ahead. Run: + + ```sh + python3 scripts/tracking-on-frame.py pulse --seconds 120 --show \ + --log /home/steamos/pulse.csv + ``` + + The per-second estimates print at the end. Compare them with what the + watch showed. +3. End the workout. On the iPhone, open Health → your picture → **Export All + Health Data**, and AirDrop `export.zip` to the Mac. +4. On the Mac: + + ```sh + scp frame:pulse.csv . && ssh frame rm pulse.csv + python3 scripts/heart-check.py compare pulse.csv ~/Downloads/export.zip + ``` + +This first version analyses after the capture ends, because the method must +prove itself before a live panel is worth building. The Apple Watch is a +reference, not ground truth: in workouts it is typically within a few BPM of +a chest strap when you are still. + ## SlimeVR: feasibility only SlimeVR is an independent application stack. Neither of our features installs, diff --git a/frame/tracking/pulse.py b/frame/tracking/pulse.py new file mode 100644 index 0000000..9af7642 --- /dev/null +++ b/frame/tracking/pulse.py @@ -0,0 +1,326 @@ +"""Experimental pulse estimate from the Frame's IR eye-tracking cameras. + +Skin brightens and darkens very slightly with each heartbeat as blood volume +changes (photoplethysmography). The eye cameras film the skin around each eye +under steady IR light at about 90 frames per second, so that rhythm may be +visible in the average brightness of small patches of skin. + +Capture uses SteamVR's own `eyetracking --calib` mode, which writes PNG pairs +to /tmp. Each image is reduced to a grid of patch averages the moment it is +complete, then deleted; the capture directory is removed on exit. No image +leaves the Frame or outlives the run. This is an experiment, not a medical +measurement. +""" +import bisect +import cmath +import json +import math +import os +from pathlib import Path +import re +import shutil +import subprocess +import time + +ET_DIR = Path("/opt/steamvr/tools/eyetracking") +ET_BIN = ET_DIR / "bin/linuxarm64/eyetracking" +ET_WEIGHTS = ET_DIR / "resources/et_dsp_20250610_03136.weights" +GRID = 16 # 16 × 16 patches of 25 × 25 pixels on the 400 × 400 image +RATE = 15.0 # analysis sample rate, Hz; the band of interest ends at 3 Hz +LOW, HIGH = 42.0, 180.0 # BPM search band +WINDOW = 15.0 # seconds per windowed estimate + + +# ---------------------------------------------------------------- capture --- + +def grid_means(pixels, width, height, stride, channels, grid=GRID): + """Average of channel 0 in each of grid × grid equal patches.""" + bw, bh = width // grid, height // grid + sums = [0] * (grid * grid) + for y in range(bh * grid): + start = y * stride + row = pixels[start:start + width * channels:channels] + base = (y // bh) * grid + for bx in range(grid): + sums[base + bx] += sum(row[bx * bw:(bx + 1) * bw]) + area = bw * bh + return [s / area for s in sums] + + +def load_grid(path): + import gi + gi.require_version("GdkPixbuf", "2.0") + from gi.repository import GdkPixbuf + image = GdkPixbuf.Pixbuf.new_from_file(str(path)) + return grid_means(image.read_pixel_bytes().get_data(), image.get_width(), image.get_height(), + image.get_rowstride(), image.get_n_channels()) + + +class Capture: + """Run SteamVR's eye-camera capture and reduce frames as they arrive.""" + NAME = re.compile(r"^(left|right)_(\d+)\.png$") + # Only SteamVR's own capture directories are read and removed. + WRITING = re.compile(r"Writing capture to: (/tmp/etcalib_[\w-]+)") + PREFIX = "/tmp/etcalib_" + + def __init__(self, seconds, runner=subprocess.Popen, loader=load_grid): + self.seconds, self.runner, self.loader = seconds, runner, loader + self.grids = {"left": {}, "right": {}} + self.directory = None + + def run(self): + # The capture tool's output goes to a private temporary file, read back + # to find the capture directory and failure messages. + import tempfile + with tempfile.TemporaryFile("w+") as log: + process = self.runner([str(ET_BIN), "-b", "CDSP", "-w", str(ET_WEIGHTS), "--calib", str(self.seconds)], + cwd=str(ET_BIN.parent), stdout=log, stderr=subprocess.STDOUT) + try: + deadline = time.monotonic() + self.seconds + 30 + while True: + if not self.directory: + log.seek(0) + match = self.WRITING.search(log.read()) + if match: + self.directory = Path(match.group(1)) + finished = process.poll() is not None + self.reduce(final=finished) + if finished: + break + if time.monotonic() > deadline: + raise RuntimeError("eye-camera capture did not finish") + time.sleep(0.02) + log.seek(0) + text = log.read() + if process.returncode or not self.directory: + reason = "cameras unavailable" if "Failed to" in text else f"exit {process.returncode}" + raise RuntimeError(f"eye-camera capture failed ({reason})") + return self.frames() + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + self.remove() + + def reduce(self, final=False): + """Reduce and delete every complete image; an image is complete once a + later one of the same eye exists, or the capture has ended.""" + if not self.directory or not self.directory.is_dir(): + return + pending = {"left": [], "right": []} + for entry in os.scandir(self.directory): + match = self.NAME.match(entry.name) + if match: + pending[match.group(1)].append((int(match.group(2)), entry.path)) + for eye, files in pending.items(): + files.sort() + ready = files if final else files[:-1] + for index, path in ready: + try: + self.grids[eye][index] = self.loader(path) + finally: + os.unlink(path) + + def frames(self): + """[(monotonic seconds, eye, grid)] joined with the capture metadata.""" + meta = json.loads((self.directory / "meta.json").read_text()) + frames = [] + for pair in meta["frames"]: + for eye in ("left", "right"): + info = pair.get(eye) or {} + match = self.NAME.match(Path(info.get("fname", "")).name) + grid = self.grids[eye].get(int(match.group(2))) if match else None + if info.get("valid") and grid is not None: + frames.append((float(info["tsMono"]), eye, grid)) + return frames + + def remove(self): + if self.directory and str(self.directory).startswith(self.PREFIX): + shutil.rmtree(self.directory, ignore_errors=True) + + +# --------------------------------------------------------------- analysis --- + +def fft(values): + """In-place iterative radix-2 FFT of a list whose length is a power of 2.""" + n = len(values) + a = list(values) + j = 0 + for i in range(1, n): + bit = n >> 1 + while j & bit: + j ^= bit + bit >>= 1 + j |= bit + if i < j: + a[i], a[j] = a[j], a[i] + size = 2 + while size <= n: + step = cmath.exp(-2j * math.pi / size) + half = size // 2 + for start in range(0, n, size): + w = 1 + for k in range(start, start + half): + t = w * a[k + half] + a[k + half] = a[k] - t + a[k] += t + w *= step + size *= 2 + return a + + +def resample(times, values, rate=RATE): + """Average samples into 1/rate bins from the first sample; empty bins are + filled from the previous bin.""" + if not times: + return [] + start = times[0] + count = int((times[-1] - start) * rate) + 1 + sums, counts = [0.0] * count, [0] * count + for t, v in zip(times, values): + i = min(int((t - start) * rate), count - 1) + sums[i] += v + counts[i] += 1 + out, last = [], None + for s, c in zip(sums, counts): + last = s / c if c else last + out.append(last) + first = next(v for v in out if v is not None) + return [first if v is None else v for v in out] + + +def clean(signal, rate=RATE): + """Relative change with slow drift removed; None if the patch is mostly + blinks or eye movement. Spikes (blinks, saccades) become gaps at the + local level rather than clipped steps, which would add false rhythm.""" + mean = sum(signal) / len(signal) + x = [v / mean - 1 for v in signal] + half = int(rate) # 2-second centred moving median removes drift and blinks + trend = [] + for i in range(len(x)): + chunk = sorted(x[max(0, i - half):i + half + 1]) + trend.append(chunk[len(chunk) // 2]) + residual = [v - m for v, m in zip(x, trend)] + mad = sorted(abs(v) for v in residual)[len(residual) // 2] or 1e-9 + limit = 4 * 1.4826 * mad + spikes = sum(1 for v in residual if abs(v) > limit) + if spikes > 0.05 * len(residual): + return None + return [v if abs(v) <= limit else 0.0 for v in residual] + + +def spectrum(signal, rate=RATE): + """[(bpm, power)] within the search band, Hann-windowed and zero-padded.""" + n = len(signal) + size = 1 + while size < max(n * 4, 256): + size *= 2 + window = [0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1)) for i in range(n)] if n > 1 else [1.0] + padded = [s * w for s, w in zip(signal, window)] + [0.0] * (size - n) + result = fft(padded) + out = [] + for k in range(size // 2): + bpm = k * rate / size * 60 + if LOW <= bpm <= HIGH: + out.append((bpm, abs(result[k]) ** 2)) + return out + + +def peak(spec): + """Peak BPM with parabolic interpolation between spectral bins.""" + i = max(range(len(spec)), key=lambda k: spec[k][1]) + if 0 < i < len(spec) - 1: + a, b, c = spec[i - 1][1], spec[i][1], spec[i + 1][1] + denominator = a - 2 * b + c + offset = 0.5 * (a - c) / denominator if denominator else 0.0 + return spec[i][0] + offset * (spec[1][0] - spec[0][0]) + return spec[i][0] + + +def snr(spec, bpm, width=4.0): + """Power near the pulse and its first harmonic against the rest of the band.""" + near = sum(p for f, p in spec if abs(f - bpm) <= width or abs(f - 2 * bpm) <= width) + rest = sum(p for f, p in spec) - near + return near / rest if rest > 0 else float("inf") + + +def normalised(spec): + total = sum(p for _, p in spec) or 1.0 + return [p / total for _, p in spec] + + +def usable(values): + """Patches that are neither dark nor saturated for the whole recording.""" + mean = sum(values) / len(values) + return 8 <= mean <= 245 + + +def estimate(times, patches, rate=RATE, share=0.2, window=WINDOW): + """Estimate pulse from patch brightness traces. + + times: monotonic seconds per frame. patches: {name: [brightness per frame]}. + Selects the share of usable patches with the clearest periodic signal, + combines their spectra and reports the overall and windowed estimates. + """ + cleaned = {} + for name, values in patches.items(): + if usable(values): + signal = clean(resample(times, values, rate), rate) + if signal is not None: + cleaned[name] = signal + if not cleaned or len(next(iter(cleaned.values()))) < rate * 8: + raise ValueError("need at least 8 seconds of usable eye-camera frames") + quality = [] + for name, signal in cleaned.items(): + spec = spectrum(signal, rate) + own = peak(spec) + quality.append((snr(spec, own), name, own, spec)) + quality.sort(reverse=True) + chosen = quality[:max(4, int(len(quality) * share))] + combined = [sum(values) for values in zip(*(normalised(spec) for _, _, _, spec in chosen))] + bins = [f for f, _ in chosen[0][3]] + bpm = peak(list(zip(bins, combined))) + top = chosen[:8] + agree = sum(1 for _, _, own, _ in top if abs(own - bpm) <= 5) / len(top) + series = [] + samples = int(window * rate) + length = len(next(iter(cleaned.values()))) + for end in range(samples, length + 1, int(rate)): + specs = [spectrum(cleaned[name][end - samples:end], rate) for _, name, _, _ in chosen] + total = [sum(values) for values in zip(*(normalised(s) for s in specs))] + window_bins = [f for f, _ in specs[0]] + series.append((times[0] + end / rate, peak(list(zip(window_bins, total))))) + return { + "bpm": bpm, + "agreement": agree, + "patches": len(chosen), + "usable": len(cleaned), + "snr": snr(list(zip(bins, combined)), bpm), + "series": series, + } + + +def analyse(frames): + """Estimate from Capture.frames(); both eyes' patches are analysed together + on a shared time base, each sample taken from that eye's nearest frame.""" + times = sorted({t for t, _, _ in frames}) + patches = {} + for eye in ("left", "right"): + eye_frames = sorted((t, grid) for t, e, grid in frames if e == eye) + if not eye_frames: + continue + eye_times = [t for t, _ in eye_frames] + nearest = [min(bisect.bisect_left(eye_times, t), len(eye_times) - 1) for t in times] + for k in range(len(eye_frames[0][1])): + patches[f"{eye}{k}"] = [eye_frames[i][1][k] for i in nearest] + return estimate(times, patches) + + +def reliable(result): + """Whether the estimate is clear enough to show as a reading. Thresholds + are provisional until checked against a reference on a real wearer.""" + return result["agreement"] >= 0.75 and result["snr"] >= 0.5 diff --git a/frame/tracking/tracking.py b/frame/tracking/tracking.py index 10b8612..eaf9c0e 100644 --- a/frame/tracking/tracking.py +++ b/frame/tracking/tracking.py @@ -341,21 +341,64 @@ def run_heart(args, osc): session.close() +def run_pulse(args, osc): + """Experimental: estimate pulse from the IR eye cameras (see pulse.py).""" + import pulse + if not pulse.ET_BIN.exists(): + raise TrackingError("SteamVR's eye-tracking tool is not installed on this Frame") + print(f"Capturing {args.seconds} s from the eye cameras. Wear the headset and keep still.", flush=True) + try: + frames = pulse.Capture(args.seconds).run() + except RuntimeError as error: # capture status only; no image data + raise TrackingError(str(error)) + print(f"Captured {len(frames)} eye frames; images already deleted. Analysing...", flush=True) + try: + result = pulse.analyse(frames) + except ValueError as error: + raise TrackingError(str(error)) + clear = pulse.reliable(result) + offset = time.time() - time.monotonic() # the capture's timestamps are CLOCK_MONOTONIC + if args.log: + fd = os.open(args.log, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as log: + log.write("unix_seconds,bpm\n") + for when, bpm in result["series"]: + log.write(f"{when + offset:.3f},{bpm:.1f}\n") + if clear: + osc.send(args.address, [round(result["bpm"])]) + if args.show: + print(f"Estimate: {result['bpm']:.1f} BPM ({'clear' if clear else 'NOT clear'}; " + f"patch agreement {result['agreement']:.0%}, signal/noise {result['snr']:.2f}, " + f"{result['patches']} of {result['usable']} usable patches)") + print("Per-second estimates (15 s windows): " + + " ".join(str(round(bpm)) for _, bpm in result["series"])) + else: + print("A clear pulse was found." if clear else "No clear pulse was found.") + return 0 if clear else 3 + + def main(): parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="command", required=True) gaze = commands.add_parser("gaze", help="headless OpenXR; prints counters only without --osc") heart = commands.add_parser("heart", help="standard BLE HRS from an explicitly selected strap") - for command in (gaze, heart): + pulse = commands.add_parser("pulse", help="experimental pulse estimate from the IR eye cameras") + for command in (gaze, heart, pulse): command.add_argument("--osc", nargs=2, metavar=("IP", "PORT"), help="explicit UDP destination; no default") + for command in (gaze, heart): command.add_argument("--seconds", type=int, default=10, help="bounded run, 1..86400 seconds (default: 10)") + pulse.add_argument("--seconds", type=int, default=60, help="capture length, 20..300 seconds (default: 60)") heart.add_argument("--device", required=True, help="strap Bluetooth address already discovered by BlueZ") heart.add_argument("--panel", action="store_true", help="show our panel on gamescope DISPLAY=:0") - heart.add_argument("--address", default="/avatar/parameters/HeartRate", help="integer BPM OSC parameter") - heart.add_argument("--log", help="new private CSV file; disabled by default") + for command in (heart, pulse): + command.add_argument("--address", default="/avatar/parameters/HeartRate", help="integer BPM OSC parameter") + command.add_argument("--log", help="new private CSV file; disabled by default") + pulse.add_argument("--show", action="store_true", help="print the estimate and per-second series") args = parser.parse_args() if not 1 <= args.seconds <= 86400: parser.error("--seconds must be 1..86400") + if args.command == "pulse" and not 20 <= args.seconds <= 300: + parser.error("pulse --seconds must be 20..300") def interrupted(signum, frame): raise KeyboardInterrupt signal.signal(signal.SIGTERM, interrupted) @@ -363,12 +406,13 @@ def main(): signal.signal(signal.SIGHUP, interrupted) osc = None try: - if args.command == "heart": + if args.command in ("heart", "pulse"): osc_message(args.address, [0]) - if args.panel: + if getattr(args, "panel", False): os.environ["DISPLAY"] = ":0" osc = Osc(args.osc) - return run_gaze(args, osc) if args.command == "gaze" else run_heart(args, osc) + run = {"gaze": run_gaze, "heart": run_heart, "pulse": run_pulse}[args.command] + return run(args, osc) except TrackingError as error: print(str(error)) return 1 diff --git a/scripts/heart-check.py b/scripts/heart-check.py new file mode 100644 index 0000000..1cc0fab --- /dev/null +++ b/scripts/heart-check.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Check Frame Control's heart-rate readings against a reference, on your computer. + + python3 scripts/heart-check.py listen --port 9000 --out ours.csv + python3 scripts/heart-check.py compare ours.csv reference.csv + python3 scripts/heart-check.py compare ours.csv ~/Downloads/export.zip + +`listen` receives our integer-BPM OSC messages (send them here with +`tracking-on-frame.py heart --osc 9000`) and shows each +reading live, so you can watch it next to a reference such as your Apple +Watch. `--out` also records `unix_seconds,bpm` to a new private file. + +`compare` lines up two recordings by time and reports how far apart they are. +The reference can be: +- a CSV whose first column is a time (unix seconds or ISO 8601) and whose + second is BPM, such as our own log, `listen --out`, or the test strap's + output (a third `flags` column marks skin-contact loss as "no reading"); +- an Apple Health export (`export.zip` or `export.xml`, from Health → your + profile → Export All Health Data). Only heart-rate records within the + recording's time range are read. + +Everything stays on this computer. Nothing is uploaded. +""" +import argparse +import bisect +import csv +from datetime import datetime +import io +import os +from pathlib import Path +import re +import socket +import struct +import sys +import time +import zipfile +from xml.etree import ElementTree + +ADDRESS = "/avatar/parameters/HeartRate" + + +def parse_osc(packet): + """Return (address, values) for a single OSC message with i/f arguments.""" + def string(offset): + end = packet.index(b"\0", offset) + return packet[offset:end].decode("utf-8"), (end + 4) & ~3 + address, offset = string(0) + tags, offset = string(offset) + if not tags.startswith(","): + raise ValueError("not an OSC message") + values = [] + for tag in tags[1:]: + if tag not in "if" or offset + 4 > len(packet): + raise ValueError("unsupported OSC argument") + values.append(struct.unpack(">i" if tag == "i" else ">f", packet[offset:offset + 4])[0]) + offset += 4 + return address, values + + +def listen(port, address, out, seconds, clock=time.time, stream=sys.stdout): + log = None + if out: + log = os.fdopen(os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") + log.write("unix_seconds,bpm\n") + received = 0 + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.bind(("0.0.0.0", port)) + sock.settimeout(0.5) + print(f"Listening for {address} on UDP port {port}. Ctrl-C stops.", file=stream, flush=True) + end = clock() + seconds if seconds else None + try: + while end is None or clock() < end: + try: + packet = sock.recv(1024) + except socket.timeout: + continue + try: + path, values = parse_osc(packet) + except (ValueError, UnicodeDecodeError): + continue + if path != address or len(values) != 1: + continue + now = clock() + bpm = int(values[0]) + received += 1 + print(f"{time.strftime('%H:%M:%S', time.localtime(now))} {bpm:3d} bpm", file=stream, flush=True) + if log: + log.write(f"{now:.3f},{bpm}\n") + log.flush() + except KeyboardInterrupt: + pass + finally: + if log: + log.close() + return received + + +def parse_time(text): + text = text.strip() + if re.fullmatch(r"\d+(\.\d+)?", text): + return float(text) + # Apple Health uses "2026-09-29 09:12:03 +1000". + text = re.sub(r" ([+-]\d{2}):?(\d{2})$", r"\1\2", text).replace("Z", "+0000") + for pattern in ("%Y-%m-%d %H:%M:%S%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"): + try: + return datetime.strptime(text, pattern).timestamp() + except ValueError: + pass + raise ValueError(f"unrecognised time {text!r}") + + +def read_csv(path): + """[(time, bpm or None)], sorted. A header row is skipped.""" + samples = [] + with open(path, newline="") as source: + for row in csv.reader(source): + if len(row) < 2: + continue + try: + when, bpm = parse_time(row[0]), int(float(row[1])) + except ValueError: + continue # header or unparseable line + if len(row) > 2 and row[2].strip(): + flags = int(row[2]) + if flags & 4 and not flags & 2: + bpm = None # contact supported and not detected: no reading + samples.append((when, bpm)) + return sorted(samples, key=lambda s: s[0]) + + +def read_health(path, start, end): + """Heart-rate records from an Apple Health export between start and end.""" + if zipfile.is_zipfile(path): + archive = zipfile.ZipFile(path) + name = next(n for n in archive.namelist() if n.endswith("/export.xml") or n == "export.xml") + source = archive.open(name) + else: + source = open(path, "rb") + samples = [] + with source: + for _, element in ElementTree.iterparse(source): + if element.tag == "Record" and element.get("type") == "HKQuantityTypeIdentifierHeartRate": + when = parse_time(element.get("startDate")) + if start <= when <= end: + samples.append((when, round(float(element.get("value"))))) + element.clear() + return sorted(samples) + + +def read_any(path, start=None, end=None): + path = str(path) + if path.endswith((".zip", ".xml")): + return read_health(path, start, end) + return read_csv(path) + + +def value_at(samples, when, hold): + """Our reading at a moment: the latest sample no older than `hold` seconds.""" + times = [s[0] for s in samples] + i = bisect.bisect_right(times, when) - 1 + if i < 0 or when - times[i] > hold: + return None + return samples[i][1] + + +def compare(ours, reference, max_lag=10.0, hold=5.0): + """Compare our readings with the reference at each reference moment. + + `lag` is the delay added to reference times before looking up ours (our + readings arrive after the sensor's). The best lag within ±max_lag is used. + """ + real = [(t, b) for t, b in reference if b is not None] + if not real or not any(b is not None for _, b in ours): + raise ValueError("both recordings need at least one reading") + best = None + steps = int(max_lag * 4) + for step in range(-steps, steps + 1): + lag = step / 4 + pairs = [(value_at(ours, t + lag, hold), b) for t, b in real] + pairs = [(o, r) for o, r in pairs if o is not None] + if not pairs: + continue + error = sum(abs(o - r) for o, r in pairs) / len(pairs) + key = (-len(pairs), error, abs(lag)) + if best is None or key < best[0]: + best = (key, lag, pairs) + if best is None: + raise ValueError("the recordings do not overlap in time") + _, lag, pairs = best + differences = [o - r for o, r in pairs] + # While the reference says "no reading" (lost skin contact), we must not + # produce new readings. Count ours that arrive during such a stretch. + gaps = [t for t, b in reference if b is None] + reference_times = [t for t, _ in reference] + shown_in_gaps = 0 + for t, bpm in ours: + i = bisect.bisect_right(reference_times, t - lag) - 1 + if bpm is not None and i >= 0 and reference[i][1] is None: + shown_in_gaps += 1 + return { + "reference_readings": len(real), + "matched": len(pairs), + "lag_seconds": lag, + "mean_abs_error": sum(abs(d) for d in differences) / len(differences), + "bias": sum(differences) / len(differences), + "max_abs_error": max(abs(d) for d in differences), + "within_5": sum(1 for d in differences if abs(d) <= 5) / len(differences), + "exact": sum(1 for d in differences if d == 0) / len(differences), + "no_contact_moments": len(gaps), + "shown_during_no_contact": shown_in_gaps, + } + + +def report(result, tolerance, stream=sys.stdout): + print(f"Reference readings: {result['reference_readings']}, matched: {result['matched']}", file=stream) + print(f"Best alignment: ours {result['lag_seconds']:+.2f} s after the reference", file=stream) + print(f"Mean absolute difference: {result['mean_abs_error']:.2f} BPM " + f"(bias {result['bias']:+.2f}, worst {result['max_abs_error']})", file=stream) + print(f"Within ±5 BPM: {result['within_5']:.0%}; identical: {result['exact']:.0%}", file=stream) + if result["no_contact_moments"]: + print(f"No-contact moments: {result['no_contact_moments']}, where we showed a stale " + f"reading: {result['shown_during_no_contact']}", file=stream) + coverage = result["matched"] / result["reference_readings"] + passed = (result["mean_abs_error"] <= tolerance and coverage >= 0.8 + and not result["shown_during_no_contact"]) + print(("PASS" if passed else "FAIL") + f" (mean difference ≤ {tolerance} BPM, ≥80% of reference " + f"readings matched, nothing shown without contact)", file=stream) + return passed + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + commands = parser.add_subparsers(dest="command", required=True) + heard = commands.add_parser("listen", help="show and optionally record our OSC readings live") + heard.add_argument("--port", type=int, default=9000) + heard.add_argument("--address", default=ADDRESS) + heard.add_argument("--out", help="new private CSV file") + heard.add_argument("--seconds", type=float, default=0, help="stop after this long (default: until Ctrl-C)") + check = commands.add_parser("compare", help="compare our recording with a reference") + check.add_argument("ours", type=Path) + check.add_argument("reference", type=Path) + check.add_argument("--tolerance", type=float, default=5.0, help="allowed mean difference in BPM (default 5)") + check.add_argument("--max-lag", type=float, default=10.0, help="largest time offset to search, seconds") + args = parser.parse_args(argv) + if args.command == "listen": + count = listen(args.port, args.address, args.out, args.seconds) + print(f"Received {count} readings.") + return 0 if count else 3 + ours = read_csv(args.ours) + if not ours: + parser.error("our recording has no readings") + reference = read_any(args.reference, ours[0][0] - 60, ours[-1][0] + 60) + try: + result = compare(ours, reference, args.max_lag) + except ValueError as error: + print(str(error)) + return 1 + return 0 if report(result, args.tolerance) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/heart-test-strap.swift b/scripts/heart-test-strap.swift new file mode 100644 index 0000000..8441068 --- /dev/null +++ b/scripts/heart-test-strap.swift @@ -0,0 +1,100 @@ +// A synthetic Bluetooth heart-rate strap for testing, run on the Mac. +// +// swiftc -O scripts/heart-test-strap.swift -o /tmp/heart-test-strap +// /tmp/heart-test-strap [seconds] # default 60 +// +// Advertises the standard Heart Rate Service (180d) as "FC Test Strap" and, +// while a central is subscribed, sends one Heart Rate Measurement (2a37) per +// second from a fixed, known sequence. Each sent value is printed to stdout as +// `unix_seconds,bpm,flags` so scripts/heart-check.py can compare what the +// Frame received with what was sent. Every value is synthetic; this is not a +// sensor. macOS asks for Bluetooth permission the first time it runs. +import CoreBluetooth +import Foundation + +let hrs = CBUUID(string: "180D") +let measurement = CBUUID(string: "2A37") + +/// The known sequence: an 8-bit ramp, 16-bit encodings, a skin-contact loss +/// (which must show as no reading) and a recovery. +func packet(_ second: Int) -> (bpm: Int, flags: UInt8) { + switch second % 60 { + case 0..<30: return (60 + second % 60 * 4, 0x06) // 60…176, contact detected + case 30..<40: return (180 - (second % 60 - 30) * 3, 0x07) // 16-bit value + case 40..<45: return (150, 0x04) // contact lost + default: return (72 + (second % 60 - 45), 0x06) + } +} + +final class Strap: NSObject, CBPeripheralManagerDelegate { + let seconds: Int + var manager: CBPeripheralManager! + var characteristic: CBMutableCharacteristic! + var subscribed = false + var sent = 0 + + init(seconds: Int) { + self.seconds = seconds + super.init() + manager = CBPeripheralManager(delegate: self, queue: nil) + } + + func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { + guard peripheral.state == .poweredOn else { + FileHandle.standardError.write("Bluetooth state \(peripheral.state.rawValue)\n".data(using: .utf8)!) + if peripheral.state == .unauthorized || peripheral.state == .unsupported || peripheral.state == .poweredOff { + FileHandle.standardError.write("Bluetooth unavailable (state \(peripheral.state.rawValue))\n".data(using: .utf8)!) + exit(1) + } + return + } + characteristic = CBMutableCharacteristic(type: measurement, properties: [.notify], value: nil, permissions: []) + let service = CBMutableService(type: hrs, primary: true) + service.characteristics = [characteristic] + peripheral.add(service) + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { + if let error { fail("add service: \(error.localizedDescription)") } + peripheral.startAdvertising([CBAdvertisementDataLocalNameKey: "FC Test Strap", + CBAdvertisementDataServiceUUIDsKey: [hrs]]) + } + + func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) { + if let error { fail("advertise: \(error.localizedDescription)") } + FileHandle.standardError.write("Advertising FC Test Strap\n".data(using: .utf8)!) + Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.tick() } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { + subscribed = true + FileHandle.standardError.write("Central subscribed\n".data(using: .utf8)!) + } + + func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { + subscribed = false + FileHandle.standardError.write("Central unsubscribed\n".data(using: .utf8)!) + } + + func tick() { + guard subscribed else { return } + if sent >= seconds { exit(0) } + let (bpm, flags) = packet(sent) + var bytes: [UInt8] = [flags, UInt8(bpm & 0xff)] + if flags & 1 != 0 { bytes.append(UInt8(bpm >> 8)) } + if manager.updateValue(Data(bytes), for: characteristic, onSubscribedCentrals: nil) { + print(String(format: "%.3f,%d,%d", Date().timeIntervalSince1970, bpm, flags)) + fflush(stdout) + sent += 1 + } + } + + func fail(_ message: String) -> Never { + FileHandle.standardError.write("\(message)\n".data(using: .utf8)!) + exit(1) + } +} + +let seconds = CommandLine.arguments.count > 1 ? Int(CommandLine.arguments[1]) ?? 60 : 60 +let strap = Strap(seconds: seconds) +RunLoop.main.run() diff --git a/scripts/tracking-on-frame.py b/scripts/tracking-on-frame.py index 2aa39e0..e2588f9 100644 --- a/scripts/tracking-on-frame.py +++ b/scripts/tracking-on-frame.py @@ -5,6 +5,7 @@ python3 scripts/tracking-on-frame.py gaze --seconds 10 python3 scripts/tracking-on-frame.py gaze --seconds 3600 --osc 127.0.0.1 9000 python3 scripts/tracking-on-frame.py heart --device AA:BB:CC:DD:EE:FF --panel + python3 scripts/tracking-on-frame.py pulse --seconds 60 --show # experimental FRAME_ALIAS overrides the SSH alias (default: frame). Runs in the foreground; Ctrl-C stops the reader. No service, autostart, sudo or SteamVR settings changes. @@ -27,13 +28,14 @@ cc -O2 -Wall -Wextra -Werror "$stage/gaze.c" \\ -L/opt/steamvr/bin/linuxarm64 -Wl,-rpath,/opt/steamvr/bin/linuxarm64 \\ -lopenxr_loader -o "$stage/gaze" chmod 700 "$stage/gaze" "$stage/tracking.py" -mv "$stage/gaze" "$stage/tracking.py" "$base/" +chmod 600 "$stage/pulse.py" +mv "$stage/gaze" "$stage/tracking.py" "$stage/pulse.py" "$base/" echo 'Installed Frame Control tracking tools (no service started).' ''' def main(): - if len(sys.argv) < 2 or sys.argv[1] not in ("install", "gaze", "heart"): + if len(sys.argv) < 2 or sys.argv[1] not in ("install", "gaze", "heart", "pulse"): print(__doc__) return 2 host = os.environ.get("FRAME_ALIAS", "frame") @@ -45,7 +47,7 @@ def main(): source = Path(__file__).resolve().parents[1] / "frame" / "tracking" with tempfile.TemporaryFile() as archive: with tarfile.open(fileobj=archive, mode="w") as tar: - for name in ("gaze.c", "tracking.py"): + for name in ("gaze.c", "tracking.py", "pulse.py"): tar.add(source / name, arcname=name) archive.seek(0) return subprocess.call(ssh + ["bash -c " + shlex.quote(INSTALL)], stdin=archive) diff --git a/tests/test_pulse.py b/tests/test_pulse.py new file mode 100644 index 0000000..7b2a5b7 --- /dev/null +++ b/tests/test_pulse.py @@ -0,0 +1,282 @@ +"""Eye-camera pulse estimate and the heart-rate comparison tool; no headset needed.""" +import importlib.util +import io +import json +import math +import os +from pathlib import Path +import random +import socket +import struct +import sys +import tempfile +import unittest +import zipfile +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, ROOT / path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +pulse = load("pulse", "frame/tracking/pulse.py") +check = load("heart_check", "scripts/heart-check.py") + + +def synthetic(bpm, pulsing, seconds=40, fps=30, patches=48, noise=0.004, seed=7): + """Patch brightness with a faint pulse in `pulsing` patches, plus sensor + noise, slow drift, blinks in some patches and eye movement in others.""" + rng = random.Random(seed) + times = [i / fps for i in range(seconds * fps)] + values = {} + for k in range(patches): + base, amplitude, trace = 40 + k, 0.003 if k < pulsing else 0.0, [] + for t in times: + v = base * (1 + amplitude * math.sin(2 * math.pi * bpm / 60 * t) + + noise * rng.gauss(0, 1) + 0.02 * math.sin(0.1 * t + k)) + if k % 8 == 7 and int(t * 10) % 47 == 0: + v *= 0.5 # blink + if k % 8 == 6 and int(t * 10) % 23 == 0: + v *= 1.3 # frequent eye movement + trace.append(v) + values[k] = trace + return times, values + + +class Estimate(unittest.TestCase): + def test_finds_pulse(self): + for bpm in (58, 72, 115): + with self.subTest(bpm=bpm): + result = pulse.estimate(*synthetic(bpm, 16)) + self.assertAlmostEqual(result["bpm"], bpm, delta=1.5) + self.assertTrue(pulse.reliable(result)) + self.assertTrue(all(abs(b - bpm) <= 2 for _, b in result["series"])) + + def test_noise_and_blinks_are_not_a_pulse(self): + result = pulse.estimate(*synthetic(72, 0)) + self.assertFalse(pulse.reliable(result)) + + def test_frequent_spike_patches_are_dropped(self): + times, values = synthetic(72, 16) + result = pulse.estimate(times, values) + self.assertLess(result["usable"], len(values)) + + def test_needs_enough_frames(self): + with self.assertRaises(ValueError): + pulse.estimate(*synthetic(72, 16, seconds=5)) + times, values = synthetic(72, 16) + with self.assertRaises(ValueError): + pulse.estimate(times, {k: [0.0] * len(times) for k in values}) # all dark + + def test_series_times(self): + times, values = synthetic(72, 16, seconds=20) + series = pulse.estimate(times, values)["series"] + self.assertAlmostEqual(series[0][0], pulse.WINDOW, delta=0.1) + self.assertEqual(len(series), 20 - int(pulse.WINDOW) + 1) + + def test_fft_matches_dft(self): + signal = [math.sin(i) + (i % 3) for i in range(16)] + fast = pulse.fft(signal) + for k in range(16): + slow = sum(signal[n] * complex(math.cos(2 * math.pi * k * n / 16), -math.sin(2 * math.pi * k * n / 16)) + for n in range(16)) + self.assertAlmostEqual(abs(fast[k] - slow), 0, places=9) + + def test_grid_means(self): + # 4 × 4 image in RGB with padding: left half 10, right half 30 (channel 0). + width, height, channels, stride = 4, 4, 3, 16 + pixels = bytearray(stride * height) + for y in range(height): + for x in range(width): + pixels[y * stride + x * channels] = 10 if x < 2 else 30 + pixels[y * stride + x * channels + 1] = 255 # other channels ignored + self.assertEqual(pulse.grid_means(bytes(pixels), width, height, stride, channels, grid=2), + [10, 30, 10, 30]) + + def test_analyse_combines_both_eyes(self): + times, values = synthetic(80, 16, patches=16) + frames = [] + for i, t in enumerate(times): + frames.append((t, "left", [values[k][i] for k in range(16)])) + frames.append((t + 0.001, "right", [values[k][i] for k in range(16)])) + result = pulse.analyse(frames) + self.assertAlmostEqual(result["bpm"], 80, delta=1.5) + self.assertEqual(result["usable"] % 2, 0) + + +class FakeCaptureTool: + """Stands in for `eyetracking --calib`: writes PNG names over a few polls.""" + def __init__(self, directory, frames=6, fail=False): + self.directory, self.frames, self.fail = Path(directory), frames, fail + self.polls = 0 + self.returncode = None + self.stdout = None + + def __call__(self, command, cwd, stdout, stderr): + self.command = command + self.stdout = stdout + if self.fail: + stdout.write("Failed to initialize cameras\n") + stdout.flush() + self.returncode = 1 + return self + stdout.write(f"Writing capture to: {self.directory}\nCapturing images...\n") + stdout.flush() + self.directory.mkdir() + return self + + def poll(self): + if self.returncode is not None: + return self.returncode + if self.polls < self.frames: + for eye in ("left", "right"): + (self.directory / f"{eye}_{self.polls}.png").write_bytes(b"png") + self.polls += 1 + return None + meta = {"frames": [{eye: {"fname": str(self.directory / f"{eye}_{i}.png"), "frameNum": i + 10, + "tsMono": 100 + i / 90, "valid": i != 2} for eye in ("left", "right")} + for i in range(self.frames)]} + (self.directory / "meta.json").write_text(json.dumps(meta)) + self.returncode = 0 + return 0 + + def terminate(self): + self.returncode = -15 + + def wait(self, timeout=None): + return self.returncode + + +class CaptureLifecycle(unittest.TestCase): + def setUp(self): + self.root = Path(tempfile.mkdtemp()) + self.directory = self.root / "etcalib_test" + self.loaded = [] + + def tearDown(self): + import shutil + shutil.rmtree(self.root, ignore_errors=True) + + def loader(self, path): + self.loaded.append(Path(path).name) + self.assertTrue(Path(path).exists()) + return [float(len(self.loaded))] + + def capture(self, tool): + class TestCapture(pulse.Capture): + # A temporary directory stands in for /tmp/etcalib_*. + WRITING = __import__("re").compile(r"Writing capture to: (\S+)") + PREFIX = str(self.root) + capture = TestCapture(20, runner=tool, loader=self.loader) + with patch.object(pulse.time, "sleep", lambda s: None): + return capture, capture.run() + + def test_reduces_deletes_and_joins_metadata(self): + tool = FakeCaptureTool(self.directory) + capture, frames = self.capture(tool) + self.assertEqual(sorted(self.loaded), sorted(f"{e}_{i}.png" for e in ("left", "right") for i in range(6))) + self.assertFalse(self.directory.exists()) # images and metadata removed + self.assertEqual(len(frames), 10) # frame 2 invalid in both eyes + self.assertEqual(tool.command[-2:], ["--calib", "20"]) + self.assertTrue(all(isinstance(t, float) and eye in ("left", "right") for t, eye, _ in frames)) + + def test_camera_failure(self): + tool = FakeCaptureTool(self.directory, fail=True) + with self.assertRaises(RuntimeError) as raised: + self.capture(tool) + self.assertIn("cameras unavailable", str(raised.exception)) + + def test_only_removes_capture_directories(self): + capture = pulse.Capture(20) + capture.directory = self.root + capture.remove() + self.assertTrue(self.root.exists()) + + +class HeartCheck(unittest.TestCase): + def write(self, name, text): + path = Path(self.enterContext(tempfile.TemporaryDirectory())) / name + path.write_text(text) + return path + + def test_osc_round_trip(self): + tracking = load("tracking", "frame/tracking/tracking.py") + self.assertEqual(check.parse_osc(tracking.osc_message("/avatar/parameters/HeartRate", [72])), + ("/avatar/parameters/HeartRate", [72])) + address, values = check.parse_osc(tracking.osc_message("/x", [1.5, -2.0])) + self.assertEqual((address, values), ("/x", [1.5, -2.0])) + with self.assertRaises(ValueError): + check.parse_osc(b"/x\0\0,s\0\0abc\0") + + def test_listen_records_readings(self): + tracking = load("tracking", "frame/tracking/tracking.py") + with tempfile.TemporaryDirectory() as directory: + out = Path(directory) / "ours.csv" + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + import threading + def send(): + import time + time.sleep(0.3) + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sender: + sender.sendto(tracking.osc_message(check.ADDRESS, [72]), ("127.0.0.1", port)) + sender.sendto(tracking.osc_message("/other", [1]), ("127.0.0.1", port)) + threading.Thread(target=send).start() + shown = io.StringIO() + count = check.listen(port, check.ADDRESS, str(out), 1.5, stream=shown) + self.assertEqual(count, 1) + self.assertIn("72 bpm", shown.getvalue()) + self.assertEqual(out.read_text().splitlines()[1].split(",")[1], "72") + self.assertEqual(out.stat().st_mode & 0o777, 0o600) + + def test_compare_finds_lag_and_contact_loss(self): + reference = [(1000.0 + i, 60 + i, 6) for i in range(40)] + [(1040.0 + i, 150, 4) for i in range(5)] + ours = [(1002.0 + i, 60 + i) for i in range(40)] # 2 s late, nothing during contact loss + ref = self.write("ref.csv", "unix_seconds,bpm,flags\n" + "".join(f"{t},{b},{f}\n" for t, b, f in reference)) + mine = self.write("ours.csv", "unix_seconds,bpm\n" + "".join(f"{t},{b}\n" for t, b in ours)) + result = check.compare(check.read_csv(mine), check.read_any(ref)) + self.assertEqual(result["lag_seconds"], 2.0) + self.assertEqual(result["mean_abs_error"], 0) + self.assertEqual(result["shown_during_no_contact"], 0) + self.assertTrue(check.report(result, 5, stream=io.StringIO())) + # A stale reading sent during contact loss fails the check. + stale = check.read_csv(mine) + [(1043.0, 99)] + result = check.compare(stale, check.read_any(ref)) + self.assertEqual(result["shown_during_no_contact"], 1) + self.assertFalse(check.report(result, 5, stream=io.StringIO())) + + def test_compare_against_apple_health_export(self): + records = "".join( + f'' + for s in range(0, 60, 5)) + other = '' + xml = f'{other}{records}' + with tempfile.TemporaryDirectory() as directory: + archive = Path(directory) / "export.zip" + with zipfile.ZipFile(archive, "w") as z: + z.writestr("apple_health_export/export.xml", xml) + start = check.parse_time("2026-09-29 12:00:00 +1000") + samples = check.read_any(archive, start - 60, start + 120) + self.assertEqual(len(samples), 12) + self.assertEqual(samples[0], (start, 70)) + ours = [(start + i + 1.0, 71) for i in range(60)] + result = check.compare(ours, samples) + self.assertLessEqual(result["mean_abs_error"], 1) + + def test_time_formats(self): + self.assertEqual(check.parse_time("1700000000.5"), 1700000000.5) + self.assertEqual(check.parse_time("2023-11-14T22:13:20Z"), 1700000000) + self.assertEqual(check.parse_time("2023-11-15 08:13:20 +1000"), 1700000000) + with self.assertRaises(ValueError): + check.parse_time("yesterday") + + +if __name__ == "__main__": + unittest.main()