Skip to content

Custom Blueprint

A blueprint is a model class paired with a data class. To build your own digital-twin structure, you implement both, respecting the small interface contracts that ReplayBG.twin() and ReplayBG.replay() rely on. The built-in single-meal pair is the best minimal reference implementation — read src/py_replay_bg/model/single_meal_t1d.py and src/py_replay_bg/data/single_meal_t1d_data.py alongside this page.

The model class contract

ReplayBG duck-types the model: any object with the methods and attributes below works. The built-in models are compiled with Numba's @jitclass via the project's jitclass_ switch; if you do the same, every state array and scalar field must be declared up front in a JITCLASS_SPEC with explicit Numba types.

from py_replay_bg.environment import jitclass_

JITCLASS_SPEC = [
    ("u2ss", float64),
    ("G", float64[:]),
    # ... every scalar/array field with its Numba type ...
]

@jitclass_(JITCLASS_SPEC)
class MyT1DModel:
    ...

The jitclass_ switch

jitclass_ (from src/py_replay_bg/environment/__init__.py) applies real JIT compilation, or falls back to a no-op identity decorator when DEBUG=True — flip that flag to debug your model in plain Python. Parameters are always passed between Python and the jitclass through Numba typed dicts (numba.typed.Dict), which you build with to_typed_f64_dict.

Your model must implement these methods:

Method Role
__init__(u2ss, theta0, x0, tsteps, [t_start,] theta_prev) Allocate state arrays and initialize. u2ss is the steady-state basal insulin; theta0/x0/theta_prev are typed dicts (empty ⇒ defaults / cold start).
reset(theta0, g0=nan, u0=<empty array>) Set parameters from a typed dict and re-initialize all state. g0 (first observed glucose) seeds the initial glucose on a cold start only; u0 (the t=0 input row) seeds the buffer that delayed inputs read from.
step(u, t) Advance the ODE by one integration step given the input row u at time index t.
output(t) Return the observable model output at time t (the CGM-comparable signal).
get_final_x0() Optional. Return a typed dict of end-of-segment state, to seed the next segment's x0.
get_theta() Optional. Return a typed dict of the current parameters, to seed the next segment's theta_prev.

get_final_x0() and get_theta() are only needed to chain segments (multi-day intervals). The multi-meal models' get_theta() also returns SI_last, used to rescale the next segment's X0: the sensitivity active at the segment's last minute (the extended model always reports SI_D).

How ReplayBG drives the model:

  • Twinning. For every objective evaluation the twinner builds a typed dict of the candidate values (integer priors rounded first) and calls model.reset(theta_dict, g0, rbg_data.u[0]), with g0 the first non-missing glucose value. It then reads each estimated parameter back with getattr(model, name) to evaluate the prior, and simulates output(0), then step(u[k], k) and output(k) for k = 1 … tsteps-1. So every key of unknown_parameters_prior must be a model attribute with that exact name.
  • Replay. replay() calls model.reset(model.theta0, g0, rbg_data.u[0]) first (only if the model has reset and theta0), then runs the same loop, invoking the callbacks before each step. Store the constructor's theta0 as self.theta0; plot_twinning also uses it when called without theta.

theta0 during twinning

The twinner's dict holds only the estimated parameters, and reset replaces the parameters wholesale. With the built-in models, every parameter you do not estimate therefore takes the default hard-coded in reset, not the value passed as the constructor's theta0, while twinning.

The constructor signature of the built-in models is:

# single-meal
SingleMealT1DModel(u2ss, theta0=<empty typed dict>, x0=<empty typed dict>,
                   tsteps=1440, theta_prev=<empty typed dict>)
# multi-meal / extended add t_start (minute-of-day the segment starts)
MultiMealT1DModel(u2ss, theta0=..., x0=..., tsteps=1440, t_start=240, theta_prev=...)

Missing keys in theta0 fall back to physiological defaults set inside reset. Missing keys in x0 fall back to the steady state, except G0/IG0: they fall back to g0 (on a cold start, i.e. empty theta_prev), then to Gb. This is what lets you estimate only a subset of parameters (the rest keep population values).

The data class contract

The data class turns a raw DataFrame (one row every 5 minutes) into the arrays the model and twinner consume. Its constructor signature matches the built-ins:

MyT1DData(data: pd.DataFrame = None, data_to_input=None,
          body_weight=100., environment: Environment = None)

It must expose (at least) these attributes:

Attribute Meaning
u Input matrix, shape (tsteps, n_channels), in model units; u[k] is the row passed to step.
data_to_input Mapping {channel_index: channel_name} describing u's columns.
tsteps Number of integration steps (from the t column and the sampling time).
yts Data sampling stride in integration steps (5, since data is 5-minutely).
y Observed output (glucose) at data resolution; may contain NaN.
y_idxs Indices of the non-missing observations (np.where(~isnan(y))); y[y_idxs[0]] is the g0 passed to reset.
t_hour Hour of day at each integration step (length tsteps); read by the replay context.
u2ss Steady-state basal insulin — mean(basal) * 1000 / body_weight.
body_weight Patient body weight (used to normalize inputs; read by the built-in callbacks).

The data_to_input order is the contract between the data class and the model: the model's step(u, t) reads channels by that index. By convention the last two channels are forcing_ip and forcing_ra (zero unless a caller overrides them for a counterfactual forcing scenario). The built-in callbacks address channels by name: CorrectionBolus needs a bolus channel and HypoTreatment a meal_H channel.

Wiring it up

Once both classes exist, they are used exactly like a built-in blueprint:

rbg = ReplayBG()
rbg_data = MyT1DData(data=df, environment=rbg.environment)
model = MyT1DModel(u2ss=rbg_data.u2ss, tsteps=rbg_data.tsteps)

result = rbg.twin(rbg_data=rbg_data, model=model,
                  unknown_parameters_prior=unknown_parameters_prior)

The twinner is model-agnostic: it only calls reset, step and output, and reads back the parameters named in your priors. As long as your two classes honor the contracts above, everything downstream — twinning, replay, callbacks, sensors, plotting and analysis — works unchanged.

The output is model-agnostic too

Sensors and plotting only ever see model.output(t) and the channel names in data_to_input. Nothing downstream assumes what your output means, so a custom model with a differently-named output still plots and analyzes fine.