README.md

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:

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:

A bundle may also provide:

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:

You control this via:

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:

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

Where to look in the code

What this package does not assume

Development

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

To regenerate after changing roxygen comments in R/:

roxygen2::roxygenise(".")


Try the fluxCore package in your browser

Any scripts or data that you put into this service are public.

fluxCore documentation built on Sept. 22, 2026, 5:07 p.m.