Partner notification

New in v1.5.5

Partner notification (PN) — also called contact tracing or partner services — identifies sexual partners of newly diagnosed agents and offers them follow-up testing. STIsim’s :class:~stisim.PartnerNotification intervention does this through two channels:

Each contact is independently subject to a notification step (probability p_notify_<scope>) and an attendance step (probability p_attends_<scope>). Attendees are scheduled for the supplied test intervention on the next timestep.

This example builds a small HIV sim and compares HIV diagnoses with and without PN.

Setup

import numpy as np
import sciris as sc
import starsim as ss
import stisim as sti
import hivsim
import matplotlib.pyplot as plt

sc.options(dpi=110)

Index cases: who triggers PN?

PN runs every timestep against an eligibility callback that returns the UIDs of “index cases” — typically agents who just tested positive. Here we use the standard HIV testing pipeline and treat any newly-diagnosed agent as an index case.

def newly_diagnosed(sim):
    """Index cases = agents whose HIV diagnosis fires this timestep."""
    hiv = sim.diseases.hiv
    return (hiv.ti_diagnosed == hiv.ti).uids

Building the sim

The active sexual network needs recall_prior=True so dissolved partnerships are pushed into the :class:~stisim.PriorPartners recall network. We set the lookback to 6 months — typical of real-world PN programs that ask about partners from the past 3–12 months.

def make_sim(use_pn=False, rand_seed=1):
    hiv_test = sti.HIVTest(name='hiv_test', test_prob_data=0.3)
    interventions = [hiv_test]

    if use_pn:
        pn = sti.PartnerNotification(
            eligibility=newly_diagnosed,
            test=hiv_test,                              # Schedule HIV test for partners
            p_notify_current=ss.bernoulli(p=0.7),       # 70% of current partners notified
            p_attends_current=ss.bernoulli(p=0.7),      # 70% attend follow-up
            p_notify_previous=ss.bernoulli(p=0.3),      # 30% of prior partners notified
            p_attends_previous=ss.bernoulli(p=0.5),     # 50% attend
            name='pn',
        )
        interventions.append(pn)

    networks = [
        sti.StructuredSexual(recall_prior=True),
        sti.PriorPartners(dur_recall=ss.years(0.5)),
    ]

    sim = hivsim.demo(
        'simple', run=False, plot=False,
        n_agents=2_000, dur=10, rand_seed=rand_seed, verbose=-1,
        networks=networks,
        interventions=interventions,
    )
    sim.label = 'With PN' if use_pn else 'No PN'
    return sim

Running scenarios

sims = ss.parallel([make_sim(use_pn=False), make_sim(use_pn=True)]).sims
results = {sim.label: sim for sim in sims}

for label, sim in results.items():
    n_dx = int(sim.results.hiv_test.new_diagnoses.sum())
    print(f'{label:>10s}: {n_dx:5d} HIV diagnoses')
Initializing sim "No PN" with 2000 agents
Initializing sim "With PN" with 2000 agents

  Running "No PN": 2000.01.01 ( 0/121) (0.00 s)  ———————————————————— 1%

  Running "With PN": 2000.01.01 ( 0/121) (0.00 s)  ———————————————————— 1%

  Running "No PN": 2001.01.01 (12/121) (0.82 s)  ••—————————————————— 11%

  Running "With PN": 2001.01.01 (12/121) (0.84 s)  ••—————————————————— 11%

  Running "No PN": 2002.01.01 (24/121) (1.06 s)  ••••———————————————— 21%

  Running "With PN": 2002.01.01 (24/121) (1.09 s)  ••••———————————————— 21%

  Running "No PN": 2003.01.01 (36/121) (1.29 s)  ••••••—————————————— 31%

  Running "With PN": 2003.01.01 (36/121) (1.33 s)  ••••••—————————————— 31%

  Running "No PN": 2004.01.01 (48/121) (1.52 s)  ••••••••———————————— 40%

  Running "With PN": 2004.01.01 (48/121) (1.58 s)  ••••••••———————————— 40%

  Running "No PN": 2005.01.01 (60/121) (1.76 s)  ••••••••••—————————— 50%

  Running "With PN": 2005.01.01 (60/121) (1.83 s)  ••••••••••—————————— 50%

  Running "No PN": 2006.01.01 (72/121) (1.99 s)  ••••••••••••———————— 60%

  Running "With PN": 2006.01.01 (72/121) (2.07 s)  ••••••••••••———————— 60%

  Running "No PN": 2007.01.01 (84/121) (2.22 s)  ••••••••••••••—————— 70%

  Running "With PN": 2007.01.01 (84/121) (2.32 s)  ••••••••••••••—————— 70%

  Running "No PN": 2008.01.01 (96/121) (2.47 s)  ••••••••••••••••———— 80%

  Running "With PN": 2008.01.01 (96/121) (2.56 s)  ••••••••••••••••———— 80%

  Running "No PN": 2009.01.01 (108/121) (2.70 s)  ••••••••••••••••••—— 90%

  Running "With PN": 2009.01.01 (108/121) (2.80 s)  ••••••••••••••••••—— 90%

  Running "No PN": 2010.01.01 (120/121) (2.92 s)  •••••••••••••••••••• 100%


  Running "With PN": 2010.01.01 (120/121) (3.02 s)  •••••••••••••••••••• 100%

     No PN:   238 HIV diagnoses
   With PN:   419 HIV diagnoses

Inspecting PN outputs

The PN intervention exposes per-timestep counts of partners notified and attending, broken down by channel.

pn_res = results['With PN'].results.pn
total_notified = int(pn_res.new_notified.sum())
total_attending = int(pn_res.new_attending.sum())
total_current = int(pn_res.new_notified_current.sum())
total_previous = int(pn_res.new_notified_previous.sum())

print(f'Notified: {total_notified} ({total_current} current, {total_previous} prior)')
print(f'Attending: {total_attending}')
Notified: 355 (355 current, 0 prior)
Attending: 246

Comparing outcomes

fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))

# Left: HIV diagnoses over time
for label, sim in results.items():
    axes[0].plot(sim.t.yearvec, sim.results.hiv_test.new_diagnoses,
                 label=label, lw=2)
axes[0].set_xlabel('Year')
axes[0].set_ylabel('New HIV diagnoses per timestep')
axes[0].set_title('HIV diagnoses over time')
axes[0].legend()

# Right: cumulative diagnoses
for label, sim in results.items():
    axes[1].plot(sim.t.yearvec,
                 np.cumsum(sim.results.hiv_test.new_diagnoses),
                 label=label, lw=2)
axes[1].set_xlabel('Year')
axes[1].set_ylabel('Cumulative HIV diagnoses')
axes[1].set_title('Cumulative diagnoses')
axes[1].legend()

sc.figlayout()
plt.show()

Key takeaways

  • PN runs through both a current-partner channel (active sexual network) and an optional prior-partner channel (PriorPartners recall network).
  • The notification and attendance probabilities are separate (p_notify_<scope> × p_attends_<scope>), reflecting real-world attrition between contact tracing and clinic attendance.
  • Set p_notify_previous=ss.bernoulli(p=0) to disable the prior-partner channel; the active sexual network must have recall_prior=True whenever the prior channel is enabled.
  • For more elaborate downstream actions — for example, applying a specific treatment regimen to partners by sex or by network channel — subclass :class:~stisim.PartnerNotification and override :meth:~stisim.PartnerNotification.notify_attendees. The default implementation schedules self.test for attendees; you can replace it with anything (set treatment eligibility, route by sex, send to a registry).