diff --git a/src/domain/AgentComponents.h b/src/domain/AgentComponents.h index c328dae..c89adb5 100644 --- a/src/domain/AgentComponents.h +++ b/src/domain/AgentComponents.h @@ -24,6 +24,7 @@ struct Agent { double hazardSensitivity{1.0}; double smokeSensitivity{1.0}; double reactionDelaySeconds{0.0}; + double detectionDelaySeconds{0.0}; double closurePatienceSeconds{0.0}; }; diff --git a/src/domain/ScenarioSimulationRunner.cpp b/src/domain/ScenarioSimulationRunner.cpp index 23c8e2e..ca5e195 100644 --- a/src/domain/ScenarioSimulationRunner.cpp +++ b/src/domain/ScenarioSimulationRunner.cpp @@ -274,6 +274,8 @@ ScenarioAgentSeed ScenarioSimulationRunner::createAgentSeed( ^ mix64(fnv1a64(sourcePlacementId)) ^ mix64(fnv1a64(startZoneId)) ^ mix64(static_cast(evacuationRoute.destinationZoneId.size())); + const auto detectionSalt = mix64(propensitySalt ^ 0x9E3779B97F4A7C15ULL); + constexpr double kAgentDetectionDelayMaxSeconds = 3.0; return { .position = {.value = position}, .agent = { @@ -282,6 +284,7 @@ ScenarioAgentSeed ScenarioSimulationRunner::createAgentSeed( .sourcePlacementId = sourcePlacementId, .sourceZoneId = startZoneId, .guidancePropensity = beta22(baseSeed, propensitySalt), + .detectionDelaySeconds = kAgentDetectionDelayMaxSeconds * beta22(baseSeed, detectionSalt), }, .velocity = {.value = {}}, .route = std::move(evacuationRoute), diff --git a/src/domain/ScenarioSimulationSystems.cpp b/src/domain/ScenarioSimulationSystems.cpp index 84ec7f6..aa6966d 100644 --- a/src/domain/ScenarioSimulationSystems.cpp +++ b/src/domain/ScenarioSimulationSystems.cpp @@ -662,6 +662,19 @@ class ScenarioControlSystem final : public engine::EngineSystem { std::uint64_t revision_{0}; }; +void resetHazardEncounter(ScenarioEnvironmentReactionAgentState& state) { + state.hazardDetected = false; + state.hazardAware = false; + state.hazardInRange = false; + state.hazardDistanceMeters = 0.0; + state.hazardRadiusMeters = 0.0; + state.hazardSpeedFactor = 1.0; + state.hazardRoutePenaltyMeters = 0.0; + state.hazardSensedSinceSeconds = 0.0; + state.hazardDetectedAtSeconds = 0.0; + state.hazardReactionReadySeconds = 0.0; +} + class ScenarioEnvironmentHazardSystem final : public engine::EngineSystem { public: ScenarioEnvironmentHazardSystem(FacilityLayout2D layout, std::vector hazards) @@ -774,21 +787,32 @@ class ScenarioEnvironmentHazardSystem final : public engine::EngineSystem { auto& state = reactions.agentsById[entity.index]; if (detectedHazard == nullptr) { - state.hazardInRange = false; + resetHazardEncounter(state); continue; } - if (!state.hazardDetected || state.hazardKey != detectedHazard->key) { - state.hazardDetected = true; - state.hazardAware = false; + if (!state.hazardInRange || state.hazardKey != detectedHazard->key) { + // New hazard encounter: restart the sense -> detect -> react pipeline. state.hazardKey = detectedHazard->key; + state.hazardSensedSinceSeconds = elapsedSeconds; + state.hazardDetected = false; + state.hazardAware = false; + } + + // Detection lags entering sensing range by the agent's detection delay; + // the reaction delay then runs from the moment of detection. + if (!state.hazardDetected + && elapsedSeconds + 1e-9 + >= state.hazardSensedSinceSeconds + std::max(0.0, agent.detectionDelaySeconds)) { + state.hazardDetected = true; state.hazardDetectedAtSeconds = elapsedSeconds; state.hazardReactionReadySeconds = elapsedSeconds + std::max(0.0, agent.reactionDelaySeconds); } state.hazardInRange = true; - state.hazardAware = elapsedSeconds + 1e-9 >= state.hazardReactionReadySeconds; + state.hazardAware = state.hazardDetected + && elapsedSeconds + 1e-9 >= state.hazardReactionReadySeconds; state.hazardKind = detectedHazard->draft.kind; state.hazardSeverity = detectedHazard->draft.severity; state.hazardPosition = detectedHazard->draft.position; diff --git a/src/domain/ScenarioSimulationSystems.h b/src/domain/ScenarioSimulationSystems.h index 17be99f..e8263cb 100644 --- a/src/domain/ScenarioSimulationSystems.h +++ b/src/domain/ScenarioSimulationSystems.h @@ -102,6 +102,7 @@ struct ScenarioEnvironmentReactionAgentState { double hazardRadiusMeters{0.0}; double hazardSpeedFactor{1.0}; double hazardRoutePenaltyMeters{0.0}; + double hazardSensedSinceSeconds{0.0}; double hazardDetectedAtSeconds{0.0}; double hazardReactionReadySeconds{0.0}; bool closureDetected{false}; diff --git a/tests/ScenarioSimulationSystemsTests.cpp b/tests/ScenarioSimulationSystemsTests.cpp index 038855a..4c0cedf 100644 --- a/tests/ScenarioSimulationSystemsTests.cpp +++ b/tests/ScenarioSimulationSystemsTests.cpp @@ -2225,6 +2225,111 @@ SC_TEST(ScenarioEnvironmentHazardSystem_DelaysFireAvoidanceUntilReactionReady) { SC_EXPECT_TRUE(awareFrame.agents.front().position.y < -0.01); } +SC_TEST(ScenarioEnvironmentHazardSystem_DelaysDetectionUntilDetectionDelayElapses) { + auto seed = straightRouteSeed({.x = 0.0, .y = 0.0}, 1.0, 0.0); + seed.agent.detectionDelaySeconds = 1.0; // must sense the hazard for 1s before detecting it + seed.agent.reactionDelaySeconds = 0.0; // react immediately once detected + + // A large radius keeps the agent inside the hazard while it moves along its route, + // so the test isolates the detection delay rather than range changes. + safecrowd::domain::EnvironmentHazardDraft fire; + fire.id = "fire-detect-delay"; + fire.kind = safecrowd::domain::EnvironmentHazardKind::Fire; + fire.name = "Test hazard"; + fire.affectedZoneId = "room"; + fire.position = {.x = 0.0, .y = 0.4}; + fire.startSeconds = 0.0; + fire.endSeconds = 0.0; + fire.severity = safecrowd::domain::ScenarioElementSeverity::High; + fire.radiusMeters = 100.0; + + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 71, + }); + runtime.addSystem(std::make_unique( + std::vector{seed}, 10.0)); + addHazardMotionSystems(runtime, straightExitLayout(), {fire}); + + runtime.play(); + + // After 0.25s the hazard is sensed but the 1s detection delay has not elapsed. + runtime.world().resources().set(safecrowd::domain::ScenarioSimulationStepResource{.deltaSeconds = 0.25}); + runtime.stepFrame(0.0); + + const auto& earlyState = + runtime.world().resources().get().agentsById.at(0); + SC_EXPECT_TRUE(earlyState.hazardInRange); + SC_EXPECT_TRUE(!earlyState.hazardDetected); + SC_EXPECT_TRUE(!earlyState.hazardAware); + + // Advance well past the detection delay; detection fires and, with no reaction delay, + // awareness follows immediately. + for (int step = 0; step < 7; ++step) { + runtime.world().resources().set(safecrowd::domain::ScenarioSimulationStepResource{.deltaSeconds = 0.25}); + runtime.stepFrame(0.0); + } + + const auto& lateState = + runtime.world().resources().get().agentsById.at(0); + SC_EXPECT_TRUE(lateState.hazardDetected); + SC_EXPECT_TRUE(lateState.hazardAware); +} + +SC_TEST(ScenarioEnvironmentHazardSystem_RestartsDetectionDelayAfterLeavingHazardRange) { + auto seed = straightRouteSeed({.x = 0.0, .y = 0.0}, 0.0, 0.0); + seed.agent.detectionDelaySeconds = 1.0; + seed.agent.reactionDelaySeconds = 0.0; + + auto fire = hazardDraft( + "fire-reentry-delay", + safecrowd::domain::EnvironmentHazardKind::Fire, + safecrowd::domain::ScenarioElementSeverity::High, + {.x = 0.0, .y = 0.0}); + fire.radiusMeters = 0.5; + + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 72, + }); + runtime.addSystem(std::make_unique( + std::vector{seed}, 10.0)); + addHazardMotionSystems(runtime, straightExitLayout(), {fire}); + + runtime.play(); + for (int step = 0; step < 5; ++step) { + stepScenarioRuntime(runtime, 0.25); + } + + const auto& detectedState = + runtime.world().resources().get().agentsById.at(0); + SC_EXPECT_TRUE(detectedState.hazardDetected); + SC_EXPECT_TRUE(detectedState.hazardAware); + + auto& query = runtime.world().query(); + const auto entity = query.view().front(); + query.get(entity).value = {.x = 5.0, .y = 0.0}; + stepScenarioRuntime(runtime, 0.25); + + const auto& outsideState = + runtime.world().resources().get().agentsById.at(0); + SC_EXPECT_TRUE(!outsideState.hazardInRange); + SC_EXPECT_TRUE(!outsideState.hazardDetected); + SC_EXPECT_TRUE(!outsideState.hazardAware); + SC_EXPECT_EQ(outsideState.hazardKey, std::string{"fire-reentry-delay"}); + + query.get(entity).value = {.x = 0.0, .y = 0.0}; + stepScenarioRuntime(runtime, 0.25); + + const auto& reenteredState = + runtime.world().resources().get().agentsById.at(0); + SC_EXPECT_TRUE(reenteredState.hazardInRange); + SC_EXPECT_TRUE(!reenteredState.hazardDetected); + SC_EXPECT_TRUE(!reenteredState.hazardAware); +} + SC_TEST(ScenarioEnvironmentHazardSystem_SmokeSlowsButDoesNotStopAgent) { std::vector baselineSeeds; baselineSeeds.push_back(straightRouteSeed({.x = 0.0, .y = 0.0}, 1.0));