For the complete documentation index, see llms.txt. This page is also available as Markdown.

Temporal and Panel Twins

Time series and multi-environment data get their own twin kinds with their own machinery: lagged dependencies, latent influence detection, one model per environment, forecasts that explain themselves, and interventions scheduled in time. This guide runs a panel end to end; outputs shown are real transcripts.

Kind
Kwargs
What it models

temporal

time=

one time series with lagged causal structure

multi-environment-static

entity=

the same system observed across environments

multi-environment-temporal

time= + entity=

a panel: many environments, each a time series

Everything from Working with Digital Twins applies unchanged; this page covers what these kinds add, including the names the other simulation families take here and the two arguments that only exist because there is a time axis.

A panel in long format

One row per store per month. time= names the timestamp column, entity= the environment column:

>>> panel.head()
        month   store  price  demand  revenue
0  2024-01-01  london  21.70    68.4    152.0
1  2024-02-01  london  21.74    50.2    107.6
2  2024-03-01  london  22.06    44.8    101.2
3  2024-04-01  london  20.16    54.6    109.7
4  2024-05-01  london  20.43    50.8    102.7

>>> graph = rc.discover(panel, time="month", entity="store")
>>> graph.edges
                 cause   effect  strength  fixed
0  Unknown influence 1   demand  1.000000  False
1  Unknown influence 1  revenue  1.000000  False
2               demand  revenue  0.964863  False
3                month    price  0.842668  False
4                month   demand  0.716051  False
5                month  revenue  0.652976  False
6                store  revenue  1.000000  False

Three things in this graph do not exist for static twins:

  • A latent influence. Unknown influence 1 is a hidden common cause the engine detected in the data but could not name. It is real model structure, not a column; it cannot be intervened on directly.

  • Time as a cause. The month edges carry trend and seasonality into the variables they touch.

  • The environment as a cause. The store edge says the environments genuinely differ, beyond what the other variables explain.

One model per environment

Panel twins hold a model per environment. Sampling narrows with environments=, the returned frame carries an environment column, and a seed derives stable per-environment child seeds, so comparisons are deterministic:

The environments really are heterogeneous: Berlin runs at more than double London's demand under the same prices, exactly the per-store scale the store -> revenue edge announced.

Forecasts that explain themselves

forecast runs per environment. environments= narrows which, aggregate= ("sum", "avg", "min", "max") adds a combined series, and origin_timestamp (ms epoch) anchors the start, which is how a backtest aligns a forecast against months the twin never saw:

Every step carries an attribution: how much of the prediction is trend, season, and each causal parent, with lags named:

That demand_lag1 entry is the lagged dependency discovery found: last month's demand carrying into this month's revenue.

Interventions scheduled in time

Temporal and panel interventions happen at moments, not in the abstract. rc.at wraps any intervention value with when it applies and for how long:

Scheduling
Meaning

rc.at(spec, persistent=True)

applies from the first step onwards

rc.at(spec, duration_steps=6)

applies for six steps, then reverts

rc.at(spec, timestamp=1782864000000)

starts at a specific moment (ms epoch)

Without rc.at, an intervention on a temporal twin is persistent: it applies from the first step onwards, indefinitely. With it, you express ramps, windows, and permanent policy changes. Everything composes with where= conditions and the metric machinery from Working with Digital Twins.

Working with a subset of environments

twin.env(...) pins a handle to some of the panel's environments. Its graph re-aggregates the causal adjacency over just those environments — edges carry agreementRate, the share of the subset's environments in which discovery found the relationship — and every simulation on the handle is scoped automatically:

Note what's gone next to the full graph above: the store -> revenue edge. Within a two-store slice there is less environment-driven variation to explain — the subset's adjacency is genuinely different structure, not a filter on the full graph. adjacency(agreement_threshold=...) turns the edge-survival knob (default 0.5), and combos() shows the exact environments the handle resolved to.

Simulations on the handle run only in the subset — same verbs, pre-scoped. A price cut in London and Berlin, leaving Paris untouched:

Over REST this is POST .../versions/{vId}/graph/slice — the subset can also be defined by column values or per-environment stat filters, not just exact combos.

Saving a subset as an environment group

A handle from twin.env(...) lives as long as the Python object does. save() puts it on the twin as a named environment group, which lives as long as the twin does:

What gets stored is the rule, not the answer. Naming environments stores the exact combos; a where= subset stores the filter, so it re-selects as the data moves — "high revenue" next quarter means whichever stores are high-revenue then, not the ones that were today:

Groups belong to the twin, not to a version, so they survive retraining, and they are the same groups the platform's environment picker lists — a group saved from a notebook is in the dropdown by the time you switch tabs. Next session it comes back by name:

A group is an EnvSubset with a memory: the same graph, environments, sample, intervene and forecast, scoped to what the group means on this version right now. Membership is resolved server-side on first use and cached on the handle, so eu.graph and eu.forecast(...) agree with each other.

Simulations differ in one way that matters. A twin.env(...) handle expands to a list of environment names and sends those; a group is sent by id, and the platform freezes what it resolved onto the run:

That snapshot is the run's provenance: it says which group the run covered and what the group meant at submit time. Editing or deleting the group afterwards never rewrites it, and droppedEnvKeys names members this version could not honour.

Edits are twin-level, so they apply to every version at once. update() takes the same vocabulary as env():

Because the rule outlives the data it was written against, a group can stop fitting. Two outcomes, and they are not the same thing: a group that resolves cleanly and matches nothing is empty — the rule is fine, the environments moved on — while a group naming a column this version does not have raises with the reason it cannot be evaluated at all.

The other families, on a temporal or panel twin

Forecasting and scheduled interventions are what these kinds are usually reached for, but the rest of the simulation families work here too, under names of their own. The SDK picks the name from the twin's kind, so the call is the same one you would write against a static twin:

Verb
static
temporal
multi-environment static
multi-environment temporal

predict

prediction

not available

prediction

not available

forecast

not available

forecast

not available

panel_forecast

explain

explanation

temporal_explanation

panel_explanation

panel_explanation

optimise

optimisation

temporal_optimisation

panel_optimisation

panel_optimisation

root_cause

root_cause_analysis

temporal_root_cause_analysis

static_panel_root_cause_analysis

panel_root_cause_analysis

anomalies

anomaly_detection

temporal_anomaly_detection

static_panel_anomaly_detection

panel_anomaly_detection

Prediction is the one that does not carry over. It answers for a row of inputs, which a series does not have; a temporal twin projects forward with forecast instead, and says so rather than guessing:

A multi-environment static panel is the exception: it has rows, so it predicts, and the scenario is plain prediction with no panel variant.

Three arguments only exist because there is a time axis, and passing one to a static twin is refused rather than dropped:

horizon is required for a temporal optimization, which plans across steps rather than picking one setting; it is optional on a panel and refused on a static twin.

Diagnosing environments

root_cause and anomalies need the observations you want diagnosed. On a panel twin you can either share one set of rows across every environment, by passing a flat list, or give each environment its own by passing a mapping:

The mapping becomes panelSamples on the scenario, keyed by environment. A flat list becomes samples, which the engine shares across the environments in scope.

Every family is scoped by a subset or a group

The environment handles from the two sections above carry the whole verb set, not just sample, intervene and forecast. A subset pins the environments by name; a saved group is sent by id, so the platform still freezes onto the run exactly what the group resolved to:

Monthly refresh: assimilate instead of retrain

When next month's rows arrive, the model doesn't need rebuilding. Extend the twin's source with the new rows and fold them into the fitted model with update() — seconds, not a training run. It finishes with a status, never an error: committed (rows folded in), up_to_date (nothing new), or retrain_required (the model can't take these rows incrementally — result.reasons says why; call twin.retrain()).

Static and temporal twins assimilate out of the box. Among panel twins, only multi-environment-temporal ones can assimilate, and only when they opt into the v2 panel engine in the twin builder; a multi-environment-static twin has no v2 option and always needs a retrain. London's series as its own temporal twin:

Running update() again with nothing new in the source is how a scheduled job stays honest — the second call is a cheap no-op:

The refreshed model forecasts onwards from the assimilated months — note the timestamps start after the two new rows, not before them:

The production shape of this loop is a monthly job: sync or extend the source, call twin.update(), branch on the status — retrain_required triggers twin.retrain() instead of a page at 3am. In Airflow, that's three tasks; pass webhook_url= to update() if you'd rather be poked than poll, and check twin.update_eligibility first when you want the decision without starting a job.

Run it yourself

The transcript above is the temporal-panel example notebook, end to end:

Download the temporal and panel notebook

Next steps

Last updated