@@ -657,9 +657,12 @@ TEST(DynamicNPIs, secir_backward_consistency_with_predefined)
657657 model.parameters .get <mio::osecir::DynamicNPIsInfectedSymptoms<double >>() = npis;
658658
659659 // t0=1: threshold exceeded at t=1, delay=2 -> t_start=3, t_start_damping=4,
660- // duration=5 -> t_end=8, t_end_damping=9
660+ // duration=5 -> t_end=8, t_end_damping=9.
661+ // Advance only to t=8 so that the keep-alive renewal (which acts at t=9 when t > t_end=8)
662+ // never triggers. The damping list then stays identical to the predefined dampings for all
663+ // time points, including t=9 (restore window [8,9] fully captured in the list).
661664 mio::osecir::Simulation<double , mio_test::MockSimulation<mio::osecir::Model>> sim (model, 1.0 );
662- sim.advance (10 .0 );
665+ sim.advance (8 .0 );
663666
664667 // Equivalent predefined dampings: place damping at t_start_damping=4 and restore at t_end_damping=9.
665668 // smoother_cosine uses window [t-1, t], so damping at t=4 gives window [3,4].
@@ -669,13 +672,59 @@ TEST(DynamicNPIs, secir_backward_consistency_with_predefined)
669672 cm_expected[0 ].add_damping (0.0 , mio::DampingLevel (0 ), mio::DampingType (0 ), mio::SimulationTime<double >(9.0 ));
670673
671674 auto const & cm_dynamic = sim.get_model ().parameters .get <mio::osecir::ContactPatterns<double >>().get_cont_freq_mat ();
672- for (double t : {1.0 , 2.0 , 3.0 , 3.5 , 4.0 , 5.0 , 7.0 , 8.0 , 8.5 , 9.0 , 10.0 }) {
675+ for (double t : {1.0 , 2.0 , 3.0 , 3.5 , 4.0 , 5.0 , 7.0 , 8.0 , 8.5 , 9.0 }) {
673676 EXPECT_DOUBLE_EQ (cm_dynamic.get_matrix_at (mio::SimulationTime<double >(t))(0 , 0 ),
674677 cm_expected.get_matrix_at (mio::SimulationTime<double >(t))(0 , 0 ))
675678 << " Mismatch at t=" << t;
676679 }
677680}
678681
682+ TEST (DynamicNPIs, secir_expiry_keep_alive)
683+ {
684+ // Verify keep-alive: when the NPI expires but the threshold is still exceeded,
685+ // the NPI is renewed immediately with no delay gap and no dip between expiry and renewal.
686+ //
687+ // Timeline (delay=2, duration=5, t0=1):
688+ // 1st NPI: t_start=3, t_start_damping=4, t_end=8, t_end_damping=9
689+ // At t=9 (expiry): keep-alive acts, t_start=9, t_start_damping=9 (no +1!),
690+ // t_end=14, t_end_damping=15 -> list collapses to [(t=4,0.5),(t=15,0.0)]
691+ mio::osecir::Model<double > model (1 );
692+ model.populations [{mio::AgeGroup (0 ), mio::osecir::InfectionState::InfectedSymptoms}] = 10 ;
693+ model.populations .set_difference_from_total ({mio::AgeGroup (0 ), mio::osecir::InfectionState::Susceptible}, 100 );
694+
695+ mio::ContactMatrixGroup<double >& cm = model.parameters .get <mio::osecir::ContactPatterns<double >>();
696+ cm[0 ] = mio::ContactMatrix<double >(Eigen::MatrixXd::Constant (1 , 1 , 1.0 ));
697+
698+ mio::DynamicNPIs<double > npis;
699+ npis.set_threshold (0.05 * 50'000 , {mio::DampingSampling<double >{0.5 ,
700+ mio::DampingLevel (0 ),
701+ mio::DampingType (0 ),
702+ mio::SimulationTime<double >(0 ),
703+ {0 },
704+ Eigen::VectorXd::Ones (1 )}});
705+ npis.set_duration (mio::SimulationTime<double >(5.0 ));
706+ npis.set_base_value (50'000 );
707+ npis.set_implementation_delay (mio::SimulationTime<double >(2.0 ));
708+ model.parameters .get <mio::osecir::DynamicNPIsInfectedSymptoms<double >>() = npis;
709+
710+ // Stop at t=14 so the 2nd keep-alive (which would act at t=15) does not trigger.
711+ mio::osecir::Simulation<double , mio_test::MockSimulation<mio::osecir::Model>> sim (model, 1.0 );
712+ sim.advance (14.0 );
713+
714+ auto const & cm_dyn = sim.get_model ().parameters .get <mio::osecir::ContactPatterns<double >>().get_cont_freq_mat ();
715+
716+ // Damping list after keep-alive collapses to [(t=4, 0.5), (t=15, 0.0)]:
717+ EXPECT_DOUBLE_EQ (cm_dyn.get_matrix_at (mio::SimulationTime<double >(4.0 ))(0 , 0 ), 0.5 ); // 1st NPI active
718+ EXPECT_DOUBLE_EQ (cm_dyn.get_matrix_at (mio::SimulationTime<double >(8.0 ))(0 , 0 ), 0.5 ); // still active
719+ // no dip at t=9 as keep-alive overwrote the restore entry, contact stays at 0.5.
720+ // (Without this fix the restore would restore to 1.0 at t=9.)
721+ EXPECT_DOUBLE_EQ (cm_dyn.get_matrix_at (mio::SimulationTime<double >(9.0 ))(0 , 0 ), 0.5 );
722+ EXPECT_DOUBLE_EQ (cm_dyn.get_matrix_at (mio::SimulationTime<double >(10.0 ))(0 , 0 ), 0.5 ); // still constant
723+ EXPECT_DOUBLE_EQ (cm_dyn.get_matrix_at (mio::SimulationTime<double >(14.0 ))(0 , 0 ), 0.5 ); // still active
724+ // Restore window [14, 15]: complete at t=15.
725+ EXPECT_DOUBLE_EQ (cm_dyn.get_matrix_at (mio::SimulationTime<double >(15.0 ))(0 , 0 ), 1.0 );
726+ }
727+
679728TEST (DynamicNPIs, secirvvs_threshold_safe)
680729{
681730 mio::osecirvvs::Model<double > model (1 );
0 commit comments