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
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_byload_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(".")