Skip to content

Usage

Overview

laser-measles is a spatial epidemiological modeling toolkit for measles transmission dynamics, built on the LASER framework. It provides a flexible, component-based architecture for disease simulation with support for multiple geographic scales and demographic configurations.

Key features include:

  • Spatial modeling: Support for geographic regions with administrative boundaries and population distributions
  • Multiple model types: ABM, Biweekly, and Compartmental models for different use cases
  • Component-based architecture: Interchangeable disease dynamics components
  • High-performance computing: Optimized data structures and Numba JIT compilation
  • Type-safe parameters: Pydantic-based configuration management

Installation and setup

Install laser-measles using pip (requires Python 3.10+):

1
pip install laser-measles

For development installation with all dependencies (recommended: use uv for faster package management):

1
2
3
4
5
6
7
# Using uv (recommended)
uv pip install -e ".[dev]"
# or for full installation including examples
uv pip install -e ".[full]"

# Alternative: using pip
pip install -e ".[dev]"

Major dependencies:

  • laser-core>=1.0.0: Core LASER framework
  • pydantic>=2.0: Parameter validation and serialization
  • polars>=1.0.0: High-performance data manipulation
  • alive-progress>=3.0: Progress bars and status indicators
  • rastertoolkit>=0.3.11: Raster data processing utilities
  • patito>=0.8: Polars DataFrame validation

Model types

laser-measles provides three complementary modeling approaches, each optimized for different use cases:

  1. ABM (Agent-Based Model): Individual-level simulation with stochastic agents
  2. Biweekly Compartmental Model: Population-level SIR dynamics with 2-week timesteps
  3. Compartmental Model: Population-level SEIR dynamics with daily timesteps

Each model type offers different trade-offs between computational efficiency, temporal resolution, and modeling detail.


ABM (agent-based model)

The ABM model provides individual-level simulation with stochastic agents, allowing for detailed tracking of disease dynamics at the person level.

Key characteristics:

  • Individual agents: Each person is represented as a discrete agent with properties like age, location, and disease state
  • Daily timesteps: Fine-grained temporal resolution for precise modeling
  • Stochastic processes: Individual-level probabilistic events for realistic variability
  • Spatial heterogeneity: Agents can move between patches and have location-specific interactions
  • Flexible demographics: Full support for births, deaths, aging, and migration

Example usage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from laser.measles.abm import ABMModel, ABMParams

# Configure model parameters
params = ABMParams(
    num_ticks=7300,  # 20 years of daily timesteps
    seed=12345
)

# Initialize and run model
model = ABMModel(scenario_data, params)
model.run()

Biweekly model

The biweekly model is a compartmental model optimized for fast simulation and parameter exploration with 2-week timesteps.

Key characteristics:

  • Compartmental approach: SIR (Susceptible-Infected-Recovered) structure. The exposed (E) compartment is omitted because the 14-day timestep is comparable to measles' typical incubation period (~10-14 days), making the distinction between exposed and infectious states negligible at this temporal resolution. For detailed SEIR dynamics with explicit incubation periods, use the Compartmental Model with daily timesteps.
  • Time resolution: 14-day fixed time steps (26 ticks per year)
  • High performance: Uses Polars DataFrames for efficient data manipulation
  • Stochastic sampling: Binomial sampling for realistic variability
  • Policy analysis: Recommended for scenario building and intervention assessment

Example usage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from laser.measles.biweekly import BiweeklyModel, BiweeklyParams

# Configure model parameters
params = BiweeklyParams(
    num_ticks=520,  # 20 years of bi-weekly time steps
    seed=12345
)

# Initialize and run model
model = BiweeklyModel(scenario_data, params)
model.run()

Compartmental model

The compartmental model provides population-level SEIR dynamics with daily timesteps, optimized for parameter estimation and detailed outbreak modeling.

Key characteristics:

  • Daily timesteps: Fine-grained temporal resolution (365 ticks per year)
  • SEIR dynamics: Detailed compartmental structure with exposed compartment
  • Parameter estimation: Recommended for fitting to surveillance data
  • Outbreak modeling: Ideal for detailed temporal analysis of disease dynamics
  • Deterministic core: Efficient ODE-based dynamics with optional stochastic elements

Example usage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from laser.measles.compartmental import CompartmentalModel, CompartmentalParams

# Configure model parameters
params = CompartmentalParams(
    num_ticks=7300,  # 20 years of daily time steps
    seed=12345
)

# Initialize and run model
model = CompartmentalModel(scenario_data, params)
model.run()

Warning

All three model constructors require both scenario and params. There is no default — omitting params raises TypeError immediately:

Do not pass only scenario to the constructor — omitting params raises TypeError: missing 1 required positional argument: 'params'.

Always create the *Params object first, then pass both to the constructor:

1
2
3
4
5
6
7
8
9
# CORRECT — both arguments are required
params = ABMParams(num_ticks=365, seed=42, start_time="2000-01")
model  = ABMModel(scenario=scenario, params=params)

params = BiweeklyParams(num_ticks=130, seed=42, start_time="2000-01")
model  = BiweeklyModel(scenario=scenario, params=params)

params = CompartmentalParams(num_ticks=730, seed=42, start_time="2000-01")
model  = CompartmentalModel(scenario=scenario, params=params)

Components are added after construction via model.add_component(). params configures duration, seed, and start date — not components.


Demographics package

The demographics package provides comprehensive geographic data handling capabilities for spatial epidemiological modeling.

Core features:

  • GADM Integration: GADMShapefile class for administrative boundary management
  • Raster processing: RasterPatchGenerator for population distribution handling
  • Shapefile utilities: Functions for geographic data visualization and analysis
  • Flexible geographic scales: Support from national to sub-district administrative levels

Key classes:

  • GADMShapefile: Manages administrative boundaries from GADM database
  • RasterPatchParams: Configuration for raster-based population patches
  • RasterPatchGenerator: Creates population patches from raster data
  • get_shapefile_dataframe: Utility for shapefile data manipulation
  • plot_shapefile_dataframe: Visualization functions for geographic data

Example usage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from laser.measles.demographics import GADMShapefile, RasterPatchGenerator, RasterPatchParams

# Load administrative boundaries
shapefile = GADMShapefile("ETH", admin_level=1)  # Ethiopia, admin level 1

# Generate population patches
params = RasterPatchParams(
    shapefile_path="path/to/shapefile.shp",
    raster_path="path/to/population.tif",
    patch_size=1000  # 1km patches
)
generator = RasterPatchGenerator(params)
patches = generator.generate_patches()

Technical features

Pydantic integration

laser-measles uses Pydantic for type-safe parameter management, providing automatic validation and documentation.

Parameter classes:

  • ABMParams: Configuration for agent-based models with individual-level parameters
  • BiweeklyParams: Configuration for biweekly models with epidemiological parameters
  • CompartmentalParams: Configuration for compartmental models with daily dynamics

Component classes: Components come in "process" and "tracker" categories and each component has a corresponding parameter class. Each model (ABM, Biweekly, or Compartmental) has its own set of components. See the API reference section for more details.

Benefits:

  • Type safety: Automatic validation of parameter types and ranges
  • Documentation: Built-in parameter descriptions and constraints
  • Serialization: JSON export/import of model configurations
  • IDE support: Enhanced autocomplete and error detection

Example:

1
2
3
4
5
6
7
8
9
from laser.measles.biweekly import BiweeklyParams

params = BiweeklyParams(
    num_ticks=520,  # Validated as positive integer
    seed=12345      # Random seed for reproducibility
)

# Export configuration
config_json = params.model_dump_json()

High-performance computing

laser-measles is optimized for performance through several technical approaches:

LaserFrame architecture: High-performance array-based structure for agent populations, built on the LASER framework.

numba JIT compilation: Performance-critical operations implemented in numba for maximum speed.

Polars DataFrames: Efficient data manipulation using Polars for biweekly model operations with Arrow backend.

Component modularity: Modular architecture allows for selective component usage and optimization.

Progress tracking: Integrated progress bars using alive-progress for long-running simulations.

Python 3.10+ support: Optimized for modern Python features and performance improvements.

Component system

The component system provides a uniform interface for disease dynamics with interchangeable modules built on a hierarchical base class architecture.

Base architecture:

  • BaseLaserModel: Abstract base class for all model types with common functionality
  • BaseComponent: Base class for all components with standardized interface
  • BasePhase: Components that execute every tick (inherit from BaseComponent)
  • Inheritance-based design: Base components define shared functionality and abstract interfaces

Base component classes:

  • base_transmission.py: Base transmission/infection logic
  • base_vital_dynamics.py: Base births/deaths logic
  • base_importation.py: Base importation pressure logic
  • base_tracker.py: Base tracking/metrics logic
  • base_infection.py: Base infection state transitions
  • base_tracker_state.py: Base state tracking functionality

Component naming convention:

  • Process components: process_*.py - Modify model state (births, deaths, infection, transmission)
  • Tracker components: tracker_*.py - Record metrics and state over time

Component Creation Patterns:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Component with parameters using Pydantic
from laser.measles.components.base_infection import BaseInfectionProcess

class MyInfectionProcess(BaseInfectionProcess):
    def __init__(self, model, verbose=False, **params):
        super().__init__(model, verbose)
        # Initialize with validated parameters

# Add to model
model.components = [MyInfectionProcess]

Complete worked examples

These end-to-end scripts are copy-paste runnable. Each one shows the full pattern — imports, scenario, params, model construction, component wiring, running, and result retrieval — with detailed inline comments on every line that commonly causes errors.

The three non-negotiable constructor facts

Warning

Read this before writing any model code.

These three facts are the source of the most common runtime failures. They apply to every model type without exception.

The only three model classes are ABMModel, BiweeklyModel, CompartmentalModel

Import them from their respective subpackages:

1
2
3
from laser.measles.abm           import ABMModel,           ABMParams
from laser.measles.biweekly      import BiweeklyModel,      BiweeklyParams
from laser.measles.compartmental import CompartmentalModel, CompartmentalParams

Warning

The following names do not exist in the package and will raise AttributeError or ImportError:

1
2
3
4
5
6
7
lm.abm.Model          # ← does not exist
lm.abm.ABM            # ← does not exist
lm.abm.LaserABM       # ← does not exist
lm.Model              # ← does not exist
lm.BiweeklyModel      # ← does not exist
lm.CompartmentalModel # ← does not exist
lm.create_model(...)  # ← does not exist

There is no convenience shortcut. Always import from the subpackage.

The constructor signature is always Model(scenario, params)

1
2
params = ABMParams(num_ticks=365, seed=42)      # ALL settings go here
model  = ABMModel(scenario, params)              # then params goes here

Warning

params is not optional. Calling the constructor with only a scenario raises TypeError immediately, before the simulation runs:

1
2
3
ABMModel(scenario=scenario)                   # TypeError: missing 1 required positional argument: 'params'
BiweeklyModel(scenario=scenario)              # TypeError: missing 1 required positional argument: 'params'
CompartmentalModel(scenario=scenario)         # TypeError: missing 1 required positional argument: 'params'

The *Params object is always the second positional argument. It is mandatory — there is no default and no shortcut.

Passing simulation settings directly as keyword arguments also fails:

1
2
3
4
5
6
ABMModel(scenario, num_ticks=365)             # TypeError
ABMModel(scenario, n_ticks=365)               # TypeError
ABMModel(scenario, seed=42)                   # TypeError
ABMModel(scenario, params, components=[...])  # TypeError
BiweeklyModel(scenario, n_ticks=26)           # TypeError
CompartmentalModel(scenario, num_ticks=730)   # TypeError

Every simulation setting — duration, seed, start date, verbosity — goes into the *Params object. Then the populated *Params object is the second argument to the model constructor.

start_time must be "YYYY-MM", never "YYYY-MM-DD"

1
2
# CORRECT — "YYYY-MM" format
params = ABMParams(num_ticks=365, seed=42, start_time="2000-01")

Warning

Passing a full date string raises a Pydantic ValidationError at construction time, before the simulation runs:

Do not pass a full date string like "2000-01-01" — it raises ValidationError: start_time must be in 'YYYY-MM' format.

Example 1 — ABM: Single-patch outbreak with StateTracker

One population of 100,000 people, no births, outbreak seeded from InfectionSeedingProcess, peak infectious tracked with StateTracker. This is the minimal correct ABM script.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import numpy as np
import polars as pl
from laser.measles.abm import ABMModel, ABMParams
from laser.measles.abm import NoBirthsProcess, InitializeEquilibriumStatesProcess
from laser.measles.abm import InfectionSeedingProcess, InfectionProcess, StateTracker
from laser.measles.scenarios import single_patch_scenario

# ── 1. Scenario ─────────────────────────────────────────────────────────────
# single_patch_scenario returns a polars DataFrame with the required columns:
# id, lat, lon, pop, mcv1.  Pass it directly to the model constructor.
scenario = single_patch_scenario(population=100_000, mcv1_coverage=0.0)

# ── 2. Params ────────────────────────────────────────────────────────────────
# ABMParams holds ALL simulation settings.  num_ticks is in days (365 = 1 year).
# start_time must be "YYYY-MM" — not "YYYY-MM-DD".
params = ABMParams(num_ticks=365, seed=42, start_time="2000-01")

# ── 3. Model construction ────────────────────────────────────────────────────
# The ONLY valid signature is ABMModel(scenario, params).
# There is no ABMModel(scenario, num_ticks=...) or ABMModel(scenario, seed=...).
model = ABMModel(scenario, params)

# ── 4. Components ────────────────────────────────────────────────────────────
# Components are added one at a time via add_component().
# Pass the CLASS (not an instance) for components that need no parameters.
# Pass create_component(CLASS, params=...) when parameters are required.
#
# NoBirthsProcess: keeps population fixed (use instead of VitalDynamicsProcess
# for short runs where demographics don't matter).
model.add_component(NoBirthsProcess)

# InitializeEquilibriumStatesProcess: sets the initial S/E/I/R split to
# the endemic equilibrium for the scenario's mcv1 coverage and default R0.
model.add_component(InitializeEquilibriumStatesProcess)

# InfectionSeedingProcess: introduces a small number of infectious individuals
# at the start of the simulation to spark an outbreak.
model.add_component(InfectionSeedingProcess)

# InfectionProcess: drives daily S→E→I→R transitions using stochastic ABM rules.
model.add_component(InfectionProcess)

# StateTracker without params → global (summed-across-all-patches) tracker.
# Access results via tracker.I (1-D array of length num_ticks).
model.add_component(StateTracker)

# ── 5. Run ───────────────────────────────────────────────────────────────────
model.run()

# ── 6. Retrieve results ──────────────────────────────────────────────────────
# get_instance("StateTracker") returns a list of all StateTracker instances
# in the order they were added.  [0] is the first (and here only) one.
tracker = model.get_instance("StateTracker")[0]

# tracker.I is a 1-D NumPy array: infectious count at each tick (day).
# Cast to Python int before printing or building Polars DataFrames.
peak_I   = int(tracker.I.max())
peak_day = int(tracker.I.argmax())
print(f"Peak infectious: {peak_I} on day {peak_day}")

Example 2 — Biweekly: Five-patch endemic run with per-patch StateTracker

Five communities, births/deaths, importation, 5 years. Uses BiweeklyModel (26 ticks per year) and a per-patch StateTracker (aggregation_level=0) to read the infectious time series per community.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import numpy as np
import polars as pl
from laser.measles.biweekly import BiweeklyModel, BiweeklyParams
from laser.measles.biweekly import InitializeEquilibriumStatesProcess, ImportationPressureProcess
from laser.measles.biweekly import InfectionProcess, VitalDynamicsProcess, StateTracker, StateTrackerParams
from laser.measles import create_component

# ── 1. Scenario ─────────────────────────────────────────────────────────────
# Build a 5-patch scenario manually.
# IMPORTANT: lat and lon MUST be Float64.  list(range(5)) produces Int64
# and will fail schema validation.  Use float literals or np.linspace/np.zeros.
scenario = pl.DataFrame({
    "id":   [f"patch_{i}" for i in range(5)],
    "lat":  [0.0] * 5,                          # Float64 ✓
    "lon":  [float(i) for i in range(5)],        # Float64 ✓  (not list(range(5)))
    "pop":  [50_000, 80_000, 120_000, 200_000, 150_000],
    "mcv1": [0.90, 0.85, 0.80, 0.75, 0.70],
})

# ── 2. Params ────────────────────────────────────────────────────────────────
# BiweeklyModel uses 14-day ticks: 26 ticks = 1 year, 130 ticks = 5 years.
# There is no BiweeklyModel(scenario, num_ticks=130) — num_ticks goes in params.
params = BiweeklyParams(num_ticks=130, seed=42, start_time="2000-01")

# ── 3. Model construction ────────────────────────────────────────────────────
# The ONLY valid signature is BiweeklyModel(scenario, params).
model = BiweeklyModel(scenario, params)

# ── 4. Components ────────────────────────────────────────────────────────────
# InitializeEquilibriumStatesProcess: set initial S/I/R near endemic equilibrium.
model.add_component(InitializeEquilibriumStatesProcess)

# ImportationPressureProcess: steady background importation that sustains
# endemic transmission.  Required when starting near equilibrium.
model.add_component(ImportationPressureProcess)

# InfectionProcess: biweekly transmission (SIR, no explicit E compartment).
model.add_component(InfectionProcess)

# VitalDynamicsProcess: births and deaths using the scenario's mcv1 coverage
# to vaccinate newborns.
# NOTE: in BiweeklyModel, VitalDynamicsProcess can appear after InfectionProcess.
# (The "VitalDynamics must be first" rule applies to ABMModel only.)
model.add_component(VitalDynamicsProcess)

# StateTracker with aggregation_level=0 → per-patch tracker.
# Results are in tracker.I with shape (num_ticks, n_patches).
model.add_component(
    create_component(
        StateTracker,
        params=StateTrackerParams(aggregation_level=0),
    )
)

# ── 5. Run ───────────────────────────────────────────────────────────────────
model.run()

# ── 6. Retrieve results ──────────────────────────────────────────────────────
tracker = model.get_instance("StateTracker")[0]

# tracker.I has shape (num_ticks, n_patches) when aggregation_level=0.
# Axis 0 = time (ticks), axis 1 = patch index.
I = tracker.I   # shape: (130, 5)

print("Mean infectious in each community (last 26 ticks = final year):")
for p, patch_id in enumerate(scenario["id"]):
    mean_I = float(I[-26:, p].mean())   # last year only
    print(f"  {patch_id}: {mean_I:.1f}")

Example 3 — Compartmental: R0 sweep with InfectionParams

Single population, three different R0 values, 2-year runs. Shows how to scale beta from the default to reach a target R0, using CompartmentalModel and a per-patch StateTracker.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import numpy as np
import polars as pl
from laser.measles.compartmental import CompartmentalModel, CompartmentalParams
from laser.measles.compartmental import InitializeEquilibriumStatesProcess, InfectionSeedingProcess
from laser.measles.compartmental import InfectionProcess, InfectionParams, StateTracker, StateTrackerParams
from laser.measles.scenarios import single_patch_scenario
from laser.measles import create_component

# Default beta (R0 ≈ 8 with default measles parameters).
# Scale it linearly to reach any other R0.
R0_DEFAULT   = 8.0
BETA_DEFAULT = 0.5714285714285714   # default beta shipped with InfectionParams

for target_r0 in [4.0, 8.0, 16.0]:

    # ── 1. Scenario ──────────────────────────────────────────────────────────
    scenario = single_patch_scenario(population=100_000, mcv1_coverage=0.0)

    # ── 2. Params ────────────────────────────────────────────────────────────
    # CompartmentalModel uses daily ticks: 730 ticks = 2 years.
    # There is no CompartmentalModel(scenario, num_ticks=730) shortcut.
    params = CompartmentalParams(num_ticks=730, seed=42, start_time="2000-01")

    # ── 3. Model construction ────────────────────────────────────────────────
    # The ONLY valid signature is CompartmentalModel(scenario, params).
    model = CompartmentalModel(scenario, params)

    # ── 4. Components ────────────────────────────────────────────────────────
    model.add_component(InitializeEquilibriumStatesProcess)
    model.add_component(InfectionSeedingProcess)

    # Scale beta to reach the desired R0.
    # InfectionParams accepts 'beta' directly — there is no 'beta_scale' field.
    scaled_beta = target_r0 * (BETA_DEFAULT / R0_DEFAULT)
    model.add_component(
        create_component(
            InfectionProcess,
            params=InfectionParams(beta=scaled_beta),
        )
    )

    model.add_component(
        create_component(
            StateTracker,
            params=StateTrackerParams(aggregation_level=0),
        )
    )

    # ── 5. Run ───────────────────────────────────────────────────────────────
    model.run()

    # ── 6. Retrieve results ──────────────────────────────────────────────────
    tracker = model.get_instance("StateTracker")[0]

    # tracker.I shape: (num_ticks, n_patches).
    # State index order in state_tracker: S=0, E=1, I=2, R=3.
    I = tracker.I[:, 0]   # single patch → 1-D array of length num_ticks
    print(f"R0={target_r0:.0f}: peak I = {int(I.max()):,} on day {int(I.argmax())}")

Gotchas & FAQ

This section documents common pitfalls when writing laser-measles models. If you encounter unexpected ImportError, tracker shape mismatches, or component configuration errors, check the items below first.

These issues occur frequently when users are learning the component system or adapting code between the ABM, biweekly, and compartmental models.

Where does create_component come from?

create_component is available from both the top-level laser.measles namespace and the shared laser.measles.components package, regardless of which model type you are using.

It lives in the shared components package because it works with all model types (ABM, biweekly, and compartmental), and is re-exported at the top level for convenience.

1
2
3
4
5
6
7
8
# PREFERRED (flattened public API)
from laser.measles import create_component

# ALSO SUPPORTED — re-exported from each model subpackage and shared components:
from laser.measles.abm import create_component
from laser.measles.biweekly import create_component
from laser.measles.compartmental import create_component
from laser.measles.components import create_component

How do I access component classes and their parameter classes?

Import component classes and their parameter classes directly from the subpackage. Each subpackage's __init__ re-exports everything from its components module, so all concrete components are available at the top level.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# PREFERRED — import directly from the subpackage
from laser.measles.abm import ABMModel, ABMParams
from laser.measles.abm import NoBirthsProcess, InfectionSeedingProcess, InfectionSeedingParams
from laser.measles.abm import InfectionProcess, StateTracker, StateTrackerParams
from laser.measles import create_component

model.components = [
    NoBirthsProcess,

    create_component(
        InfectionSeedingProcess,
        params=InfectionSeedingParams(target_patches=["patch_0"])
    ),

    InfectionProcess,

    create_component(
        StateTracker,
        params=StateTrackerParams(aggregation_level=0)
    ),
]

The same pattern applies to biweekly and compartmental — import directly from laser.measles.biweekly or laser.measles.compartmental.

Warning

Component and param classes are model-specific. InfectionParams, SIACalendarParams, NoBirthsProcess, and similar classes have different fields per model type and live in their respective subpackage. Do not import them from the shared laser.measles.components package or from the wrong model subpackage:

Do not import InfectionParams from laser.measles.components or from laser.measles directly — those paths raise ImportError. Always import model-specific classes from the correct model subpackage (laser.measles.abm, laser.measles.biweekly, or laser.measles.compartmental).

Do not import scenario helpers (single_patch_scenario, two_patch_scenario, two_cluster_scenario) from laser.measles.abm or any model subpackage — they are not there. Import them from laser.measles or laser.measles.scenarios:

1
2
3
4
5
6
7
8
9
# CORRECT — import each class from its own model subpackage
from laser.measles.abm import InfectionParams            # ABM variant
from laser.measles.biweekly import InfectionParams       # Biweekly variant
from laser.measles.compartmental import InfectionParams  # Compartmental variant

# Scenario helpers live at the top level or laser.measles.scenarios:
from laser.measles import single_patch_scenario, two_patch_scenario, two_cluster_scenario
# or equivalently:
from laser.measles.scenarios import single_patch_scenario

NoBirthsProcess and SIACalendarProcess exist in the ABM subpackage only — there is no equivalent in the biweekly or compartmental subpackages.

model.components is assigned after construction

The model constructors only accept scenario and params.

Components must be attached by assigning to model.components after the model object is created.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# CORRECT
model = BiweeklyModel(scenario=scenario, params=params)

model.components = [
    InitializeEquilibriumStatesProcess,
    ImportationPressureProcess,
    InfectionProcess,
    VitalDynamicsProcess,
    StateTracker,
]

The model internally instantiates the component classes when the list is assigned.

Do not pass components as a constructor argument — it raises TypeError: unexpected keyword argument "components". Always assign model.components as a separate statement after construction.

This applies to all three model types:

  • ABMModel
  • BiweeklyModel
  • CompartmentalModel

There is no lm object in laser.measles

The top-level laser.measles package does not export a convenience object such as lm.

Some tutorials or AI-generated examples use this alias, but it is not part of the package API.

Do not try from laser.measles import lm — it raises ImportError. Import the specific model class directly:

1
2
# CORRECT
from laser.measles.abm import ABMModel, ABMParams

StateTracker output shape depends on aggregation_level

The StateTracker component stores time-series data differently depending on how it is configured.

Default behavior (global aggregation)

Adding StateTracker without any params (or with aggregation_level=-1) sums across all patches. Do not pass aggregation_level=0 or aggregation_level=1 when you want global results — those activate per-patch or per-region tracking and will produce multi-dimensional arrays.

Arrays are 1-D with shape:

1
(num_ticks,)
1
2
3
tracker = model.get_instance("StateTracker")[0]

peak_I = int(tracker.I.max())

Patch-level tracking

If aggregation_level=0 is used, the tracker stores values per patch (for flat patch IDs with no ":" hierarchy).

Arrays become 2-D with shape:

1
(num_ticks, n_patches)
1
2
3
tracker = model.get_instance("StateTracker")[0]

peak_patch_0 = int(tracker.I[:, 0].max())

Retrieve the tracker instance after model.run():

1
tracker = model.get_instance("StateTracker")[0]

Cast NumPy scalars before building a Polars DataFrame

Tracker arrays are NumPy arrays, so operations like .max() return NumPy scalar types (np.int64, np.float64).

Polars expects Python primitive types when constructing row-oriented DataFrames. Passing NumPy scalars can trigger TypeError or DataOrientationWarning.

Do not pass NumPy scalar results (e.g. tracker.I[:, p].max()) directly to Polars DataFrame constructors — wrap with int() or call .item():

1
2
# CORRECT
rows.append([patch_id, int(tracker.I[:, p].max())])

An alternative is to use .item():

1
rows.append([patch_id, tracker.I[:, p].max().item()])

Components are classes, not instances

Components should be passed as classes, not instantiated objects.

The model constructs the component instances internally.

1
2
3
4
# CORRECT — pass the class, not an instance
model.components = [
    InfectionProcess
]

Do not instantiate components before adding them. Neither model.components = [InfectionProcess()] nor model.add_component(InfectionProcess()) works — the model constructs component instances internally. Passing an already-created instance causes TypeError: 'InfectionProcess' object is not callable.

If parameters are needed, use create_component:

1
2
3
4
5
6
model.components = [
    create_component(
        InfectionProcess,
        params=InfectionParams(beta=0.8)
    )
]

Scenario DataFrame must contain required columns

All models expect the scenario DataFrame to contain at least the following columns:

  • id — patch identifier
  • lat — latitude
  • lon — longitude
  • pop — population size
  • mcv1 — routine vaccination coverage

Missing columns will trigger a validation error when constructing the model.

1
2
3
4
5
6
7
scenario = pl.DataFrame({
    "id": ["patch_0"],
    "lat": [0.0],
    "lon": [0.0],
    "pop": [50000],
    "mcv1": [0.8],
})

Use laser.measles.scenarios.synthetic for test scenarios

The synthetic module provides ready-made scenario DataFrames for testing and development. It is available via several import paths:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Functions re-exported at the scenarios package level
from laser.measles.scenarios import single_patch_scenario, two_patch_scenario
from laser.measles.scenarios import two_cluster_scenario, satellites_scenario

# Access via the synthetic submodule
from laser.measles.scenarios import synthetic
scenario = synthetic.single_patch_scenario(population=50_000, mcv1_coverage=0.85)

# synthetic is also re-exported at the top level
from laser.measles import synthetic

# WRONG — laser_measles (underscore) does not exist
from laser_measles.scenarios import synthetic

Each function returns a polars.DataFrame with all required columns (id, lat, lon, pop, mcv1) already populated. Pass it directly to any model constructor:

1
2
3
4
5
6
from laser.measles.abm import ABMModel, ABMParams
from laser.measles.scenarios import single_patch_scenario

scenario = single_patch_scenario(population=50_000, mcv1_coverage=0.85)
params = ABMParams(num_ticks=365, seed=42)
model = ABMModel(scenario, params)

Warning

The patch IDs returned by the helper functions are 1-indexed, not 0-indexed:

  • single_patch_scenario()id = "patch_1" (not "patch_0")
  • two_patch_scenario()id = ["patch_1", "patch_2"]

If you pass target_patches=["patch_0"] to InfectionSeedingParams when using a helper-built scenario, the model will raise:

1
ValueError: Target patches not found in model: ['patch_0']

The safest approach is to omit target_patches entirely — it defaults to seeding all patches, which is correct for single-patch scenarios:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# PREFERRED — omit target_patches to seed all patches
model.add_component(InfectionSeedingProcess)

# If you need to specify a patch explicitly, read the ID from the scenario:
patch_id = scenario["id"][0]   # "patch_1" for single_patch_scenario
from laser.measles.abm import InfectionSeedingParams
from laser.measles import create_component
model.add_component(
    create_component(InfectionSeedingProcess,
                     params=InfectionSeedingParams(target_patches=[patch_id]))
)

Available helpers: single_patch_scenario, two_patch_scenario, two_cluster_scenario, satellites_scenario. See the API reference for full parameter details.

Retrieval of results from StateTracker

The StateTracker component does not expose a .data, .results, or .to_polars() attribute. These names do not exist.

After model.run(), retrieve the tracker instance with model.get_instance("StateTracker")[0] and access the time-series arrays directly as properties.

Global tracker (default, aggregation_level=-1):

1
2
3
4
5
6
7
# CORRECT — add the class, retrieve via get_instance, access .I
model.add_component(StateTracker)
model.run()

tracker = model.get_instance("StateTracker")[0]
peak_I = int(tracker.I.max())          # global infectious peak
peak_day = int(tracker.I.argmax())     # day of peak

Per-patch tracker (aggregation_level=0):

StateTrackerParams is available from all model subpackages:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from laser.measles import create_component
from laser.measles.abm import StateTracker, StateTrackerParams          # ABM
# or: from laser.measles.biweekly import StateTracker, StateTrackerParams
# or: from laser.measles.compartmental import StateTracker, StateTrackerParams

model.add_component(
    create_component(
        StateTracker,
        params=StateTrackerParams(aggregation_level=0),
    )
)
model.run()

tracker = model.get_instance("StateTracker")[0]
st = tracker.state_tracker   # shape: (n_states, n_ticks, n_patches)
# State index order: S=0, E=1, I=2, R=3
peak_I_patch0 = int(st[2, :, 0].max())   # patch 0 infectious peak

Global + per-patch together (add both, retrieve by index):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from laser.measles.abm import StateTracker, StateTrackerParams

model.add_component(StateTracker)           # index [0] — global
model.add_component(
    create_component(
        StateTracker,
        params=StateTrackerParams(aggregation_level=0),
    )
)                                                      # index [1] — per-patch
model.run()

global_tracker = model.get_instance("StateTracker")[0]
patch_tracker  = model.get_instance("StateTracker")[1]

The following attributes do not exist on any tracker and will raise AttributeError: tracker.data, tracker.results, tracker.to_polars(), tracker.df. Use get_dataframe() for global trackers or .state_tracker for per-patch trackers.

VitalDynamicsProcess must be the first component

When using vital dynamics (births and deaths), VitalDynamicsProcess must be the first component added to the model.

This is because VitalDynamicsProcess calls calculate_capacity to pre-allocate the LaserFrame with enough headroom for the births that will occur over the simulation. If any other component is added first, the LaserFrame is already initialized at the wrong size, which causes a crash.

1
2
3
4
5
6
# CORRECT
model.add_component(VitalDynamicsProcess)        # FIRST
model.add_component(InitializeEquilibriumStatesProcess)
model.add_component(ImportationPressureProcess)
model.add_component(InfectionProcess)
model.add_component(StateTracker)

Do not add InitializeEquilibriumStatesProcess or any other component before VitalDynamicsProcess. If VitalDynamicsProcess is not first, the LaserFrame is already initialized at the wrong capacity and will crash at runtime.

lat and lon columns must be Float64, not Int64

The scenario schema requires lat and lon to be floating-point. Using Python's range() or integer literals produces Int64 columns, which fail Polars schema validation when the model is constructed.

Do not use [0] * N or list(range(N)) for lat/lon columns — Python integer lists produce Int64 which fails schema validation. Always use float literals:

1
2
3
4
5
6
7
8
# CORRECT — explicit float literals
scenario = pl.DataFrame({
    "id":   [f"patch_{i}" for i in range(5)],
    "pop":  [10_000] * 5,
    "lat":  [0.0] * 5,
    "lon":  [float(i) for i in range(5)],
    "mcv1": [0.0] * 5,
})

Tick granularity: Daily vs. biweekly

ABMModel and CompartmentalModel use daily ticks (1 tick = 1 day). BiweeklyModel uses 14-day ticks (1 tick = 2 weeks, 26 ticks = 1 year).

Scale num_ticks accordingly:

1
2
3
4
# 5 years
ABMParams(num_ticks=5 * 365)          # 1825 daily ticks
BiweeklyParams(num_ticks=5 * 26)      # 130 biweekly ticks
CompartmentalParams(num_ticks=5 * 365) # 1825 daily ticks

Scenario id must be a string; pop must be Int32

Two dtype requirements that produce cryptic errors if violated:

id must be a string (str / Utf8), not an integer. Python list comprehensions like [0, 1, 2] produce Int64, which fails schema validation. Use string patch IDs:

Do not use integer lists for id[0, 1, 2] produces Int64 which fails schema validation. Always use string patch IDs:

1
2
# CORRECT — string id
scenario = pl.DataFrame({"id": ["patch_0", "patch_1", "patch_2"], ...})

pop (and all integer columns) must be Int32, not the default Int64. Python integer lists and np.array(...) without a dtype both produce Int64:

Do not use plain Python integer lists for pop[100_000, ...] produces Int64 which fails schema validation. Use np.array(..., dtype=np.int32):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import numpy as np, polars as pl

# CORRECT — explicit Int32 via numpy
scenario = pl.DataFrame({
    "pop": np.array([100_000, 80_000, 60_000], dtype=np.int32),
    ...
})

# ALSO CORRECT — build with defaults then cast
scenario = pl.DataFrame({"pop": [100_000, 80_000, 60_000], ...}).with_columns(
    pl.col("pop").cast(pl.Int32)
)

The scenario helper functions (single_patch_scenario, two_patch_scenario, etc.) handle these dtypes correctly and are the safest way to build test scenarios.

Do NOT add TransmissionProcess separately when using InfectionProcess (ABM)

InfectionProcess already instantiates TransmissionProcess internally and registers the etimer property on the population. Adding TransmissionProcess as a separate component causes a ValueError: Property 'etimer' already exists.

Do not add TransmissionProcess separately — InfectionProcess already creates it internally. Adding TransmissionProcess before or alongside InfectionProcess causes ValueError: Property 'etimer' already exists.

1
2
# CORRECT — InfectionProcess is self-contained; add it alone
model.add_component(InfectionProcess)

The same applies to any component that is a sub-component of another: check the docs to see which components are stand-alone vs. internally managed.

StateTracker values are StateArray objects, not plain Python scalars

When you index into a tracker's .S, .I, .R (etc.) arrays you get a StateArray, not a float. Passing a StateArray to an f-string format spec (e.g. f"{val:.4f}") raises TypeError: unsupported format string.

Always extract a Python scalar first:

Do not use tracker.I[tick] directly in f-string format specs like f"{frac:.4f}"StateArray does not support format specs and raises TypeError.

1
2
3
# CORRECT — call float() or .item() to get a plain Python float
frac = float(tracker.I[tick])              # or tracker.I[tick].item()
print(f"infected fraction: {frac:.4f}")

For per-patch trackers (aggregation_level=0) the shape is (n_states, n_ticks, n_patches) — index with [state_idx, tick, patch_idx] and wrap with int() or float() before arithmetic or formatting.

SIA schedule date column must use datetime.date values, not strings

SIACalendarProcess filters the schedule by comparing a polars date column to the current simulation date. If the column contains Python str values (e.g. "2024-06-01") rather than datetime.date objects, polars raises:

1
InvalidOperationError: cannot compare 'date/datetime/time' to a string value

Build the schedule with datetime.date objects (or cast the column):

Do not use string literals like "2024-06-01" for the date column — polars raises InvalidOperationError when comparing a string column to a date. Always use datetime.date objects:

1
2
3
4
5
6
7
8
9
import datetime, polars as pl

# CORRECT — use datetime.date objects
sia_df = pl.DataFrame({
    "date": [datetime.date(2024, 6, 1), datetime.date(2025, 6, 1)],
    ...
})
# OR cast after construction
sia_df = sia_df.with_columns(pl.col("date").str.to_date())

Read the age distribution data from AgePyramidTracker

AgePyramidTracker stores snapshots in its .age_pyramid dict, keyed by date string ("YYYY-MM-DD"), with numpy histogram arrays as values. There is no .counts attribute.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from laser.measles.abm import AgePyramidTracker, AgePyramidTrackerParams

model.add_component(AgePyramidTracker)   # default: yearly snapshots

model.run()

# Retrieve the tracker instance
apt = model.get_instance(AgePyramidTracker)[0]

# Iterate over snapshots  {date_str: np.ndarray of counts per age bin}
for date_str, counts in apt.age_pyramid.items():
    print(f"{date_str}: total tracked = {counts.sum()}, bins = {counts}")

# Compare start vs end
dates = sorted(apt.age_pyramid.keys())
start_counts = apt.age_pyramid[dates[0]]
end_counts   = apt.age_pyramid[dates[-1]]
change = end_counts.astype(float) - start_counts.astype(float)

The bin edges are set by AgePyramidTrackerParams.age_bins (in days). Default bins come from pyvd.constants.MORT_XVAL[::2].

Per-patch attack rates from StateTracker (multi-patch models)

When using a per-patch tracker (aggregation_level=0), the raw array has shape (n_states, n_ticks, n_patches). To compute attack rates per patch at the end of a run:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import numpy as np
from laser.measles.abm import StateTracker, StateTrackerParams

# Add per-patch tracker
model.add_component(StateTracker,
                    params=StateTrackerParams(aggregation_level=0))

model.run()

st = model.get_instance(StateTracker)[1]   # index 1 = per-patch tracker
# state_tracker shape: (n_states, n_ticks, n_patches)
arr = st.state_tracker   # e.g. shape (5, 365, 10) for 10 patches

# State indices (check StateTracker docs for your model)
S_IDX, I_IDX, R_IDX = 0, 2, 3   # typical ABM order: S E I R D

initial_S = arr[S_IDX, 0, :]    # shape (n_patches,)
final_R   = arr[R_IDX, -1, :]   # shape (n_patches,)
pop       = initial_S            # approx total population per patch at t=0

attack_rate = final_R / pop      # shape (n_patches,) — fraction ever infected

# Pop must come from the scenario, NOT from tracker, for the denominator:
pop_from_scenario = scenario["pop"].to_numpy()   # Int32 array, shape (n_patches,)
attack_rate = final_R / pop_from_scenario.astype(float)

Key rule: the number of patches in the scenario must equal n_patches in the tracker array. Do not mix a 100-patch scenario with a tracker configured for 2 patches, or vice versa.

two_cluster_scenario returns 100 patches by default (2 × 50)

two_cluster_scenario(n_nodes_per_cluster=50) creates 100 patches (2 clusters × 50 nodes each). A per-patch StateTracker will have shape (n_states, n_ticks, 100). Using a global tracker and indexing [-1] gives shape (n_states,) which cannot be divided by a 100-element pop array.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from laser.measles.scenarios import two_cluster_scenario
from laser.measles.biweekly import BiweeklyModel, BiweeklyParams
from laser.measles.biweekly import StateTracker, StateTrackerParams

scenario = two_cluster_scenario()   # 100 patches

params = BiweeklyParams(...)
model  = BiweeklyModel(scenario, params)

# Add per-patch tracker
model.add_component(StateTracker,
                    params=StateTrackerParams(aggregation_level=0))
model.run()

st = model.get_instance(StateTracker)[0]
arr = st.state_tracker                     # (n_states, n_ticks, 100)

# Attack rate per patch (biweekly model state order: S=0, I=1, R=2)
initial_S = arr[0,  0, :].astype(float)   # shape (100,)
final_R   = arr[2, -1, :].astype(float)   # shape (100,)
pop       = scenario["pop"].to_numpy().astype(float)   # shape (100,)
attack_rate = (initial_S - arr[0, -1, :]) / pop        # fraction ever infected

For a smaller scenario pass n_nodes_per_cluster:

1
scenario = two_cluster_scenario(n_nodes_per_cluster=5)  # 10 patches

Multiprocessing workers must be defined at module level

Python's multiprocessing module uses pickle to transfer functions to worker processes. Functions defined inside another function (closures / nested defs) cannot be pickled and will raise:

1
AttributeError: Can't pickle local object 'run_all_models.<locals>.worker'

Define worker functions at the top level of the module, not inside another function:

Do not define worker functions inside another function (closures / nested defs) — they cannot be pickled and raise AttributeError: Can't pickle local object. Define the worker at the top level of the module:

1
2
3
4
5
6
7
# CORRECT — top-level function is picklable
def _worker(model_type):
    ...

def run_all_models():
    with Pool() as p:
        results = p.map(_worker, model_types)  # works

Alternatively, use concurrent.futures.ProcessPoolExecutor with functools.partial if you need to pass extra arguments.

Scenario helpers are in laser.measles or laser.measles.scenarios, not in subpackages

Scenario generators (single_patch_scenario, two_patch_scenario, two_cluster_scenario, etc.) are exported from laser.measles and laser.measles.scenarios. They are not available from the model-specific subpackages (laser.measles.abm, laser.measles.biweekly, etc.).

Do not import scenario helpers from laser.measles.abm, laser.measles.biweekly, or laser.measles.compartmental — they are not defined there and will raise ImportError. Always import them from laser.measles or laser.measles.scenarios:

1
2
3
4
# CORRECT
from laser.measles import single_patch_scenario
# or
from laser.measles.scenarios import single_patch_scenario

SIACalendarParams.aggregation_level must be ≥ 1

SIACalendarParams validates that aggregation_level >= 1. Passing 0 raises:

1
ValueError: aggregation_level must be at least 1

Use aggregation_level=1 for flat (single-level) hierarchies:

1
2
from laser.measles.abm.components import SIACalendarParams
params = SIACalendarParams(aggregation_level=1, sia_schedule=schedule_df, ...)

For hierarchical IDs like "country:state:lga", use aggregation_level=3.

Custom components added via add_component must accept verbose

ABMModel.add_component(ComponentClass) instantiates the class as ComponentClass(model, verbose=False). Any custom component class must accept verbose as a keyword argument or the framework raises:

1
TypeError: MyTracker.__init__() got an unexpected keyword argument 'verbose'

Always include verbose=False in custom component __init__:

1
2
3
4
class MyTracker:
    def __init__(self, model, verbose: bool = False):
        self.model = model
        # ...

25. model.people has date_of_birth, not age

The ABM people LaserFrame stores date_of_birth (in ticks), not an age column. Accessing model.people.age raises AttributeError. To get age in years at a given tick:

Do not access model.people.age — that attribute does not exist and raises AttributeError. Use date_of_birth (stored in ticks) instead:

1
2
3
4
5
# CORRECT — date_of_birth is stored in ticks
dob = model.people.date_of_birth[model.people.active.view(bool)]
current_tick = model.params.num_ticks - 1
age_ticks = current_tick - dob
age_years  = age_ticks / 365.0

Available people properties: state, susceptibility, patch_id, active, date_of_birth, date_of_vaccination.

Scenario pop column must be integer (Int32), not float

The scenario DataFrame validator requires pop to be an integer type. Passing a float column raises:

1
ValueError: DataFrame validation error: Column 'pop' must be integer type

Cast pop to Int32 when building a scenario:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import polars as pl

scenario = pl.DataFrame({
    "id":   ["patch_0", "patch_1"],
    "lat":  [0.0, 1.0],
    "lon":  [0.0, 1.0],
    "pop":  pl.Series([100_000, 50_000], dtype=pl.Int32),
    "mcv1": [0.8, 0.7],
})
# or cast after the fact:
scenario = scenario.with_columns(pl.col("pop").cast(pl.Int32))

Polars with_column (singular) was removed — use with_columns

Older Polars had DataFrame.with_column(expr) (singular). Current Polars only has with_columns(*exprs) (plural). Using the singular form raises:

1
2
AttributeError: 'DataFrame' object has no attribute 'with_column'.
Did you mean: 'with_columns'?

Always use the plural form with_columns (not with_column):

1
2
# CORRECT
df = df.with_columns(pl.col("pop").cast(pl.Int32))

get_mixing_matrix() takes no arguments — pass scenario at construction

All mixing models (GravityMixing, RadiationMixing, etc.) accept the scenario at construction time, not at get_mixing_matrix() call time. Calling mixer.get_mixing_matrix(scenario) raises:

1
TypeError: BaseMixing.get_mixing_matrix() takes 1 positional argument but 2 were given

Correct pattern:

1
2
3
4
from laser.measles import RadiationMixing, RadiationParams

mixer = RadiationMixing(scenario=scenario, params=RadiationParams())
mixing_matrix = mixer.get_mixing_matrix()   # no arguments

lookup_state_idx does not exist — use params.states.index()

There is no lookup_state_idx function exported from laser.measles. To find state indices, use the states list on the model params:

1
2
3
4
params = BiweeklyParams(...)   # or ABMParams, CompartmentalParams
S_IDX = params.states.index('S')
I_IDX = params.states.index('I')
R_IDX = params.states.index('R')

For the biweekly model the default order is ['S', 'I', 'R'] (indices 0, 1, 2).

AgePyramidTracker.age_pyramid is a dict keyed by date strings — not an array

AgePyramidTracker.age_pyramid returns a dict[str, np.ndarray] where the keys are date strings (e.g. "2000-01-01"). Indexing with an integer raises KeyError:

Do not index age_pyramid with integers — it is a dict, not a list. tracker.age_pyramid[0] raises KeyError: 0. Use dict access:

1
2
3
keys = list(tracker.age_pyramid.keys())   # sorted date strings
start_pyramid = tracker.age_pyramid[keys[0]]   # first recorded date
end_pyramid   = tracker.age_pyramid[keys[-1]]  # last recorded date

Or iterate:

1
first_array = next(iter(tracker.age_pyramid.values()))

numpy has no cummax — use np.maximum.accumulate

np.cummax does not exist in NumPy. The equivalent is np.maximum.accumulate:

Do not use np.cummax — it does not exist in NumPy and raises AttributeError. Use np.maximum.accumulate instead:

1
2
# CORRECT
result = np.maximum.accumulate(arr)

AgePyramidTracker.age_pyramid key format — do not hard code date strings

The keys of age_pyramid are date strings generated internally and may not match the format you expect (e.g. '2005-01-01' vs '2005-1-1'). Always retrieve keys dynamically:

1
2
3
keys = sorted(tracker.age_pyramid.keys())
start_pyramid = tracker.age_pyramid[keys[0]]   # first snapshot
end_pyramid   = tracker.age_pyramid[keys[-1]]  # last snapshot

Never do tracker.age_pyramid['2005-01-01'] — use keys[-1] instead.

Never pass a plain dict as params to create_component or model constructors

All params objects (ABMParams, BiweeklyParams, InfectionParams, etc.) are Pydantic models, not plain dicts. Passing a dict raises AttributeError immediately at model construction — BaseLaserModel.__init__ accesses params.verbose and params.start_time before any component runs.

Always instantiate the typed params class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# CORRECT — use the typed Pydantic class
from laser.measles.abm import InfectionProcess, InfectionParams
from laser.measles.abm import InfectionSeedingProcess, InfectionSeedingParams
from laser.measles import create_component

model.components = [
    create_component(
        InfectionSeedingProcess,
        params=InfectionSeedingParams(target_patches=["patch_0"])
    ),
    create_component(
        InfectionProcess,
        params=InfectionParams(beta=1.2)
    ),
]

Do not write params={"beta": 1.2} — this will fail immediately at model construction with AttributeError: 'dict' object has no attribute 'verbose'.

Do not use try/except import blocks or dict fallbacks for params

Do not write defensive import blocks like:

1
2
3
4
try:
    InfectionParams = ...
except ImportError:
    InfectionParams = None

and then fall back to passing a dict as params. These fallback patterns produce broken code. If an import fails, fix the import path rather than working around it. Consult gotcha #2 for correct import paths.