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)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:
- Current partners: anyone with an active edge in the sexual network.
- Previous partners: anyone with an edge in the :class:
~stisim.PriorPartnersrecall network — dissolved partnerships within a configurable lookback window.
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
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).uidsBuilding 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 simRunning 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 haverecall_prior=Truewhenever 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.PartnerNotificationand override :meth:~stisim.PartnerNotification.notify_attendees. The default implementation schedulesself.testfor attendees; you can replace it with anything (set treatment eligibility, route by sex, send to a registry).