Common gotchas
A quick reference to the API details and pitfalls that most often trip people up when
building models with laser-generic. Each item shows the correct pattern and the mistake
to avoid (including the error you'll see when you get it wrong).
Imports and namespace
laser-generic is a namespace package — always import it with dot notation:
1 2 | |
Do not use an underscore (import laser_generic) — that is not how the package is
imported.
Disease components live in per-model modules
Import the disease components from the module that matches the model you are building —
laser.generic.SI, .SIS, .SIR, .SIRS, .SEIR, .SEIRS. Each exposes the components
appropriate to that model (Susceptible, Infectious, Transmission, and where relevant
Recovered / Exposed):
1 | |
Prefer these per-model classes. The low-level variants in laser.generic.components
(TransmissionSI, TransmissionSE, InfectiousIR, …) have misleading names — e.g.
components.TransmissionSI still requires an infdurdist argument — so reach for them
only if you specifically need them.
Component constructor arguments differ by model
Build the duration distributions as callables from laser.core.distributions
(exponential, poisson, constant_int, gamma, …) and pass them positionally. The
signatures differ by model:
| Model | Susceptible | Exposed | Infectious | Recovered | Transmission |
|---|---|---|---|---|---|
| SI | (model) |
— | (model) |
— | (model) |
| SIS | (model) |
— | (model, infdurdist) |
— | (model, infdurdist) |
| SIR | (model) |
— | (model, infdurdist) |
(model) |
(model, infdurdist) |
| SIRS | (model) |
— | (model, infdurdist, wandurdist) |
(model, wandurdist) |
(model, infdurdist) |
| SEIR | (model) |
(model, expdurdist, infdurdist) |
(model, infdurdist) |
(model) |
(model, expdurdist) |
| SEIRS | (model) |
(model, expdurdist, infdurdist) |
(model, infdurdist, wandurdist) |
(model, wandurdist) |
(model, expdurdist) |
Key points:
Transmissiontakesexpdurdistfor theSE*models, butinfdurdistfor the rest.- The
*RS(waning-immunity) models add a waning-duration distributionwandurdist. Susceptible(model)takes only the model.
1 2 3 4 | |
Parameters (PropertySet)
The model's PropertySet must include beta (the per-day transmission rate) and nticks
(number of ticks to run). The Transmission component reads model.params.beta and raises
AttributeError: 'PropertySet' object has no attribute 'beta' if it is missing.
The random seed is optional: Model.__init__ looks for prng_seed, prngseed, or
seed (in that order) and falls back to a fixed default of 20260101 for reproducibility.
Set one only when you want to control or vary the seed yourself.
1 2 | |
Also include inf_mean (mean infectious duration, in days) whenever you call
seed_infections_randomly / seed_infections_in_patch — they read it to set the infection
timer of seeded agents.
PropertySet supports both attribute access (model.params.beta) and dict-style subscript
access (model.params["beta"]), but attribute access is strongly preferred — it reads
more naturally and is the style used throughout the laser-generic codebase. For optional
parameters, use getattr(model.params, "name", default).
Seeding and importation
seed_infections_randomly(model, n) (from laser.generic.importation) infects n random
susceptibles — it sets their state and draws their itimer from model.params.inf_mean
(so inf_mean must be in params). Use it for initial seeding, before model.run().
For periodic / mid-run importation it is not sufficient on its own: it does not update the
node-level compartment counts, so Transmission won't see the imported infections and the
outbreak never starts (peak stays 0). Instead write a small custom component whose
step(self, tick) (a) picks random susceptible agents, (b) sets their state to
INFECTIOUS and their itimer from the infectious-duration distribution, and (c) updates
the next tick's node counts:
1 2 | |
Note: Infect_Random_Agents is not a per-tick component (it has no step method), and
there is no class named Importation to import.
Vital dynamics and age pyramids
MortalityByCDR(model, mortalityrates) (deaths) and
ConstantPopVitalDynamics(model, recycle_rates) (balanced births and deaths, total
population conserved) need no age pyramid.
BirthsByCBR(model, birthrates, pyramid=...) requires a pyramid argument, and that
argument must be an AliasedDistribution (not a raw array). AliasedDistribution and
load_pyramid_csv are imported from laser.core.demographics (not
laser.core.distributions). A simple synthetic pyramid is fine:
1 2 3 4 5 6 | |
Passing a degenerate or empty array as the pyramid raises ValueError: high <= 0.
Reading results
Compartment time series are tracked at the node level as arrays on model.nodes:
model.nodes.S, model.nodes.I, model.nodes.E, model.nodes.R (plus
newly_infectious / newly_infected where relevant). Each is allocated with length
nticks + 1, so the shape is [nticks + 1, n_nodes] — there is one row per tick from 0
through nticks inclusive. (This means I[-1] is the state at tick nticks, not
nticks - 1.) There is no StateTracker or ResultsWriter class.
1 2 3 4 | |
Reading model.nodes.I[:, 0] after model.run() is always safe. Do not compute
compartment counts by testing the per-agent model.people.state against a bare integer
(e.g. people.state == 1); if you need per-agent state, compare against the State enum
value (e.g. State.INFECTIOUS.value, with from laser.generic import State).
When recording a series from inside a custom component's step(self, tick), be aware
that the node array for the current tick may not be written yet if your component runs
before the disease components. The simplest fix is to place the recording component after
the disease components in model.components so that model.nodes.I[tick, 0] is already
up to date when your component runs:
1 2 3 4 5 6 7 | |
If you cannot reorder (e.g. you need counts mid-pipeline), note that iterating over all
agents to build a boolean mask is a LASER anti-pattern — it allocates a temporary array
as large as the agent population on every tick. Prefer a Numba kernel that accumulates
per-node counts directly. Reading model.nodes.I[:, 0] after model.run() completes
is always safe and has no such overhead.
Writing a custom component
A component is a plain class the model calls as component.step(tick) each tick. Store the
model in __init__ as self.model = model and reference self.model.people /
self.model.nodes inside step — never a bare module-level model. model.nodes is a
LaserFrame; the number of patches is model.nodes.count (an int) — don't treat
model.nodes itself as a number.
1 2 3 4 5 6 7 | |
Spatial models and migration
Migration model functions (gravity, radiation, stouffer, competing_destinations) and
the distance helper all live in laser.core.migration, not laser.generic.model:
1 | |
distance(lat1, lon1, lat2=None, lon2=None) computes great-circle distances in km. Pass
two 1-D arrays (lats, lons) and it returns the full N×N pairwise matrix. Do
not pass a single 2-D coordinate array or call distance(centroids):
1 | |
For synthetic patches, define lats/lons as plain numpy arrays (or read existing scenario
columns with scenario["lat"].to_numpy()). Only use get_centroids when you have a real
geopandas GeoDataFrame of polygons — calling it on an ordinary pandas DataFrame raises
AttributeError: 'DataFrame' object has no attribute 'to_crs'.
Working with laser-core primitives
laser-generic is built on laser-core, so two laser-core conventions apply throughout:
LaserFrameproperties are modified in place. Useframe.S[:] = new_values; never rebind a property (frame.S = ...raisesRuntimeError: Cannot reassign property). Access properties by attribute (frame.state), not by subscript (frame['state']).PropertySetprefers attribute access (model.params.beta) over dict-style subscript access (model.params["beta"]) — both work, but attribute access is the idiomatic style throughout the codebase (see Parameters above).