Drug resistance and multi-strain user guide

A practical guide for researchers using TBsim’s drug-resistance and multi-strain extension (tbsim.resistance). It focuses on how to set up, run, and interpret simulations: defining strains, transmission and competition, de-novo and acquired resistance, drug-susceptibility testing and regimen routing, treatment monitoring, and strain-aware preventive therapy.

This guide is task-oriented and recipe-based. For a narrative, plotted walkthrough of the same features, see the drug resistance tutorial; for implementation internals, see the tbsim.resistance README.

Audience: epidemiologists and modelers comfortable with Python / Starsim. Requirements: Python ≥ 3.11, tbsim installed editable (pip install -e .), Starsim ≥ 3.5.

Quick start

The shortest path from install to a multi-strain run:

import numpy as np
import starsim as ss
import tbsim

# 1) Disease module with two drugs and fitness costs
tb = tbsim.TBResistant(
    drugs=['RIF', 'BDQ'],
    rel_fitness={'RIF': 0.9, 'BDQ': 0.85},
    beta=ss.permonth(0.35),
    init_prev=ss.bernoulli(0.10),
    init_strains=[0.85, 0.12, 0.03, 0.0],  # pan / RIF / BDQ / RIF+BDQ
)

# 2) Small contact network + sim
net = ss.RandomNet(pars=dict(n_contacts=ss.poisson(lam=8), dur=0))
sim = tbsim.Sim(
    n_agents=2000, networks=net, diseases=tb, demographics=[],
    dt=ss.days(30), start=ss.date('2000-01-01'), stop=ss.date('2030-12-31'),
    rand_seed=0, verbose=0,
)
sim.run()

# 3) Inspect resistance among active TB
res = sim.results.tb
print('Final active prevalence:', float(res['prevalence_active'][-1]))
print('Final any-resistance fraction:', float(res['frac_resist'][-1]))
print('Final RIF-resistant fraction:', float(res['frac_resist_RIF'][-1]))
Final active prevalence: 0.07608034083992696
Final any-resistance fraction: 0.03148148148148148
Final RIF-resistant fraction: 0.03148148148148148

Reusable helper used in the recipes below (copy once into a notebook or script):

import starsim as ss
import tbsim


def build_sim(tb, interventions=None, analyzers=None, n_agents=2000,
              start='2000-01-01', stop='2035-12-31', seed=0):
    """Small multi-strain TB sim on a random contact network."""
    net = ss.RandomNet(pars=dict(n_contacts=ss.poisson(lam=8), dur=0))
    return tbsim.Sim(
        n_agents=n_agents, networks=net, diseases=tb, demographics=[],
        interventions=interventions, analyzers=analyzers, dt=ss.days(30),
        start=ss.date(start), stop=ss.date(stop), rand_seed=seed, verbose=0,
    )

Concepts in brief

Idea Meaning in TBsim
Drug / class Named label you choose ('RIF', 'BDQ', 'INH', …). Nothing is hard-coded.
Strain One of 2ⁿ binary resistance profiles over n drugs. Id 0 = pan-susceptible.
Fitness cost r_i Multiplicative reduction in transmission for resistance to drug i (∈ [0,1]). Strain fitness = product of costs for drugs it resists.
strain_mask Per-agent integer: bit j set means the agent carries strain j. Agents can carry several strains (superinfection).
Transmission bottleneck A source transmits at the rate of its fittest carried strain; which strain is passed is drawn ∝ fitness.
Product / delivery Same pattern as the rest of TBsim: products define what (efficacy, DST, TPT); deliveries define who / when.

Natural history (SUSCEPTIBLE → INFECTION → …) remains agent-level. Strains are an overlay on that state machine.

Building a multi-strain simulation

Defining strains

import tbsim

strains = tbsim.Strains(drugs=['RIF', 'BDQ'], rel_fitness={'RIF': 0.5, 'BDQ': 0.8})
print(f'{strains.n} drugs → {strains.m} strains')
for j in range(strains.m):
    print(f'  id {j}: {strains.labels[j]:9s} '
          f'profile={strains.profile[j].astype(int)} '
          f'fitness={strains.fitness[j]:.2f}')
2 drugs → 4 strains
  id 0: pan       profile=[0 0] fitness=1.00
  id 1: RIF       profile=[1 0] fitness=0.50
  id 2: BDQ       profile=[0 1] fitness=0.80
  id 3: RIF+BDQ   profile=[1 1] fitness=0.40

Expected layout for two drugs:

id label profile fitness (example)
0 pan [0 0] 1.00
1 RIF [1 0] 0.50
2 BDQ [0 1] 0.80
3 RIF+BDQ [1 1] 0.40

Adding a third drug (e.g. 'FQ') only requires appending to drugs; m becomes 8. You usually do not construct Strains yourself for a sim — TBResistant builds it and exposes it as tb.strains for products (TxR, DST, TPTRx).

Creating TBResistant

TBResistant is a drop-in replacement for tbsim.TB:

import starsim as ss
import tbsim

tb = tbsim.TBResistant(
    drugs=['RIF', 'BDQ'],
    rel_fitness={'RIF': 0.9, 'BDQ': 0.85},
    beta=ss.permonth(0.35),
    init_prev=ss.bernoulli(0.10),
    # Superinfection susceptibility (σ); defaults couple to rr_reinfection_rec
    rr_reinfection_inf=1.0,
    rr_reinfection_non=1.0,
    rr_reinfection_asy=0.0,   # no superinfection in active disease (default)
    rr_reinfection_sym=0.0,
    p_multi=1.0,              # keep all strains when progressing to ASYMPTOMATIC
)
print(tb.strains.labels)
['pan', 'RIF', 'BDQ', 'RIF+BDQ']

The module name defaults to 'tb', so existing interventions that look up disease 'tb' continue to work. To get single-strain tbsim.TB behavior from the resistance machinery (no resistance ever arises), use the convenience factory tbsim.TBResistant.agnostic(pars=...).

Seeding the epidemic

init_strains is a probability vector over strain ids for seeded infections (length m, need not be normalized — it is renormalized internally):

tb = tbsim.TBResistant(
    drugs=['RIF', 'BDQ'], rel_fitness={'RIF': 0.9, 'BDQ': 0.85},
    beta=ss.permonth(0.35), init_prev=ss.bernoulli(0.10),
    init_strains=[0.85, 0.12, 0.03, 0.0],  # [pan, RIF, BDQ, RIF+BDQ]
)
sim = build_sim(tb, stop='2010-12-31')  # uses helper from Quick start
sim.run()
print('Final frac_resist:', float(sim.results.tb['frac_resist'][-1]))
Final frac_resist: 0.15757575757575756

Default (if omitted): all seeds are pan-susceptible ([1, 0, …, 0]).

Reading results

Alongside standard TB outputs, TBResistant records:

Result Meaning
frac_resist Fraction of active TB with any resistance
frac_resist_<drug> Fraction resistant to that drug (aggregate phenotype)
frac_super Fraction of active TB that is superinfected
new_denovo_resistance De-novo acquisition events this step
new_transmitted_resistance New acquisitions of a resistant strain via transmission
new_identical_superinf Identical-strain re-exposures this step (each increments the agent’s per-strain count)
sim = build_sim(tb, stop='2015-12-31'); sim.run()
res = sim.results.tb
print('Mean frac_resist (last 5 years):', float(res['frac_resist'][-60:].mean()))
print('Cumulative identical-strain superinfections:', int(res['new_identical_superinf'].sum()))
Mean frac_resist (last 5 years): 0.07324808313341648
Cumulative identical-strain superinfections: 643

For origin decomposition and per-strain counts, see Analyzing where resistance comes from below.

Transmission and fitness

Rules, as implemented:

  1. Infectees only receive a strain the source already carries (no resistance emergence on transmission).
  2. Overall infectiousness = fitness of the fittest carried strain.
  3. Conditional on transmission, which strain is passed ∝ fitness among carried strains.

Reproduce the specification’s worked example (≈56% / 44% → 0.28β / 0.22β):

import numpy as np
import tbsim

s = tbsim.Strains(['RIF', 'BDQ'], rel_fitness={'RIF': 0.5, 'BDQ': 0.8})
mask = np.array([(1 << 1) | (1 << 3)])  # carries {RIF} and {RIF,BDQ}
print('max fitness (relative infectiousness):', s.max_fitness(mask)[0])
tp = s.transmit_probs(mask)[0]
print(f'P(pass RIF)     = {tp[1]:.1%}')
print(f'P(pass RIF+BDQ) = {tp[3]:.1%}')
max fitness (relative infectiousness): 0.5
P(pass RIF)     = 55.6%
P(pass RIF+BDQ) = 44.4%

Implication for research: superinfection does not dilute a source’s overall transmission risk relative to mono-infection with its fittest strain, but it does split which strain is passed.

Superinfection and competition

Already-infected agents can acquire a second (distinct) strain. Relative risk vs a fully susceptible person depends on disease state:

State Parameter Default behavior
INFECTION rr_reinfection_inf Defaults to rr_reinfection_rec
NON_INFECTIOUS rr_reinfection_non Defaults to rr_reinfection_inf
ASYMPTOMATIC rr_reinfection_asy 0 (closed)
SYMPTOMATIC rr_reinfection_sym 0 (closed)

Protection is strain-agnostic (a third distinct strain is not harder to acquire than a second). Re-exposure to an already-carried strain is allowed: rather than being blocked, it increments that agent’s per-strain count (tracked in tb.strain_counts, one array per strain id) and is tallied in new_identical_superinf. The count feeds only two consumers — the transmission multinomial (which strain is passed ∝ count × fitness) and the progression bottleneck under p_multi < 1 (which strain survives ∝ count) — and leaves transition rates, DST, treatment efficacy, and the probability of resistance acquisition count-agnostic (all copies of a strain behave as one).

You can inspect the count-weighting directly on the Strains registry:

import numpy as np
import tbsim

s = tbsim.Strains(['TX'], rel_fitness=None)  # neutral fitness
# A source carrying {pan:2, resistant:1} passes pan 2/3 of the time; infectiousness is count-independent.
print(s.transmit_probs(np.array([0b11]), counts=np.array([[2, 1]]))[0])  # → [0.667, 0.333]
print(s.max_fitness(np.array([0b11]))[0])                                 # → 1.0
[0.66666667 0.33333333]
1.0

Fitness costs drive competition. Without treatment, a less-fit resistant strain tends to decline:

import matplotlib.pyplot as plt


def resist_over_time(sigma):
    tb = tbsim.TBResistant(
        rel_fitness={'TX': 0.7},  # single drug, 30% fitness cost
        beta=ss.permonth(0.35), init_prev=ss.bernoulli(0.12),
        init_strains=[0.7, 0.3], rr_reinfection_inf=sigma, rr_reinfection_non=sigma,
    )
    sim = build_sim(tb, stop='2050-12-31'); sim.run()
    return sim.results.timevec, sim.results.tb['frac_resist']


fig, ax = plt.subplots(figsize=(7, 4))
for sigma, label in [(0.0, 'σ = 0 (no superinfection)'), (1.0, 'σ = 1')]:
    t, fr = resist_over_time(sigma)
    ax.plot(t, fr, label=label)
ax.set(title='Resistant fraction', xlabel='year', ylabel='fraction')
ax.legend(frameon=False); plt.tight_layout(); plt.show()

De-novo resistance

Resistance can arise endogenously at progression out of INFECTION (→ NON_INFECTIOUS or → ASYMPTOMATIC), as a one-time per-drug probability — not a per-timestep rate.

Parameter Role
p_rand Dict {drug: probability} per not-yet-resistant drug
prog_resist_mode 'mixed' (add resistant variant → superinfection; default) or 'replacement'
tb = tbsim.TBResistant(
    rel_fitness={'TX': 0.9}, beta=ss.permonth(0.35), init_prev=ss.bernoulli(0.12),
    init_strains=[1.0, 0.0],       # start 100% pan-susceptible
    p_rand={'TX': 0.02}, prog_resist_mode='mixed',
    rr_reinfection_inf=0.0, rr_reinfection_non=0.0,
)
sim = build_sim(tb, stop='2050-12-31'); sim.run()
print('Cumulative de-novo events:', int(sim.results.tb['new_denovo_resistance'].sum()))
print('Final resistant fraction:', float(sim.results.tb['frac_resist'][-1]))
Cumulative de-novo events: 77
Final resistant fraction: 0.030660377358490566

p_rand is per drug, so RIF’s rate can be 0 while another drug’s is positive, and each carried strain of a multi-strain agent mutates independently. Both modes create resistance; only 'mixed' produces lasting superinfected (AB) agents from de-novo events.

Treatment and acquired resistance

Use the product/delivery pair:

  • TxR — per-strain efficacy, adherence, acquisition-on-failure (q_acq)
  • TxDeliveryR — who starts treatment and when (rates from ASYMPTOMATIC/SYMPTOMATIC, or a custom eligibility callable)

Efficacy for strain j is base_efficacy × product of resist_penalty over regimen drugs that strain resists. If that constrained form is too restrictive, pass an explicit per-strain efficacy vector efficacy_by_strain (length m) — it is used verbatim and overrides base_efficacy/resist_penalty. Failed courses can acquire resistance to regimen drugs by replacement: each surviving drug-susceptible strain rolls independently, once per regimen drug it is susceptible to, scaled by TB-state RR (acq_state_rr; default 1 for ASYMPTOMATIC/SYMPTOMATIC, 0 elsewhere).

adherence is a per-course completion probability that correlates all of an agent’s strains through a single draw (a non-completer clears nothing that course). Pass a float for one regimen-level probability shared by every agent, or a callable uids -> per-agent probability to make adherence a distribution that varies by agent:

import numpy as np
# First half of agents fully adherent, second half never — a per-agent adherence distribution.
adherence = lambda uids: np.where(np.asarray(uids) < 1000, 1.0, 0.0)

A worked treatment run that selects for resistance (susceptible strain cured well, resistant strain poorly):

tb = tbsim.TBResistant(
    rel_fitness={'TX': 0.6}, beta=ss.permonth(0.35), init_prev=ss.bernoulli(0.12),
    init_strains=[0.95, 0.05], rr_reinfection_inf=1.0, rr_reinfection_non=1.0,
)
tx = tbsim.TxDeliveryR(
    name='tx',
    product=tbsim.TxR(strains=tb.strains, base_efficacy=0.8, resist_penalty={'TX': 0.2},
                      adherence=0.9, q_acq={'TX': 0.04}),
    rate_sym=ss.peryear(1.5), rate_asym=ss.peryear(0.1),
)
sim = build_sim(tb, interventions=tx, stop='2050-12-31'); sim.run()
print('Courses started:', int(sim.results['tx'].n_treated.sum()))
print('Acquisitions on failure:', int(sim.results['tx'].n_acquired.sum()))
print('Final resistant fraction:', float(sim.results.tb['frac_resist'][-1]))
Courses started: 611
Acquisitions on failure: 5
Final resistant fraction: 0.646551724137931

Research tip: lower resist_penalty values (stronger efficacy loss against resistant strains) and higher q_acq both tend to raise the resistant share of active TB — useful for sensitivity analysis. Partial cure is supported: if only some strains clear, the agent returns to the pre-treatment TB state carrying the survivors.

Treating latent (INFECTION) agents. TxDeliveryR(treat_latent=...) controls what happens when a latent agent is selected for treatment (only reachable via a custom/DST-routed eligibility). With treat_latent=False (default) the agent undergoes strain-aware sterilization: every strain susceptible to all regimen drugs is cleared with certainty, any regimen-resistant strain is kept, and the agent moves to CLEARED only if no strain remains — no course is run and these agents are not counted in n_treated. Set treat_latent=True to instead run latent agents through a full failable course.

Drug-susceptibility testing (DST)

DST produces an observed n-drug profile (not strain identities). Sensitivity/specificity are applied at the strain level; p_strain_obs (default = strain fitness) can drop strains from the sample. Sensitivity/specificity errors are drawn independently per (strain, drug), so a multi-drug DST behaves like independent per-drug tests. DSTDelivery.matches(...) turns the observed profile into eligibility callables for regimen routing.

A key point about regimen_drugs: it names the drugs the regimen acts on. A realistic second-line for RIF-resistant TB is built from a different drug the resistant strain is still susceptible to — not RIF. The example uses a two-drug space (RIF, BDQ): first-line is a RIF regimen routed to observed RIF-susceptible cases, and second-line is a BDQ regimen routed to observed RIF-resistant cases (which are BDQ-susceptible here).

tb = tbsim.TBResistant(
    drugs=['RIF', 'BDQ'], rel_fitness={'RIF': 0.9},
    beta=ss.permonth(0.35), init_prev=ss.bernoulli(0.12),
    init_strains=[0.8, 0.2, 0.0, 0.0],  # 80% pan-susceptible, 20% RIF-resistant
    rr_reinfection_inf=1.0, rr_reinfection_non=1.0,
)
dst = tbsim.DSTDelivery(
    name='dst', product=tbsim.DST(strains=tb.strains, sens=0.95, spec=0.98),
    eligibility=lambda sim: sim.get_tb().active_tb.uids,
)
first = tbsim.TxDeliveryR(   # first-line: RIF regimen for observed RIF-susceptible TB
    name='first', rate_sym=ss.peryear(1.0), eligibility=dst.matches(RIF=False),
    product=tbsim.TxR(strains=tb.strains, base_efficacy=0.85, regimen_drugs=['RIF'],
                      resist_penalty={'RIF': 0.1}),
)
second = tbsim.TxDeliveryR(  # second-line: BDQ regimen for observed RIF-resistant TB
    name='second', rate_sym=ss.peryear(1.0), eligibility=dst.matches(RIF=True),
    product=tbsim.TxR(strains=tb.strains, base_efficacy=0.8, regimen_drugs=['BDQ']),
)
sim = build_sim(tb, interventions=[dst, first, second], stop='2040-12-31'); sim.run()
print('DST tests:', int(sim.results['dst'].n_tested.sum()))
print('First-line courses:', int(sim.results['first'].n_treated.sum()))
print('Second-line (RIF-R):', int(sim.results['second'].n_treated.sum()))
DST tests: 28
First-line courses: 26
Second-line (RIF-R): 5

Also available: dst.observed_resistant('RIF') for a single-drug eligibility callable. Both matches(...) and observed_resistant(...) accept max_age=<ss.dur> to require a fresh DST result, and DSTDelivery(result_validity=<ss.dur>) wipes stored results older than the window so agents must be re-tested.

Retreatment vs new case. Every TxDeliveryR stamps a durable, cross-regimen tb.ti_last_treatment at each initiation, so a later presentation can be classified by time since last treatment. TxDeliveryR.failure_case_eligibility(within=<ss.dur>) returns a sim -> uids callable selecting active-TB agents whose most recent treatment was within within (manage as retreatment); pass new_case=True for the complement, and base=<callable> to restrict the candidate pool.

failed = tbsim.TxDeliveryR.failure_case_eligibility(within=ss.years(2))   # recent treatment → retreatment
second_line = tbsim.TxDeliveryR(name='second', eligibility=failed, supersedes=['first'],
                                product=tbsim.TxR(strains=tb.strains, regimen_drugs=['BDQ']))

Treatment monitoring and regimen switching

To change regimen mid-course:

  1. Name the first-line delivery.
  2. Build a second-line delivery with eligibility=treatment_monitoring_eligibility(...) and supersedes=['first'].
  3. The second line interrupts the ongoing course, then starts the new regimen.
tb = tbsim.TBResistant(
    drugs=['INH', 'RIF'], rel_fitness={'INH': 0.95},
    beta=ss.permonth(0.3), init_prev=ss.bernoulli(0.10),
    init_strains=[0.5, 0.5, 0.0, 0.0],  # pan + INH-resistant
)
first = tbsim.TxDeliveryR(
    name='first', rate_sym=ss.peryear(2.0),
    product=tbsim.TxR(strains=tb.strains, regimen_drugs=['INH'], base_efficacy=0.8,
                      resist_penalty={'INH': 0.1}),
)
switch = tbsim.TxDeliveryR(
    name='switch', supersedes=['first'],
    eligibility=tbsim.treatment_monitoring_eligibility('first', after_steps=2),
    product=tbsim.TxR(strains=tb.strains, regimen_drugs=['RIF'], base_efficacy=0.85),
)
sim = build_sim(tb, interventions=[first, switch], stop='2015-12-31'); sim.run()
print('First-line initiations:', int(sim.results['first'].n_treated.sum()))
print('Switched to second-line:', int(sim.results['switch'].n_treated.sum()))
First-line initiations: 13
Switched to second-line: 13

after_steps is in simulation timesteps (with dt=ss.days(30), after_steps=2 ≈ 2 months). Richer routing is available via the composable eligibility helpers tbsim.eligibility_all / tbsim.eligibility_any (intersection / union of sim → uids callables) and tbsim.will_fail(tx_name) (agents whose pre-rolled course outcome is a failure).

Strain-aware preventive therapy (TPT)

TPTRx sterilizes per strain: only strains susceptible to every drug in the TPT regimen are cleared. A resistant strain in a co-infected agent can survive and later progress/transmit — the classic “TPT unmasks resistance” dynamic. Ineffective TPT can also select resistance (p_tpt_acq), scaled by TB state.

import matplotlib.pyplot as plt


def run_tpt(with_tpt):
    tb = tbsim.TBResistant(
        drugs=['INH'], rel_fitness={'INH': 0.9},
        beta=ss.permonth(0.35), init_prev=ss.bernoulli(0.15),
        init_strains=[0.8, 0.2], rr_reinfection_inf=0.0, rr_reinfection_non=0.0,
    )
    ivs = None
    if with_tpt:
        ivs = tbsim.TPTSimple(
            product=tbsim.TPTRx(strains=tb.strains, regimen_drugs=['INH'],
                                pars=dict(efficacy=ss.bernoulli(0.9), p_sterilize=ss.bernoulli(1.0))),
            pars=dict(coverage=ss.bernoulli(0.5)),
        )
    sim = build_sim(tb, interventions=ivs, n_agents=4000, stop='2035-12-31'); sim.run()
    return sim.results.timevec, sim.results.tb['frac_resist']


fig, ax = plt.subplots(figsize=(7, 4))
for with_tpt, label in [(False, 'no TPT'), (True, 'INH TPT')]:
    t, fr = run_tpt(with_tpt)
    ax.plot(t, fr, label=label)
ax.set(title='INH TPT can raise the resistant share of active TB', xlabel='year', ylabel='resistant fraction')
ax.legend(frameon=False); plt.tight_layout(); plt.show()

Set p_sterilize > 0 so the strain-aware clearance path runs. With p_multi=1 (default), the main harm pathway is transmission unmasking, not progression bottlenecking.

Analyzing where resistance comes from

ResistanceStats decomposes new resistance into de-novo, treatment-acquired, transmitted, and TPT-acquired fluxes. StrainResults records per-strain active-TB counts.

tb = tbsim.TBResistant(
    rel_fitness={'TX': 0.9}, beta=ss.permonth(0.35), init_prev=ss.bernoulli(0.12),
    init_strains=[0.9, 0.1], p_rand={'TX': 0.01},
    rr_reinfection_inf=1.0, rr_reinfection_non=1.0,
)
tx = tbsim.TxDeliveryR(
    product=tbsim.TxR(strains=tb.strains, base_efficacy=0.8, resist_penalty={'TX': 0.2},
                      q_acq={'TX': 0.05}),
    rate_sym=ss.peryear(1.0),
)
stats = tbsim.ResistanceStats()
sim = build_sim(tb, interventions=tx, analyzers=[stats, tbsim.StrainResults()], stop='2045-12-31')
sim.run()

df = stats.to_df(sim)
origins = {'de-novo': int(df.flux_denovo.sum()),
           'treatment-acquired': int(df.flux_txacq.sum()),
           'transmitted': int(df.flux_transmitted.sum()),
           'TPT-acquired': int(df.flux_tptacq.sum())}
print(origins)
{'de-novo': 23, 'treatment-acquired': 3, 'transmitted': 5882, 'TPT-acquired': 0}

Once resistance is established, transmission usually dominates cumulative events; de-novo and treatment acquisition seed and top up the pool.

Parameter reference (cheat sheet)

TBResistant / Strains

Parameter Type Default Notes
drugs list[str] ['TX'] Ordered drug names; m = 2**len(drugs)
rel_fitness dict {} Per-drug r_i ∈ [0,1]; missing → 1.0
rr_reinfection_inf float rr_reinfection_rec σ for INFECTION
rr_reinfection_non float rr_reinfection_inf σ for NON_INFECTIOUS
rr_reinfection_asy / _sym float 0 Superinfection in active disease
p_multi float 1 Prob. keep all strains at →ASYMPTOMATIC
prog_select str 'random' Or 'fitness' under bottleneck
rr_prog_super / rr_clear_super float 1 Optional multi-strain rate multipliers
p_rand dict off De-novo {drug: p}
prog_resist_mode str 'mixed' Or 'replacement'
init_strains array pan only Seed mix over strain ids

TxR / TxDeliveryR

Parameter Notes
base_efficacy Cure prob. for a fully susceptible strain
resist_penalty Per-drug multiplier on efficacy for resistance to regimen drugs
efficacy_by_strain Explicit per-strain cure-prob vector (length m); overrides base_efficacy/resist_penalty
adherence Per-course completion prob. correlating an agent’s strains; float (shared) or callable uids → prob
q_acq Per-drug acquisition-on-failure (replacement)
acq_state_rr Scale q_acq by TB state at failure
regimen_drugs Which drugs the regimen contains
rate_asym / rate_sym Initiation rates
dur_treatment Course length before the outcome resolves (default ss.months(6))
eligibility Optional sim → uids override
supersedes Names of deliveries to interrupt before starting
retreat_after Refractory ss.dur after a course before the same agent is re-treated by this delivery
treat_latent False (default) = strain-aware sterilization of a selected latent agent (not counted in n_treated); True = full failable course
failure_case_eligibility(within, base=, new_case=) Classifier sim → uids for retreatment vs new case (reads durable tb.ti_last_treatment)

DST / TPTRx

Parameter Notes
sens / spec (DST) Scalar or per-drug dict
p_strain_obs (DST) None → use fitness; or scalar/dict
result_validity (DSTDelivery) Stored results older than this are wiped, forcing a re-test
DSTDelivery.matches(**drugs, max_age=) Eligibility from the observed profile (optionally within max_age)
regimen_drugs (TPT) Drugs that must all be susceptible for sterilization
p_tpt_acq (TPT) Acquisition among ineffective TPT outcomes
acq_state_rr (TPT) Scale p_tpt_acq by TB state
p_sterilize (TPT) Must be > 0 to exercise strain-aware clearance

Known limitations

Topic Current behavior
DST indeterminate Binary observed profile only (no explicit indeterminate outcome)
TPT partial efficacy Sterilization is all-or-nothing per strain; no per-drug TPT resist_penalty
Time-varying progression hazard Optional exponential decline via k_asy/k_non (defaults 0 = constant hazard)
LTFU outcome Not modeled as a separate treatment outcome

See also

  • The drug resistance tutorial — a narrative, plotted walkthrough of the same features.
  • The tbsim.resistance README — implementation internals and the mapping to the reference two-strain ODE.
  • Validation lives in tests/test_resistance.py and tests/test_compartmental.py.

Public API entry points (all re-exported on tbsim): Strains, TBResistant, TxR, TxDeliveryR, treatment_monitoring_eligibility, DST, DSTDelivery, TPTRx, ResistanceStats, StrainResults.