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.
The latest stable release is available from CRAN:
install.packages("fluxCore")
Install the development version from GitHub:
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 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.
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, ...)
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.
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.
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().
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.
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)
}
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.
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 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.
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.
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:
This is one modeling pattern rather than a fluxCore API requirement.
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/decision_points.R: declaring policy opportunities and action eventsR/load_model.R: validated assembly of schema, bundle, policy, and runtime settingsman/ and NAMESPACE are generated — do not edit them by hand.
To regenerate after changing roxygen comments in R/:
roxygen2::roxygenise(".")
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.