Skip to content

Commit a46a3fd

Browse files
committed
dynamic npis (tutorial 11)
1 parent 823fdbb commit a46a3fd

1 file changed

Lines changed: 307 additions & 0 deletions

File tree

tutorial11.py

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import marimo
2+
3+
__generated_with = "0.19.11"
4+
app = marimo.App(width="medium")
5+
6+
7+
@app.cell
8+
def _():
9+
import marimo as mo
10+
import matplotlib.pyplot as plt
11+
import numpy as np
12+
13+
return mo, np, plt
14+
15+
16+
@app.cell(hide_code=True)
17+
def _(mo):
18+
mo.md(r"""
19+
# Dynamic NPIs
20+
21+
## Introduction
22+
23+
In Tutorial 10, we applied NPIs at a **predefined fixed time** (day 20). In practice, interventions are often activated reactively and triggered when the number of infections exceeds a critical threshold, such as a specific incidence per 100,000 individuals.
24+
25+
MEmilio supports this pattern through **dynamic NPIs**: a set of contact dampings that are automatically activated whenever a specified infection threshold is exceeded, remain active for a defined duration, and are then automatically lifted if the incidence is below the threshold again.
26+
27+
Key parameters of a dynamic NPI:
28+
29+
| Parameter | Meaning |
30+
|---------------|----------------------------------------------------------------------------------|
31+
| `threshold` | Incidence limit that triggers the NPI |
32+
| `base_value` | Reference population size for the incidence calculation (typically 100,000) |
33+
| `duration` | Minimum number of days the NPI stays active once triggered |
34+
| `interval` | How often (in days) the incidence is re-evaluated |
35+
""")
36+
return
37+
38+
39+
@app.cell(hide_code=True)
40+
def _(mo):
41+
mo.md(r"""
42+
## Model Setup
43+
44+
We first import the needed functions from the memilio-simulation package:
45+
""")
46+
return
47+
48+
49+
@app.cell
50+
def _():
51+
import memilio.simulation as mio
52+
import memilio.simulation.osecir as osecir
53+
from memilio.simulation import AgeGroup, Damping, LogLevel, set_log_level
54+
set_log_level(LogLevel.Off)
55+
return AgeGroup, mio, osecir
56+
57+
58+
@app.cell(hide_code=True)
59+
def _(mo):
60+
mo.md(r"""
61+
We reuse the four contact locations from Tutorial 10 and keep the same simulation parameters:
62+
""")
63+
return
64+
65+
66+
@app.cell
67+
def _():
68+
from enum import IntEnum
69+
70+
class Location(IntEnum):
71+
Home = 0
72+
School = 1
73+
Work = 2
74+
Other = 3
75+
76+
total_population = 100000
77+
t0 = 0
78+
tmax = 100
79+
dt = 0.1
80+
return Location, dt, t0, tmax, total_population
81+
82+
83+
@app.cell(hide_code=True)
84+
def _(mo):
85+
mo.md(r"""
86+
We reuse the `create_model()` helper from Tutorial 10, which already sets up location-specific contact matrices (baseline sum = 10 contacts/day):
87+
""")
88+
return
89+
90+
91+
@app.cell
92+
def _(AgeGroup, Location, mio, np, osecir, total_population):
93+
def create_model():
94+
"""Create an OSECIR model with location-specific contact matrices."""
95+
m = osecir.Model(1)
96+
g = AgeGroup(0)
97+
98+
# Set infection state stay times (in days)
99+
m.parameters.TimeExposed[g] = 3.2
100+
m.parameters.TimeInfectedNoSymptoms[g] = 2.
101+
m.parameters.TimeInfectedSymptoms[g] = 6.
102+
m.parameters.TimeInfectedSevere[g] = 12.
103+
m.parameters.TimeInfectedCritical[g] = 8.
104+
105+
# Set infection state transition probabilities
106+
m.parameters.RelativeTransmissionNoSymptoms[g] = 0.67
107+
m.parameters.TransmissionProbabilityOnContact[g] = 0.1
108+
m.parameters.RecoveredPerInfectedNoSymptoms[g] = 0.2
109+
m.parameters.RiskOfInfectionFromSymptomatic[g] = 0.25
110+
m.parameters.SeverePerInfectedSymptoms[g] = 0.2
111+
m.parameters.CriticalPerSevere[g] = 0.25
112+
m.parameters.DeathsPerCritical[g] = 0.3
113+
114+
# Create ContactMatrixGroup: 4 location matrices, each of size 1x1 (one age group)
115+
contacts = mio.ContactMatrixGroup(4, 1)
116+
contacts[Location.Home] = mio.ContactMatrix(np.array([[4.0]]), np.array([[1.0]]))
117+
contacts[Location.School] = mio.ContactMatrix(np.array([[3.0]]), np.array([[0.0]]))
118+
contacts[Location.Work] = mio.ContactMatrix(np.array([[2.0]]), np.array([[0.0]]))
119+
contacts[Location.Other] = mio.ContactMatrix(np.array([[1.0]]), np.array([[0.0]]))
120+
m.parameters.ContactPatterns.cont_freq_mat = contacts
121+
122+
# Initial populations: 0.5% Exposed, 0.5% InfectedNoSymptoms, rest Susceptible
123+
m.populations[g, osecir.InfectionState.Exposed] = 0.005 * total_population
124+
m.populations[g, osecir.InfectionState.InfectedNoSymptoms] = 0.005 * total_population
125+
m.populations.set_difference_from_total(
126+
(g, osecir.InfectionState.Susceptible), total_population)
127+
128+
return m
129+
130+
return (create_model,)
131+
132+
133+
@app.cell(hide_code=True)
134+
def _(mo):
135+
mo.md(r"""
136+
## Baseline Simulation Without NPIs
137+
138+
We first run the model without any interventions:
139+
""")
140+
return
141+
142+
143+
@app.cell
144+
def _(create_model, dt, osecir, t0, tmax):
145+
model_baseline = create_model()
146+
result_baseline = osecir.simulate(t0, tmax, dt, model_baseline)
147+
return (result_baseline,)
148+
149+
150+
@app.cell(hide_code=True)
151+
def _(mo):
152+
mo.md(r"""
153+
## Defining Dynamic NPIs
154+
155+
Dynamic NPIs are attached to the model via `model.parameters.DynamicNPIsInfectedSymptoms`. The simulator checks every `interval` days whether the current number of `InfectedSymptoms` relative to `base_value` exceeds a threshold. If so, the corresponding set of `DampingSampling` objects is applied for `duration` days.
156+
157+
Each `DampingSampling` describes one location-specific contact reduction and is constructed as:
158+
159+
```python
160+
mio.DampingSampling(
161+
value = mio.UncertainValue(damping_coefficient),
162+
level = 0, # damping level (for combining multiple dampings)
163+
type = 0, # damping type (for combining multiple dampings)
164+
time = 0.0, # time offset within the NPI duration
165+
matrix_indices = [loc_index], # which location matrix to damp
166+
group_weights = np.ones(1) # one entry per age group
167+
)
168+
```
169+
170+
We define two escalation levels:
171+
172+
| Level | Threshold (per 100k) | School | Work | Other |
173+
|-------|---------------------|--------|------|-------|
174+
| Mild | 500 | 0.3 | 0.3 | 0.3 |
175+
| Strict| 5000 | 1.0 | 0.6 | 0.8 |
176+
""")
177+
return
178+
179+
180+
@app.cell
181+
def _(Location, mio, np):
182+
# Helper: create a DampingSampling for one location
183+
def loc_damping(coefficient, location_index):
184+
return mio.DampingSampling(
185+
value = mio.UncertainValue(coefficient),
186+
level = 0,
187+
type = 0,
188+
time = 0.0,
189+
matrix_indices = [int(location_index)],
190+
group_weights = np.ones(1)
191+
)
192+
193+
# Mild restrictions (threshold: 500 per 100k)
194+
mild_npis = [
195+
loc_damping(0.3, Location.School), # school contacts reduced by 30 %
196+
loc_damping(0.3, Location.Work), # work contacts reduced by 30 %
197+
loc_damping(0.3, Location.Other), # other contacts reduced by 30 %
198+
]
199+
200+
# Strict lockdown (threshold: 2000 per 100k)
201+
strict_npis = [
202+
loc_damping(1.0, Location.School), # schools fully closed
203+
loc_damping(0.6, Location.Work), # work contacts reduced by 60 %
204+
loc_damping(0.8, Location.Other), # other contacts reduced by 80 %
205+
]
206+
return mild_npis, strict_npis
207+
208+
209+
@app.cell(hide_code=True)
210+
def _(mo):
211+
mo.md(r"""
212+
## Setting Up the Model with Dynamic NPIs
213+
214+
We create a new model and set the two dynamic NPIs. The simulator will automatically select the **highest exceeded threshold** at each check interval:
215+
""")
216+
return
217+
218+
219+
@app.cell
220+
def _(create_model, mild_npis, strict_npis):
221+
model_dynamic = create_model()
222+
223+
dynamic_npis = model_dynamic.parameters.DynamicNPIsInfectedSymptoms
224+
dynamic_npis.interval = 3.0 # check every 3 days
225+
dynamic_npis.duration = 14.0 # NPIs stay active for at least 14 days
226+
dynamic_npis.base_value = 100000 # normalize to per-100k incidence
227+
228+
# Register thresholds (lower first; MEmilio sorts them internally)
229+
dynamic_npis.set_threshold(500.0, mild_npis) # 500 per 100k: mild
230+
dynamic_npis.set_threshold(5000.0, strict_npis) # 2000 per 100k: strict
231+
return (model_dynamic,)
232+
233+
234+
@app.cell(hide_code=True)
235+
def _(mo):
236+
mo.md(r"""
237+
## Simulation with Dynamic NPIs
238+
239+
To benefit from dynamic NPI checking, we must use `osecir.Simulation` first. The `Simulation` class overrides `advance()`. We create the simulation object and advance it to `tmax`.
240+
""")
241+
return
242+
243+
244+
@app.cell
245+
def _(dt, model_dynamic, osecir, t0, tmax):
246+
sim = osecir.Simulation(model_dynamic, t0, dt)
247+
sim.advance(tmax)
248+
result_dynamic = sim.result
249+
return (result_dynamic,)
250+
251+
252+
@app.cell(hide_code=True)
253+
def _(mo):
254+
mo.md(r"""
255+
## Visualization of Model Output
256+
257+
We compare the `InfectedSymptoms` trajectories from the baseline and the dynamic-NPI scenario. The shaded horizontal bands mark the two thresholds:
258+
""")
259+
return
260+
261+
262+
@app.cell
263+
def _(osecir, plt, result_baseline, result_dynamic, total_population):
264+
arr_baseline = result_baseline.as_ndarray()
265+
arr_dynamic = result_dynamic.as_ndarray()
266+
267+
inf_idx = 1 + int(osecir.InfectionState.InfectedSymptoms)
268+
269+
# Convert absolute numbers to incidence per 100k for the y-axis
270+
scale = 100000 / total_population
271+
272+
fig, ax = plt.subplots()
273+
ax.plot(arr_baseline[0], arr_baseline[inf_idx] * scale,
274+
label='Without NPIs', color='tab:blue')
275+
ax.plot(arr_dynamic[0], arr_dynamic[inf_idx] * scale,
276+
label='With dynamic NPIs', linestyle='--', color='tab:orange')
277+
278+
# Mark the two thresholds
279+
ax.axhline(y=500, color='gold', linestyle=':', linewidth=1.5, label='Mild threshold (500 / 100k)')
280+
ax.axhline(y=5000, color='tab:red', linestyle=':', linewidth=1.5, label='Strict threshold (5000 / 100k)')
281+
282+
ax.set_xlabel('Time [days]')
283+
ax.set_ylabel('InfectedSymptoms')
284+
ax.set_title('Dynamic NPIs: Automatic Threshold-based Interventions')
285+
ax.legend()
286+
plt.show()
287+
return
288+
289+
290+
@app.cell(hide_code=True)
291+
def _(mo):
292+
mo.md(r"""
293+
## Summary and Next Steps
294+
295+
In this tutorial, we introduced **dynamic NPIs** contact reductions which are triggered automatically when an incidence threshold is exceeded. Key takeaways:
296+
297+
- Dynamic NPIs are configured via `model.parameters.DynamicNPIsInfectedSymptoms`.
298+
- Three control parameters determine the mechanism: `interval` (check frequency), `duration` (minimum active time), and `base_value` (reference population).
299+
- Each threshold is paired with a list of `DampingSampling` objects that specify **which location** and **how much** to damp.
300+
- If multiple thresholds are defined, MEmilio automatically selects the highest exceeded threshold at each check.
301+
- Dynamic NPIs require using `osecir.Simulation(model, t0, dt)` and `sim.advance(tmax)`, since the threshold check is embedded in `advance()`.
302+
""")
303+
return
304+
305+
306+
if __name__ == "__main__":
307+
app.run()

0 commit comments

Comments
 (0)