Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 62 additions & 14 deletions src/domain/AlternativeRecommendationService.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -505,9 +505,30 @@ void finalizeDiffKeys(const AlternativeRecommendationInput& request, ScenarioDra
}
}

bool sourceHasConnectionBlock(const ScenarioDraft& scenario, const std::string& connectionId) {
bool connectionBlockConstrainsReachability(
const ConnectionBlockDraft& block,
const std::optional<double>& elapsedSeconds) {
if (block.connectionId.empty()) {
return false;
}
if (elapsedSeconds.has_value()) {
return connectionBlockActiveAt(block, *elapsedSeconds);
}
if (block.intervals.empty()) {
return true;
}
return std::any_of(block.intervals.begin(), block.intervals.end(), [](const auto& interval) {
return interval.startSeconds <= 0.0 && interval.endSeconds <= interval.startSeconds;
});
}

bool sourceHasReachabilityConnectionBlock(
const ScenarioDraft& scenario,
const std::string& connectionId,
const std::optional<double>& elapsedSeconds) {
return std::any_of(scenario.control.connectionBlocks.begin(), scenario.control.connectionBlocks.end(), [&](const auto& block) {
return block.connectionId == connectionId;
return block.connectionId == connectionId
&& connectionBlockConstrainsReachability(block, elapsedSeconds);
});
}

Expand Down Expand Up @@ -876,9 +897,10 @@ bool connectionTraversableFromZone(
const ScenarioDraft& scenario,
const Connection2D& connection,
const std::string& zoneId,
std::string& nextZoneId) {
std::string& nextZoneId,
const std::optional<double>& elapsedSeconds) {
if (connection.directionality == TravelDirection::Closed
|| sourceHasConnectionBlock(scenario, connection.id)) {
|| sourceHasReachabilityConnectionBlock(scenario, connection.id, elapsedSeconds)) {
return false;
}
if (connection.fromZoneId == zoneId && connection.directionality != TravelDirection::ReverseOnly) {
Expand All @@ -896,7 +918,8 @@ bool routeExistsBetweenZones(
const FacilityLayout2D& layout,
const ScenarioDraft& scenario,
const std::string& startZoneId,
const std::string& targetZoneId) {
const std::string& targetZoneId,
const std::optional<double>& elapsedSeconds = std::nullopt) {
if (startZoneId.empty() || targetZoneId.empty()) {
return false;
}
Expand All @@ -910,7 +933,7 @@ bool routeExistsBetweenZones(
const auto zoneId = pending[index];
for (const auto& connection : layout.connections) {
std::string nextZoneId;
if (!connectionTraversableFromZone(scenario, connection, zoneId, nextZoneId)
if (!connectionTraversableFromZone(scenario, connection, zoneId, nextZoneId, elapsedSeconds)
|| visited.find(nextZoneId) != visited.end()) {
continue;
}
Expand All @@ -926,7 +949,8 @@ bool routeExistsBetweenZones(

bool exitReachableFromScenarioSources(
const AlternativeRecommendationInput& request,
const std::string& exitZoneId) {
const std::string& exitZoneId,
const std::optional<double>& elapsedSeconds = std::nullopt) {
if (exitZoneId.empty()) {
return false;
}
Expand All @@ -939,7 +963,7 @@ bool exitReachableFromScenarioSources(
return false;
}
return std::any_of(zones.begin(), zones.end(), [&](const auto& zoneId) {
return routeExistsBetweenZones(request.layout, request.sourceScenario, zoneId, exitZoneId);
return routeExistsBetweenZones(request.layout, request.sourceScenario, zoneId, exitZoneId, elapsedSeconds);
});
}

Expand Down Expand Up @@ -979,12 +1003,13 @@ bool guidanceDetourAcceptable(

std::optional<ExitUsageMetric> leastUsedReachableExit(
const AlternativeRecommendationInput& request,
const std::vector<std::string>& excludedExitZoneIds = {}) {
const std::vector<std::string>& excludedExitZoneIds = {},
const std::optional<double>& elapsedSeconds = std::nullopt) {
const auto candidates = exitUsageCandidates(request);
std::optional<ExitUsageMetric> best;
for (const auto& usage : candidates) {
if (containsString(excludedExitZoneIds, usage.exitZoneId)
|| !exitReachableFromScenarioSources(request, usage.exitZoneId)) {
|| !exitReachableFromScenarioSources(request, usage.exitZoneId, elapsedSeconds)) {
continue;
}
if (!best.has_value()
Expand All @@ -996,6 +1021,25 @@ std::optional<ExitUsageMetric> leastUsedReachableExit(
return best;
}

std::size_t reachableExitCandidateCount(
const AlternativeRecommendationInput& request,
const std::optional<double>& elapsedSeconds = std::nullopt) {
std::unordered_set<std::string> reachableExitZoneIds;
for (const auto& usage : exitUsageCandidates(request)) {
if (!usage.exitZoneId.empty()
&& exitReachableFromScenarioSources(request, usage.exitZoneId, elapsedSeconds)) {
reachableExitZoneIds.insert(usage.exitZoneId);
}
}
return reachableExitZoneIds.size();
}

bool hasAlternativeReachableExit(
const AlternativeRecommendationInput& request,
const std::optional<double>& elapsedSeconds = std::nullopt) {
return reachableExitCandidateCount(request, elapsedSeconds) >= 2;
}

bool bottleneckLessSevere(const ScenarioBottleneckMetric& lhs, const ScenarioBottleneckMetric& rhs) {
if (lhs.stalledAgentCount == rhs.stalledAgentCount) {
return lhs.nearbyAgentCount < rhs.nearbyAgentCount;
Expand Down Expand Up @@ -1410,10 +1454,14 @@ std::optional<AlternativeRecommendationCandidate> makeBottleneckGuidanceCandidat
if (!bottleneck.has_value() || bottleneck->connectionId.empty()) {
return std::nullopt;
}
if (!hasAlternativeReachableExit(request, bottleneck->detectedAtSeconds)) {
return std::nullopt;
}
const auto adjacentExitZoneIds = adjacentExitZoneIdsForConnection(request, bottleneck->connectionId);
const auto targetExit = leastUsedReachableExit(
request,
adjacentExitZoneIds);
adjacentExitZoneIds,
bottleneck->detectedAtSeconds);
if (!targetExit.has_value() || targetExit->exitZoneId.empty()) {
return std::nullopt;
}
Expand Down Expand Up @@ -1487,7 +1535,7 @@ std::optional<AlternativeRecommendationCandidate> makeBottleneckGuidanceCandidat
std::optional<AlternativeRecommendationCandidate> makeExitBalancingCandidate(
const AlternativeRecommendationInput& request,
const RecommendationContext& context) {
if (exitUsageCandidates(request).size() < 2) {
if (!hasAlternativeReachableExit(request)) {
return std::nullopt;
}
const auto low = context.leastUsedReachableExit;
Expand Down Expand Up @@ -1561,7 +1609,7 @@ std::optional<AlternativeRecommendationCandidate> makePressureHotspotCandidate(
if (!hasPressureSignal(request)) {
return std::nullopt;
}
if (exitUsageCandidates(request).size() < 2) {
if (!hasAlternativeReachableExit(request)) {
return std::nullopt;
}

Expand Down Expand Up @@ -1670,7 +1718,7 @@ std::optional<AlternativeRecommendationCandidate> makeCrossFlowCandidate(
}

const auto severity = signal->severity;
if (!context.exitUsageImbalanced && exitUsageCandidates(request).size() >= 2) {
if (!context.exitUsageImbalanced && hasAlternativeReachableExit(request)) {
const auto targetExit = context.leastUsedReachableExit;
if (targetExit.has_value()
&& context.mostUsedExit.has_value()
Expand Down
69 changes: 68 additions & 1 deletion tests/AlternativeRecommendationServiceTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,59 @@ SC_TEST(AlternativeRecommendationService_addsBottleneckGuidanceAtExit) {
SC_EXPECT_TRUE(containsDiffKey(it->recommendedScenario, "control.routeGuidances"));
}

SC_TEST(AlternativeRecommendationService_skipsBottleneckGuidanceForSingleExitLayout) {
ScenarioRiskSnapshot risk;
risk.bottlenecks.push_back({
.connectionId = "door-main",
.nearbyAgentCount = 8,
.stalledAgentCount = 5,
});

const AlternativeRecommendationService service;
const auto result = service.recommend({
.layout = makeSingleExitRecommendationLayout(),
.sourceScenario = makeScenario(),
.risk = risk,
.artifacts = makeSingleExitUsageArtifacts("exit-main", "Main Exit", 20, 1.0),
});

SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::BottleneckBypassGuidance));
SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::ExitUsageBalancing));
}

SC_TEST(AlternativeRecommendationService_keepsInactiveTimedBlockReachableForBottleneckGuidance) {
auto scenario = makeScenario();
scenario.control.connectionBlocks.push_back({
.id = "block-main-early",
.connectionId = "door-main",
.intervals = {{.startSeconds = 0.0, .endSeconds = 5.0}},
});

ScenarioRiskSnapshot risk;
risk.bottlenecks.push_back({
.connectionId = "door-main",
.nearbyAgentCount = 8,
.stalledAgentCount = 5,
.detectedAtSeconds = 20.0,
});

const AlternativeRecommendationService service;
const auto result = service.recommend({
.layout = makeRecommendationLayout(),
.sourceScenario = scenario,
.risk = risk,
.artifacts = makeExitUsageArtifacts(),
});

SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::BlockedConnectionRelief));
const auto it = std::find_if(result.candidates.begin(), result.candidates.end(), [](const auto& candidate) {
return candidate.kind == AlternativeRecommendationKind::BottleneckBypassGuidance;
});
SC_EXPECT_TRUE(it != result.candidates.end());
SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.size(), std::size_t{1});
SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.front().guidedExitZoneId, std::string{"exit-east"});
}

SC_TEST(AlternativeRecommendationService_installsCorridorBottleneckGuidanceAtExitOnly) {
ScenarioRiskSnapshot risk;
risk.bottlenecks.push_back({
Expand Down Expand Up @@ -1007,11 +1060,25 @@ SC_TEST(AlternativeRecommendationService_sortsBlockedReliefBeforeGuidance) {
.nearbyAgentCount = 8,
.stalledAgentCount = 5,
});
auto layout = makeRecommendationLayout();
layout.zones.push_back({
.id = "exit-west",
.floorId = "L1",
.kind = ZoneKind::Exit,
.label = "West Exit",
});
layout.connections.push_back({
.id = "door-west",
.floorId = "L1",
.kind = ConnectionKind::Exit,
.fromZoneId = "room-a",
.toZoneId = "exit-west",
});
const auto artifacts = makeExitUsageArtifacts();

const AlternativeRecommendationService service;
const auto result = service.recommend({
.layout = makeRecommendationLayout(),
.layout = layout,
.sourceScenario = scenario,
.risk = risk,
.artifacts = artifacts,
Expand Down
Loading