Probabilistic Simulation of Single-Entity Systems in Irregular Time

A foundation for probabilistic simulation of single-entity systems in which events occur at irregular times and each event updates only a small, sparse subset of the entity's state. Models are assembled from a declared schema and a bundle of callback functions for event proposal, state transition, and stopping, with their contracts validated before simulation. Supports competing event processes, schema-declared decision points with user-supplied policies, typed parameter draws for representing uncertainty, and optional trajectory recording for auditing simulated decisions. Designed to be domain agnostic: this package contains no model of any particular system, only the scaffolding for building one.


fluxCore

Release CRAN downloads License: LGPL-3 Language: R

A small set of R tools for building entity-level simulation models where things happen at irregular times (not every day, not every month), and where each event can change only a few state variables.

This package is meant to be a foundation. It does not contain a domain-specific model. Instead, it gives you a clear way to:

  • store an entity’s current variables (“state”)
  • record a time-ordered list of events (what happened, when)
  • advance time from one event to the next
  • update only the variables that change at an event (sparse updates)
  • optionally record extra outputs (costs, utilities, measurements)
  • run many entities, optionally in parallel
  • (optional) represent parameter uncertainty using global parameter draws shared across entities
  • (optional) declare decision points and policy-selected actions without rewriting baseline model dynamics

If you can describe your model as “a sequence of events over time that change state variables”, this scaffold is a good fit.

Installation

The latest stable release is available from CRAN:

install.packages("fluxCore")

Install the development version from GitHub:

remotes::install_github("jarrod-dalton/fluxCore")

Key terms (plain-language definitions)

Entity (currently implemented as Entity)
An object that holds (1) the current values of state variables and (2) a record of events over time.

State
The set of variables that describe the entity right now and can influence what happens next (route zone, battery level, payload, dispatch mode, etc.).

Event
Something that occurs at a particular time and may change the entity state (dispatch check, delivery completion, shift end, maintenance event, etc.).

Event time
A numeric time value on a single global time axis shared across processes.

The Engine treats time as unitless math, but your model should declare a unit (e.g., days, months, years) so rates, cadences, and derived-variable lookbacks are interpretable and consistent.

Event type
A label for what kind of event occurred (e.g., "dispatch_check", "delivery_completed", "end_shift").

State update (patch)
A named list of only the variables that change at an event. Variables not in the patch are unchanged.

Example patch: list(battery_pct = 82, payload_kg = 3.1)

Observation (optional)
Information you want to record for analysis/reporting that does not affect future events (cost, utility, “was this delivery late?”, etc.). Observations are separate from state updates on purpose.

Episode (optional modeling pattern) — A bounded piece of model logic, such as a multi-stop delivery tour, that returns a summary to the main entity state and can optionally retain a detailed record ("artifact"). Episodes are a modeling pattern, not a special fluxCore API.


What happens in a simulation run?

A single simulation run repeatedly does the following:

  1. Decide the next event and its time
    Based on the current state, the model determines what happens next and when it happens.

  2. Compute the state changes caused by that event
    Return a sparse update patch (or NULL if nothing changes).

  3. Record the event and apply the state changes
    The entity event log gets a new row; state variables are updated.

  4. Optionally record observations
    If you want to log costs or other outputs, compute and store them here.

  5. Stop or repeat
    Stop if the model says the simulation is finished (for example, shift end), otherwise go back to step 1.

That is the full conceptual loop.


What you need to provide: a “ModelBundle”

In fluxCore, a model is represented by a ModelBundle: a named list of functions that define your simulation rules.

A bundle must provide:

  • propose_events(entity, ...) Returns one proposed future event per process, as a named list keyed by process_id. Each proposed event is a list that includes at least time_next and event_type.

  • transition(entity, event, ...) Returns a sparse patch: a named list of state updates, or NULL.

  • stop(entity, event, ...) Returns TRUE to stop the run, FALSE to continue.

A bundle may also provide:

  • observe(entity, event) Returns extra outputs you want to record (costs, utilities, measurements, etc.).

  • refresh_rules(entity, last_event, changes) Controls which processes should regenerate their next proposal after an event. Return "ALL" to refresh all processes, or a character vector of process_ids.

  • sample_params(D)
    Returns a list of length D containing typed ParamContext objects. This is used for parameter uncertainty (see below). Components that do not use parameter draws can ignore this.

Optional callback inputs

Callbacks declare only the inputs they use. The engine injects sim_ctx for simulation-level metadata and param_ctx for the current parameter draw when those named formals are present. propose_events() may also declare process_ids, current_proposals, and last_event; last_event is NULL on initial proposal generation and is the realized event on later refreshes.

Declare the model time axis once with time_spec(unit = "..."). Callbacks that do not need optional inputs can use the compact signatures shown above.


Minimal example (single entity)

This uses a small urban delivery toy bundle so the example is self-contained.

library(fluxCore)
set.seed(1)

schema <- list(
  route_zone = list(
    type = "categorical",
    levels = c("urban", "suburban", "rural"),
    default = "urban",
    coerce = as.character
  ),
  battery_pct = list(type = "continuous", default = 100, coerce = as.numeric),
  payload_kg = list(type = "continuous", default = 0, coerce = as.numeric)
)

toy_bundle <- list(
  time_spec = time_spec(unit = "hours"),
  event_catalog = c("dispatch_check", "delivery_completed", "end_shift"),
  terminal_events = "end_shift",
  propose_events = function(entity, process_ids = NULL, current_proposals = NULL) {
    list(
      dispatch = list(time_next = entity$last_time + stats::rexp(1, rate = 0.8), event_type = "dispatch_check"),
      delivery = list(time_next = entity$last_time + stats::rexp(1, rate = 1.2), event_type = "delivery_completed"),
      end_shift = list(time_next = 8, event_type = "end_shift")
    )
  },
  transition = function(entity, event) {
    if (identical(event$event_type, "dispatch_check")) {
      return(list(payload_kg = max(0, stats::rlnorm(1, log(2), 0.3))))
    }
    if (identical(event$event_type, "delivery_completed")) {
      s <- entity$as_list(c("battery_pct", "payload_kg"))
      return(list(
        battery_pct = max(0, as.numeric(s$battery_pct) - stats::rexp(1, rate = 1 / 4)),
        payload_kg = max(0, as.numeric(s$payload_kg) - stats::rlnorm(1, log(1), 0.4))
      ))
    }
    list()
  },
  stop = function(entity, event) identical(event$event_type, "end_shift")
)

full_schema <- set_schema(schema = schema, time_spec = toy_bundle$time_spec)

p <- Entity$new(
  init   = list(route_zone = "urban", battery_pct = 100, payload_kg = 0),
  schema = full_schema$variables,
  entity_type = "courier",
  time0  = 0
)

eng <- load_model(schema = full_schema, bundle = toy_bundle)

out <- eng$run(p, max_events = 50)

tail(out$events, 5)
out$entity$state(c("route_zone", "battery_pct", "payload_kg"))
out$stopped_by

load_model() is the validated assembly path: a full schema and its bundle must declare semantically matching clocks. For a simpler bundle-only model, Engine$new(bundle = toy_bundle) remains available; it does not perform the cross-component validation provided by load_model().

Trajectory output contract (v2 trajectory logger)

When using v2 assembly (load_model(..., trajectory = ...)), Engine$run() adds trajectory_records to the returned list.

Current contract for trajectory_records:

  • It is a list of plain named lists (JSON-serializable by default).
  • A record is emitted when a fired decision point reaches policy evaluation. A condition veto is recorded only when that decision point has audit = TRUE.
  • state_before/state_after follow trajectory$detail:
    • none: both are NULL
    • summary: both are summary lists from summary_fn (default state_summary_default)
    • full: both are full entity$current snapshots

Each trajectory record contains:

  • run_id, entity_id, t, decision_point_id
  • observation, realized_event
  • candidate_actions, proposed_actions, selected_action
  • condition_met
  • state_before, state_after, reward

Here selected_action means the policy's selection at that decision point. It does not by itself claim that the action was later realized: pending-action rules can retain or replace scheduled actions before their event time.

Use trajectory_table() for a plain data frame with run and entity identity, decision-point id, triggering event, selected action, and condition result. The raw record shape is designed to round-trip through JSON cleanly.


Schema blocks and vectorized updates

For convenience when building multivariate models (e.g., battery/payload telemetry), schema entries may include an optional blocks field (many-to-many). This lets you refer to groups of variables by name:

schema <- list(
  battery_pct = list(type = "continuous", default = 100, coerce = as.numeric, blocks = c("vehicle_status", "telemetry")),
  payload_kg = list(type = "continuous", default = 0, coerce = as.numeric, blocks = "vehicle_status")
)
# battery_pct appears in two blocks; payload_kg appears in one.

vehicle_vars <- block_vars(schema, "vehicle_status")   # c("battery_pct", "payload_kg")

Model transition() functions can generate vector-valued predictions and expand them into per-variable updates with helpers:

transition <- function(entity, event) {
  if (event$event_type != "dispatch_check") return(NULL)
  draw <- c(82, 3.1) # battery_pct, payload_kg
  set_vars(vehicle_vars, draw)
}

Running many entities (batch simulation)

Use run_cohort() to run a list of entities. You can also run in parallel across entities.

library(fluxCore)
set.seed(1)

schema <- list(
  route_zone = list(type = "categorical", levels = c("urban", "suburban", "rural"), default = "urban", coerce = as.character),
  battery_pct = list(type = "continuous", default = 100, coerce = as.numeric),
  payload_kg = list(type = "continuous", default = 0, coerce = as.numeric)
)

entities <- lapply(1:10, function(i) {
  Entity$new(
    init = list(
      route_zone = c("urban", "suburban", "rural")[((i - 1) %% 3) + 1],
      battery_pct = 100 - i,
      payload_kg = i %% 4
    ),
    schema = schema,
    entity_type = "courier",
    time0 = 0
  )
})
names(entities) <- paste0("id", seq_along(entities))

eng <- Engine$new(bundle = toy_bundle)

batch <- run_cohort(
  engine = eng,
  entities = entities,
  n_param_draws = 1,
  n_sims = 1,
  max_events = 100,
  backend = "none",
  seed = 123
)

head(batch$index)

batch$index describes each run (entity × draw × sim), so your outputs are easy to identify.


Parameter uncertainty (optional): global parameter draws

Sometimes fitted statistical models support drawing parameters from an approximate sampling distribution, for example using a covariance matrix for regression coefficients.

This package supports the common workflow:

  • draw D parameter sets once (global draws)
  • reuse those draws across all entities
  • run S stochastic simulations per entity per draw

You control this via:

  • n_param_draws = D
  • n_sims = S

Where do the parameter draws come from?

  • If your bundle provides sample_params(D), run_cohort() will use its typed ParamContext list.
  • If not, fluxCore creates default typed contexts, which is fine for models that do not have parameter uncertainty.

Bundle callbacks that use parameter draws can declare param_ctx = NULL and read param_ctx$params. A direct run without sampled draws still receives an empty/default ParamContext; callbacks that do not use it can omit the argument.


Policies and interventions (optional)

Often you want to compare baseline model dynamics with one or more interventions without duplicating the baseline bundle. Declare each policy opportunity as a DecisionPoint in the schema, and let the policy select an ActionEvent when that point fires. Assemble the pieces with load_model(policy = ...).

Actions occur on the same timeline as model events. A decision point's named action handlers translate selected action events into sparse state updates when they occur.

When one event should open several related decisions for one coordinated policy consultation, reference those leaf decisions from a GroupedDecisionPoint and return one complete DecisionPlan. Core determines which leaves are eligible after the event transition; accepted member actions still enter the timeline and realize independently. Tutorial 03 develops both ordinary and grouped workflows using the urban food-delivery model.


Episodes (optional pattern): detailed logic without bloating the main state

Some events, such as a multi-stop delivery tour, may have internal dynamics that you may or may not want to record in detail.

A simple approach is:

  • represent “delivery tour” as an event on the main timeline
  • optionally run detailed logic for its stops
  • return a summary patch to update main state (e.g., elapsed time, delivered orders, battery used)
  • optionally return an artifact (a detailed trace) stored outside the entity state

This is one modeling pattern rather than a fluxCore API requirement.


Where to look in the code

  • R/Entity.R : entity state, history, and event log
  • R/schema.R : defining entity variables (schema)
  • R/engine.R : running a single entity simulation
  • R/batch.R : running many entities (serial/parallel; draws/sims)
  • R/decision_points.R: declaring policy opportunities and action events
  • R/load_model.R: validated assembly of schema, bundle, policy, and runtime settings

What this package does not assume

  • It does not assume a specific domain or industry.
  • It does not assume daily time steps or fixed visit schedules.
  • It does not require parameter uncertainty (you can ignore draws).
  • It does not require episodes/submodels (you can keep everything at the top level).

Development

man/ and NAMESPACE are generated — do not edit them by hand.

To regenerate after changing roxygen comments in R/:

roxygen2::roxygenise(".")

Reference manual

It appears you don't have a PDF plugin for this browser. You can click here to download the reference manual.

install.packages("fluxCore")

2.1.0 by Jarrod Dalton, a month ago


Browse source code at https://github.com/cran/fluxCore


Authors: Jarrod Dalton [aut, cre]


Documentation:   PDF Manual  


LGPL-3 license


Imports R6

Suggests future, future.apply, jsonlite, parallel, testthat


See at CRAN