-
Notifications
You must be signed in to change notification settings - Fork 8
Release 1.1: Batch Rework, Custom Individuals & Intervention Overhaul #85
Welcome to the 1.1 release! This release rebuilds the batch API around a streaming, memory-bounded processing model, adds support for custom per-individual attributes, and overhauls the intervention engine for type-stable, allocation-free execution of measures.
Note that the batch rewrite and parts of the intervention internals contain breaking changes. Please read the relevant sections below before upgrading.
Batch Rework
The Batch workflow has been redesigned so that memory no longer scales with the number of runs. Instead of instantiating and retaining a Simulation (and later a ResultData) per run, a Batch now stores lightweight simulation configurations and folds each run's results into running accumulators as it goes.
- Streaming, memory-bounded processing:
BatchProcessornow aggregates results using Welford online statistics (mean, variance, min/max, 95% CI). Each run is executed, accumulated, and discarded, so peak memory is proportional to a single run rather than to the size of the batch. A newWelfordStateaccumulator and helpers (welford_update!,welford_to_aggregate,welford_df_to_stats_df,welford_df_to_stats_df_multicol) back this model and produce the same aggregate schema as the existingaggregate_*utilities. - Configuration-based
Batch:Batchnow holds a vector of simulation-configNamedTuples and per-run setup functions instead ofSimulationobjects. Create one withBatch(n_runs = N; simargs...), wheresimargsare any keyword arguments accepted bySimulation(). An optionalsetup = sim -> ...hook lets you attach interventions, strategies, or triggers to each run after construction but before it executes. process!replacesrun!'s old semantics:process!(batch; ...)runs every configuration sequentially and returns aBatchProcessor.run!(::Batch)is retained as an alias forprocess!(it now returns aBatchProcessorrather than mutating and returning theBatch).- Per-simulation seeding: Every run receives a deterministic seed derived from a single master seed. Pass
seed = Ntoprocess!/BatchDatafor fully reproducible batches, and retrieve the seed actually used viaseed(bp). - Representative (median) run: Pass
median_by = pp -> ...to select a criterion (e.g. total infections); the run whose criterion is closest to the median across the batch is re-run in full and stored asmedian_run(bp). For multi-group batches, one median run is computed per group. Disable withmedian_by = nothing(the default). - Per-label / per-group aggregation: Pass
group_by = :label(or any config field) to accumulate statistics separately per group, accessible viaper_label(bd)/per_group.gemsplot(bd)then draws one mean±CI ribbon per label automatically. BatchData-native plots:gemsplot(bd::BatchData)now dispatches directly to mean±CI ribbon plots built from the streamed accumulators — no need to retain every per-runResultData.BatchData(b)remains a convenient shorthand forBatchData(process!(b)).keep_rundatafor individual runs: Individual run traces are retained by default in the lightweightLightRDformat and are available viaruns(bd). Passkeep_rundata = falseto drop them entirely for minimum memory.customloggerpassthrough:process!(batch; customlogger = ...)attaches an independent copy of aCustomLoggerto each run, so logged data is never shared across runs.- New accessors:
BatchProcessor/BatchDatagaincumulative_cases,dark_figure,generation_times,n_runs,r0,median_run,seed, and more.
Breaking changes
Batchno longer storesSimulationobjects. UseBatch(n_runs = N, kwarg = val, ...)andsimconfigs(batch)/simsetups(batch).simulations(batch)has been removed; usesimconfigs(batch).add!(::Simulation, ::Batch)andremove!(::Simulation, ::Batch)have been removed; useadd!(::NamedTuple, ::Batch).ResultData(::Batch)andPostProcessor(::Batch)have been removed; useBatchData(::Batch).gemsplot'ssplitlabelis renamedsplitgroup, and thecombined = :bylabeloption is renamedcombined = :bygroup. The old names are no longer exported.customlogger!(::Batch, ::CustomLogger)has been removed; passcustomloggertoprocess!instead.- Several
BatchProcessoraccessors that required storing everyResultData(config_files,pathogens,settingdata,population_pyramid,setting_age_contacts,strategies,run_ids,runtime,allocations,extract,extract_unique, ...) have been removed. Usesimconfigs(batch)for configuration metadata, or process withkeep_rundata = trueand read per-run data viarundata(bp).
See the [batch tutorial](https://immidd.github.io/GEMS/stable/tut_batches/) for the updated workflow and migration examples.
Custom Individual Extensions
You can now attach arbitrary per-agent attributes to Individuals without modifying the core struct, by passing ind_extension to the Population or Simulation constructor. Extension fields behave exactly like built-in fields, and every existing GEMS function that accepts an ::Individual continues to work unchanged.
- Three ways to define extensions: (1) named columns — pass a vector of column names that already exist in the population DataFrame (e.g.
ind_extension = [:my_attr]), whose values are read from each row and wrapped automatically; (2) a separate extensionDataFramejoined byid(individuals absent from the table receive zero-filled values with a warning); (3) a factory functionind -> extthat computes each individual's extension from its base attributes — it may return any struct, including a@kwdefstruct constructed by keyword arguments when the struct has many fields. - Transparent field access: Custom attributes are read and written like core fields (
ind.my_attr,ind.my_attr = 0.9). Access is implemented viagetproperty/setproperty!overloads; for literal core-field names the dispatch is constant-folded and inlines to a singlegetfield, so built-in field access keeps its original performance. Extension data is held in a boxedextensions::Anyslot, wrapped in anAutoExtensionNamedTuple for the named-column/DataFrame paths. Populationfrom aDataFrame:Simulation'spopulationargument now also accepts aDataFramedirectly (in addition to a path, identifier, orPopulationobject), andPopulation(df; ind_extension = ...)builds individuals in parallel as before.- Round-trips to
DataFrame:dataframe(population)appends extension fields as additional columns after the base columns, taking the schema from the first extended individual. - Collision safety: Extension field names must not clash with core
Individualfields. Collisions are rejected at load time with an explicit error, preventing a custom field from silently shadowing a built-in attribute and keeping the population exportable. - Type-stability caveat: Because the
extensionsslot is typedAny, reading custom fields is type-unstable. This is negligible in dynamically-dispatched code such as customtransmission_probabilityorsample_contacts!methods, but for heavy per-contact computation on custom fields you can recover full type stability with a function barrier.
See the [Custom Individual Extensions](https://immidd.github.io/GEMS/stable/tut_configfiles/#Custom-Individual-Extension) section of the config-file tutorial.
Intervention Engine Optimization
The intervention pipeline — the event queue, strategy callbacks, and per-tick setting iteration — has been reworked to be type-stable and allocation-free on the hot path. This adds a dependency on FunctionWrappers.jl.
- Tick-bucketed event queue:
EventQueuehas been rewritten from a sorted-vector priority queue (withO(N)searchsortedfirst+insert!) into a "calendar" queue of per-tick buckets.enqueue!is now anO(1)push!into the relevant tick bucket, and the queue is drained in tick order via a forward-advancing head pointer. Newpeektickandempty!(capacity-retaining) functions are provided;firstis no longer part of the public queue API (usepeek). - Type-stable strategy callbacks: Strategy conditions, delays, and the per-measure
process_measurecalls are now wrapped in concreteFunctionWrappertypes (IPredicate/SPredicate,IDelayFn/SDelayFn,IProcessFn/SProcessFn).MeasureEntryis now parameterized on the focal object type (IndividualorSetting), keeping each strategy's measure vector concretely typed. Theprocess_measurecallback for each measure is built once atadd_measure!time — where the concrete measure type is statically known — and stored on the event, turning the former dynamicprocess_measuredispatch into a fixed indirect call.IMeasureEvent/SMeasureEventare now immutable structs carrying this prebuilt callback. - Type-stable setting iteration:
SettingsContainernow stores each setting type in a concretely-typedVector{T}(the dict value type was widened fromVector{Setting}toVector). A newforeach_setting_vectoriterates the built-in setting types (BUILTIN_SETTING_TYPES) with full type stability and no dynamic dispatch, and a newsettings(sim, ::Type{T})accessor returns a typedVector{T}.step!,open!,close!, and the tick triggers now route through these type-stable paths via function barriers. - Compile-time debug switch: All
@debuglogging on the intervention hot path is now gated behind theINTERVENTION_DEBUGconstant (defaultfalse), so the logging machinery is fully elided in normal runs. Flip it totruewhen debugging. FindMembersfast path: Theselectionfilteris now wrapped as anIPredicatewith ahas_filterflag and a_select_allsentinel default. When no custom filter is supplied, the filtering pass is skipped entirely, andprocess_measureno longer rebuilds the member list multiple times.
Note for API users
condition(strategy) and condition(measure_entry) now return a wrapped predicate (IPredicate/SPredicate) rather than a bare Function. The wrappers are callable exactly like the original closures, but code that asserted condition(str) isa Function or compared the stored condition by identity will need updating.
Bugfixes
- Disease progression
InexactError: Transition-time calculations across all progression categories (Asymptomatic,Symptomatic,Severe,Hospitalized,Critical) now useround(Int16, ...)instead ofInt16(...). Previously, a sampled transition duration that wasn't already a whole number would throw anInexactError; the values are now rounded to the nearest tick.death_probabilityis also explicitly cast toFloat64before comparison, andDiseaseProgression's default keyword values are nowInt16-typed.
Other Changes & API Cleanup
- Internal
PostProcessorconstructor removed: The undocumented 7-argumentPostProcessor(simulation, population, infections, vaccinations, deaths, tests, quarantines)reconstruction constructor has been removed. - Expanded test coverage: This release adds substantial new tests across interventions, settings, populations, simulations, batches, reporting, and post-processing — including the new tick-bucketed event queue, type-stable accessors, the
Dict-basedSimulationconstructor, and the batch streaming pipeline.
This discussion was created from the release Release 1.1: Batch Rework, Custom Individuals & Intervention Overhaul.