Release 1.2: The Multipathogen Update #98
Julian-Patzner
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Welcome to the 1.2 release! This release turns GEMS from a single-disease simulator into a multipathogen framework: an arbitrary number of pathogens can now circulate, interact, and co-infect the same population within a single run. On top of that core change it introduces a composable transmission system, a wider set of shipped transmission functions and modifiers (cross-immunity, viral interference, and seasonal forcing), customizable infectiousness and immunity profiles, per-pathogen seeding and recurring case importation, a full separation of host-level care and mortality from per-pathogen disease progression, and a contact-tracing index that makes
TraceInfectiousContactsthree to four orders of magnitude faster.Backwards compatibility was a design goal throughout. Old-format configs and single-pathogen scripts are auto-detected and rerouted through a self-contained legacy layer, so existing work should keep running unchanged. The breaking surface that remains is narrow and listed immediately below.
Upgrading
Most code needs no changes. These are the exceptions:
Symptomaticwas renamed toMild. The old name still works, sinceSymptomaticis retained as a delegating compatibility type, but preferMildin new code.Hospitalizedis no longer a disease progression tier. Hospitalization is now a host-level decision made by aHealthProgression. TheHospitalizedcategory survives in the legacy layer for old configs. If you are porting a config to the new format rather than leaning on that layer, care parameters have two destinations: the top-level[HealthProgression]section, or, for a single-pathogen simulation, embedded directly on theSevereandCriticalcategories, from where they are collected into the default policy automatically.hospital_admission,hospital_discharge,icu_admission,icu_discharge,ventilation_admission, andventilation_discharge. Care is no longer a property of one infection; usehealth_episodes(rd)for reconstructed stays.infections()DataFrame changed columns. Seven columns were dropped: the six per-infection care ticks (hospital_admission,hospital_discharge,icu_admission,icu_discharge,ventilation_admission,ventilation_discharge) anddeath, since care and mortality are host-level now. Usehealth_episodes(rd)for stays. Five columns were added:pathogen_id,progression_category,critical_onset,critical_offset, andremoved, the tick at which the infection ended, whether by recovery or by the host death that cut it short. Post-processing that read the dropped columns must be updated.compartment_periodschanged columns. Thehospitalized,icu, andventilateddurations were replaced by a singlecriticalduration, because those are host-level periods rather than per-infection ones. The same applies toaggregated_compartment_periods. Care durations now come fromhealth_episodes(rd).TestTypeandSeroprevalenceTestTypetake apathogen_id::Int8instead of aPathogenobject, and the accessor ispathogen_id(tt)rather thanpathogen(tt). Note thatpathogenis still exported as aSimulationaccessor, so calling it on a test type fails at the call site rather than at import.pathogen = "Covid19", orpathogen = ALL_PATHOGENSto seed every pathogen.Agent,Entity,Parameter,pathogen!,progression, andlast_reported_atare no longer exported.structs/andmethods/were dissolved into domain folders; see Repository Restructure.Multipathogen Core
The simulation no longer tracks a single infection per agent. Each individual can carry multiple concurrent infections, one per distinct pathogen, and the entire disease timeline moved off the
Individualstruct into dedicated infection records.InfectionState: An immutable,isbitsrecord holding one infection's complete timeline (exposure,infectiousness_onset,symptom_onset, …,recovery), itspathogen_id, a precomputed per-tickinfectiousness::Int8, and anactiveflag.ImmunityStatemirrors this for immunity records, tagged withIMMUNITY_SOURCE_NATURALorIMMUNITY_SOURCE_VACCINE.infected::Boolsemantics were replaced withactive_pathogens_mask::UInt32anddetected_mask::UInt32onIndividual, where bitid - 1flags active infection and detection for a pathogen. This gives O(1) "is this agent infected with pathogen X?" queries without touching a registry, and the width of those masks is what setsMAX_PATHOGENS = 32(shared with the test-registry key packing). Pathogen ids are validated and auto-assigned as a contiguous1..Nrange by_finalize_pathogen_ids!, whether pathogens arrive from a config file or as a user-supplied tuple.infected!(ind, pid, val)anddetected!(ind, pid, val), giving one place to enforce the rule thatinfect!upholds: at most one active infection per(individual, pathogen)pair, with a concurrent re-infection by the same pathogen warned and skipped rather than overwriting the existing one.Record Storage & Lock-Free Concurrency
Those records live in two places: a small inline cache on the agent for the common case, and three new registry types holding what does not fit.
InfectionRegistryandImmunityRegistryshare one design;TestRegistryis deliberately different.Individualcarries a small inlineinfection_cache::NTuple{INFECTIONS_CACHE_SIZE, InfectionState}(and a parallelimmunity_cache) plus aninfection_head::Int32/immunity_head::Int32pointer into a registry-backed linked list holding any additional concurrent infections. The common single-infection case stays allocation-free and cache-local, while co-infection is unbounded. Both cache sizes are tunable constants (default1).InfectionRegistryis astates::Vector{InfectionState}plus afree_slots::Vector{Int32}free list, andImmunityRegistrymirrors it forImmunityState. The overflow chain hanging offinfection_headis a linked list of indices into that vector, not heap-allocated nodes, so walking a co-infected agent's records stays inside one contiguous array. Registries can be pre-sized at construction withoverflow_fraction, which reserves capacity for the expected share of co-infected agents, divided across shards.free_slotsand reused by the next insertion instead of appending tostates. Registry size therefore settles at the peak number of concurrent overflow records, not the cumulative number of infections over the run.Simulationas aVectorsharded by_owner_shard(id) = mod(id - 1, maxthreadid()) + 1, so every agent deterministically belongs to exactly one shard. Element-wise reads and writes during the threaded update and spread phases are therefore contention-free without locking._PendingInfection/_EndedInfectionrecords and applied in a per-shard threaded flush (flush_pending_infections!,flush_ended_infections!) between phases. The parallel hot loops stay purely element-wise. Overflow unlinks are ordered before cache promotions, so an ended overflow record can never be resurrected into a freed cache slot.each_infectionandeach_immunityreturn type-constrainedInfectionIterator/ImmunityIteratorvalues that resolve shards lazily and walk an individual's inline cache and overflow chain, presenting all active records through one uniform interface regardless of where they live.TestRegistryis a dictionary, not a sharded vector: Test state is sparse, since only tested agents have any, so it is held as aDict{UInt64, TestState}keyed by a packed composite of individual id and pathogen id. This is the packing thatMAX_PATHOGENSbounds alongside the bitmasks.get_test_state(ind, reg, pid)returns a defaultTestState()for a pair that was never tested rather than inserting one, so reads never grow the dictionary.natural_immunity_recorded/vaccine_immunity_recorded,natural_immunity_active/vaccine_immunity_active,natural_immunity_pending/vaccine_immunity_pending, andimmunity_activedistinguish an immunity that exists, one that is currently protective, and one still building up.Type-Stable Pathogen Dispatch
Supporting many pathogens naively would put a dynamic dispatch on every contact. Instead, the pathogen set is encoded in the type system.
Pathogen{PRG, PA, TF, IP, IM}: A pathogen carries the concrete types of its progressions tuple, progression-assignment function, transmission function, and infectiousness and immunity profiles, so every dispatch on it resolves to a concrete method.Simulation{P<:Tuple, HP<:HealthProgression}: Pathogens are stored as a statically-typed heterogeneous tuple and the health policy as a second type parameter, making the whole pathogen and care configuration part of the simulation's type.@generatedlookup:get_pathogen(sim, pid)unrolls the tuple search at compile time, recovering the concretePathogen{…}type even when indexed by a runtimepid::Int8. The spread and progression loops specialize on each pathogen's concrete transmission, infectiousness, and immunity types instead of paying dynamic dispatch per contact.Pathogens: API & Configuration
Simulation(pathogens = ...)accepts a singlePathogen, aVector, or aTuple. AVectorrequires all elements to share one concrete type; for heterogeneous pathogens pass aTuple, and the error message says so explicitly.pathogen = ...(singular) is upgraded topathogensautomatically, so existing single-pathogen scripts are unchanged.pathogens(sim),get_pathogen(sim, id),first_pathogen(sim), andpathogen(sim)for the single-pathogen case.[Pathogens.<Name>]tables, each with its owntransmission_function,infectiousness_profile,immunity_profile,progressions, andprogression_assignmentblocks.assign_values_to_parameters!now understandsname[i]segments in a parameter path, so calibration can target one element of a collection (pathogens[2].transmission_function.transmission_rate) rather than only named fields.pathogenalso resolves against a multi-pathogen simulation for single-pathogen paths.Composable Transmission
Transmission was decomposed into a base rate × stackable modifiers architecture, separating
TransmissionFunction(produces a base probability) fromTransmissionModifier(adjusts it through atransmission_factorcontract).TransmissionFunctionnow returns only the base rate; infectiousness and immunity are applied exactly once, centrally, rather than being folded in by each function.CompositeTransmissionRate{B, M}: Wraps a baseTransmissionFunctiontogether with a statically-typed tuple of modifiers, multiplying in each modifier'stransmission_factorwithout re-multiplying base rates. The tuple is part of the type, so the composition stays allocation- and dispatch-free.CrossImmunityModifier/CrossImmunityTransmissionRate: Prior exposure or immunity to one pathogen partially protects against another. Configured as aDict{String, Float64}ofcross_immunitiesplus adefault_cross_factor. Pathogens are referenced by name and resolved to ids at construction, so the configuration cannot silently depend on id ordering.ViralInterferenceModifier/ViralInterferenceTransmissionRate: An active infection with one pathogen suppresses transmission of another. Takesinterferencesandpersistencedictionaries (with defaults) so interference can outlast the interfering infection itself. It reads interfering state from the registry rather than same-tick staged state, making it independent of intra-tick spread order.SinusoidalSeasonalModifier/SinusoidalSeasonalTransmissionRate: Sinusoidal seasonal forcing with anamplitudeand apeak_day, for flu- and RSV-style seasonality.ConstantTransmissionRateandAgeDependentTransmissionRateare retained, adapted to the new interface, and concretely typed.Each modifier also ships as a standalone
…TransmissionRatefor the common case of one base rate plus one modifier, so simple setups do not need to build a composite by hand.Infectiousness & Immunity Profiles
Pathogenis now a composition root: progression, transmission, infectiousness, and immunity are each independently swappable per pathogen.InfectiousnessProfile(calculate_infectiousness) computes an individual's shedding level over the course of an infection from itsInfectionState. The result is cached per tick on the agent, so the spread phase reads it directly.ConstantInfectiousness: One fixedlevelacross the whole infectious window.StagedInfectiousness: A separate level per disease stage (asymptomatic,presymptomatic,symptomatic,severe,critical), which is what makes presymptomatic transmission modelling possible.BetaInfectiousness: A continuous shedding curve with atime_to_peakand aconcentrationshape parameter, scaled byleveland optionally adjusted per stage viaasymptomatic_factor…critical_factor. The defaultconcentrationis drawn from the SARS-CoV-2 viral load and shedding kinetics literature (Eckerle et al.).ImmunityProfile(calculate_immunity) defines how immunity is acquired and decays.FullImmunity(the previous behaviour) andNoImmunity.ExponentialWaningandSigmoidalWaning: Configurablehalflife(default 180), a protectionfloor, avaccine_buildup_durationramp from vaccination to peak immunity, and, for the sigmoidal form, ahillshape parameter.susceptibility_factorexposes the resulting protection to the transmission path.immunity_is_stable: A hook that lets the engine clearneeds_immunity_updateonce an individual's immunity has settled, skipping per-tick recomputation while it is constant.infect!calls two immunity-aware entry points, each falling through to its immunity-blind form so they are opt-in.assign(individual, pa_func, immunities, pathogen_id, rng)lets an assignment function choose the progression category from the infectee's pre-exposure immunity, andcalculate_progression(individual, tick, dp, immunities, pathogen_id, rng)lets a category shape the timings within it. Both can read same-pathogen protection throughimmunity_level(individual, immunities, pathogen_id)or prior exposure to other pathogens througheach_immunity(individual, immunities), which is what makes a milder course for previously immune hosts expressible.Host Health Progression: Decoupling Disease from Care
This is the conceptually largest change in the release. Hospitalization, ICU, ventilation, and death used to be baked directly into the disease-progression categories. That is coherent for one pathogen and incoherent for two: if pathogen A schedules a hospital admission and pathogen B independently schedules another, neither stay is authoritative, and two independently scheduled deaths for one host is not an outcome at all.
Disease progression now describes severity only:
Asymptomatic→Mild→Severe→Critical. Everything host-level is decided separately.HealthProgressionandHealthProfile: AHealthProgressionis a host-level policy that looks at all of an individual's active infections at once.select_health_profile(hp, infection)picks the profile for one infection's peak tier, andcalculate_health_profile(profile, individual, infection, rng)turns it into a(CareContribution, HealthOutcome)pair.calculate_health_progression!folds those contributions into a single forward plan for the host.CareContributionandCareLevel: Each infection contributes care demand at one or more ofCARE_HOSPITAL,CARE_ICU,CARE_VENTILATION, as an admission/discharge tick pair.HealthOutcomecarries a proposed death and thepathogen_idto attribute it to;combine_outcomeresolves competing proposals to the earliest death, so mortality has one unambiguous cause.Individualcarrieshospital_demands,icu_demands, andventilation_demandscounters rather than a single care episode. A host is at a care level while at least one infection demands it, which makes overlapping stays from different pathogens compose correctly instead of one overwriting the other.HealthSchedule: A tick-keyed schedule ofCareTransitionrecords (host, level, admission-or-discharge) with awake_ticksset for scheduled deaths. Transitions are emitted per level and applied when due. This replaces an earlier single-episode host timeline that merged two disjoint care episodes into one incorrectly widened stay; separate episodes now stay separate. Schedules are sharded per thread on theSimulationalongside the registries.calculate_health_progression!produces a plan that_validate_health_planchecks against the current tick, instead of silently clamping inconsistent ticks into range. Corrupted care and mortality output under co-infection was a real bug this fixed.DefaultHealthProgression{S, C}: The shipped policy, splitting care by peak tier intoSevereHealthProfile(ward admission only) andCriticalHealthProfile(hospital → ICU → ventilation, plus a death probability that is ungated by hospital or ICU admission, so a host can die without ever being admitted).HealthLoggerandhealth_episodes(): Care is logged as transitions and reconstructed into clean, joinable hospital, ICU, and ventilator episodes, withpp_health_episodes.jlandpp_hospital_df.jlbacking the post-processing.[HealthProgression]section configures the policy, withsevereandcriticalparameter blocks (hospital_probability,hospital_to_icu_probability,icu_to_ventilation_probability,death_probability, and the corresponding delay distributions).SevereandCriticalcategories, either as flat keyword arguments or as a prebuiltHealthProfile, and are harvested into aDefaultHealthProgressionwhen the simulation is assembled. Harvesting is global across the pathogen set and allows only one embedded profile per tier, so it raises anArgumentErrorif two pathogens both embed care forSevere. It is a single-pathogen convenience; multi-pathogen setups configure care through[HealthProgression]or their own policy.Breaking changes
Symptomatic→Mild, andHospitalizedis no longer a disease tier. Both remain available through the legacy layer.Criticalprogression's parameters changed shape:severeness_onset_to_hospital_admission,hospital_admission_to_icu_admission,icu_admission_to_icu_discharge,icu_discharge_to_hospital_discharge,icu_admission_to_death, anddeath_probabilitymoved out of the category and into the health progression, replaced bysevereness_onset_to_critical_onset,critical_onset_to_critical_offset, andcritical_offset_to_severeness_offset.hospital_admission,icu_admission,ventilation_discharge, …) are no longer exported. Usehealth_episodes(rd).HOSPITAL_STATUS_*constants were removed.The legacy compatibility layer
src/pathogen/legacy_progressions/reproduces the pre-decoupling behaviour for old configs and code, and is deliberately isolated rather than scattered through the codebase: the folder and its singleincludecan be deleted outright to remove it.Symptomaticis a delegating wrapper aroundMildthat accepts the same parameters and produces an identical progression.Hospitalizedresolves directly to its legacy type.Criticalis the hard case, because the name is reused rather than renamed._is_legacy_criticaldetects an old-formatCriticalblock by its parameters and_normalize_legacy_pathogen!reroutes just that block toLegacyCritical. A new-formatCriticalis left untouched even when the same config uses legacy names for other tiers.LegacyHealthProgression/LegacyCriticalHealthProfilereproduce the old coupled care course: deterministic hospital → ICU admission with death anchored to the drawn ICU-admission tick, preserving the previous ICU/death coupling.See Custom Health Progression for writing your own policy.
Seeding, Start Conditions & Imported Cases
InfectedFraction,PatientZero,PatientZeros, andRegionalSeedsall take apathogenname and seed that pathogen. Previously apathogenargument was accepted and silently ignored with a warning that GEMS only supported single-pathogen simulations.ALL_PATHOGENS("all"), or leaves the name empty for a single-pathogen simulation._expand_pathogensresolves the selection against the simulation's pathogens at construction:ALL_PATHOGENSexpands into one condition per pathogen, a name is validated against the pathogen set, and an empty name is rejected with an actionable error when more than one pathogen exists. Because this happens at construction, the config-file and programmatic paths agree.MultiStartCondition: A composite condition wrapping one sub-condition per pathogen, initialized in order, for example a different infected fraction per disease in one run. It is constructed automatically when multiple pathogens are supplied.ImportedCases: A new start condition for recurring case importation, which previously had no equivalent.ticksaccepts an explicit vector, a schedule, or a window with anintervalandoffsetthat may each be a distribution;countaccepts a fixed number, a vector, or a distribution; andagsoptionally restricts imports to a region or set of regions. An import only lands on a host that is alive, not hospitalized, not self-isolating, and not already carrying that pathogen. It makes multi-season and endemic scenarios expressible without driving importation from outside the simulation.Contact Tracing:
InfecterIndexget_infections_between, which backs the contact-tracing measures, linearly scanned every thread's infection log shard on every call. It was the one hot path deliberately left out of the 1.1 intervention-optimization pass because it needed the most implementation work of that batch.InfecterIndex: An inverted index mapping each infecter to a tick-ordered list of its infectees.get_infections_betweenwalks the relevant slice of that list instead of scanning the log._PendingLinkrecords and merged in with a k-way merge that preserves tick ordering, keeping the parallel logging path lock-free.Measured impact
Average
process_measureruntime forTraceInfectiousContacts, measured on a 16-thread desktop CPU using the default configuration and the shipped population files:The scaling is expected: the old implementation's cost grew with the size of the infection log, while an indexed lookup grows with the number of infectees actually traced. The Germany figure is extrapolated from the Saxony and NRW measurements, not measured directly.
Pathogen-Aware Analysis, Reporting & Plotting
pathogen_id. The logging layer was reorganized intologging/{logger, event_loggers, infection_logger, tick_loggers, infecter_index}.jl.pp_*suite (incidence, attack rates, effective R, R0, serial and generation intervals, tick cases, deaths, tests, and detection) is pathogen-aware, splitting and grouping results per pathogen.aggregate_by_pathogenwas added, andResultDataandBatchaggregation were updated to render and store per-pathogen results across runs (also required becauseSimulationis now parameterized).pathogenkeyword accepting an id or a name, defaulting to showing all pathogens with an automatic per-pathogen subplot layout.Repository Restructure
A large structural cleanup with no behavioural intent, reorganizing the source tree around its actual domains. Worth knowing about if you are looking for a file at its old path.
structs/andmethods/were dissolved intopathogen/(pathogens, progressions, health progression, transmission functions, profiles, vaccines),population/,settings/,registries/,simulation/(simulation, infections, calibration, batch, initialization, termination),contacts/,analysis/(formerlymodel_analysis/), andlogging/(formerlylogger/).agents.jlwas split intopopulation/individuals.jl(the struct) andpopulation/individual_methods.jl(behaviour), with setting membership and lookup moved onto the individual.simulation/initialization/start_conditions/andsimulation/termination/stop_criteria/._prefixes (e.g._basefolder).Reproducibility Notes
Seeded runs from 1.1.x will not reproduce bit-identically on 1.2.0. The infection model, the order in which random draws are made during progression and care assignment, and the separation of care from disease progression all change the draw sequence. Results remain equally valid; they are simply a different realization. Configs that relied on the coupled
Criticalbehaviour and are rerouted through the legacy layer reproduce the old behaviour, not the old random stream.Testing
test/registriestest.jl(new) covers cache and overflow promotion, sharded ownership, deferred flush ordering, and iterator traversal.test/healthprogressiontest.jl(new) covers the contribution and schedule API, including a regression test that two disjoint care contributions produce two episodes rather than one widened stay.test/pathogentest.jlwas substantially expanded for the new transmission functions and modifiers, all three infectiousness profiles, the waning immunity profiles, and the single-active-infection guard.test/testutils.jlis new, andagentstest,infectionstest,interventionstest,simulationtest,settingstest,postprocessortest,loggertest, andutilstestwere updated for the per-(individual, pathogen)model, the bitmask accessors, and the new start conditions.This discussion was created from the release Release 1.2: The Multipathogen Update.
All reactions