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:
If you can describe your model as “a sequence of events over time that change state variables”, this scaffold is a good fit. ## Installation
# Latest stable release (recommended):
install.packages("fluxCore", repos = "https://jarrod-dalton.r-universe.dev")
# Pre-release install from source code:
remotes::install_github("jarrod-dalton/fluxCore")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 an ED
visit?”, etc.). Observations are separate from state updates on
purpose.
Episode (optional)
A short period with its own internal logic (e.g., a hospitalization)
that returns a summary back to the main entity state, and
optionally a detailed record (“artifact”) if you want it.
A single simulation run repeatedly does the following:
Decide the next event and its time
Based on the current state, the model determines what happens next and
when it happens.
Compute the state changes caused by that
event
Return a sparse update patch (or NULL if nothing
changes).
Record the event and apply the state
changes
The entity event log gets a new row; state variables are
updated.
Optionally record observations
If you want to log costs or other outputs, compute and store them
here.
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.
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, ctx, ...)
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, ctx)
Returns a sparse patch: a named list of state updates, or
NULL.
stop(entity, event, ctx)
Returns TRUE to stop the run, FALSE to
continue.
A bundle may also provide:
observe(entity, event, ctx)
Returns extra outputs you want to record (costs, utilities,
measurements, etc.).
refresh_rules(entity, last_event, changes, ctx)
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 parameter draw
objects. This is used for parameter uncertainty (see below). Components
that do not use parameter draws can ignore this.
ctx?ctx is an optional “context” list passed into bundle
functions. It can include: - time / time_spec
(internal/canonical time metadata populated by the engine) -
param_draw_id and sim_id (identifiers when
running repeated simulations) - params (a parameter draw,
if you are doing parameter uncertainty) - any other run-level inputs you
want to pass through cleanly
Declare the model time axis once in the model bundle via
time_spec(unit = "..."). Runtime ctx must not
override canonical time settings.
Bundles that do not need context can ignore it.
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, ctx = NULL, 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, ctx = NULL) {
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, ctx = NULL) identical(event$event_type, "end_shift")
)
p <- Entity$new(
init = list(route_zone = "urban", battery_pct = 100, payload_kg = 0),
schema = schema,
entity_type = "courier",
time0 = 0
)
eng <- Engine$new(
provider = list(load = function(model_spec = NULL, ...) toy_bundle)
)
out <- eng$run(p, max_events = 50)
tail(out$events, 5)
out$entity$state(c("route_zone", "battery_pct", "payload_kg"))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). - One record is
emitted per fired decision point. -
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 - state_before,
state_after, reward
This output shape is the portability-facing surface for Stage 3 and is designed to round-trip through JSON cleanly.
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, ctx) {
if (event$event_type != "dispatch_check") return(NULL)
draw <- c(82, 3.1) # battery_pct, payload_kg
set_vars(vehicle_vars, draw)
}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(provider = list(load = function(model_spec = NULL, ...) 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.
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:
D parameter sets once (global draws)S stochastic simulations per entity per drawYou control this via:
n_param_draws = Dn_sims = SWhere do the parameter draws come from? - If your bundle provides
sample_params(D), run_cohort() will use it. -
If not, draws default to NULL (which is fine for models
that do not have parameter draws).
Bundles/components that use parameter draws can look at
ctx$params. Components that do not (e.g., some machine
learning models) can ignore ctx$params.
Often you want to compare: - baseline model dynamics - baseline + intervention/policy
The goal is to avoid duplicating baseline code.
Use compose_bundles(baseline, policy) where
policy is another bundle-like object that can add or
override behavior.
Most commonly, a policy adds extra state updates during
transition().
Some events (e.g., hospitalization) may have rich internal dynamics that you may or may not want to record in detail.
A simple approach is:
The core package does not force any one approach. The example model package demonstrates a practical pattern.
R/Entity.R : entity state, history, and event logR/schema.R : defining entity variables (schema)R/engine.R : running a single entity simulationR/batch.R : running many entities (serial/parallel;
draws/sims)R/compose.R : layering interventions/policiesR/providers.R: loading bundles from
package/files/MLflow (stub)man/ and NAMESPACE are
generated — do not edit them by hand.
To regenerate after changing roxygen comments in R/:
roxygen2::roxygenise(".")