Skip to content

The CGM Error Model

During replay, ReplayBG can turn the model's true output (interstitial glucose) into a realistic, noisy CGM measurement. This is what callbacks observe when you close the loop, so control policies act on the same imperfect signal a real device would produce.

The Sensor interface

The sensor is deliberately output-agnostic: it knows nothing about glucose or any specific model — it only sees model.output(k). All sensors subclass the abstract Sensor base class (src/py_replay_bg/sensors/sensor.py).

from py_replay_bg.sensors import Sensor
Attribute Meaning
ts Sample time, in integration steps (default 5). The replay loop measures when k % ts == 0.
t_offset Extra age of the current sensor, in integration steps (default 0). Reset to 0 by every connect_new().
max_lifetime Sensor lifetime, in integration steps (default 1440 * 10 = 14400, i.e. 10 days at 1-minute steps).
connected_at The step at which the current sensor was connected, in the current replay's step frame (negative for a sensor carried over from an earlier run, see below).
Method Role
connect_new(connected_at=0) Connect a fresh sensor: sets connected_at, resets t_offset to 0; subclasses also resample their error realization.
age(k) The sensor's age at step k, in integration steps: k - connected_at + t_offset.
measure(value, past_values, t) Produce a measurement from the current output value, the past outputs past_values (out[:k]), and t, the sensor's age in days (t_offset already included). Abstract — implemented by subclasses.
add_offset(to_add) Age the current sensor by to_add integration steps (adds to t_offset).

At each sample step k (k % ts == 0, including k = 0), the replay loop first reconnects a fresh sensor (connect_new(connected_at=k)) if age(k) >= max_lifetime, then calls measure with t = age(k) * environment.ts / (24 * 60).

Units

ts, t_offset, max_lifetime and connected_at are all in integration steps; only the t handed to measure is in days. The defaults (ts = 5, max_lifetime = 14400) assume 1-minute integration steps (ReplayBG(ts=1), the default): with a coarser environment.ts, scale them yourself to keep a 5-minute sample time and a 10-day lifetime.

The default: Vettoretti19CGM

The default CGM error model is Vettoretti19CGM, the model published in Vettoretti et al., Sensors, 2019 — a factory-calibrated device with a 10-day lifespan and a 5-minute sample time.

from py_replay_bg.sensors import Vettoretti19CGM

It applies a time-varying calibration error (quadratic in time) to the interstitial glucose, plus colored noise from an AR(2) process:

\[ \begin{cases} IG_S(t) = (a_0 + a_1 t + a_2 t^2) \cdot IG(t) + b_0 \\ v(k) = c_1 v(k-1) + c_2 v(k-2) + \sigma_w w(k), \quad w(k) \sim \mathcal{N}(0, 1) \\ CGM(t) = IG_S(t) + v(k) \end{cases} \]

with \(t\) the sensor's age in days (t_offset included), \(k\) the sample index, and \(v\) the colored output noise. The parameter vector is \(p = [a_0, a_1, a_2, b_0, c_1, c_2, \sigma_w]\). On each connect_new(), \(p\) is sampled from a multivariate normal with the paper's mean and covariance (the covariance scaled by 0.9), rejecting draws until the AR(2) process is stable and the output-noise standard deviation is ≤ 10 mg/dL; the noise memory is reset to zero.

Using a sensor in a replay

Pass a sensor instance to replay(sensor=...):

from py_replay_bg.sensors import Vettoretti19CGM

sensor = Vettoretti19CGM()
results = rbg.replay(rbg_data=rbg_data, model=model, sensor=sensor)

When a sensor is supplied, replay() returns two extra keys:

  • measurement — the sensor samples at sensor cadence,
  • measurement_time — the integration-step index of each sample.

and the callbacks see the noisy reading via ctx.measurement. Callbacks run before the model steps, so at step k they see the latest sample, zero-order held, from step k-1. When no sensor is supplied, ctx.measurement is the true output at step k-1, so policies behave as if perfectly observed.

Reproducibility

By default the replay loop reseeds NumPy's global RNG (np.random.seed) with environment.seed and then calls sensor.connect_new(connected_at=0), so a given ReplayBG(seed=...) yields the same noise realization every run. This reconnection also resets t_offset: every default replay starts with a new, unaged sensor, whatever the sensor object went through before.

Sharing a sensor across replay runs

To replay a multi-day recording one segment at a time (see Replaying multi-day intervals) with one continuous sensor, pass the same sensor object to every run and set continue_sensor=True from the second run on:

sensor = Vettoretti19CGM()
day_1 = rbg.replay(rbg_data=data_1, model=model_1, sensor=sensor)
day_2 = rbg.replay(rbg_data=data_2, model=model_2, sensor=sensor,
                   continue_sensor=True)

With continue_sensor=True, replay() neither reseeds the RNG nor reconnects the sensor: it keeps its error parameters and noise memory, and its age keeps counting from where the previous run left it. At the end of every run with a sensor, replay() rebases connected_at by subtracting that run's tsteps, so the next run's step 0 is the step right after the previous run's last one; back-to-back runs therefore need nothing else. The sensor is still reconnected when it reaches max_lifetime, mid-run or at step 0.

If the segments are not contiguous, account for the gap with sensor.add_offset(gap_steps) before the next run. The first run (without continue_sensor) seeds the RNG; later runs draw their noise from the RNG state the previous one left, so the whole chain is reproducible as long as nothing else uses np.random in between. Split this way, a chain of runs yields exactly the same measurements as one replay over the whole span.

Writing a custom CGM error model

Subclass Sensor and implement measure. The example below is a toy model that just doubles the interstitial glucose:

from py_replay_bg.sensors.sensor import Sensor

class FakeCGM(Sensor):
    def __init__(self):
        super().__init__()
        self.max_lifetime = 10   # integration steps

    def measure(self, value, past_values, t):
        return 2.0 * value

results = rbg.replay(rbg_data=rbg_data, model=model, sensor=FakeCGM())

Because the sensor is output-agnostic, your measure only needs the current output value, the history past_values, and the time t (the sensor's age in days, t_offset included — do not add t_offset again).