How to Create a New Simulation¶
LuPNT is a config-driven, agent-based framework: a scenario is a set of
Agents that own Dynamics, Devices, and a State, and that host
Applications (mission / navigation logic). Applications build Measurements
and run Filters over composable States. Everything is scheduled by an
event-driven Simulation engine and assembled from YAML
configuration through a string-keyed asset factory.
This page maps those building blocks and their relationships, then gives the minimal recipe for authoring a new scenario.
One engine, one path¶
Every scenario in LuPNT is built from a YAML config by the same generic
event-driven engine, lupnt::Simulation (cpp/lupnt/simulations/simulation.{h,cc}).
Agents, applications, devices, and dynamics are assembled by name through
factories; a top-level world: block defines the shared read-only environment
(epoch, integration frame, force model) that both the truth agents and the
estimators draw from; and any new mission / navigation logic goes into a
factory-registered Application that self-drives from the world: block.
There is no “monolithic” alternative — nothing subclasses Simulation
or hand-writes a Run() loop; the ground-station ODTS, GNSS ODTS, ISL ODTS
(centralized and distributed), ephemeris, surface-rover and lander scenarios
all run this one way (see configs/*.yaml).
Launch a config from Python with pnt.Simulation(yaml_or_dict) (the pattern
used by every python/examples/exN_run_*.py script) or from a small C++
driver such as the tutorials under cpp/examples/tutorials/ (which just build a
Simulation from the YAML and call Run()).
The only real design choice is how you decompose the per-epoch logic across
apps, and even a heavyweight Monte-Carlo engine can live inside a single app
(LunarGnssOdtsApp calls RunLunarGnssODTSMonteCarlo from its Step):
Sensor / estimator split — separate sensor apps push observations to a manager app that estimates (ground-station ODTS:
GroundStationTrackingApp→GroundStationManagerApp).Single coordinator app — one manager agent hosts an app that runs the whole estimator itself (centralized ground ODTS:
SurfaceStationManager+GroundOdtsApp, a single EKF over the surface-station beacons).Fully distributed — every platform is its own agent running its own app, exchanging state over the
Publish/Subscribebus (distributed ISL:Spacecraft+SatelliteOdtsApp, plusSurfaceStation+StationBeaconSensor→SurfaceStationManager+GroundOdtsApp).Several apps on one platform — one physical agent hosts multiple apps in an
applications:list, run in order and sharing the agent’s truth state (lander descent: a guidanceLanderGncAppthat owns the truth trajectory + a navigationLanderNavAppMEKF that reads it, resolved viaGetApplicationByName).
The building blocks¶
Block |
Base class (header) |
Role |
|---|---|---|
Simulation |
|
Discrete-event engine: a time-ordered priority queue of |
World |
|
The shared, read-only physical environment built from the top-level
|
Agent |
|
A platform (satellite, ground station, rover, lander, surface station).
Owns a |
Application |
|
The mission / navigation logic run per step. Builds |
Device |
|
A sensor / component on an agent ( |
Measurement |
|
The observable model. |
Dynamics |
|
The propagation model. |
State |
|
A labeled Eigen vector with a type, per-element names/units, and a frame.
|
Simulation — the engine¶
Simulation is a discrete-event engine backed by a
std::priority_queue<Event>. An Event (core/event.h) is a
{time, frequency, priority, callback} tuple ordered earliest-time first,
higher-priority first. Priorities resolve ties at equal time so that, within a
step, DYNAMICS / AGENT callbacks run before DEVICE callbacks, which
run before APPLICATION / LOGGING callbacks:
DYNAMICS = AGENT (2) > DEVICE (1) > APPLICATION = LOGGING (0)
The main loop (Simulation::Run) repeatedly pops the earliest event, sets the
simulation clock, invokes its callback, and — if the event has a non-zero
frequency — reschedules it at t + 1/frequency (this is how periodic
Step callbacks recur). Schedule(...) adds events; Subscribe /
Publish provide a topic-based message bus. GetAgent(name) /
GetChannel(name) accept bare or <simname>/<name> keys.
Different dynamics for truth and filter¶
Truth and filter dynamics are independent knobs, so you can (and for a realistic OD study usually should) run the estimator against a deliberately mismodeled truth:
Truth is whatever propagates the physical agent. A self-propagating
AgentWithDynamics(Satellite,Spacecraft, …) integrates its owndynamics:block; an app reads that truth throughGetStateAt(t)/World::GetStateAt(name, t)(which just forwards toagent->GetStateAt(t)).Filter dynamics are built inside the estimator
Application— either by reusing the sharedWorld::MakeDynamics()(theworld: force_model:block) or from app-specific config. Whatever the filter uses also supplies its STM, so keepautodiff: trueon that model.
Same model (no mismatch). Give the target agent a dynamics: block that
matches world: force_model: and have the app build its filter from
World::MakeDynamics() — this is exactly what ex7 does
(configs/ground_station_odts.yaml).
One force-model schema everywhere. Every place that specifies an orbit force model —
the shared world: force_model:, a physical agent’s dynamics:, and an estimator’s
filter force model — uses the same force_model: { integrator, autodiff, bodies: [...] }
block: a bodies: list of BODY: { n, m } (omit n/m or use {} for point-mass),
plus optional relativity and SRP (mass/area/CR). There is no separate
moon_gravity_degree_* / include_earth dialect — filters and agents read the identical
schema (parsed by ParseForceModelSpec).
Different models (mismatch). Configure the two sides separately: the truth
force model lives on the physical agent (or the shared world: block), while the
filter fidelity is an app knob. In the ISL ODTS scenario the truth is propagated
from world: force_model: — inherited by every Spacecraft — at a high gravity
resolution, and each SatelliteOdtsApp runs its EKF at a lower one
(configs/isl_odts_distributed.yaml):
world:
force_model: # truth propagated at 16x16 lunar gravity
bodies:
- MOON: { n: 16, m: 16 }
- EARTH: {}
- SUN: {}
# ... every Spacecraft inherits that truth force model; the onboard app filters coarser
# using the SAME force_model schema:
application:
class: SatelliteOdtsApp
force_model: # EKF runs at 8x8 -> intentional mismatch
bodies:
- MOON: { n: 8, m: 8 }
- EARTH: {}
- SUN: {}
relativity: true
More generally: give the truth agent a high-fidelity dynamics: block (more
third bodies, higher gravity degree/order, SRP, drag) and point the filter at a
coarser model — a lower-fidelity world: force_model: consumed by
World::MakeDynamics(), or a separate app-level filter config as above. In a
custom app, simply build two Dynamics objects (one for the truth grid, one for
filter_) instead of sharing dynamics_.
An estimator app can expose the filter model as config, too. GroundStationManagerApp
(ex7) accepts an optional filter_dynamics: block (an NBodyDynamics-style force
model applied to both its batch and its sequential SRIF/smoother filters), or
batch_dynamics: / sequential_dynamics: to set each estimator independently.
Absent, both inherit the shared world: force_model: (truth == filter, the default).
Agent — a platform¶
Agent owns name_, a back-pointer to the Simulation, a State, a
map of Devices, and one or more Application s (an application: map or an
applications: list; retrieve with GetApplication() /
GetApplications() / GetApplicationByName("...")). Its pure-virtual
Cart6 GetStateAt(Real t) const is the contract every agent must answer:
“where am I at time t?” — used by measurement models for light-time geometry.
Almost every physical agent derives from AgentWithDynamics, which adds a
Ptr<Dynamics>, an AttitudeDynamics, and a Cart6 state_. Its
Step(t) calls Propagate(t) (integrating the dynamics in place) then
Log(t). Concrete agents (all registered with the factory except Lander):
Satellite, Spacecraft (orbit + clock 8-state truth), GroundStation,
Rover, SurfaceStation, Lander. Group generators:
Constellation / GnssConstellation (from the constellations: block) and
LunarNavConstellation — a single agents: entry that expands into N child
``Spacecraft`` so a scenario need not declare one agent per navigation satellite,
from either an explicit satellites: list or a symmetric walker: spec
(n_planes, sats_per_plane, frozen ELFO a/e/i/omega); the surface-rover
example (ex10) sources its relay truth from it via GetSatelliteStateAt(j, t).
Application — the mission logic¶
Application holds a back-pointer to its owning Agent. Setup()
schedules a periodic Step(t) (at APPLICATION priority) and seeds the
filter state/covariance; the pure-virtual Step(Real t) runs one
predict/update cycle. An agent can host several applications (an
applications: list, run in insertion order and resolved by
GetApplicationByName); you can also compose logic within one app via the
sub-app pattern: LunaNetSatApp holds a std::vector<Ptr<LunaNetSubApp>>
and fans its Step out to each sub-app (e.g. IslOdtsApp and
EphemerisGenApp).
Concrete apps (all factory-registered): the sensor / estimator split for
ground-station OD — GroundStationTrackingApp on each GroundStation
(a sensor: visibility-gated range / range-rate, pushed to the manager) feeding a
GroundStationManagerApp on a GroundStationManager agent (the centralized
batch + SRIF/smoother estimator, with configurable filter dynamics); LunarGnssOdtsApp
(GNSS ODTS); the distributed SatelliteOdtsApp (per-satellite onboard filter) with
StationBeaconSensor → GroundOdtsApp (the centralized station-only ground filter);
EphemerisApp / LunaNetSatApp (sub-app host) + IslOdtsApp / EphemerisGenApp
(sub-apps); SurfaceStationApp; the surface-rover INS SurfaceRoverNavApp (which
sources its relay truth from a LunarNavConstellation agent); and the lander, whose
descent is split across two apps on one ``Lander`` agent — a guidance LanderGncApp
that owns the truth trajectory and writes the lander state, and a navigation
LanderNavApp (error-state INS MEKF) that reads that truth to synthesize its
IMU / altimeter / crater / LunaNet measurements each Step.
Device, Measurement, Dynamics, State¶
Device — created from the agent’s
devices:block, stored inAgent::devices_, and stepped independently on its ownDEVICE-priority events, so its state is current when an application reads it withGetDevice(name).Measurement —
MeasData Compute(const State& x, MatXd* H) constreturns the predicted value \(z = h(x)\), its covariance \(R\), and (whenH != nullptr) the Jacobian \(H = \partial h/\partial x\). Derive fromMeasurementClone<Derived>(which suppliesClone) or, for INS/error-state observables, fromErrorStateMeasurement.CreateFunction()adapts a measurement toFilter::SetMeasurementFunction. See Estimation Filters and GNSS Measurement Model.Dynamics — the
Propagatefamily is the contract (see Dynamics Models and Numerical Integration). The STM overload is used by filters for covariance propagation; if a subclass does not provide one, the base computes it by autodiff. Concrete:NBodyDynamics,CartesianTwoBodyDynamics,JToCartTwoBodyDynamics,JointOrbitClockDynamics,ClockDynamics,ImuDynamics, the analytical relative-motion models, andSurfaceDynamics2D.State — a labeled vector (
Cart6,ClassicalOE,ClockState3,ImuState, …).JointStateconcatenates(State, Dynamics, ParamState, est_types, tauq)triples and exposes the combined dynamics/STM/process-noise functions theFilterconsumes — this is how orbit + clock (+ estimated or consider parameters) become one filter state.
How they relate¶
Containment and ownership:
Simulation
├── priority_queue<Event> (the clock / scheduler)
├── agents_ : map<name, Agent>
├── channels_ : map<name, Channel>
└── constellations_ : map<name, Constellation>
Agent (AgentWithDynamics)
├── Dynamics dynamics_ ── owns ParamState (force / clock parameters)
├── AttitudeDynamics
├── State state_, Attitude attitude_, State control_
├── devices_ : map<name, Device> (Clock, Imu, Camera, Tx/Rx, GnssReceiver)
└── Application application_ (one; compose many via LunaNetSatApp + sub-apps)
Application
├── Agent* agent_ (back-pointer, GetAgent())
├── builds Measurement model(s) + a Filter (EKF / UKF / SRIF / batch)
└── JointState composes State + Dynamics (+ params) for the Filter
Per-step data flow. At a given time, the engine runs due callbacks in
priority order: AgentWithDynamics::Step first advances the truth state via
Propagate; then Device steps advance clocks/IMUs/comms; then the
Application::Step reads other agents’ truth via GetStateAt(t) and its own
devices via GetDevice(...), forms a Measurement (z, H, R), and runs a
filter predict (using the Dynamics / JointState STM) and update. Results
are logged through Application::Log ← Agent::Log. Periodic events
reschedule themselves.
As pseudocode, the engine loop (Simulation::Run) and a periodic navigation
Application::Step are:
# Simulation::Run (simulations/simulation.cc)
while queue not empty and queue.top().time <= duration:
e = queue.pop() # earliest time, then highest priority
time = e.time
e.callback(time) # Agent/Device/Application Step, or a Publish
if e.frequency > 0: # periodic -> reschedule
queue.push(Event(time + 1/e.frequency, e.frequency, e.priority, e.callback))
DataLogger::Flush()
# AgentWithDynamics::Step(t) (priority DYNAMICS) — advance the truth state
state_, attitude_ = dynamics_.Propagate(state_, time_, t, control_)
time_ = t; Log(t)
# A navigation Application::Step(t) (priority APPLICATION)
for tx in visible_transmitters(t): # geometry via tx.GetStateAt(t)
z, H, R = measurement(tx, receiver=agent_).Compute(x_hat, &H) # h(x), Jacobian, noise
stack z, H, R
filter.Predict(t) # x_hat, P via Dynamics/JointState STM + process noise
filter.Update(z, H, R) # innovation, gain, covariance update
Log(t)
Configuration and the asset factory¶
Classes are instantiated by name through
AssetFactory<Base, Config&> (core/asset_factory.h). A class opts in with
REGISTER_FACTORY_CLASS(Application, MyApp) // at file scope in MyApp.cc
which registers a creator under the string "MyApp"; the YAML class: value
must match that string exactly. Typed aliases exist for each base
(AgentFactory, DeviceFactory, ApplicationFactory,
DynamicsFactory, ChannelFactory).
Simulation::Setup reads the top-level name / epoch / duration /
log_level / channels, builds the shared World from the world:
block, then for each agents: entry uses its class: to build the agent,
whose constructor in turn builds its dynamics:, initial_state:,
devices:, and application: sub-blocks through the matching factories. For
example (abridged from configs/ground_station_odts.yaml):
world: # shared, read-only environment
frame: MOON_CI
force_model: { integrator: RKF45, autodiff: true,
bodies: [ { MOON: { n: 2, m: 2 } }, { EARTH: {} }, { SUN: {} } ] }
agents:
sat: # truth target
class: Satellite
dynamics: { class: NBodyDynamics, frame: MOON_CI, bodies: [...] }
initial_state: { class: ClassicalOE, frame: MOON_OP, a: ..., e: ..., i: ... }
gs_manager: # centralized OD estimator
class: GroundStationManager
application: { class: GroundStationManagerApp, target: sat, run_srif: true }
DSS14: # a tracking station (sensor)
class: GroundStation
latitude_deg: 35.4
longitude_deg: 243.1
altitude_m: 1001
application:
class: GroundStationTrackingApp
target: sat # resolved via GetSimulation()->GetAgent("sat")
manager: gs_manager # where observations are pushed
elevation_mask_deg: 10
use_range: true
range_sigma_m: 10.0
Run it from Python (the pattern in every exN_run_*.py script):
import yaml, pylupnt as pnt
sim = pnt.Simulation(yaml.safe_load(open("configs/ground_station_odts.yaml")))
sim.run()
results = sim.get_agent("gs_manager").get_application().get_results()
Working in Python¶
The whole simulation lifecycle is driven from Python — you compose a scenario and
run it, reusing the registered C++ agent/app/measurement/dynamics classes. A scenario
is just a dict (or a loaded YAML), so it can be built, mutated, and swept
programmatically:
import copy, yaml, pylupnt as pnt
cfg = yaml.safe_load(open("configs/ground_station_odts.yaml"))
cfg["agents"]["gs_manager"]["application"]["filter"]["process_accel_sigma_mps2"] = 1e-8
sim = pnt.Simulation(cfg) # instantiates World + agents + apps from `class:` keys
sim.run() # runs the event loop
app = sim.get_agent("gs_manager").get_application()
results = app.get_results() # each app binds its own result accessors
Monte-Carlo trials run at the simulation level via pnt.run_monte_carlo (parallel across
processes; each trial deep-copies the config with a distinct top-level seed), or — for a
scenario with an expensive shared precompute — via the engine’s in-config
monte_carlo_runs (one precompute, seed = base_seed + i per run). See
python/examples/ex6_gnss_odts.ipynb §7d for both.
Authoring a new Application in Python. You can subclass pnt.Application in pure Python
and have the C++ simulation drive it — no C++ build required. Register the class with
pnt.register_application(name, cls) and reference it by class: name in a config; the
engine constructs it as cls(config_dict) (the application: block, as a dict) and calls
its step(t) (and setup() / log(t)) each epoch. The app reads truth through its host
agent, builds a filter dynamics model, and stores results on self (sim.get_agent(...)
.get_application() hands the same Python object back):
import numpy as np, yaml, pylupnt as pnt
class MyOdtsApp(pnt.Application):
def __init__(self, config):
pnt.Application.__init__(self)
self.target = config["target"]
self.set_frequency(config.get("frequency", 1.0 / 60))
self.est = []
def setup(self):
pnt.Application.setup(self) # base schedules step() at get_frequency()
self.dyn = pnt.NBodyDynamics() # a filter force model, built in Python
for b in (pnt.Body.Moon(8, 8), pnt.Body.Earth(), pnt.Body.Sun()):
self.dyn.add_body(b)
self.dyn.set_frame(pnt.Frame.MOON_CI); self.dyn.set_autodiff(True)
def step(self, t):
world = self.get_agent().get_world()
r_obs = self.get_agent().get_state_at(t) # this agent's truth [r; v]
r_tgt = world.get_state_at(self.target, t) # any agent's truth by name
xf, F = self.dyn.propagate_stm(self.x, t0, t) # predict with STM (numpy in/out)
# ... EKF update from a measurement computed in numpy; store on self ...
self.est.append(self.x.copy())
pnt.register_application("MyOdtsApp", MyOdtsApp)
cfg = yaml.safe_load(open("configs/sat_bearing_odts.yaml"))
cfg["agents"]["observer"]["application"] = {"class": "MyOdtsApp", "target": "target"}
sim = pnt.Simulation(cfg); sim.run()
est = sim.get_agent("observer").get_application().est # results read straight off self
A complete, converging example — an angles-only OD EKF authored this way — is Example 17,
python/examples/ex17_python_new_sim_example.ipynb.
Authoring a new Agent in Python. A physical platform (whose truth trajectory you define in
Python — an analytic ephemeris, a scripted path) works the same way: subclass pnt.Agent,
implement get_state_at(self, t) returning a numpy [r; v] 6-vector, and register it with
pnt.register_agent(name, cls):
class PyEphemeris(pnt.Agent):
def __init__(self, config):
pnt.Agent.__init__(self)
self.a = config["radius_m"]; self.w = (pnt.GM_MOON / self.a**3) ** 0.5
def get_state_at(self, t):
c, s = np.cos(self.w * t), np.sin(self.w * t)
return np.array([self.a*c, self.a*s, 0, -self.a*self.w*s, self.a*self.w*c, 0])
pnt.register_agent("PyEphemeris", PyEphemeris) # config: {class: PyEphemeris, radius_m: 2.0e6}
Authoring a new Measurement in Python. An observable model is a pnt.Measurement subclass
implementing compute(self, x) -> (z, H, R) (the predicted observable, its Jacobian
H = dh/dx, and the noise R, all numpy). measurement.evaluate(x) runs it through the
C++ base — the same path a filter uses — so the same Python class both generates an
observation (apply it to a truth state from the target agent/device, add noise) and predicts
one (apply it to the filter state):
class BearingMeasurement(pnt.Measurement):
def __init__(self, sigma_rad):
pnt.Measurement.__init__(self); self.sigma = sigma_rad; self.target = np.zeros(3)
def compute(self, x):
d = self.target - x[:3]; rn = np.linalg.norm(d); u = d / rn
H = np.zeros((3, 6)); H[:, :3] = -(np.eye(3) - np.outer(u, u)) / rn
return u, H, self.sigma**2 * np.eye(3)
m = BearingMeasurement(np.radians(2 / 3600)); m.target = tgt_truth[:3]
z = m.evaluate(obs_truth)[0] + rng.normal(0, m.sigma, 3) # generate from the target's truth
u, Hx, R = m.evaluate(self.x) # predict at the filter state
The angles-only Example 17 notebook (python/examples/ex17_python_new_sim_example.ipynb) uses
exactly this. So new Applications, Agents, and Measurements are all authorable in pure
Python; new Dynamics
classes remain C++ (reuse pnt.NBodyDynamics from Python).
The skeleton of the two most common extensions — a new Application and a new
Measurement — is:
// --- A new navigation application ---------------------------------------
class MyOdtsApp : public Application {
public:
MyOdtsApp(Config& cfg) : Application(cfg) {
target_ = cfg["target"].as<std::string>(); // agent name to track
sigma_ = cfg["range_sigma_m"].as<double>();
}
void Setup() override {
Application::Setup(); // schedules Step at frequency_
target_agent_ = GetAgent()->GetSimulation()->GetAgent(target_);
// seed filter_ state x_hat_ and covariance P_ here
}
void Step(Real t) override {
Cart6 x_tx = target_agent_->GetStateAt(t); // truth geometry
MatXd H; MyMeasurement meas(BuildConfig(x_tx));
MeasData zHR = meas.Compute(x_hat_, &H); // z = h(x), R, and H
filter_.Predict(t); // STM + process noise
filter_.Update(zHR.value, H, zHR.covariance); // innovation, gain, update
Log(t);
}
};
REGISTER_FACTORY_CLASS(Application, MyOdtsApp) // YAML: class: MyOdtsApp
// --- A new observable ---------------------------------------------------
class MyMeasurement : public MeasurementClone<MyMeasurement> {
public:
MeasData Compute(const State& x, MatXd* H = nullptr) const override {
VecXd z(1); z(0) = /* h(x): range, Doppler, ... */;
if (H) { H->resize(1, x.size()); *H = /* dh/dx */; }
return MeasData{timestamp_, z, R_}; // value, covariance
}
};
Decomposing tightly-coupled logic¶
For a tightly-coupled estimator, error-state INS, or distributed filter, the same
config-driven path still applies — the whole per-epoch body simply lives in one or
more factory-registered Application s driven by pnt.Simulation(config)
(there is no Simulation subclass). How you spread the work across apps is the
only choice, and three shapes recur:
Centralized coordinator. One manager agent hosts an app that runs the whole estimator itself. Template:
SurfaceStationManager+GroundOdtsApp(applications/lunar_sat_odts/ground_odts_app.{h,cc}) — a single EKF over the surface-station beacons — orGroundStationManager+GroundStationManagerApp(ex7), driven byconfigs/isl_odts_distributed.yaml/configs/ground_station_odts.yaml.Fully distributed. Each platform is its own agent hosting its own app that generates its measurements, runs its own filter, and exchanges state over the
Simulation::Publish/Subscribebus. Template:Spacecraft+SatelliteOdtsAppwithSurfaceStation+StationBeaconSensor, driven byconfigs/isl_odts_distributed.yaml.Several apps on one platform. One physical agent hosts multiple
applications:, run in order and sharing the agent’s truth state. Template: aLanderhosting a guidanceLanderGncApp(owns the descent truth) + a navigationLanderNavAppMEKF that resolves the guidance app viaGetApplicationByName, driven byconfigs/lander_nav.yaml.Heavyweight engine inside one app. An existing batch/Monte-Carlo engine can be called straight from a single app’s
Step— e.g.LunarGnssOdtsAppinvokesRunLunarGnssODTSMonteCarlo— so no rewrite of the numerics is needed.
Because Simulation(config) runs Setup() in its constructor, any state a
host must inject after construction (e.g. the lander guidance app’s reference
trajectory) should be applied through a lazy Initialize inside the app’s first
Step.
Reference files¶
simulations/simulation.{h,cc}, simulations/world.{h,cc}, core/event.h,
core/asset_factory.h, agents/agent.h, applications/application.h,
applications/lunanet_sat_app.h, devices/device.h,
measurements/measurement.h, dynamics/dynamics.h, states/state.h +
states/joint_state.h, configs/ground_station_odts.yaml,
python/examples/ex7_run_odts.py.