From cfa44b8cbdbb7e12a5350bb8878b908c46ead527 Mon Sep 17 00:00:00 2001 From: 95x8x9 Date: Thu, 4 Jun 2026 20:10:54 +0900 Subject: [PATCH] Improve operational conflict detection --- src/application/ProjectWorkspaceState.h | 2 +- src/application/ResultArtifactsCodec.cpp | 184 +++-- src/application/ScenarioBatchResultWidget.cpp | 114 ++- src/application/ScenarioBatchResultWidget.h | 2 +- src/application/ScenarioResultNavigation.cpp | 130 +++- src/application/ScenarioResultNavigation.h | 5 +- src/application/SimulationCanvasWidget.cpp | 161 ++++- src/application/SimulationCanvasWidget.h | 17 +- .../AlternativeRecommendationService.cpp | 147 ++-- src/domain/AlternativeRecommendationService.h | 4 +- src/domain/ScenarioResultArtifacts.h | 24 +- src/domain/ScenarioRiskMetrics.cpp | 10 +- src/domain/ScenarioRiskMetrics.h | 62 +- src/domain/ScenarioRiskMetricsSystem.cpp | 680 +++++++++++++++--- src/domain/ScenarioSimulationSystems.cpp | 96 ++- src/domain/ScenarioSimulationSystems.h | 41 +- .../AlternativeRecommendationServiceTests.cpp | 100 ++- tests/ProjectPersistenceTests.cpp | 77 +- tests/ScenarioSimulationSystemsTests.cpp | 298 +++++++- 19 files changed, 1672 insertions(+), 482 deletions(-) diff --git a/src/application/ProjectWorkspaceState.h b/src/application/ProjectWorkspaceState.h index 4a4c196..1b6f9a1 100644 --- a/src/application/ProjectWorkspaceState.h +++ b/src/application/ProjectWorkspaceState.h @@ -37,7 +37,7 @@ enum class SavedResultNavigationView { Groups = 3, Recommendations = 4, HazardExposure = 5, - CrossFlow = 6, + OperationalConflict = 6, }; struct SavedScenarioState { diff --git a/src/application/ResultArtifactsCodec.cpp b/src/application/ResultArtifactsCodec.cpp index 1a8f2ee..5cf13c6 100644 --- a/src/application/ResultArtifactsCodec.cpp +++ b/src/application/ResultArtifactsCodec.cpp @@ -227,7 +227,7 @@ safecrowd::domain::ScenarioCriticalPressureEvent criticalPressureEventFromJson(c return event; } -QJsonObject crossFlowCellToJson(const safecrowd::domain::ScenarioCrossFlowCellMetric& cell) { +QJsonObject operationalConflictCellToJson(const safecrowd::domain::ScenarioOperationalConflictCellMetric& cell) { QJsonObject object; object["center"] = pointArray(cell.center); object["cellMin"] = pointArray(cell.cellMin); @@ -235,14 +235,17 @@ QJsonObject crossFlowCellToJson(const safecrowd::domain::ScenarioCrossFlowCellMe object["floorId"] = QString::fromStdString(cell.floorId); object["movingAgentCount"] = static_cast(cell.movingAgentCount); object["peakAgentCount"] = static_cast(cell.peakAgentCount); - object["primaryFlowCount"] = static_cast(cell.primaryFlowCount); - object["crossFlowCount"] = static_cast(cell.crossFlowCount); - object["crossFlowRatio"] = cell.crossFlowRatio; + object["forwardCount"] = static_cast(cell.forwardCount); + object["reverseCount"] = static_cast(cell.reverseCount); + object["counterflowRatio"] = cell.counterflowRatio; object["averageSpeed"] = cell.averageSpeed; object["speedDropRatio"] = cell.speedDropRatio; - object["crossFlowScore"] = cell.crossFlowScore; + object["conflictScore"] = cell.conflictScore; object["durationSeconds"] = cell.durationSeconds; object["exposureAgentSeconds"] = cell.exposureAgentSeconds; + object["nearestConnectionId"] = QString::fromStdString(cell.nearestConnectionId); + object["nearestConnectionLabel"] = QString::fromStdString(cell.nearestConnectionLabel); + object["oppositionScore"] = cell.oppositionScore; object["detectedAtSeconds"] = optionalDoubleToJson(cell.detectedAtSeconds); if (cell.detectionFrame.has_value()) { object["detectionFrame"] = simulationFrameToJson(*cell.detectionFrame); @@ -250,23 +253,26 @@ QJsonObject crossFlowCellToJson(const safecrowd::domain::ScenarioCrossFlowCellMe return object; } -safecrowd::domain::ScenarioCrossFlowCellMetric crossFlowCellFromJson(const QJsonObject& object) { - safecrowd::domain::ScenarioCrossFlowCellMetric cell{ +safecrowd::domain::ScenarioOperationalConflictCellMetric operationalConflictCellFromJson(const QJsonObject& object) { + safecrowd::domain::ScenarioOperationalConflictCellMetric cell{ .center = pointFromJson(object.value("center")), .cellMin = pointFromJson(object.value("cellMin")), .cellMax = pointFromJson(object.value("cellMax")), .floorId = object.value("floorId").toString().toStdString(), .movingAgentCount = static_cast(object.value("movingAgentCount").toInteger()), .peakAgentCount = static_cast(object.value("peakAgentCount").toInteger()), - .primaryFlowCount = static_cast(object.value("primaryFlowCount").toInteger()), - .crossFlowCount = static_cast(object.value("crossFlowCount").toInteger()), - .crossFlowRatio = object.value("crossFlowRatio").toDouble(), + .forwardCount = static_cast(object.value("forwardCount").toInteger()), + .reverseCount = static_cast(object.value("reverseCount").toInteger()), + .counterflowRatio = object.value("counterflowRatio").toDouble(), .averageSpeed = object.value("averageSpeed").toDouble(), .speedDropRatio = object.value("speedDropRatio").toDouble(), - .crossFlowScore = object.value("crossFlowScore").toDouble(), + .conflictScore = object.value("conflictScore").toDouble(), .durationSeconds = object.value("durationSeconds").toDouble(), .exposureAgentSeconds = object.value("exposureAgentSeconds").toDouble(), + .nearestConnectionId = object.value("nearestConnectionId").toString().toStdString(), + .nearestConnectionLabel = object.value("nearestConnectionLabel").toString().toStdString(), }; + cell.oppositionScore = object.value("oppositionScore").toDouble(); cell.detectedAtSeconds = optionalDoubleFromJson(object.value("detectedAtSeconds")); if (object.value("detectionFrame").isObject()) { cell.detectionFrame = simulationFrameFromJson(object.value("detectionFrame").toObject()); @@ -274,39 +280,107 @@ safecrowd::domain::ScenarioCrossFlowCellMetric crossFlowCellFromJson(const QJson return cell; } -QJsonObject crossFlowTimelineSampleToJson(const safecrowd::domain::CrossFlowTimelineSample& sample) { +QJsonObject operationalConflictConnectionToJson(const safecrowd::domain::ScenarioOperationalConflictConnectionMetric& connection) { + QJsonObject object; + object["connectionId"] = QString::fromStdString(connection.connectionId); + object["label"] = QString::fromStdString(connection.label); + object["floorId"] = QString::fromStdString(connection.floorId); + object["passageStart"] = pointArray(connection.passage.start); + object["passageEnd"] = pointArray(connection.passage.end); + object["nearbyAgentCount"] = static_cast(connection.nearbyAgentCount); + object["movingAgentCount"] = static_cast(connection.movingAgentCount); + object["queueAgentCount"] = static_cast(connection.queueAgentCount); + object["forwardCount"] = static_cast(connection.forwardCount); + object["reverseCount"] = static_cast(connection.reverseCount); + object["counterflowRatio"] = connection.counterflowRatio; + object["averageSpeed"] = connection.averageSpeed; + object["speedDropRatio"] = connection.speedDropRatio; + object["conflictScore"] = connection.conflictScore; + object["durationSeconds"] = connection.durationSeconds; + object["exposureAgentSeconds"] = connection.exposureAgentSeconds; + object["oppositionScore"] = connection.oppositionScore; + object["detectedAtSeconds"] = optionalDoubleToJson(connection.detectedAtSeconds); + if (connection.detectionFrame.has_value()) { + object["detectionFrame"] = simulationFrameToJson(*connection.detectionFrame); + } + return object; +} + +safecrowd::domain::ScenarioOperationalConflictConnectionMetric operationalConflictConnectionFromJson( + const QJsonObject& object) { + safecrowd::domain::ScenarioOperationalConflictConnectionMetric connection{ + .connectionId = object.value("connectionId").toString().toStdString(), + .label = object.value("label").toString().toStdString(), + .floorId = object.value("floorId").toString().toStdString(), + .passage = { + .start = pointFromJson(object.value("passageStart")), + .end = pointFromJson(object.value("passageEnd")), + }, + .nearbyAgentCount = static_cast(object.value("nearbyAgentCount").toInteger()), + .movingAgentCount = static_cast(object.value("movingAgentCount").toInteger()), + .queueAgentCount = static_cast(object.value("queueAgentCount").toInteger()), + .forwardCount = static_cast(object.value("forwardCount").toInteger()), + .reverseCount = static_cast(object.value("reverseCount").toInteger()), + .counterflowRatio = object.value("counterflowRatio").toDouble(), + .averageSpeed = object.value("averageSpeed").toDouble(), + .speedDropRatio = object.value("speedDropRatio").toDouble(), + .conflictScore = object.value("conflictScore").toDouble(), + .durationSeconds = object.value("durationSeconds").toDouble(), + .exposureAgentSeconds = object.value("exposureAgentSeconds").toDouble(), + }; + connection.oppositionScore = object.value("oppositionScore").toDouble(); + connection.detectedAtSeconds = optionalDoubleFromJson(object.value("detectedAtSeconds")); + if (object.value("detectionFrame").isObject()) { + connection.detectionFrame = simulationFrameFromJson(object.value("detectionFrame").toObject()); + } + return connection; +} + +QJsonObject operationalConflictTimelineSampleToJson(const safecrowd::domain::OperationalConflictTimelineSample& sample) { QJsonObject object; object["timeSeconds"] = sample.timeSeconds; - object["peakCrossFlowScore"] = sample.peakCrossFlowScore; - object["activeCrossFlowCellCount"] = static_cast(sample.activeCrossFlowCellCount); + object["peakConflictScore"] = sample.peakConflictScore; + object["activeConflictCellCount"] = static_cast(sample.activeConflictCellCount); + object["activeConflictConnectionCount"] = static_cast(sample.activeConflictConnectionCount); return object; } -safecrowd::domain::CrossFlowTimelineSample crossFlowTimelineSampleFromJson(const QJsonObject& object) { +safecrowd::domain::OperationalConflictTimelineSample operationalConflictTimelineSampleFromJson(const QJsonObject& object) { return { .timeSeconds = object.value("timeSeconds").toDouble(), - .peakCrossFlowScore = object.value("peakCrossFlowScore").toDouble(), - .activeCrossFlowCellCount = static_cast(object.value("activeCrossFlowCellCount").toInteger()), + .peakConflictScore = object.value("peakConflictScore").toDouble(), + .activeConflictCellCount = static_cast(object.value("activeConflictCellCount").toInteger()), + .activeConflictConnectionCount = + static_cast(object.value("activeConflictConnectionCount").toInteger()), }; } -QJsonObject crossFlowSummaryToJson(const safecrowd::domain::CrossFlowSummary& summary) { +QJsonObject operationalConflictSummaryToJson(const safecrowd::domain::OperationalConflictSummary& summary) { QJsonObject object; - object["peakCrossFlowScore"] = summary.peakCrossFlowScore; + object["peakConflictScore"] = summary.peakConflictScore; object["peakAtSeconds"] = optionalDoubleToJson(summary.peakAtSeconds); - object["totalCrossFlowExposureAgentSeconds"] = summary.totalCrossFlowExposureAgentSeconds; - object["longestCrossFlowDurationSeconds"] = summary.longestCrossFlowDurationSeconds; - object["crossFlowHotspotCount"] = static_cast(summary.crossFlowHotspotCount); + object["totalConflictExposureAgentSeconds"] = summary.totalConflictExposureAgentSeconds; + object["longestConflictDurationSeconds"] = summary.longestConflictDurationSeconds; + object["conflictConnectionCount"] = static_cast(summary.conflictConnectionCount); + object["connectionConcentrationIndex"] = summary.connectionConcentrationIndex; + object["topConflictConnectionId"] = QString::fromStdString(summary.topConflictConnectionId); + object["topConflictConnectionLabel"] = QString::fromStdString(summary.topConflictConnectionLabel); return object; } -safecrowd::domain::CrossFlowSummary crossFlowSummaryFromJson(const QJsonObject& object) { - safecrowd::domain::CrossFlowSummary summary; - summary.peakCrossFlowScore = object.value("peakCrossFlowScore").toDouble(); +safecrowd::domain::OperationalConflictSummary operationalConflictSummaryFromJson(const QJsonObject& object) { + safecrowd::domain::OperationalConflictSummary summary; + summary.peakConflictScore = object.value("peakConflictScore").toDouble(); summary.peakAtSeconds = optionalDoubleFromJson(object.value("peakAtSeconds")); - summary.totalCrossFlowExposureAgentSeconds = object.value("totalCrossFlowExposureAgentSeconds").toDouble(); - summary.longestCrossFlowDurationSeconds = object.value("longestCrossFlowDurationSeconds").toDouble(); - summary.crossFlowHotspotCount = static_cast(object.value("crossFlowHotspotCount").toInteger()); + summary.totalConflictExposureAgentSeconds = object.value("totalConflictExposureAgentSeconds").toDouble(); + summary.longestConflictDurationSeconds = object.value("longestConflictDurationSeconds").toDouble(); + const auto connectionCountValue = object.contains("conflictConnectionCount") + ? object.value("conflictConnectionCount") + : object.value("operationalConflictHotspotCount"); + summary.conflictConnectionCount = static_cast(connectionCountValue.toInteger()); + summary.connectionConcentrationIndex = object.value("connectionConcentrationIndex").toDouble(); + summary.topConflictConnectionId = object.value("topConflictConnectionId").toString().toStdString(); + summary.topConflictConnectionLabel = object.value("topConflictConnectionLabel").toString().toStdString(); return summary; } @@ -315,9 +389,9 @@ QJsonObject riskSnapshotToJson(const safecrowd::domain::ScenarioRiskSnapshot& ri object["stalledAgentCount"] = static_cast(risk.stalledAgentCount); object["pressureExposedAgentCount"] = static_cast(risk.pressureExposedAgentCount); object["criticalPressureAgentCount"] = static_cast(risk.criticalPressureAgentCount); - object["crossFlowAgentCount"] = static_cast(risk.crossFlowAgentCount); - object["peakCrossFlowScore"] = risk.peakCrossFlowScore; - object["totalCrossFlowExposureAgentSeconds"] = risk.totalCrossFlowExposureAgentSeconds; + object["conflictAgentCount"] = static_cast(risk.conflictAgentCount); + object["peakConflictScore"] = risk.peakConflictScore; + object["totalConflictExposureAgentSeconds"] = risk.totalConflictExposureAgentSeconds; QJsonArray hotspots; for (const auto& hotspot : risk.hotspots) { hotspots.append(hotspotToJson(hotspot)); @@ -343,11 +417,16 @@ QJsonObject riskSnapshotToJson(const safecrowd::domain::ScenarioRiskSnapshot& ri bottlenecks.append(bottleneckToJson(bottleneck)); } object["bottlenecks"] = bottlenecks; - QJsonArray crossFlowCells; - for (const auto& cell : risk.crossFlowCells) { - crossFlowCells.append(crossFlowCellToJson(cell)); + QJsonArray operationalConflictCells; + for (const auto& cell : risk.operationalConflictCells) { + operationalConflictCells.append(operationalConflictCellToJson(cell)); } - object["crossFlowCells"] = crossFlowCells; + object["operationalConflictCells"] = operationalConflictCells; + QJsonArray operationalConflictConnections; + for (const auto& connection : risk.operationalConflictConnections) { + operationalConflictConnections.append(operationalConflictConnectionToJson(connection)); + } + object["operationalConflictConnections"] = operationalConflictConnections; return object; } @@ -358,9 +437,9 @@ safecrowd::domain::ScenarioRiskSnapshot riskSnapshotFromJson(const QJsonObject& static_cast(object.value("pressureExposedAgentCount").toInteger()); risk.criticalPressureAgentCount = static_cast(object.value("criticalPressureAgentCount").toInteger()); - risk.crossFlowAgentCount = static_cast(object.value("crossFlowAgentCount").toInteger()); - risk.peakCrossFlowScore = object.value("peakCrossFlowScore").toDouble(); - risk.totalCrossFlowExposureAgentSeconds = object.value("totalCrossFlowExposureAgentSeconds").toDouble(); + risk.conflictAgentCount = static_cast(object.value("conflictAgentCount").toInteger()); + risk.peakConflictScore = object.value("peakConflictScore").toDouble(); + risk.totalConflictExposureAgentSeconds = object.value("totalConflictExposureAgentSeconds").toDouble(); for (const auto& value : object.value("hotspots").toArray()) { risk.hotspots.push_back(hotspotFromJson(value.toObject())); } @@ -376,8 +455,11 @@ safecrowd::domain::ScenarioRiskSnapshot riskSnapshotFromJson(const QJsonObject& for (const auto& value : object.value("bottlenecks").toArray()) { risk.bottlenecks.push_back(bottleneckFromJson(value.toObject())); } - for (const auto& value : object.value("crossFlowCells").toArray()) { - risk.crossFlowCells.push_back(crossFlowCellFromJson(value.toObject())); + for (const auto& value : object.value("operationalConflictCells").toArray()) { + risk.operationalConflictCells.push_back(operationalConflictCellFromJson(value.toObject())); + } + for (const auto& value : object.value("operationalConflictConnections").toArray()) { + risk.operationalConflictConnections.push_back(operationalConflictConnectionFromJson(value.toObject())); } return risk; } @@ -860,7 +942,7 @@ QJsonObject resultArtifactsToJson(const safecrowd::domain::ScenarioResultArtifac object["occupancyHeatmap"] = occupancyHeatmapToJson(artifacts.occupancyHeatmap); object["pressureSummary"] = pressureSummaryToJson(artifacts.pressureSummary); object["hazardExposureSummary"] = hazardExposureSummaryToJson(artifacts.hazardExposureSummary); - object["crossFlowSummary"] = crossFlowSummaryToJson(artifacts.crossFlowSummary); + object["operationalConflictSummary"] = operationalConflictSummaryToJson(artifacts.operationalConflictSummary); QJsonArray exitUsage; for (const auto& exit : artifacts.exitUsage) { @@ -880,11 +962,11 @@ QJsonObject resultArtifactsToJson(const safecrowd::domain::ScenarioResultArtifac } object["placementCompletion"] = placementCompletion; - QJsonArray crossFlowTimeline; - for (const auto& sample : artifacts.crossFlowTimeline) { - crossFlowTimeline.append(crossFlowTimelineSampleToJson(sample)); + QJsonArray operationalConflictTimeline; + for (const auto& sample : artifacts.operationalConflictTimeline) { + operationalConflictTimeline.append(operationalConflictTimelineSampleToJson(sample)); } - object["crossFlowTimeline"] = crossFlowTimeline; + object["operationalConflictTimeline"] = operationalConflictTimeline; return object; } @@ -931,9 +1013,9 @@ safecrowd::domain::ScenarioResultArtifacts resultArtifactsFromJson(const QJsonOb artifacts.hazardExposureSummary = hazardExposureSummaryFromJson(object.value("hazardExposureSummary").toObject()); } - if (object.value("crossFlowSummary").isObject()) { - artifacts.crossFlowSummary = - crossFlowSummaryFromJson(object.value("crossFlowSummary").toObject()); + if (object.value("operationalConflictSummary").isObject()) { + artifacts.operationalConflictSummary = + operationalConflictSummaryFromJson(object.value("operationalConflictSummary").toObject()); } for (const auto& value : object.value("exitUsage").toArray()) { artifacts.exitUsage.push_back(exitUsageMetricFromJson(value.toObject())); @@ -944,9 +1026,9 @@ safecrowd::domain::ScenarioResultArtifacts resultArtifactsFromJson(const QJsonOb for (const auto& value : object.value("placementCompletion").toArray()) { artifacts.placementCompletion.push_back(placementCompletionMetricFromJson(value.toObject())); } - for (const auto& value : object.value("crossFlowTimeline").toArray()) { - artifacts.crossFlowTimeline.push_back( - crossFlowTimelineSampleFromJson(value.toObject())); + for (const auto& value : object.value("operationalConflictTimeline").toArray()) { + artifacts.operationalConflictTimeline.push_back( + operationalConflictTimelineSampleFromJson(value.toObject())); } return artifacts; diff --git a/src/application/ScenarioBatchResultWidget.cpp b/src/application/ScenarioBatchResultWidget.cpp index 0c58843..e94ee1f 100644 --- a/src/application/ScenarioBatchResultWidget.cpp +++ b/src/application/ScenarioBatchResultWidget.cpp @@ -986,8 +986,8 @@ std::optional existingScenarioIndexBySourceTemplate( ScenarioResultNavigationView resultNavigationViewFromSaved(SavedResultNavigationView view) { switch (view) { - case SavedResultNavigationView::CrossFlow: - return ScenarioResultNavigationView::CrossFlow; + case SavedResultNavigationView::OperationalConflict: + return ScenarioResultNavigationView::OperationalConflict; case SavedResultNavigationView::Hotspot: return ScenarioResultNavigationView::Hotspot; case SavedResultNavigationView::HazardExposure: @@ -1006,8 +1006,8 @@ ScenarioResultNavigationView resultNavigationViewFromSaved(SavedResultNavigation SavedResultNavigationView savedResultNavigationView(ScenarioResultNavigationView view) { switch (view) { - case ScenarioResultNavigationView::CrossFlow: - return SavedResultNavigationView::CrossFlow; + case ScenarioResultNavigationView::OperationalConflict: + return SavedResultNavigationView::OperationalConflict; case ScenarioResultNavigationView::Hotspot: return SavedResultNavigationView::Hotspot; case ScenarioResultNavigationView::HazardExposure: @@ -1129,7 +1129,7 @@ QWidget* ScenarioBatchResultWidget::createCanvasPanel() { overlayCombo_->addItem("Pressure", static_cast(OverlayMode::Pressure)); overlayCombo_->addItem("Hotspots", static_cast(OverlayMode::Hotspots)); overlayCombo_->addItem("Bottlenecks", static_cast(OverlayMode::Bottlenecks)); - overlayCombo_->addItem("Cross Flow", static_cast(OverlayMode::CrossFlow)); + overlayCombo_->addItem("Operational Conflict", static_cast(OverlayMode::OperationalConflict)); overlayCombo_->addItem("None", static_cast(OverlayMode::None)); overlayCombo_->setCurrentIndex(0); selectorLayout->addWidget(overlayCombo_); @@ -1672,7 +1672,9 @@ void ScenarioBatchResultWidget::applySelectedResultStaticCanvasState() { canvas_->setRouteGuidances(result.scenario.control.routeGuidances); canvas_->setHotspotOverlay(result.risk.hotspots); canvas_->setBottleneckOverlay(result.risk.bottlenecks); - canvas_->setCrossFlowOverlay(result.risk.crossFlowCells); + canvas_->setOperationalConflictOverlay( + result.risk.operationalConflictCells, + result.risk.operationalConflictConnections); canvas_->setOccupancyHeatmapOverlay(result.artifacts.occupancyHeatmap); canvas_->setDensityOverlay( result.artifacts.densitySummary.peakField.cells.empty() @@ -1713,8 +1715,8 @@ void ScenarioBatchResultWidget::applyOverlayModeToCanvas() { case OverlayMode::Bottlenecks: canvas_->setResultOverlayMode(ResultOverlayMode::Bottlenecks); break; - case OverlayMode::CrossFlow: - canvas_->setResultOverlayMode(ResultOverlayMode::CrossFlow); + case OverlayMode::OperationalConflict: + canvas_->setResultOverlayMode(ResultOverlayMode::OperationalConflict); break; case OverlayMode::None: canvas_->setResultOverlayMode(ResultOverlayMode::None); @@ -2175,14 +2177,14 @@ void ScenarioBatchResultWidget::refreshResultNavigationPanel() { canvas_->focusBottleneck(index); } }; - auto crossFlowCellFocusHandler = [this](std::size_t index) { + auto operationalConflictCellFocusHandler = [this](std::size_t index) { if (results_.empty() || currentResultIndex_ < 0 || currentResultIndex_ >= static_cast(results_.size())) { return; } const auto& selected = results_[static_cast(currentResultIndex_)]; - if (index < selected.risk.crossFlowCells.size()) { - setOverlayMode(OverlayMode::CrossFlow); - const auto& cell = selected.risk.crossFlowCells[index]; + if (index < selected.risk.operationalConflictCells.size()) { + setOverlayMode(OverlayMode::OperationalConflict); + const auto& cell = selected.risk.operationalConflictCells[index]; if (cell.detectionFrame.has_value()) { showReplayFrame(*cell.detectionFrame); } else if (cell.detectedAtSeconds.has_value()) { @@ -2190,7 +2192,25 @@ void ScenarioBatchResultWidget::refreshResultNavigationPanel() { } } if (canvas_ != nullptr) { - canvas_->focusCrossFlowCell(index); + canvas_->focusOperationalConflictCell(index); + } + }; + auto operationalConflictConnectionFocusHandler = [this](std::size_t index) { + if (results_.empty() || currentResultIndex_ < 0 || currentResultIndex_ >= static_cast(results_.size())) { + return; + } + const auto& selected = results_[static_cast(currentResultIndex_)]; + if (index < selected.risk.operationalConflictConnections.size()) { + setOverlayMode(OverlayMode::OperationalConflict); + const auto& connection = selected.risk.operationalConflictConnections[index]; + if (connection.detectionFrame.has_value()) { + showReplayFrame(*connection.detectionFrame); + } else if (connection.detectedAtSeconds.has_value()) { + showClosestReplayFrameAtSeconds(*connection.detectedAtSeconds); + } + } + if (canvas_ != nullptr) { + canvas_->focusOperationalConflictConnection(index); } }; auto hotspotFocusHandler = [this](std::size_t index) { @@ -2217,7 +2237,8 @@ void ScenarioBatchResultWidget::refreshResultNavigationPanel() { result.risk, result.artifacts, std::move(bottleneckFocusHandler), - std::move(crossFlowCellFocusHandler), + std::move(operationalConflictCellFocusHandler), + std::move(operationalConflictConnectionFocusHandler), std::move(hotspotFocusHandler), [this](ScenarioResultNavigationView view, std::size_t index) { setDetailSelection(view, index); @@ -2348,12 +2369,12 @@ void ScenarioBatchResultWidget::refreshOverviewPanel() { .arg(formatSeconds(result.artifacts.timingSummary.t50Seconds)) .arg(formatSeconds(result.artifacts.timingSummary.t90Seconds)) .arg(formatSeconds(result.artifacts.timingSummary.t95Seconds)) - + QString("\nCross flow: %1 score / %2 cells") - .arg(result.artifacts.crossFlowSummary.peakCrossFlowScore, 0, 'f', 2) - .arg(static_cast(result.artifacts.crossFlowSummary.crossFlowHotspotCount)) - + QString("\nCross-flow exposure: %1 agent-sec | Longest duration: %2 sec") - .arg(result.artifacts.crossFlowSummary.totalCrossFlowExposureAgentSeconds, 0, 'f', 1) - .arg(result.artifacts.crossFlowSummary.longestCrossFlowDurationSeconds, 0, 'f', 1)); + + QString("\nOperational conflict: %1 score / %2 connections") + .arg(result.artifacts.operationalConflictSummary.peakConflictScore, 0, 'f', 2) + .arg(static_cast(result.artifacts.operationalConflictSummary.conflictConnectionCount)) + + QString("\nConflict exposure: %1 agent-sec | Longest duration: %2 sec") + .arg(result.artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds, 0, 'f', 1) + .arg(result.artifacts.operationalConflictSummary.longestConflictDurationSeconds, 0, 'f', 1)); } void ScenarioBatchResultWidget::refreshDetailPanel() { @@ -2400,19 +2421,49 @@ QString ScenarioBatchResultWidget::detailTextForSelection( .arg(QString::fromStdString(bottleneck.label)), lines); } - case ScenarioResultNavigationView::CrossFlow: { - if (index >= result.risk.crossFlowCells.size()) { - return "The selected cross-flow cell is no longer available."; + case ScenarioResultNavigationView::OperationalConflict: { + if (index < result.risk.operationalConflictConnections.size()) { + const auto& connection = result.risk.operationalConflictConnections[index]; + QStringList lines{ + QString("Score: %1").arg(connection.conflictScore, 0, 'f', 2), + QString("Opposition: %1").arg(formatRatioPercent(connection.oppositionScore)), + QString("Counterflow ratio: %1").arg(formatRatioPercent(connection.counterflowRatio)), + QString("Forward / reverse: %1 / %2") + .arg(static_cast(connection.forwardCount)) + .arg(static_cast(connection.reverseCount)), + QString("Nearby agents: %1").arg(static_cast(connection.nearbyAgentCount)), + QString("Queue agents: %1").arg(static_cast(connection.queueAgentCount)), + QString("Duration: %1 sec").arg(connection.durationSeconds, 0, 'f', 1), + QString("Exposure: %1 agent-sec").arg(connection.exposureAgentSeconds, 0, 'f', 1), + QString("Average speed: %1 m/s").arg(connection.averageSpeed, 0, 'f', 2), + QString("Speed drop: %1").arg(formatRatioPercent(connection.speedDropRatio)), + QString("Detected: %1").arg(formatSeconds(connection.detectedAtSeconds)), + QString("Passage: %1 to %2").arg(formatPoint(connection.passage.start), formatPoint(connection.passage.end)), + }; + if (!connection.floorId.empty()) { + lines.push_back(QString("Floor: %1").arg(QString::fromStdString(connection.floorId))); + } + return detailText( + QString("Operational Conflict Connection %1: %2") + .arg(static_cast(index + 1)) + .arg(QString::fromStdString(connection.label)), + lines); } - const auto& cell = result.risk.crossFlowCells[index]; + + const auto cellIndex = index - result.risk.operationalConflictConnections.size(); + if (cellIndex >= result.risk.operationalConflictCells.size()) { + return "The selected operational-conflict item is no longer available."; + } + const auto& cell = result.risk.operationalConflictCells[cellIndex]; QStringList lines{ - QString("Score: %1").arg(cell.crossFlowScore, 0, 'f', 2), - QString("Ratio: %1").arg(formatRatioPercent(cell.crossFlowRatio)), + QString("Score: %1").arg(cell.conflictScore, 0, 'f', 2), + QString("Opposition: %1").arg(formatRatioPercent(cell.oppositionScore)), + QString("Counterflow ratio: %1").arg(formatRatioPercent(cell.counterflowRatio)), QString("Moving agents: %1").arg(static_cast(cell.movingAgentCount)), QString("Peak agents: %1").arg(static_cast(cell.peakAgentCount)), - QString("Primary / crossing: %1 / %2") - .arg(static_cast(cell.primaryFlowCount)) - .arg(static_cast(cell.crossFlowCount)), + QString("Forward / reverse: %1 / %2") + .arg(static_cast(cell.forwardCount)) + .arg(static_cast(cell.reverseCount)), QString("Duration: %1 sec").arg(cell.durationSeconds, 0, 'f', 1), QString("Exposure: %1 agent-sec").arg(cell.exposureAgentSeconds, 0, 'f', 1), QString("Average speed: %1 m/s").arg(cell.averageSpeed, 0, 'f', 2), @@ -2421,10 +2472,13 @@ QString ScenarioBatchResultWidget::detailTextForSelection( QString("Center: %1").arg(formatPoint(cell.center)), QString("Cell: %1").arg(formatBounds(cell.cellMin, cell.cellMax)), }; + if (!cell.nearestConnectionLabel.empty()) { + lines.push_back(QString("Nearest connection: %1").arg(QString::fromStdString(cell.nearestConnectionLabel))); + } if (!cell.floorId.empty()) { lines.push_back(QString("Floor: %1").arg(QString::fromStdString(cell.floorId))); } - return detailText(QString("Cross-Flow Cell %1").arg(static_cast(index + 1)), lines); + return detailText(QString("Operational Conflict Cell %1").arg(static_cast(cellIndex + 1)), lines); } case ScenarioResultNavigationView::Hotspot: { if (index >= result.risk.hotspots.size()) { diff --git a/src/application/ScenarioBatchResultWidget.h b/src/application/ScenarioBatchResultWidget.h index 1b6de2f..b035827 100644 --- a/src/application/ScenarioBatchResultWidget.h +++ b/src/application/ScenarioBatchResultWidget.h @@ -52,7 +52,7 @@ class ScenarioBatchResultWidget : public QWidget { Pressure = 2, Hotspots = 3, Bottlenecks = 4, - CrossFlow = 5, + OperationalConflict = 5, None = 6, }; diff --git a/src/application/ScenarioResultNavigation.cpp b/src/application/ScenarioResultNavigation.cpp index 034acbd..b26f84b 100644 --- a/src/application/ScenarioResultNavigation.cpp +++ b/src/application/ScenarioResultNavigation.cpp @@ -197,28 +197,60 @@ QPushButton* createHotspotRowButton( return button; } -QPushButton* createCrossFlowCellRowButton( - const safecrowd::domain::ScenarioCrossFlowCellMetric& cell, +QPushButton* createOperationalConflictCellRowButton( + const safecrowd::domain::ScenarioOperationalConflictCellMetric& cell, std::size_t index, QWidget* parent) { QStringList lines{ - QString("%1. Cross-flow score %2") + QString("%1. Conflict score %2") .arg(static_cast(index + 1)) - .arg(cell.crossFlowScore, 0, 'f', 2), - QString("Cross flow %1 | %2 primary / %3 crossing movers") - .arg(formatPercent(cell.crossFlowRatio)) - .arg(static_cast(cell.primaryFlowCount)) - .arg(static_cast(cell.crossFlowCount)), + .arg(cell.conflictScore, 0, 'f', 2), + QString("Counterflow %1 | %2 forward / %3 reverse") + .arg(formatPercent(cell.counterflowRatio)) + .arg(static_cast(cell.forwardCount)) + .arg(static_cast(cell.reverseCount)), + QString("Opposition %1").arg(formatPercent(cell.oppositionScore)), QString("Duration %1 sec | Speed %2 m/s") .arg(cell.durationSeconds, 0, 'f', 1) .arg(cell.averageSpeed, 0, 'f', 2), }; + if (!cell.nearestConnectionLabel.empty()) { + lines.push_back(QString("Nearest: %1").arg(QString::fromStdString(cell.nearestConnectionLabel))); + } if (!cell.floorId.empty()) { lines.push_back(QString("Floor: %1").arg(QString::fromStdString(cell.floorId))); } auto* button = createReportRowButton(lines, parent); - button->setToolTip(QString("%1\nClick to focus this cross-flow hotspot on the canvas.") - .arg(safecrowd::domain::scenarioCrossFlowDefinition())); + button->setToolTip(QString("%1\nClick to focus this operational-conflict cell on the canvas.") + .arg(safecrowd::domain::scenarioOperationalConflictDefinition())); + return button; +} + +QPushButton* createOperationalConflictConnectionRowButton( + const safecrowd::domain::ScenarioOperationalConflictConnectionMetric& connection, + std::size_t index, + QWidget* parent) { + QStringList lines{ + QString("%1. %2") + .arg(static_cast(index + 1)) + .arg(QString::fromStdString(connection.label.empty() ? connection.connectionId : connection.label)), + QString("Conflict score %1 | Opposition %2") + .arg(connection.conflictScore, 0, 'f', 2) + .arg(formatPercent(connection.oppositionScore)), + QString("%1 forward / %2 reverse | Queue %3") + .arg(static_cast(connection.forwardCount)) + .arg(static_cast(connection.reverseCount)) + .arg(static_cast(connection.queueAgentCount)), + QString("Duration %1 sec | Speed %2 m/s") + .arg(connection.durationSeconds, 0, 'f', 1) + .arg(connection.averageSpeed, 0, 'f', 2), + }; + if (!connection.floorId.empty()) { + lines.push_back(QString("Floor: %1").arg(QString::fromStdString(connection.floorId))); + } + auto* button = createReportRowButton(lines, parent); + button->setToolTip(QString("%1\nClick to focus this connection on the canvas.") + .arg(safecrowd::domain::scenarioOperationalConflictDefinition())); return button; } @@ -531,42 +563,68 @@ QWidget* createGroupsReportPanel( return parts.panel; } -QWidget* createCrossFlowReportPanel( +QWidget* createOperationalConflictReportPanel( const safecrowd::domain::ScenarioRiskSnapshot& risk, const safecrowd::domain::ScenarioResultArtifacts& artifacts, - std::function crossFlowCellFocusHandler, + std::function operationalConflictCellFocusHandler, + std::function operationalConflictConnectionFocusHandler, ResultItemSelectionHandler itemSelectionHandler, QWidget* parent) { - auto parts = createResultReportPanel("Cross Flow", "Non-aligned movement streams", parent); + auto parts = createResultReportPanel("Operational Conflict", "Counterflow at shared passages", parent); auto* summaryHeader = createReportSectionHeader("Summary", parts.content); - summaryHeader->setToolTip(safecrowd::domain::scenarioCrossFlowDefinition()); + summaryHeader->setToolTip(safecrowd::domain::scenarioOperationalConflictDefinition()); parts.contentLayout->addWidget(summaryHeader); parts.contentLayout->addWidget(createReportInfoRow({ - QString("Peak cross-flow score: %1") - .arg(artifacts.crossFlowSummary.peakCrossFlowScore, 0, 'f', 2), + QString("Peak conflict score: %1") + .arg(artifacts.operationalConflictSummary.peakConflictScore, 0, 'f', 2), QString("Total exposure: %1 agent-sec") - .arg(artifacts.crossFlowSummary.totalCrossFlowExposureAgentSeconds, 0, 'f', 1), + .arg(artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds, 0, 'f', 1), QString("Longest duration: %1 sec") - .arg(artifacts.crossFlowSummary.longestCrossFlowDurationSeconds, 0, 'f', 1), - QString("Cross-flow hotspots: %1") - .arg(static_cast(artifacts.crossFlowSummary.crossFlowHotspotCount)), + .arg(artifacts.operationalConflictSummary.longestConflictDurationSeconds, 0, 'f', 1), + QString("Conflict connections: %1") + .arg(static_cast(artifacts.operationalConflictSummary.conflictConnectionCount)), }, parts.content)); - auto* cellHeader = createReportSectionHeader("Cross-Flow Cells", parts.content); + auto* connectionHeader = createReportSectionHeader("Connections", parts.content); + parts.contentLayout->addWidget(connectionHeader); + if (risk.operationalConflictConnections.empty()) { + auto* empty = createLabel("None detected", parts.content); + empty->setStyleSheet(ui::mutedTextStyleSheet()); + parts.contentLayout->addWidget(empty); + } else { + for (std::size_t index = 0; index < risk.operationalConflictConnections.size(); ++index) { + auto* row = createOperationalConflictConnectionRowButton( + risk.operationalConflictConnections[index], + index, + parts.content); + QObject::connect(row, &QPushButton::clicked, parts.content, [operationalConflictConnectionFocusHandler, itemSelectionHandler, index]() { + if (itemSelectionHandler) { + itemSelectionHandler(ScenarioResultNavigationView::OperationalConflict, index); + } + if (operationalConflictConnectionFocusHandler) { + operationalConflictConnectionFocusHandler(index); + } + }); + parts.contentLayout->addWidget(row); + } + } + + auto* cellHeader = createReportSectionHeader("Conflict Cells", parts.content); parts.contentLayout->addWidget(cellHeader); - if (risk.crossFlowCells.empty()) { + if (risk.operationalConflictCells.empty()) { auto* empty = createLabel("None detected", parts.content); empty->setStyleSheet(ui::mutedTextStyleSheet()); parts.contentLayout->addWidget(empty); } else { - for (std::size_t index = 0; index < risk.crossFlowCells.size(); ++index) { - auto* row = createCrossFlowCellRowButton(risk.crossFlowCells[index], index, parts.content); - QObject::connect(row, &QPushButton::clicked, parts.content, [crossFlowCellFocusHandler, itemSelectionHandler, index]() { + for (std::size_t index = 0; index < risk.operationalConflictCells.size(); ++index) { + auto* row = createOperationalConflictCellRowButton(risk.operationalConflictCells[index], index, parts.content); + const auto selectionIndex = risk.operationalConflictConnections.size() + index; + QObject::connect(row, &QPushButton::clicked, parts.content, [operationalConflictCellFocusHandler, itemSelectionHandler, index, selectionIndex]() { if (itemSelectionHandler) { - itemSelectionHandler(ScenarioResultNavigationView::CrossFlow, index); + itemSelectionHandler(ScenarioResultNavigationView::OperationalConflict, selectionIndex); } - if (crossFlowCellFocusHandler) { - crossFlowCellFocusHandler(index); + if (operationalConflictCellFocusHandler) { + operationalConflictCellFocusHandler(index); } }); parts.contentLayout->addWidget(row); @@ -593,7 +651,7 @@ std::vector scenarioResultNavigationTabs() { }, { .id = "cross-flow", - .label = "Cross Flow", + .label = "Operational Conflict", .icon = makeResultNavigationIcon("cross-flow", QColor("#1f5fae")), }, { @@ -626,7 +684,7 @@ std::vector scenarioResultNavigationTabs() { QString scenarioResultNavigationTabId(ScenarioResultNavigationView view) { switch (view) { - case ScenarioResultNavigationView::CrossFlow: + case ScenarioResultNavigationView::OperationalConflict: return "cross-flow"; case ScenarioResultNavigationView::Hotspot: return "hotspot"; @@ -646,7 +704,7 @@ QString scenarioResultNavigationTabId(ScenarioResultNavigationView view) { ScenarioResultNavigationView scenarioResultNavigationViewFromTabId(const QString& tabId) { if (tabId == "cross-flow") { - return ScenarioResultNavigationView::CrossFlow; + return ScenarioResultNavigationView::OperationalConflict; } if (tabId == "hotspot") { return ScenarioResultNavigationView::Hotspot; @@ -671,16 +729,18 @@ QWidget* createScenarioResultNavigationPanel( const safecrowd::domain::ScenarioRiskSnapshot& risk, const safecrowd::domain::ScenarioResultArtifacts& artifacts, std::function bottleneckFocusHandler, - std::function crossFlowCellFocusHandler, + std::function operationalConflictCellFocusHandler, + std::function operationalConflictConnectionFocusHandler, std::function hotspotFocusHandler, std::function itemSelectionHandler, QWidget* parent) { switch (view) { - case ScenarioResultNavigationView::CrossFlow: - return createCrossFlowReportPanel( + case ScenarioResultNavigationView::OperationalConflict: + return createOperationalConflictReportPanel( risk, artifacts, - std::move(crossFlowCellFocusHandler), + std::move(operationalConflictCellFocusHandler), + std::move(operationalConflictConnectionFocusHandler), std::move(itemSelectionHandler), parent); case ScenarioResultNavigationView::Hotspot: diff --git a/src/application/ScenarioResultNavigation.h b/src/application/ScenarioResultNavigation.h index 8fd6e90..2dac42a 100644 --- a/src/application/ScenarioResultNavigation.h +++ b/src/application/ScenarioResultNavigation.h @@ -16,7 +16,7 @@ namespace safecrowd::application { enum class ScenarioResultNavigationView { Bottleneck, - CrossFlow, + OperationalConflict, Hotspot, HazardExposure, Zone, @@ -33,7 +33,8 @@ QWidget* createScenarioResultNavigationPanel( const safecrowd::domain::ScenarioRiskSnapshot& risk, const safecrowd::domain::ScenarioResultArtifacts& artifacts, std::function bottleneckFocusHandler, - std::function crossFlowCellFocusHandler, + std::function operationalConflictCellFocusHandler, + std::function operationalConflictConnectionFocusHandler, std::function hotspotFocusHandler, std::function itemSelectionHandler, QWidget* parent); diff --git a/src/application/SimulationCanvasWidget.cpp b/src/application/SimulationCanvasWidget.cpp index ca573ad..f9ef7aa 100644 --- a/src/application/SimulationCanvasWidget.cpp +++ b/src/application/SimulationCanvasWidget.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -37,11 +38,12 @@ constexpr double kViewportPadding = 32.0; constexpr double kDefaultHotspotCellSize = 1.5; constexpr double kHotspotFocusZoom = 2.8; constexpr double kBottleneckFocusZoom = 2.4; -constexpr double kCrossFlowCellFocusZoom = 2.8; +constexpr double kOperationalConflictCellFocusZoom = 2.8; +constexpr double kOperationalConflictConnectionFocusZoom = 2.6; constexpr double kDefaultDensityScaleMaxPeoplePerSquareMeter = 4.0; constexpr double kDefaultPressureScaleMaxScore = 1.0; -constexpr double kCrossFlowInfluenceRadiusMultiplier = 1.3; -constexpr double kCrossFlowMinimumScreenRadius = 16.0; +constexpr double kOperationalConflictInfluenceRadiusMultiplier = 1.3; +constexpr double kOperationalConflictMinimumScreenRadius = 16.0; constexpr int kHotspotMinCoreAlpha = 72; constexpr int kHotspotMaxCoreAlpha = 190; constexpr int kFloorSelectorMargin = 14; @@ -681,7 +683,7 @@ QColor pressureHeatmapColor(double ratio, int alpha) { return QColor(153, 27, 27, alpha); } -QColor crossFlowHeatmapColor(double ratio, int alpha) { +QColor operationalConflictHeatmapColor(double ratio, int alpha) { const auto t = std::clamp(ratio, 0.0, 1.0); if (t < 0.3) { return QColor(245, 158, 11, alpha); @@ -1164,11 +1166,18 @@ void SimulationCanvasWidget::setBottleneckOverlay(std::vector cells) { - crossFlowCellOverlay_ = std::move(cells); - if (focusedCrossFlowCellIndex_.has_value() - && *focusedCrossFlowCellIndex_ >= crossFlowCellOverlay_.size()) { - focusedCrossFlowCellIndex_.reset(); +void SimulationCanvasWidget::setOperationalConflictOverlay( + std::vector cells, + std::vector connections) { + operationalConflictCellOverlay_ = std::move(cells); + operationalConflictConnectionOverlay_ = std::move(connections); + if (focusedOperationalConflictCellIndex_.has_value() + && *focusedOperationalConflictCellIndex_ >= operationalConflictCellOverlay_.size()) { + focusedOperationalConflictCellIndex_.reset(); + } + if (focusedOperationalConflictConnectionIndex_.has_value() + && *focusedOperationalConflictConnectionIndex_ >= operationalConflictConnectionOverlay_.size()) { + focusedOperationalConflictConnectionIndex_.reset(); } invalidateOverlayCache(); update(); @@ -1193,7 +1202,8 @@ void SimulationCanvasWidget::focusHotspot(std::size_t index) { } focusedHotspotIndex_ = index; focusedBottleneckIndex_.reset(); - focusedCrossFlowCellIndex_.reset(); + focusedOperationalConflictCellIndex_.reset(); + focusedOperationalConflictConnectionIndex_.reset(); invalidateOverlayCache(); focusWorldPoint(hotspotOverlay_[index].center, std::max(camera_.zoom(), kHotspotFocusZoom)); } @@ -1209,27 +1219,51 @@ void SimulationCanvasWidget::focusBottleneck(std::size_t index) { } focusedBottleneckIndex_ = index; focusedHotspotIndex_.reset(); - focusedCrossFlowCellIndex_.reset(); + focusedOperationalConflictCellIndex_.reset(); + focusedOperationalConflictConnectionIndex_.reset(); invalidateOverlayCache(); focusWorldPoint( {.x = (passage.start.x + passage.end.x) / 2.0, .y = (passage.start.y + passage.end.y) / 2.0}, std::max(camera_.zoom(), kBottleneckFocusZoom)); } -void SimulationCanvasWidget::focusCrossFlowCell(std::size_t index) { - if (index >= crossFlowCellOverlay_.size()) { +void SimulationCanvasWidget::focusOperationalConflictCell(std::size_t index) { + if (index >= operationalConflictCellOverlay_.size()) { return; } - const auto& cell = crossFlowCellOverlay_[index]; + const auto& cell = operationalConflictCellOverlay_[index]; if (!cell.floorId.empty() && cell.floorId != currentFloorId_) { setCurrentFloorId(cell.floorId, true); } - focusedCrossFlowCellIndex_ = index; + focusedOperationalConflictCellIndex_ = index; focusedHotspotIndex_.reset(); focusedBottleneckIndex_.reset(); + focusedOperationalConflictConnectionIndex_.reset(); invalidateOverlayCache(); - focusWorldPoint(cell.center, std::max(camera_.zoom(), kCrossFlowCellFocusZoom)); + focusWorldPoint(cell.center, std::max(camera_.zoom(), kOperationalConflictCellFocusZoom)); +} + +void SimulationCanvasWidget::focusOperationalConflictConnection(std::size_t index) { + if (index >= operationalConflictConnectionOverlay_.size()) { + return; + } + + const auto& connection = operationalConflictConnectionOverlay_[index]; + if (!connection.floorId.empty() && connection.floorId != currentFloorId_) { + setCurrentFloorId(connection.floorId, true); + } + focusedOperationalConflictConnectionIndex_ = index; + focusedOperationalConflictCellIndex_.reset(); + focusedHotspotIndex_.reset(); + focusedBottleneckIndex_.reset(); + invalidateOverlayCache(); + focusWorldPoint( + { + .x = (connection.passage.start.x + connection.passage.end.x) / 2.0, + .y = (connection.passage.start.y + connection.passage.end.y) / 2.0, + }, + std::max(camera_.zoom(), kOperationalConflictConnectionFocusZoom)); } bool SimulationCanvasWidget::eventFilter(QObject* watched, QEvent* event) { @@ -1591,8 +1625,8 @@ void SimulationCanvasWidget::refreshOverlayCache(const LayoutCanvasBounds& bound drawHotspotOverlay(painter, transform); } else if (overlayMode_ == ResultOverlayMode::Bottlenecks) { drawBottleneckOverlay(painter, transform); - } else if (overlayMode_ == ResultOverlayMode::CrossFlow) { - drawCrossFlowOverlay(painter, transform); + } else if (overlayMode_ == ResultOverlayMode::OperationalConflict) { + drawOperationalConflictOverlay(painter, transform); } overlayCacheSize_ = currentSize; @@ -2116,8 +2150,8 @@ void SimulationCanvasWidget::drawBottleneckOverlay(QPainter& painter, const Layo painter.restore(); } -void SimulationCanvasWidget::drawCrossFlowOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const { - if (crossFlowCellOverlay_.empty()) { +void SimulationCanvasWidget::drawOperationalConflictOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const { + if (operationalConflictCellOverlay_.empty() && operationalConflictConnectionOverlay_.empty()) { return; } @@ -2136,20 +2170,20 @@ void SimulationCanvasWidget::drawCrossFlowOverlay(QPainter& painter, const Layou painter.setClipPath(walkableClip); } - std::vector visibleCells; - visibleCells.reserve(crossFlowCellOverlay_.size()); - for (const auto& cell : crossFlowCellOverlay_) { + std::vector visibleCells; + visibleCells.reserve(operationalConflictCellOverlay_.size()); + for (const auto& cell : operationalConflictCellOverlay_) { if (!matchesFloor(cell.floorId, currentFloorId_)) { continue; } - if (cell.crossFlowScore <= 0.0) { + if (cell.conflictScore <= 0.0) { continue; } visibleCells.push_back(&cell); } std::sort(visibleCells.begin(), visibleCells.end(), [](const auto* lhs, const auto* rhs) { - if (std::fabs(lhs->crossFlowScore - rhs->crossFlowScore) > 1e-9) { - return lhs->crossFlowScore < rhs->crossFlowScore; + if (std::fabs(lhs->conflictScore - rhs->conflictScore) > 1e-9) { + return lhs->conflictScore < rhs->conflictScore; } return lhs->movingAgentCount < rhs->movingAgentCount; }); @@ -2159,32 +2193,32 @@ void SimulationCanvasWidget::drawCrossFlowOverlay(QPainter& painter, const Layou const auto center = transform.map(cell->center); const auto cellWidth = cell->cellMax.x > cell->cellMin.x ? cell->cellMax.x - cell->cellMin.x - : safecrowd::domain::kScenarioCrossFlowCellSize; + : safecrowd::domain::kScenarioOperationalConflictCellSize; const auto cellHeight = cell->cellMax.y > cell->cellMin.y ? cell->cellMax.y - cell->cellMin.y - : safecrowd::domain::kScenarioCrossFlowCellSize; + : safecrowd::domain::kScenarioOperationalConflictCellSize; const auto influenceRadiusWorld = - std::max(cellWidth, cellHeight) * kCrossFlowInfluenceRadiusMultiplier; + std::max(cellWidth, cellHeight) * kOperationalConflictInfluenceRadiusMultiplier; const auto radiusAnchor = transform.map({ .x = cell->center.x + influenceRadiusWorld, .y = cell->center.y, }); const auto radius = std::max( - kCrossFlowMinimumScreenRadius, + kOperationalConflictMinimumScreenRadius, std::hypot(radiusAnchor.x() - center.x(), radiusAnchor.y() - center.y())); - const auto intensity = std::clamp(cell->crossFlowScore, 0.0, 1.0); + const auto intensity = std::clamp(cell->conflictScore, 0.0, 1.0); const auto coreAlpha = 68 + static_cast(144.0 * intensity); QRadialGradient gradient(center, radius); - gradient.setColorAt(0.0, crossFlowHeatmapColor(intensity, std::clamp(coreAlpha, 68, 212))); - gradient.setColorAt(0.4, crossFlowHeatmapColor(intensity, static_cast(coreAlpha * 0.45))); - gradient.setColorAt(1.0, crossFlowHeatmapColor(intensity, 0)); + gradient.setColorAt(0.0, operationalConflictHeatmapColor(intensity, std::clamp(coreAlpha, 68, 212))); + gradient.setColorAt(0.4, operationalConflictHeatmapColor(intensity, static_cast(coreAlpha * 0.45))); + gradient.setColorAt(1.0, operationalConflictHeatmapColor(intensity, 0)); painter.setBrush(gradient); painter.drawEllipse(center, radius, radius); - if (focusedCrossFlowCellIndex_.has_value() - && *focusedCrossFlowCellIndex_ < crossFlowCellOverlay_.size() - && cell == &crossFlowCellOverlay_[*focusedCrossFlowCellIndex_]) { + if (focusedOperationalConflictCellIndex_.has_value() + && *focusedOperationalConflictCellIndex_ < operationalConflictCellOverlay_.size() + && cell == &operationalConflictCellOverlay_[*focusedOperationalConflictCellIndex_]) { painter.setBrush(Qt::NoBrush); painter.setPen(QPen(QColor(120, 53, 15, 230), 2.2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); painter.drawEllipse(center, radius + 4.0, radius + 4.0); @@ -2192,6 +2226,59 @@ void SimulationCanvasWidget::drawCrossFlowOverlay(QPainter& painter, const Layou } } + painter.setClipping(false); + painter.setBrush(Qt::NoBrush); + for (std::size_t index = 0; index < operationalConflictConnectionOverlay_.size(); ++index) { + const auto& connection = operationalConflictConnectionOverlay_[index]; + if (!matchesFloor(connection.floorId, currentFloorId_)) { + continue; + } + if (connection.conflictScore <= 0.0) { + continue; + } + + const auto focused = focusedOperationalConflictConnectionIndex_.has_value() + && *focusedOperationalConflictConnectionIndex_ == index; + const auto intensity = std::clamp(connection.conflictScore, 0.0, 1.0); + const auto color = operationalConflictHeatmapColor(intensity, focused ? 245 : 210); + const auto start = transform.map(connection.passage.start); + const auto end = transform.map(connection.passage.end); + const auto center = QPointF((start.x() + end.x()) * 0.5, (start.y() + end.y()) * 0.5); + auto span = end - start; + const auto spanLength = std::hypot(span.x(), span.y()); + if (spanLength <= 1e-6) { + span = QPointF(1.0, 0.0); + } else { + span /= spanLength; + } + const QPointF normal{-span.y(), span.x()}; + const auto arrowLength = 22.0 + (12.0 * intensity); + const auto arrowStart = center - (normal * arrowLength); + const auto arrowEnd = center + (normal * arrowLength); + + painter.setPen(QPen(color, focused ? 6.0 : 4.5, Qt::SolidLine, Qt::RoundCap)); + painter.drawLine(start, end); + painter.setPen(QPen(color, focused ? 3.4 : 2.6, Qt::SolidLine, Qt::RoundCap)); + painter.drawLine(arrowStart, arrowEnd); + + const auto drawArrowHead = [&](const QPointF& tip, const QPointF& direction) { + const auto length = std::max(1e-6, std::hypot(direction.x(), direction.y())); + const QPointF unit{direction.x() / length, direction.y() / length}; + const QPointF side{-unit.y(), unit.x()}; + const auto headLength = focused ? 11.0 : 9.0; + const auto headWidth = focused ? 7.0 : 5.5; + QPolygonF head; + head << tip + << (tip - unit * headLength + side * headWidth) + << (tip - unit * headLength - side * headWidth); + painter.setBrush(color); + painter.drawPolygon(head); + painter.setBrush(Qt::NoBrush); + }; + drawArrowHead(arrowEnd, arrowEnd - arrowStart); + drawArrowHead(arrowStart, arrowStart - arrowEnd); + } + painter.restore(); } diff --git a/src/application/SimulationCanvasWidget.h b/src/application/SimulationCanvasWidget.h index 6335794..7e4d00d 100644 --- a/src/application/SimulationCanvasWidget.h +++ b/src/application/SimulationCanvasWidget.h @@ -35,7 +35,7 @@ enum class ResultOverlayMode { Pressure, Hotspots, Bottlenecks, - CrossFlow, + OperationalConflict, }; class SimulationCanvasWidget : public QWidget { @@ -56,11 +56,14 @@ class SimulationCanvasWidget : public QWidget { double scaleMaxPressureScore = 1.0); void setHotspotOverlay(std::vector hotspots); void setBottleneckOverlay(std::vector bottlenecks); - void setCrossFlowOverlay(std::vector cells); + void setOperationalConflictOverlay( + std::vector cells, + std::vector connections); void setResultOverlayMode(ResultOverlayMode mode); void focusHotspot(std::size_t index); void focusBottleneck(std::size_t index); - void focusCrossFlowCell(std::size_t index); + void focusOperationalConflictCell(std::size_t index); + void focusOperationalConflictConnection(std::size_t index); protected: bool eventFilter(QObject* watched, QEvent* event) override; @@ -91,7 +94,7 @@ class SimulationCanvasWidget : public QWidget { void drawPressureOverlay(QPainter& painter, const LayoutCanvasTransform& transform); void drawHotspotOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawBottleneckOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; - void drawCrossFlowOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; + void drawOperationalConflictOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; bool switchFloorByWheel(QWheelEvent* event); void setCurrentFloorId(std::string floorId, bool manualSelection); void setupFloorSelector(); @@ -110,11 +113,13 @@ class SimulationCanvasWidget : public QWidget { double pressureScaleMaxScore_{1.0}; std::vector hotspotOverlay_{}; std::vector bottleneckOverlay_{}; - std::vector crossFlowCellOverlay_{}; + std::vector operationalConflictCellOverlay_{}; + std::vector operationalConflictConnectionOverlay_{}; ResultOverlayMode overlayMode_{ResultOverlayMode::None}; std::optional focusedHotspotIndex_{}; std::optional focusedBottleneckIndex_{}; - std::optional focusedCrossFlowCellIndex_{}; + std::optional focusedOperationalConflictCellIndex_{}; + std::optional focusedOperationalConflictConnectionIndex_{}; LayoutCanvasCamera camera_{}; std::optional layoutBounds_{}; std::string currentFloorId_{}; diff --git a/src/domain/AlternativeRecommendationService.cpp b/src/domain/AlternativeRecommendationService.cpp index 4762fb2..033052b 100644 --- a/src/domain/AlternativeRecommendationService.cpp +++ b/src/domain/AlternativeRecommendationService.cpp @@ -781,23 +781,23 @@ std::optional pressureGuidanceAnchor(const AlternativeRec return sourceGuidanceAnchor(request, std::nullopt, {}, "ScenarioDraft.population fallback"); } -std::optional crossFlowGuidanceAnchor(const AlternativeRecommendationInput& request) { - if (!request.risk.crossFlowCells.empty()) { +std::optional operationalConflictGuidanceAnchor(const AlternativeRecommendationInput& request) { + if (!request.risk.operationalConflictCells.empty()) { const auto it = std::max_element( - request.risk.crossFlowCells.begin(), - request.risk.crossFlowCells.end(), + request.risk.operationalConflictCells.begin(), + request.risk.operationalConflictCells.end(), [](const auto& lhs, const auto& rhs) { - if (lhs.crossFlowScore == rhs.crossFlowScore) { + if (lhs.conflictScore == rhs.conflictScore) { return lhs.exposureAgentSeconds < rhs.exposureAgentSeconds; } - return lhs.crossFlowScore < rhs.crossFlowScore; + return lhs.conflictScore < rhs.conflictScore; }); return pointGuidanceAnchor( request, it->center, it->floorId, - "cross-flow hotspot", - "ScenarioRiskSnapshot.crossFlowCells"); + "operational-conflict hotspot", + "ScenarioRiskSnapshot.operationalConflictCells"); } return sourceGuidanceAnchor(request, std::nullopt, {}, "ScenarioDraft.population fallback"); } @@ -1147,52 +1147,77 @@ const SimulationFrame* finalFrameForRequest(const AlternativeRecommendationInput return nullptr; } -std::optional makeCrossFlowRiskSignal( +std::optional makeOperationalConflictRiskSignal( const AlternativeRecommendationInput& request) { - if (request.risk.crossFlowCells.empty() - && request.artifacts.crossFlowSummary.peakCrossFlowScore <= 0.0) { + if (request.risk.operationalConflictCells.empty() + && request.risk.operationalConflictConnections.empty() + && request.artifacts.operationalConflictSummary.peakConflictScore <= 0.0) { return std::nullopt; } AlternativeRecommendationRiskSignal signal; - signal.kind = AlternativeRecommendationRiskKind::CrossFlow; - signal.summary = "Cross flow detected from non-aligned movement streams."; + signal.kind = AlternativeRecommendationRiskKind::OperationalConflict; + signal.summary = "Operational conflict detected from opposing movement intent at shared passages."; - double severity = request.artifacts.crossFlowSummary.peakCrossFlowScore * 100.0; - severity += request.artifacts.crossFlowSummary.longestCrossFlowDurationSeconds * 4.0; - severity += request.artifacts.crossFlowSummary.totalCrossFlowExposureAgentSeconds * 0.2; - severity += static_cast(request.artifacts.crossFlowSummary.crossFlowHotspotCount * 8U); + double severity = request.artifacts.operationalConflictSummary.peakConflictScore * 100.0; + severity += request.artifacts.operationalConflictSummary.longestConflictDurationSeconds * 4.0; + severity += request.artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds * 0.2; + severity += static_cast(request.artifacts.operationalConflictSummary.conflictConnectionCount * 10U); - if (!request.risk.crossFlowCells.empty()) { - const auto& cell = request.risk.crossFlowCells.front(); - severity += static_cast(cell.primaryFlowCount + cell.crossFlowCount); + if (!request.risk.operationalConflictConnections.empty()) { + const auto& connection = request.risk.operationalConflictConnections.front(); + severity += static_cast(connection.forwardCount + connection.reverseCount); signal.evidence.push_back(evidence( - "Cross flow", - std::to_string(cell.primaryFlowCount) + " primary / " - + std::to_string(cell.crossFlowCount) + " crossing movers", - "ScenarioRiskSnapshot.crossFlowCells")); + "Conflict connection", + connection.label.empty() ? connection.connectionId : connection.label, + "ScenarioRiskSnapshot.operationalConflictConnections")); signal.evidence.push_back(evidence( - "Cross-flow duration", + "Opposing intent", + std::to_string(connection.forwardCount) + " forward / " + + std::to_string(connection.reverseCount) + " reverse", + "ScenarioRiskSnapshot.operationalConflictConnections")); + signal.evidence.push_back(evidence( + "Opposition score", + fixed(connection.oppositionScore, 2), + "ScenarioRiskSnapshot.operationalConflictConnections")); + signal.evidence.push_back(evidence( + "Queued agents", + std::to_string(connection.queueAgentCount), + "ScenarioRiskSnapshot.operationalConflictConnections")); + } else if (!request.risk.operationalConflictCells.empty()) { + const auto& cell = request.risk.operationalConflictCells.front(); + severity += static_cast(cell.forwardCount + cell.reverseCount); + signal.evidence.push_back(evidence( + "Conflict cell", + std::to_string(cell.forwardCount) + " forward / " + + std::to_string(cell.reverseCount) + " reverse movers", + "ScenarioRiskSnapshot.operationalConflictCells")); + signal.evidence.push_back(evidence( + "Conflict duration", fixed(cell.durationSeconds, 1) + " sec", - "ScenarioRiskSnapshot.crossFlowCells")); + "ScenarioRiskSnapshot.operationalConflictCells")); + signal.evidence.push_back(evidence( + "Opposition score", + fixed(cell.oppositionScore, 2), + "ScenarioRiskSnapshot.operationalConflictCells")); signal.evidence.push_back(evidence( "Average speed", fixed(cell.averageSpeed, 2) + " m/s", - "ScenarioRiskSnapshot.crossFlowCells")); + "ScenarioRiskSnapshot.operationalConflictCells")); } signal.evidence.push_back(evidence( - "Peak cross-flow score", - fixed(request.artifacts.crossFlowSummary.peakCrossFlowScore, 2), - "ScenarioResultArtifacts.crossFlowSummary")); + "Peak conflict score", + fixed(request.artifacts.operationalConflictSummary.peakConflictScore, 2), + "ScenarioResultArtifacts.operationalConflictSummary")); signal.evidence.push_back(evidence( - "Cross-flow exposure", - fixed(request.artifacts.crossFlowSummary.totalCrossFlowExposureAgentSeconds, 1) + " agent-sec", - "ScenarioResultArtifacts.crossFlowSummary")); + "Conflict exposure", + fixed(request.artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds, 1) + " agent-sec", + "ScenarioResultArtifacts.operationalConflictSummary")); signal.evidence.push_back(evidence( - "Cross-flow hotspots", - std::to_string(request.artifacts.crossFlowSummary.crossFlowHotspotCount), - "ScenarioResultArtifacts.crossFlowSummary")); + "Conflict connections", + std::to_string(request.artifacts.operationalConflictSummary.conflictConnectionCount), + "ScenarioResultArtifacts.operationalConflictSummary")); signal.severity = static_cast(std::round(severity)); return signal; @@ -1265,8 +1290,8 @@ std::vector detectRiskSignals( bottleneck.has_value()) { signals.push_back(makeBottleneckRiskSignal(request, *bottleneck, AlternativeRecommendationRiskKind::CorridorBottleneck)); } - if (const auto crossFlow = makeCrossFlowRiskSignal(request); crossFlow.has_value()) { - signals.push_back(*crossFlow); + if (const auto operationalConflict = makeOperationalConflictRiskSignal(request); operationalConflict.has_value()) { + signals.push_back(*operationalConflict); } if (const auto timeLimit = makeTimeLimitRiskSignal(request); timeLimit.has_value()) { signals.push_back(*timeLimit); @@ -1661,10 +1686,10 @@ std::optional makePressureHotspotCandidate( return candidate; } -std::optional makeCrossFlowCandidate( +std::optional makeOperationalConflictCandidate( const AlternativeRecommendationInput& request, const RecommendationContext& context) { - const auto* signal = findRiskSignal(context, AlternativeRecommendationRiskKind::CrossFlow); + const auto* signal = findRiskSignal(context, AlternativeRecommendationRiskKind::OperationalConflict); if (signal == nullptr) { return std::nullopt; } @@ -1678,16 +1703,16 @@ std::optional makeCrossFlowCandidate( && !hasRouteGuidanceForExit(request.sourceScenario, targetExit->exitZoneId) && !exitHasBottleneck(request, targetExit->exitZoneId) && guidanceDetourAcceptable(request, context.mostUsedExit->exitZoneId, targetExit->exitZoneId)) { - const auto installAnchor = crossFlowGuidanceAnchor(request); + const auto installAnchor = operationalConflictGuidanceAnchor(request); if (!installAnchor.has_value()) { return std::nullopt; } auto draft = makeRecommendedDraft( request, - AlternativeRecommendationKind::CrossFlowSeparation, - "Recommended: guide cross-flow away from shared movement"); + AlternativeRecommendationKind::OperationalConflictSeparation, + "Recommended: guide operational conflict away from shared movement"); auto guidance = makeGuidance( - "recommendation-guidance-cross-flow-" + sanitizeId(targetExit->exitZoneId), + "recommendation-guidance-operational-conflict-" + sanitizeId(targetExit->exitZoneId), targetExit->exitZoneId, guidanceComplianceForSeverity(severity)); configureGuidanceForRecommendation(guidance, request, *installAnchor, severity); @@ -1695,11 +1720,11 @@ std::optional makeCrossFlowCandidate( finalizeDiffKeys(request, draft); if (recommendedDraftChangesSource(request, draft)) { AlternativeRecommendationCandidate candidate; - candidate.kind = AlternativeRecommendationKind::CrossFlowSeparation; - candidate.riskKind = AlternativeRecommendationRiskKind::CrossFlow; - candidate.id = "guide-cross-flow-" + sanitizeId(targetExit->exitZoneId); + candidate.kind = AlternativeRecommendationKind::OperationalConflictSeparation; + candidate.riskKind = AlternativeRecommendationRiskKind::OperationalConflict; + candidate.id = "guide-operational-conflict-" + sanitizeId(targetExit->exitZoneId); candidate.priority = priorityForCandidate(130, severity, true); - candidate.title = "Guide cross-flow to another exit"; + candidate.title = "Guide operational conflict to another exit"; candidate.summary = "Guide part of the crossing stream toward " + zoneName(request.layout, targetExit->exitZoneId) + " so the shared movement area carries less conflicting flow."; @@ -1728,8 +1753,8 @@ std::optional makeCrossFlowCandidate( })) { auto draft = makeRecommendedDraft( request, - AlternativeRecommendationKind::CrossFlowSeparation, - "Recommended: time-separate cross-flow groups"); + AlternativeRecommendationKind::OperationalConflictSeparation, + "Recommended: time-separate operational-conflict groups"); draft.population.initialPlacements.clear(); draft.population.occupantSources.insert( draft.population.occupantSources.end(), @@ -1738,13 +1763,13 @@ std::optional makeCrossFlowCandidate( finalizeDiffKeys(request, draft); if (recommendedDraftChangesSource(request, draft)) { AlternativeRecommendationCandidate candidate; - candidate.kind = AlternativeRecommendationKind::CrossFlowSeparation; - candidate.riskKind = AlternativeRecommendationRiskKind::CrossFlow; - candidate.id = "stage-cross-flow-groups"; + candidate.kind = AlternativeRecommendationKind::OperationalConflictSeparation; + candidate.riskKind = AlternativeRecommendationRiskKind::OperationalConflict; + candidate.id = "stage-operational-conflict-groups"; candidate.priority = priorityForCandidate(135, severity, true); - candidate.title = "Time-separate cross-flow groups"; - candidate.summary = "Release source groups sequentially to reduce simultaneous crossing streams."; - candidate.expectedImprovement = "Turns the cross-flow rule into a rerunnable staged-release draft."; + candidate.title = "Time-separate operational-conflict groups"; + candidate.summary = "Release source groups sequentially to reduce simultaneous opposing streams."; + candidate.expectedImprovement = "Turns the operational-conflict rule into a rerunnable staged-release draft."; candidate.artifactSource = "AlternativeRecommendationRiskSignal + ScenarioDraft.population.initialPlacements"; candidate.evidence = signal->evidence; candidate.evidence.push_back(evidence( @@ -1853,8 +1878,8 @@ const char* alternativeRecommendationKindId(AlternativeRecommendationKind kind) return "pressure-hotspot-relief"; case AlternativeRecommendationKind::CorridorOneWayFlow: return "corridor-one-way-flow"; - case AlternativeRecommendationKind::CrossFlowSeparation: - return "cross-flow-separation"; + case AlternativeRecommendationKind::OperationalConflictSeparation: + return "operational-conflict-separation"; case AlternativeRecommendationKind::StagedEvacuation: return "staged-evacuation"; } @@ -1867,8 +1892,8 @@ const char* alternativeRecommendationRiskKindId(AlternativeRecommendationRiskKin return "exit-bottleneck"; case AlternativeRecommendationRiskKind::CorridorBottleneck: return "corridor-bottleneck"; - case AlternativeRecommendationRiskKind::CrossFlow: - return "cross-flow"; + case AlternativeRecommendationRiskKind::OperationalConflict: + return "operational-conflict"; case AlternativeRecommendationRiskKind::TimeLimitMissed: return "time-limit-missed"; case AlternativeRecommendationRiskKind::PressureHotspot: @@ -1899,7 +1924,7 @@ AlternativeRecommendationResult recommendFromInput(const AlternativeRecommendati if (const auto candidate = makeExitBalancingCandidate(request, context); candidate.has_value()) { result.candidates.push_back(*candidate); } - if (const auto candidate = makeCrossFlowCandidate(request, context); candidate.has_value()) { + if (const auto candidate = makeOperationalConflictCandidate(request, context); candidate.has_value()) { result.candidates.push_back(*candidate); } if (const auto candidate = makePressureHotspotCandidate(request, context); candidate.has_value()) { diff --git a/src/domain/AlternativeRecommendationService.h b/src/domain/AlternativeRecommendationService.h index f5b8968..c84b5ac 100644 --- a/src/domain/AlternativeRecommendationService.h +++ b/src/domain/AlternativeRecommendationService.h @@ -18,14 +18,14 @@ enum class AlternativeRecommendationKind { ExitUsageBalancing, PressureHotspotRelief, CorridorOneWayFlow, - CrossFlowSeparation, + OperationalConflictSeparation, StagedEvacuation, }; enum class AlternativeRecommendationRiskKind { ExitBottleneck, CorridorBottleneck, - CrossFlow, + OperationalConflict, TimeLimitMissed, PressureHotspot, }; diff --git a/src/domain/ScenarioResultArtifacts.h b/src/domain/ScenarioResultArtifacts.h index b7125e9..2344ab5 100644 --- a/src/domain/ScenarioResultArtifacts.h +++ b/src/domain/ScenarioResultArtifacts.h @@ -130,18 +130,22 @@ struct HazardExposureSummary { std::vector hazards{}; }; -struct CrossFlowTimelineSample { +struct OperationalConflictTimelineSample { double timeSeconds{0.0}; - double peakCrossFlowScore{0.0}; - std::size_t activeCrossFlowCellCount{0}; + double peakConflictScore{0.0}; + std::size_t activeConflictCellCount{0}; + std::size_t activeConflictConnectionCount{0}; }; -struct CrossFlowSummary { - double peakCrossFlowScore{0.0}; +struct OperationalConflictSummary { + double peakConflictScore{0.0}; std::optional peakAtSeconds{}; - double totalCrossFlowExposureAgentSeconds{0.0}; - double longestCrossFlowDurationSeconds{0.0}; - std::size_t crossFlowHotspotCount{0}; + double totalConflictExposureAgentSeconds{0.0}; + double longestConflictDurationSeconds{0.0}; + std::size_t conflictConnectionCount{0}; + double connectionConcentrationIndex{0.0}; + std::string topConflictConnectionId{}; + std::string topConflictConnectionLabel{}; }; struct ExitUsageMetric { @@ -179,8 +183,8 @@ struct ScenarioResultArtifacts { OccupancyHeatmap occupancyHeatmap{}; PressureSummary pressureSummary{}; HazardExposureSummary hazardExposureSummary{}; - CrossFlowSummary crossFlowSummary{}; - std::vector crossFlowTimeline{}; + OperationalConflictSummary operationalConflictSummary{}; + std::vector operationalConflictTimeline{}; std::vector exitUsage{}; std::vector zoneCompletion{}; std::vector placementCompletion{}; diff --git a/src/domain/ScenarioRiskMetrics.cpp b/src/domain/ScenarioRiskMetrics.cpp index 978c5d9..2e0df13 100644 --- a/src/domain/ScenarioRiskMetrics.cpp +++ b/src/domain/ScenarioRiskMetrics.cpp @@ -21,11 +21,11 @@ const char* scenarioBottleneckDefinition() noexcept { "are within 1.25 m and at least one is stalled or average speed is low."; } -const char* scenarioCrossFlowDefinition() noexcept { - return "Cross flow highlights non-aligned movement streams sharing the same space while slowed down. " - "Cross-flow cells use a 2.0 m grid derived from Pathfinder's 4 m^2 measurement-region " - "influence area and compare observed speed against " - "Pathfinder's 1.30 m/s mean and 0.97 m/s minimum walking speeds."; +const char* scenarioOperationalConflictDefinition() noexcept { + return "Operational conflict highlights opposing route intent and queueing around shared passages. " + "Cells use a 2.0 m grid derived from Pathfinder's 4 m^2 measurement-region influence area, " + "while connection metrics require forward and reverse movement intent before scoring speed drop. " + "This separates bidirectional conflict from one-way bottleneck concentration."; } bool scenarioAgentStalled(double speedMetersPerSecond, double routeStalledSeconds) noexcept { diff --git a/src/domain/ScenarioRiskMetrics.h b/src/domain/ScenarioRiskMetrics.h index 2905dd2..01f0540 100644 --- a/src/domain/ScenarioRiskMetrics.h +++ b/src/domain/ScenarioRiskMetrics.h @@ -35,13 +35,16 @@ inline constexpr double kScenarioCriticalPressureEventDurationThresholdSeconds = inline constexpr std::size_t kScenarioCriticalPressureEventAgentThreshold = 2; inline constexpr double kScenarioBottleneckRadius = 1.25; inline constexpr std::size_t kScenarioBottleneckAgentThreshold = 3; -inline constexpr double kScenarioCrossFlowCellSize = 2.0; -inline constexpr double kScenarioCrossFlowReferenceSpeedMetersPerSecond = 1.30; -inline constexpr double kScenarioCrossFlowMinimumExpectedSpeedMetersPerSecond = 0.97; -inline constexpr std::size_t kScenarioCrossFlowDirectionBinCount = 16; -inline constexpr std::size_t kScenarioCrossFlowMinMovingAgents = 4; -inline constexpr double kScenarioCrossFlowSideRatioThreshold = 0.30; -inline constexpr double kScenarioCrossFlowCosineThreshold = 0.5; +inline constexpr double kScenarioOperationalConflictCellSize = 2.0; +inline constexpr double kScenarioOperationalConflictInfluenceRadius = 1.41; +inline constexpr double kScenarioOperationalConflictReferenceSpeedMetersPerSecond = 1.30; +inline constexpr double kScenarioOperationalConflictMinimumExpectedSpeedMetersPerSecond = 0.97; +inline constexpr std::size_t kScenarioOperationalConflictDirectionBinCount = 16; +inline constexpr std::size_t kScenarioOperationalConflictMinMovingAgents = 4; +inline constexpr double kScenarioOperationalConflictSideRatioThreshold = 0.30; +inline constexpr double kScenarioOperationalConflictCosineThreshold = -0.5; +inline constexpr double kScenarioOperationalConflictQueueSpeedThreshold = kScenarioStalledSpeedThreshold; +inline constexpr double kScenarioOperationalConflictWindowSeconds = 5.0; struct ScenarioCongestionHotspot { Point2D center{}; @@ -101,45 +104,70 @@ struct ScenarioBottleneckMetric { std::optional detectionFrame{}; }; -struct ScenarioCrossFlowCellMetric { +struct ScenarioOperationalConflictCellMetric { Point2D center{}; Point2D cellMin{}; Point2D cellMax{}; std::string floorId{}; std::size_t movingAgentCount{0}; std::size_t peakAgentCount{0}; - std::size_t primaryFlowCount{0}; - std::size_t crossFlowCount{0}; - double crossFlowRatio{0.0}; + std::size_t forwardCount{0}; + std::size_t reverseCount{0}; + double counterflowRatio{0.0}; double averageSpeed{0.0}; double speedDropRatio{0.0}; - double crossFlowScore{0.0}; + double conflictScore{0.0}; double durationSeconds{0.0}; double exposureAgentSeconds{0.0}; + std::string nearestConnectionId{}; + std::string nearestConnectionLabel{}; std::optional detectedAtSeconds{}; std::optional detectionFrame{}; + double oppositionScore{0.0}; +}; + +struct ScenarioOperationalConflictConnectionMetric { + std::string connectionId{}; + std::string label{}; + std::string floorId{}; + LineSegment2D passage{}; + std::size_t nearbyAgentCount{0}; + std::size_t movingAgentCount{0}; + std::size_t queueAgentCount{0}; + std::size_t forwardCount{0}; + std::size_t reverseCount{0}; + double counterflowRatio{0.0}; + double averageSpeed{0.0}; + double speedDropRatio{0.0}; + double conflictScore{0.0}; + double durationSeconds{0.0}; + double exposureAgentSeconds{0.0}; + std::optional detectedAtSeconds{}; + std::optional detectionFrame{}; + double oppositionScore{0.0}; }; struct ScenarioRiskSnapshot { std::size_t stalledAgentCount{0}; std::size_t pressureExposedAgentCount{0}; std::size_t criticalPressureAgentCount{0}; - std::size_t crossFlowAgentCount{0}; - double peakCrossFlowScore{0.0}; - double totalCrossFlowExposureAgentSeconds{0.0}; + std::size_t conflictAgentCount{0}; + double peakConflictScore{0.0}; + double totalConflictExposureAgentSeconds{0.0}; std::vector hotspots{}; std::vector pressureHotspots{}; std::vector pressureAgents{}; std::vector criticalPressureEvents{}; std::vector bottlenecks{}; - std::vector crossFlowCells{}; + std::vector operationalConflictCells{}; + std::vector operationalConflictConnections{}; }; const char* scenarioStalledDefinition() noexcept; const char* scenarioHotspotDefinition() noexcept; const char* scenarioPressureHotspotDefinition() noexcept; const char* scenarioBottleneckDefinition() noexcept; -const char* scenarioCrossFlowDefinition() noexcept; +const char* scenarioOperationalConflictDefinition() noexcept; bool scenarioAgentStalled(double speedMetersPerSecond, double routeStalledSeconds) noexcept; } // namespace safecrowd::domain diff --git a/src/domain/ScenarioRiskMetricsSystem.cpp b/src/domain/ScenarioRiskMetricsSystem.cpp index c5930a1..b003dcb 100644 --- a/src/domain/ScenarioRiskMetricsSystem.cpp +++ b/src/domain/ScenarioRiskMetricsSystem.cpp @@ -25,8 +25,20 @@ constexpr std::size_t kMaxReportedPressureHotspots = 5; constexpr std::size_t kMaxReportedPressureAgents = 5; constexpr std::size_t kMaxReportedCriticalPressureEvents = 5; constexpr std::size_t kMaxReportedBottlenecks = 5; -constexpr std::size_t kMaxReportedCrossFlowCells = 5; -constexpr double kCrossFlowDirectionEpsilon = 1e-6; +constexpr std::size_t kMaxReportedOperationalConflictCells = 5; +constexpr std::size_t kMaxReportedOperationalConflictConnections = 5; +constexpr double kOperationalConflictDirectionEpsilon = 1e-6; +constexpr double kOperationalConflictMinimumSpeedDropRatio = 0.15; +constexpr std::size_t kOperationalConflictMinimumAgentsPerSide = 2; +constexpr double kOperationalConflictConnectionMinimumSpeedDropRatio = 0.05; +constexpr std::size_t kOperationalConflictConnectionMinimumAgentsPerSide = 1; +constexpr double kOperationalConflictConnectionSideRatioThreshold = 0.20; + +enum class FlowDirection { + Unknown, + Forward, + Reverse, +}; template void sortAndTrimTop(std::vector& values, std::size_t maxCount, Compare compare) { @@ -49,14 +61,14 @@ struct RiskCellAccumulator { std::vector entities{}; }; -struct CrossFlowCellAccumulator { +struct OperationalConflictCellAccumulator { Point2D positionSum{}; Point2D cellMin{}; Point2D cellMax{}; std::string floorId{}; std::size_t movingAgentCount{0}; double speedSum{0.0}; - std::array directionCounts{}; + std::array directionCounts{}; }; struct ActiveAgentContext { @@ -64,24 +76,50 @@ struct ActiveAgentContext { std::uint64_t agentId{0}; Point2D position{}; Point2D velocity{}; + Point2D intendedDirection{}; std::string floorId{}; + std::string nextConnectionId{}; + std::string nextFromZoneId{}; + std::string nextToZoneId{}; double radius{0.25}; bool stalled{false}; + bool hasIntendedDirection{false}; }; -struct CrossFlowCellAddress { +struct OperationalConflictCellAddress { SpatialCell cell{}; std::string floorId{}; }; -struct CrossFlowObservation { +struct OperationalConflictObservation { std::size_t movingAgentCount{0}; - std::size_t primaryFlowCount{0}; - std::size_t crossFlowCount{0}; + std::size_t forwardCount{0}; + std::size_t reverseCount{0}; + double averageSpeed{0.0}; + double counterflowRatio{0.0}; + double oppositionScore{0.0}; + double speedDropRatio{0.0}; + double conflictScore{0.0}; +}; + +struct ConnectionFlowObservation { + const Connection2D* connection{nullptr}; + std::string connectionId{}; + std::string label{}; + std::string floorId{}; + LineSegment2D passage{}; + std::size_t nearbyAgentCount{0}; + std::size_t stalledAgentCount{0}; + std::size_t queueAgentCount{0}; + std::size_t forwardCount{0}; + std::size_t reverseCount{0}; + std::size_t unknownDirectionCount{0}; + double speedSum{0.0}; double averageSpeed{0.0}; - double crossFlowRatio{0.0}; + double counterflowRatio{0.0}; + double oppositionScore{0.0}; double speedDropRatio{0.0}; - double crossFlowScore{0.0}; + double conflictScore{0.0}; }; struct ActivePressureFeedbackContext { @@ -134,30 +172,69 @@ Point2D riskCellMax(const RiskCellAddress& cell) { return spatialCellMax(cell.cell, kScenarioHotspotCellSize); } -CrossFlowCellAddress crossFlowCellAddress(const Point2D& point, const std::string& floorId) { +OperationalConflictCellAddress operationalConflictCellAddress(const Point2D& point, const std::string& floorId) { return { - .cell = spatialCellFor(point, kScenarioCrossFlowCellSize), + .cell = spatialCellFor(point, kScenarioOperationalConflictCellSize), .floorId = floorId, }; } -long long crossFlowCellKey(const CrossFlowCellAddress& cell) { +long long operationalConflictCellKey(const OperationalConflictCellAddress& cell) { const auto cellKey = spatialKey(cell.cell); return cellKey ^ (static_cast(std::hash{}(cell.floorId)) << 1); } -Point2D crossFlowCellMin(const CrossFlowCellAddress& cell) { - return spatialCellMin(cell.cell, kScenarioCrossFlowCellSize); +Point2D operationalConflictCellMin(const OperationalConflictCellAddress& cell) { + return spatialCellMin(cell.cell, kScenarioOperationalConflictCellSize); } -Point2D crossFlowCellMax(const CrossFlowCellAddress& cell) { - return spatialCellMax(cell.cell, kScenarioCrossFlowCellSize); +Point2D operationalConflictCellMax(const OperationalConflictCellAddress& cell) { + return spatialCellMax(cell.cell, kScenarioOperationalConflictCellSize); } bool isStalled(const Velocity& velocity, const EvacuationRoute& route) { return scenarioAgentStalled(lengthOf(velocity.value), route.stalledSeconds); } +std::string routeConnectionIdAt(const EvacuationRoute& route) { + return route.nextWaypointIndex < route.waypointConnectionIds.size() + ? route.waypointConnectionIds[route.nextWaypointIndex] + : std::string{}; +} + +std::string routeFromZoneIdAt(const EvacuationRoute& route) { + return route.nextWaypointIndex < route.waypointFromZoneIds.size() + ? route.waypointFromZoneIds[route.nextWaypointIndex] + : std::string{}; +} + +std::string routeToZoneIdAt(const EvacuationRoute& route) { + return route.nextWaypointIndex < route.waypointZoneIds.size() + ? route.waypointZoneIds[route.nextWaypointIndex] + : std::string{}; +} + +std::optional intendedDirectionForRoute( + const EvacuationRoute& route, + const Point2D& position, + const Point2D& velocity) { + if (route.nextWaypointIndex < route.waypoints.size()) { + const auto target = routeWaypointTarget(route, position); + auto routeVector = target - position; + if (lengthOf(routeVector) <= kOperationalConflictDirectionEpsilon) { + routeVector = target - route.currentSegmentStart; + } + if (lengthOf(routeVector) > kOperationalConflictDirectionEpsilon) { + return normalizedOr(routeVector, {.x = 1.0, .y = 0.0}); + } + } + + if (lengthOf(velocity) > kOperationalConflictDirectionEpsilon) { + return normalizedOr(velocity, {.x = 1.0, .y = 0.0}); + } + return std::nullopt; +} + bool isHotspotSetWorse( const std::vector& candidate, const std::vector& currentPeak) { @@ -254,9 +331,9 @@ bool isBottleneckSetWorse( return lhs.averageSpeed < rhs.averageSpeed; } -bool isCrossFlowCellSetWorse( - const std::vector& candidate, - const std::vector& currentPeak) { +bool isOperationalConflictCellSetWorse( + const std::vector& candidate, + const std::vector& currentPeak) { if (candidate.empty()) { return false; } @@ -266,8 +343,8 @@ bool isCrossFlowCellSetWorse( const auto& lhs = candidate.front(); const auto& rhs = currentPeak.front(); - if (std::fabs(lhs.crossFlowScore - rhs.crossFlowScore) > 1e-9) { - return lhs.crossFlowScore > rhs.crossFlowScore; + if (std::fabs(lhs.conflictScore - rhs.conflictScore) > 1e-9) { + return lhs.conflictScore > rhs.conflictScore; } if (std::fabs(lhs.durationSeconds - rhs.durationSeconds) > 1e-9) { return lhs.durationSeconds > rhs.durationSeconds; @@ -275,6 +352,27 @@ bool isCrossFlowCellSetWorse( return lhs.movingAgentCount > rhs.movingAgentCount; } +bool isOperationalConflictConnectionSetWorse( + const std::vector& candidate, + const std::vector& currentPeak) { + if (candidate.empty()) { + return false; + } + if (currentPeak.empty()) { + return true; + } + + const auto& lhs = candidate.front(); + const auto& rhs = currentPeak.front(); + if (std::fabs(lhs.conflictScore - rhs.conflictScore) > 1e-9) { + return lhs.conflictScore > rhs.conflictScore; + } + if (std::fabs(lhs.durationSeconds - rhs.durationSeconds) > 1e-9) { + return lhs.durationSeconds > rhs.durationSeconds; + } + return lhs.nearbyAgentCount > rhs.nearbyAgentCount; +} + bool barrierMatchesFloor(const Barrier2D& barrier, const std::string& floorId) { return matchesFloor(barrier.floorId, floorId); } @@ -354,23 +452,23 @@ ScenarioAgentSpatialIndexResource buildDisplayFloorPressureIndex( return index; } -std::size_t crossFlowDirectionBinForVelocity(Point2D velocity) { +std::size_t operationalConflictDirectionBinForVelocity(Point2D velocity) { constexpr double kTau = 6.28318530717958647692; auto angle = std::atan2(velocity.y, velocity.x); if (angle < 0.0) { angle += kTau; } const auto bin = static_cast(std::floor( - angle / (kTau / static_cast(kScenarioCrossFlowDirectionBinCount)))); - return std::min(kScenarioCrossFlowDirectionBinCount - 1, bin); + angle / (kTau / static_cast(kScenarioOperationalConflictDirectionBinCount)))); + return std::min(kScenarioOperationalConflictDirectionBinCount - 1, bin); } -std::array makeCrossFlowDirectionVectors() { +std::array makeOperationalConflictDirectionVectors() { constexpr double kTau = 6.28318530717958647692; - std::array vectors{}; + std::array vectors{}; for (std::size_t index = 0; index < vectors.size(); ++index) { const auto angle = ((static_cast(index) + 0.5) - / static_cast(kScenarioCrossFlowDirectionBinCount)) * kTau; + / static_cast(kScenarioOperationalConflictDirectionBinCount)) * kTau; vectors[index] = { .x = std::cos(angle), .y = std::sin(angle), @@ -379,77 +477,173 @@ std::array makeCrossFlowDirectionV return vectors; } -double crossFlowDirectionCosine(std::size_t lhs, std::size_t rhs) { - static const auto directions = makeCrossFlowDirectionVectors(); +double operationalConflictDirectionCosine(std::size_t lhs, std::size_t rhs) { + static const auto directions = makeOperationalConflictDirectionVectors(); return dot(directions[lhs], directions[rhs]); } -double crossFlowSpeedDropRatio(double averageSpeed) { +double operationalConflictSpeedDropRatio(double averageSpeed) { return std::clamp( - 1.0 - (averageSpeed / std::max(1e-9, kScenarioCrossFlowReferenceSpeedMetersPerSecond)), + 1.0 - (averageSpeed / std::max(1e-9, kScenarioOperationalConflictReferenceSpeedMetersPerSecond)), 0.0, 1.0); } -double crossFlowScore(double crossFlowRatio, double averageSpeed) { - const auto speedDropRatio = crossFlowSpeedDropRatio(averageSpeed); - return std::clamp((crossFlowRatio * 0.65) + (speedDropRatio * 0.35), 0.0, 1.0); +double operationalConflictOppositionScore(double counterflowRatio) { + return std::clamp(counterflowRatio * 2.0, 0.0, 1.0); } -std::optional detectCrossFlow( - const std::array& directionCounts, +double conflictScore(double counterflowRatio, double averageSpeed) { + const auto oppositionScore = operationalConflictOppositionScore(counterflowRatio); + const auto speedDropRatio = operationalConflictSpeedDropRatio(averageSpeed); + return std::clamp((oppositionScore * 0.75) + (speedDropRatio * 0.25), 0.0, 1.0); +} + +std::optional detectOperationalConflict( + const std::array& directionCounts, std::size_t movingAgentCount, double averageSpeed) { - if (movingAgentCount < kScenarioCrossFlowMinMovingAgents) { + if (movingAgentCount < kScenarioOperationalConflictMinMovingAgents) { return std::nullopt; } - if (averageSpeed > kScenarioCrossFlowMinimumExpectedSpeedMetersPerSecond + kCrossFlowDirectionEpsilon) { + if (averageSpeed > kScenarioOperationalConflictMinimumExpectedSpeedMetersPerSecond + kOperationalConflictDirectionEpsilon) { return std::nullopt; } - CrossFlowObservation best; + OperationalConflictObservation best; best.averageSpeed = averageSpeed; best.movingAgentCount = movingAgentCount; - for (std::size_t anchorBin = 0; anchorBin < kScenarioCrossFlowDirectionBinCount; ++anchorBin) { + for (std::size_t anchorBin = 0; anchorBin < kScenarioOperationalConflictDirectionBinCount; ++anchorBin) { if (directionCounts[anchorBin] == 0) { continue; } - CrossFlowObservation candidate; + OperationalConflictObservation candidate; candidate.averageSpeed = averageSpeed; candidate.movingAgentCount = movingAgentCount; - for (std::size_t bin = 0; bin < kScenarioCrossFlowDirectionBinCount; ++bin) { + for (std::size_t bin = 0; bin < kScenarioOperationalConflictDirectionBinCount; ++bin) { if (directionCounts[bin] == 0) { continue; } - const auto cosine = crossFlowDirectionCosine(anchorBin, bin); - if (cosine > kScenarioCrossFlowCosineThreshold) { - candidate.primaryFlowCount += directionCounts[bin]; + const auto cosine = operationalConflictDirectionCosine(anchorBin, bin); + if (cosine > kScenarioOperationalConflictCosineThreshold) { + candidate.forwardCount += directionCounts[bin]; } else { - candidate.crossFlowCount += directionCounts[bin]; + candidate.reverseCount += directionCounts[bin]; } } - const auto primaryRatio = static_cast(candidate.primaryFlowCount) + const auto primaryRatio = static_cast(candidate.forwardCount) / static_cast(candidate.movingAgentCount); - const auto crossRatio = static_cast(candidate.crossFlowCount) + const auto crossRatio = static_cast(candidate.reverseCount) / static_cast(candidate.movingAgentCount); - if (primaryRatio < kScenarioCrossFlowSideRatioThreshold - || crossRatio < kScenarioCrossFlowSideRatioThreshold) { + if (candidate.forwardCount < kOperationalConflictMinimumAgentsPerSide + || candidate.reverseCount < kOperationalConflictMinimumAgentsPerSide) { + continue; + } + if (primaryRatio < kScenarioOperationalConflictSideRatioThreshold + || crossRatio < kScenarioOperationalConflictSideRatioThreshold) { continue; } - candidate.crossFlowRatio = std::min(primaryRatio, crossRatio); - candidate.speedDropRatio = crossFlowSpeedDropRatio(averageSpeed); - candidate.crossFlowScore = crossFlowScore(candidate.crossFlowRatio, averageSpeed); - if (candidate.crossFlowScore > best.crossFlowScore - || (std::fabs(candidate.crossFlowScore - best.crossFlowScore) <= 1e-9 - && candidate.primaryFlowCount + candidate.crossFlowCount > best.primaryFlowCount + best.crossFlowCount)) { + candidate.counterflowRatio = std::min(primaryRatio, crossRatio); + candidate.speedDropRatio = operationalConflictSpeedDropRatio(averageSpeed); + if (candidate.speedDropRatio < kOperationalConflictMinimumSpeedDropRatio + && averageSpeed > kScenarioOperationalConflictMinimumExpectedSpeedMetersPerSecond + + kOperationalConflictDirectionEpsilon) { + continue; + } + candidate.oppositionScore = operationalConflictOppositionScore(candidate.counterflowRatio); + candidate.conflictScore = conflictScore(candidate.counterflowRatio, averageSpeed); + if (candidate.conflictScore > best.conflictScore + || (std::fabs(candidate.conflictScore - best.conflictScore) <= 1e-9 + && candidate.forwardCount + candidate.reverseCount > best.forwardCount + best.reverseCount)) { best = candidate; } } - return best.crossFlowScore <= 0.0 ? std::nullopt : std::optional{best}; + return best.conflictScore <= 0.0 ? std::nullopt : std::optional{best}; +} + +Point2D connectionAxisDirection(const FacilityLayout2D& layout, const Connection2D& connection) { + const auto* fromZone = findZone(layout, connection.fromZoneId); + const auto* toZone = findZone(layout, connection.toZoneId); + if (fromZone != nullptr && toZone != nullptr) { + const auto axis = polygonCenter(toZone->area) - polygonCenter(fromZone->area); + if (lengthOf(axis) > kOperationalConflictDirectionEpsilon) { + return normalizedOr(axis, {.x = 1.0, .y = 0.0}); + } + } + + const auto span = connection.centerSpan.end - connection.centerSpan.start; + const auto normal = perpendicularLeft(span); + return normalizedOr(normal, {.x = 1.0, .y = 0.0}); +} + +FlowDirection directionFromConnectionZones( + const Connection2D& connection, + const std::string& fromZoneId, + const std::string& toZoneId) { + if (fromZoneId.empty() || toZoneId.empty()) { + return FlowDirection::Unknown; + } + if (connection.fromZoneId == fromZoneId && connection.toZoneId == toZoneId) { + return FlowDirection::Forward; + } + if (connection.toZoneId == fromZoneId && connection.fromZoneId == toZoneId) { + return FlowDirection::Reverse; + } + return FlowDirection::Unknown; +} + +FlowDirection directionFromVector(Point2D vector, Point2D axis) { + if (lengthOf(vector) <= kOperationalConflictDirectionEpsilon) { + return FlowDirection::Unknown; + } + + const auto cosine = dot(normalizedOr(vector, axis), axis); + if (cosine >= 0.25) { + return FlowDirection::Forward; + } + if (cosine <= -0.25) { + return FlowDirection::Reverse; + } + return FlowDirection::Unknown; +} + +FlowDirection agentDirectionForConnection( + const FacilityLayout2D& layout, + const ActiveAgentContext& agent, + const Connection2D& connection) { + if (agent.nextConnectionId == connection.id) { + const auto routeDirection = directionFromConnectionZones( + connection, + agent.nextFromZoneId, + agent.nextToZoneId); + if (routeDirection != FlowDirection::Unknown) { + return routeDirection; + } + } + + const auto axis = connectionAxisDirection(layout, connection); + if (agent.hasIntendedDirection) { + const auto intendedDirection = directionFromVector(agent.intendedDirection, axis); + if (intendedDirection != FlowDirection::Unknown) { + return intendedDirection; + } + } + return directionFromVector(agent.velocity, axis); +} + +bool connectionObservationHasOperationalConflict(const ConnectionFlowObservation& observation) { + const auto directionalCount = observation.forwardCount + observation.reverseCount; + return directionalCount >= kScenarioOperationalConflictMinMovingAgents + && observation.forwardCount >= kOperationalConflictConnectionMinimumAgentsPerSide + && observation.reverseCount >= kOperationalConflictConnectionMinimumAgentsPerSide + && observation.counterflowRatio >= kOperationalConflictConnectionSideRatioThreshold + && (observation.speedDropRatio >= kOperationalConflictConnectionMinimumSpeedDropRatio + || observation.averageSpeed <= kScenarioOperationalConflictMinimumExpectedSpeedMetersPerSecond + + kOperationalConflictDirectionEpsilon); } double pressureFeedbackLevel(double compressionForce, double exposureSeconds, bool critical) { @@ -778,7 +972,7 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { void configure(engine::EngineWorld& world) override { world.resources().set(ScenarioRiskMetricsResource{}); world.resources().set(ScenarioPressureTrackingResource{}); - world.resources().set(ScenarioCrossFlowResource{}); + world.resources().set(ScenarioOperationalConflictResource{}); } void update(engine::EngineWorld& world, const engine::EngineStepContext& step) override { @@ -811,14 +1005,20 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { } const auto floorId = agentDisplayFloorId(route); + const auto intendedDirection = intendedDirectionForRoute(route, position.value, velocity.value); activeAgents.push_back({ .entity = entity, .agentId = entity.index, .position = position.value, .velocity = velocity.value, + .intendedDirection = intendedDirection.value_or(Point2D{}), .floorId = floorId, + .nextConnectionId = routeConnectionIdAt(route), + .nextFromZoneId = routeFromZoneIdAt(route), + .nextToZoneId = routeToZoneIdAt(route), .radius = static_cast(query.get(entity).radius), .stalled = stalled, + .hasIntendedDirection = intendedDirection.has_value(), }); const auto address = riskCellAddress(position.value, floorId); auto& cell = cells[riskCellKey(address)]; @@ -858,18 +1058,22 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { collectPressureHotspots(snapshot, query, cells); collectCriticalPressureEvents(snapshot, cells, clock.elapsedSeconds, deltaSeconds, pressureTracking); collectBottlenecks(snapshot, activeAgents, *pressureIndex, activeLayout); - collectCrossFlows( + collectOperationalConflicts( snapshot, activeAgents, + *pressureIndex, + activeLayout, + query, clock, deltaSeconds, - resources.get()); + resources.get()); if (!snapshot.hotspots.empty() || !snapshot.pressureHotspots.empty() || !snapshot.criticalPressureEvents.empty() || !snapshot.bottlenecks.empty() - || !snapshot.crossFlowCells.empty()) { + || !snapshot.operationalConflictCells.empty() + || !snapshot.operationalConflictConnections.empty()) { attachDetectionState(snapshot, captureSimulationFrame(query, clock), clock.elapsedSeconds); } @@ -888,11 +1092,11 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { peak.stalledAgentCount = std::max(peak.stalledAgentCount, current.stalledAgentCount); peak.pressureExposedAgentCount = std::max(peak.pressureExposedAgentCount, current.pressureExposedAgentCount); peak.criticalPressureAgentCount = std::max(peak.criticalPressureAgentCount, current.criticalPressureAgentCount); - peak.crossFlowAgentCount = std::max(peak.crossFlowAgentCount, current.crossFlowAgentCount); - peak.peakCrossFlowScore = std::max(peak.peakCrossFlowScore, current.peakCrossFlowScore); - peak.totalCrossFlowExposureAgentSeconds = std::max( - peak.totalCrossFlowExposureAgentSeconds, - current.totalCrossFlowExposureAgentSeconds); + peak.conflictAgentCount = std::max(peak.conflictAgentCount, current.conflictAgentCount); + peak.peakConflictScore = std::max(peak.peakConflictScore, current.peakConflictScore); + peak.totalConflictExposureAgentSeconds = std::max( + peak.totalConflictExposureAgentSeconds, + current.totalConflictExposureAgentSeconds); if (isHotspotSetWorse(current.hotspots, peak.hotspots)) { peak.hotspots = current.hotspots; } @@ -908,10 +1112,15 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { if (isBottleneckSetWorse(current.bottlenecks, peak.bottlenecks)) { peak.bottlenecks = current.bottlenecks; } - if (isCrossFlowCellSetWorse( - current.crossFlowCells, - peak.crossFlowCells)) { - peak.crossFlowCells = current.crossFlowCells; + if (isOperationalConflictCellSetWorse( + current.operationalConflictCells, + peak.operationalConflictCells)) { + peak.operationalConflictCells = current.operationalConflictCells; + } + if (isOperationalConflictConnectionSetWorse( + current.operationalConflictConnections, + peak.operationalConflictConnections)) { + peak.operationalConflictConnections = current.operationalConflictConnections; } } @@ -935,10 +1144,14 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { bottleneck.detectedAtSeconds = elapsedSeconds; bottleneck.detectionFrame = frame; } - for (auto& cell : snapshot.crossFlowCells) { + for (auto& cell : snapshot.operationalConflictCells) { cell.detectedAtSeconds = elapsedSeconds; cell.detectionFrame = frame; } + for (auto& connection : snapshot.operationalConflictConnections) { + connection.detectedAtSeconds = elapsedSeconds; + connection.detectionFrame = frame; + } } double updatePressureTracking( @@ -1290,6 +1503,129 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { return {}; } + std::vector collectConnectionFlowObservations( + const std::vector& activeAgents, + const ScenarioAgentSpatialIndexResource& spatialIndex, + const FacilityLayout2D& layout, + double influenceRadius) const { + std::unordered_map activeAgentIndices; + activeAgentIndices.reserve(activeAgents.size()); + for (std::size_t index = 0; index < activeAgents.size(); ++index) { + activeAgentIndices.emplace(activeAgents[index].agentId, index); + } + + auto nearbyEntitiesForConnection = [&](const std::string& floorId, const LineSegment2D& passage) { + std::vector candidates; + std::unordered_set seen; + const auto appendFloor = [&](const std::string& candidateFloorId) { + const auto floorIt = spatialIndex.displayCellsByFloor.find(candidateFloorId); + if (floorIt == spatialIndex.displayCellsByFloor.end()) { + return; + } + const Point2D minPoint{ + .x = std::min(passage.start.x, passage.end.x) - influenceRadius, + .y = std::min(passage.start.y, passage.end.y) - influenceRadius, + }; + const Point2D maxPoint{ + .x = std::max(passage.start.x, passage.end.x) + influenceRadius, + .y = std::max(passage.start.y, passage.end.y) + influenceRadius, + }; + for (const auto& cell : spatialCellsForBounds(minPoint, maxPoint, spatialIndex.cellSize)) { + const auto cellIt = floorIt->second.find(spatialKey(cell)); + if (cellIt == floorIt->second.end()) { + continue; + } + for (const auto entity : cellIt->second) { + const auto packed = + (static_cast(entity.generation) << 32U) | entity.index; + if (seen.insert(packed).second) { + candidates.push_back(entity); + } + } + } + }; + + appendFloor(floorId); + if (!floorId.empty()) { + appendFloor(std::string{}); + } + return candidates; + }; + + std::vector observations; + observations.reserve(layout.connections.size()); + for (const auto& connection : layout.connections) { + if (connection.directionality == TravelDirection::Closed) { + continue; + } + + ConnectionFlowObservation observation; + observation.connection = &connection; + observation.connectionId = connection.id; + observation.label = connectionLabel(layout, connection); + observation.floorId = connectionFloorId(layout, connection); + observation.passage = connection.centerSpan; + + for (const auto entity : nearbyEntitiesForConnection(observation.floorId, connection.centerSpan)) { + const auto activeIt = activeAgentIndices.find(entity.index); + if (activeIt == activeAgentIndices.end()) { + continue; + } + const auto& agent = activeAgents[activeIt->second]; + if (agent.floorId != observation.floorId) { + continue; + } + const auto distanceToConnection = distanceBetween( + agent.position, + closestPointOnSegment(agent.position, connection.centerSpan.start, connection.centerSpan.end)); + if (distanceToConnection > influenceRadius) { + continue; + } + + const auto speed = lengthOf(agent.velocity); + ++observation.nearbyAgentCount; + observation.speedSum += speed; + if (agent.stalled) { + ++observation.stalledAgentCount; + } + if (speed <= kScenarioOperationalConflictQueueSpeedThreshold + 1e-9) { + ++observation.queueAgentCount; + } + + switch (agentDirectionForConnection(layout, agent, connection)) { + case FlowDirection::Forward: + ++observation.forwardCount; + break; + case FlowDirection::Reverse: + ++observation.reverseCount; + break; + case FlowDirection::Unknown: + ++observation.unknownDirectionCount; + break; + } + } + + if (observation.nearbyAgentCount == 0) { + continue; + } + observation.averageSpeed = + observation.speedSum / static_cast(observation.nearbyAgentCount); + const auto directionalCount = observation.forwardCount + observation.reverseCount; + if (directionalCount > 0) { + observation.counterflowRatio = + static_cast(std::min(observation.forwardCount, observation.reverseCount)) + / static_cast(directionalCount); + observation.oppositionScore = operationalConflictOppositionScore(observation.counterflowRatio); + } + observation.speedDropRatio = operationalConflictSpeedDropRatio(observation.averageSpeed); + observation.conflictScore = conflictScore(observation.counterflowRatio, observation.averageSpeed); + + observations.push_back(std::move(observation)); + } + + return observations; + } + void collectBottlenecks( ScenarioRiskSnapshot& snapshot, const std::vector& activeAgents, @@ -1392,39 +1728,77 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { }); } - void collectCrossFlows( + void collectOperationalConflicts( ScenarioRiskSnapshot& snapshot, const std::vector& activeAgents, + const ScenarioAgentSpatialIndexResource& spatialIndex, + const FacilityLayout2D& layout, + engine::WorldQuery& query, const ScenarioSimulationClockResource& clock, double deltaSeconds, - ScenarioCrossFlowResource& crossFlow) const { - std::unordered_map cells; + ScenarioOperationalConflictResource& operationalConflict) const { + (void)query; + operationalConflict.previousElapsedSeconds = clock.elapsedSeconds; + operationalConflict.hasPreviousElapsedSeconds = true; + + std::unordered_map cells; cells.reserve(activeAgents.size()); for (const auto& agent : activeAgents) { const auto speed = lengthOf(agent.velocity); - if (speed <= 0.05) { + const auto directionVector = speed > 0.05 + ? agent.velocity + : agent.intendedDirection; + if (speed <= 0.05 && !agent.hasIntendedDirection) { continue; } - const auto address = crossFlowCellAddress(agent.position, agent.floorId); - auto& cell = cells[crossFlowCellKey(address)]; + const auto address = operationalConflictCellAddress(agent.position, agent.floorId); + auto& cell = cells[operationalConflictCellKey(address)]; if (cell.movingAgentCount == 0) { - cell.cellMin = crossFlowCellMin(address); - cell.cellMax = crossFlowCellMax(address); + cell.cellMin = operationalConflictCellMin(address); + cell.cellMax = operationalConflictCellMax(address); cell.floorId = address.floorId; } cell.positionSum = cell.positionSum + agent.position; ++cell.movingAgentCount; cell.speedSum += speed; - ++cell.directionCounts[crossFlowDirectionBinForVelocity(agent.velocity)]; + ++cell.directionCounts[operationalConflictDirectionBinForVelocity(directionVector)]; } + const auto nearestConnectionForPoint = + [&](const Point2D& point, const std::string& floorId) -> std::pair { + double bestDistance = kScenarioOperationalConflictInfluenceRadius + 1e-9; + std::pair best; + for (const auto& connection : layout.connections) { + if (connection.directionality == TravelDirection::Closed) { + continue; + } + const auto connectionFloor = connectionFloorId(layout, connection); + if (!matchesFloor(connectionFloor, floorId)) { + continue; + } + const auto distanceToConnection = distanceBetween( + point, + closestPointOnSegment(point, connection.centerSpan.start, connection.centerSpan.end)); + if (distanceToConnection > bestDistance) { + continue; + } + bestDistance = distanceToConnection; + best = { + connection.id, + connectionLabel(layout, connection), + }; + } + return best; + }; + std::unordered_set activeCellKeys; activeCellKeys.reserve(cells.size()); - snapshot.crossFlowCells.clear(); - snapshot.crossFlowAgentCount = 0; - snapshot.peakCrossFlowScore = 0.0; - snapshot.totalCrossFlowExposureAgentSeconds = 0.0; + snapshot.operationalConflictCells.clear(); + snapshot.operationalConflictConnections.clear(); + snapshot.conflictAgentCount = 0; + snapshot.peakConflictScore = 0.0; + snapshot.totalConflictExposureAgentSeconds = 0.0; for (const auto& [cellKey, cell] : cells) { if (cell.movingAgentCount == 0) { @@ -1432,7 +1806,7 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { } const auto averageSpeed = cell.speedSum / static_cast(cell.movingAgentCount); - const auto observation = detectCrossFlow( + const auto observation = detectOperationalConflict( cell.directionCounts, cell.movingAgentCount, averageSpeed); @@ -1441,7 +1815,7 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { } activeCellKeys.insert(cellKey); - const auto [stateIt, inserted] = crossFlow.activeCellsByAddress.try_emplace(cellKey); + const auto [stateIt, inserted] = operationalConflict.activeCellsByAddress.try_emplace(cellKey); auto& state = stateIt->second; if (inserted) { state.startedAtSeconds = clock.elapsedSeconds; @@ -1449,8 +1823,8 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { const auto exposureDelta = static_cast(observation->movingAgentCount) * std::max(0.0, deltaSeconds); state.exposureAgentSeconds += exposureDelta; - crossFlow.totalCrossFlowExposureAgentSeconds += exposureDelta; - state.peakCrossFlowScore = std::max(state.peakCrossFlowScore, observation->crossFlowScore); + operationalConflict.totalConflictExposureAgentSeconds += exposureDelta; + state.peakConflictScore = std::max(state.peakConflictScore, observation->conflictScore); state.peakAgentCount = std::max(state.peakAgentCount, observation->movingAgentCount); const auto count = static_cast(cell.movingAgentCount); @@ -1458,47 +1832,147 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { .x = count <= 0.0 ? 0.0 : cell.positionSum.x / count, .y = count <= 0.0 ? 0.0 : cell.positionSum.y / count, }; + if (state.nearestConnectionId.empty()) { + const auto nearest = nearestConnectionForPoint(center, cell.floorId); + state.nearestConnectionId = nearest.first; + state.nearestConnectionLabel = nearest.second; + } const auto durationSeconds = std::max(0.0, (clock.elapsedSeconds - state.startedAtSeconds) + deltaSeconds); - snapshot.crossFlowAgentCount += observation->movingAgentCount; - snapshot.peakCrossFlowScore = std::max(snapshot.peakCrossFlowScore, observation->crossFlowScore); - snapshot.crossFlowCells.push_back({ + snapshot.conflictAgentCount += observation->movingAgentCount; + snapshot.peakConflictScore = std::max(snapshot.peakConflictScore, observation->conflictScore); + snapshot.operationalConflictCells.push_back({ .center = center, .cellMin = cell.cellMin, .cellMax = cell.cellMax, .floorId = cell.floorId, .movingAgentCount = observation->movingAgentCount, .peakAgentCount = state.peakAgentCount, - .primaryFlowCount = observation->primaryFlowCount, - .crossFlowCount = observation->crossFlowCount, - .crossFlowRatio = observation->crossFlowRatio, + .forwardCount = observation->forwardCount, + .reverseCount = observation->reverseCount, + .counterflowRatio = observation->counterflowRatio, .averageSpeed = observation->averageSpeed, .speedDropRatio = observation->speedDropRatio, - .crossFlowScore = state.peakCrossFlowScore, + .conflictScore = state.peakConflictScore, .durationSeconds = durationSeconds, .exposureAgentSeconds = state.exposureAgentSeconds, + .nearestConnectionId = state.nearestConnectionId, + .nearestConnectionLabel = state.nearestConnectionLabel, + .oppositionScore = observation->oppositionScore, }); } - for (auto it = crossFlow.activeCellsByAddress.begin(); - it != crossFlow.activeCellsByAddress.end();) { + for (auto it = operationalConflict.activeCellsByAddress.begin(); + it != operationalConflict.activeCellsByAddress.end();) { if (activeCellKeys.contains(it->first)) { ++it; continue; } - it = crossFlow.activeCellsByAddress.erase(it); + it = operationalConflict.activeCellsByAddress.erase(it); + } + + const auto connectionObservations = collectConnectionFlowObservations( + activeAgents, + spatialIndex, + layout, + kScenarioOperationalConflictInfluenceRadius); + std::unordered_set observedConnectionIds; + observedConnectionIds.reserve(connectionObservations.size()); + for (const auto& observation : connectionObservations) { + if (observation.connection == nullptr) { + continue; + } + observedConnectionIds.insert(observation.connectionId); + + auto& state = operationalConflict.connectionsById[observation.connectionId]; + state.connectionId = observation.connectionId; + state.label = observation.label; + state.floorId = observation.floorId; + state.passage = observation.passage; + + if (observation.nearbyAgentCount > 0) { + state.observedSpeedSum += observation.averageSpeed; + ++state.observedSpeedSamples; + } + if (observation.queueAgentCount > 0) { + state.queueExposureAgentSeconds += + static_cast(observation.queueAgentCount) * std::max(0.0, deltaSeconds); + } + state.currentQueueAgents = observation.queueAgentCount; + if (observation.queueAgentCount > state.peakQueuedAgents) { + state.peakQueuedAgents = observation.queueAgentCount; + state.peakQueuedAtSeconds = clock.elapsedSeconds; + } + + const auto directionalCount = observation.forwardCount + observation.reverseCount; + if (!connectionObservationHasOperationalConflict(observation)) { + state.conflictActive = false; + continue; + } + + if (!state.conflictActive) { + state.conflictActive = true; + state.conflictStartedAtSeconds = clock.elapsedSeconds; + ++state.counterflowEventCount; + } + const auto durationSeconds = + std::max(0.0, (clock.elapsedSeconds - state.conflictStartedAtSeconds) + deltaSeconds); + state.peakConflictScore = std::max(state.peakConflictScore, observation.conflictScore); + state.longestConflictDurationSeconds = + std::max(state.longestConflictDurationSeconds, durationSeconds); + state.counterflowExposureAgentSeconds += + static_cast(directionalCount) * std::max(0.0, deltaSeconds); + + snapshot.peakConflictScore = std::max(snapshot.peakConflictScore, observation.conflictScore); + snapshot.totalConflictExposureAgentSeconds += state.counterflowExposureAgentSeconds; + snapshot.operationalConflictConnections.push_back({ + .connectionId = state.connectionId, + .label = state.label, + .floorId = state.floorId, + .passage = state.passage, + .nearbyAgentCount = observation.nearbyAgentCount, + .movingAgentCount = directionalCount, + .queueAgentCount = observation.queueAgentCount, + .forwardCount = observation.forwardCount, + .reverseCount = observation.reverseCount, + .counterflowRatio = observation.counterflowRatio, + .averageSpeed = observation.averageSpeed, + .speedDropRatio = observation.speedDropRatio, + .conflictScore = state.peakConflictScore, + .durationSeconds = durationSeconds, + .exposureAgentSeconds = state.counterflowExposureAgentSeconds, + .oppositionScore = observation.oppositionScore, + }); + } + for (auto& [connectionId, state] : operationalConflict.connectionsById) { + if (observedConnectionIds.contains(connectionId)) { + continue; + } + state.conflictActive = false; + state.currentQueueAgents = 0; } - sortAndTrimTop(snapshot.crossFlowCells, kMaxReportedCrossFlowCells, [](const auto& lhs, const auto& rhs) { - if (std::fabs(lhs.crossFlowScore - rhs.crossFlowScore) > 1e-9) { - return lhs.crossFlowScore > rhs.crossFlowScore; + sortAndTrimTop(snapshot.operationalConflictCells, kMaxReportedOperationalConflictCells, [](const auto& lhs, const auto& rhs) { + if (std::fabs(lhs.conflictScore - rhs.conflictScore) > 1e-9) { + return lhs.conflictScore > rhs.conflictScore; } if (std::fabs(lhs.durationSeconds - rhs.durationSeconds) > 1e-9) { return lhs.durationSeconds > rhs.durationSeconds; } return lhs.movingAgentCount > rhs.movingAgentCount; }); - snapshot.totalCrossFlowExposureAgentSeconds = crossFlow.totalCrossFlowExposureAgentSeconds; + sortAndTrimTop(snapshot.operationalConflictConnections, kMaxReportedOperationalConflictConnections, [](const auto& lhs, const auto& rhs) { + if (std::fabs(lhs.conflictScore - rhs.conflictScore) > 1e-9) { + return lhs.conflictScore > rhs.conflictScore; + } + if (std::fabs(lhs.durationSeconds - rhs.durationSeconds) > 1e-9) { + return lhs.durationSeconds > rhs.durationSeconds; + } + return lhs.nearbyAgentCount > rhs.nearbyAgentCount; + }); + snapshot.totalConflictExposureAgentSeconds = std::max( + snapshot.totalConflictExposureAgentSeconds, + operationalConflict.totalConflictExposureAgentSeconds); } FacilityLayout2D layout_{}; diff --git a/src/domain/ScenarioSimulationSystems.cpp b/src/domain/ScenarioSimulationSystems.cpp index 84ec7f6..273b961 100644 --- a/src/domain/ScenarioSimulationSystems.cpp +++ b/src/domain/ScenarioSimulationSystems.cpp @@ -1768,51 +1768,83 @@ void ScenarioResultArtifactsSystem::update(engine::EngineWorld& world, const eng }); } - result.artifacts.crossFlowSummary = {}; - if (resources.contains()) { - const auto& crossFlow = resources.get(); - result.artifacts.crossFlowSummary.totalCrossFlowExposureAgentSeconds = - crossFlow.totalCrossFlowExposureAgentSeconds; + result.artifacts.operationalConflictSummary = {}; + if (resources.contains()) { + const auto& operationalConflict = resources.get(); + result.artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds = + operationalConflict.totalConflictExposureAgentSeconds; + for (const auto& [_, state] : operationalConflict.connectionsById) { + auto& summary = result.artifacts.operationalConflictSummary; + summary.totalConflictExposureAgentSeconds = + std::max(summary.totalConflictExposureAgentSeconds, state.counterflowExposureAgentSeconds); + summary.longestConflictDurationSeconds = + std::max(summary.longestConflictDurationSeconds, state.longestConflictDurationSeconds); + if (state.peakConflictScore > summary.peakConflictScore + 1e-9) { + summary.peakConflictScore = state.peakConflictScore; + summary.topConflictConnectionId = state.connectionId; + summary.topConflictConnectionLabel = state.label; + } + } } if (resources.contains()) { const auto& metrics = resources.get(); - auto& summary = result.artifacts.crossFlowSummary; - summary.peakCrossFlowScore = + auto& summary = result.artifacts.operationalConflictSummary; + summary.peakConflictScore = std::max( - summary.peakCrossFlowScore, - metrics.peakSnapshot.peakCrossFlowScore); - summary.totalCrossFlowExposureAgentSeconds = + summary.peakConflictScore, + metrics.peakSnapshot.peakConflictScore); + summary.totalConflictExposureAgentSeconds = std::max( - summary.totalCrossFlowExposureAgentSeconds, - metrics.peakSnapshot.totalCrossFlowExposureAgentSeconds); - summary.crossFlowHotspotCount = + summary.totalConflictExposureAgentSeconds, + metrics.peakSnapshot.totalConflictExposureAgentSeconds); + summary.conflictConnectionCount = std::max( - summary.crossFlowHotspotCount, - metrics.peakSnapshot.crossFlowCells.size()); - - std::optional peakCrossFlowScore; - std::optional peakCrossFlowAtSeconds; - for (const auto& cell : metrics.peakSnapshot.crossFlowCells) { - summary.longestCrossFlowDurationSeconds = - std::max(summary.longestCrossFlowDurationSeconds, cell.durationSeconds); - if (!peakCrossFlowScore.has_value() - || cell.crossFlowScore > *peakCrossFlowScore + 1e-9) { - peakCrossFlowScore = cell.crossFlowScore; - peakCrossFlowAtSeconds = cell.detectedAtSeconds; + summary.conflictConnectionCount, + metrics.peakSnapshot.operationalConflictConnections.size()); + if (!metrics.peakSnapshot.operationalConflictConnections.empty()) { + const auto& topConnection = metrics.peakSnapshot.operationalConflictConnections.front(); + summary.connectionConcentrationIndex = + std::max(summary.connectionConcentrationIndex, topConnection.counterflowRatio); + if (summary.topConflictConnectionId.empty() + || topConnection.conflictScore >= summary.peakConflictScore - 1e-9) { + summary.topConflictConnectionId = topConnection.connectionId; + summary.topConflictConnectionLabel = topConnection.label; + } + } + + std::optional peakConflictScore; + std::optional peakOperationalConflictAtSeconds; + for (const auto& cell : metrics.peakSnapshot.operationalConflictCells) { + summary.longestConflictDurationSeconds = + std::max(summary.longestConflictDurationSeconds, cell.durationSeconds); + if (!peakConflictScore.has_value() + || cell.conflictScore > *peakConflictScore + 1e-9) { + peakConflictScore = cell.conflictScore; + peakOperationalConflictAtSeconds = cell.detectedAtSeconds; + } + } + for (const auto& connection : metrics.peakSnapshot.operationalConflictConnections) { + summary.longestConflictDurationSeconds = + std::max(summary.longestConflictDurationSeconds, connection.durationSeconds); + if (!peakConflictScore.has_value() + || connection.conflictScore > *peakConflictScore + 1e-9) { + peakConflictScore = connection.conflictScore; + peakOperationalConflictAtSeconds = connection.detectedAtSeconds; } } - if (peakCrossFlowScore.has_value()) { - summary.peakAtSeconds = peakCrossFlowAtSeconds; + if (peakConflictScore.has_value()) { + summary.peakAtSeconds = peakOperationalConflictAtSeconds; } - if (result.artifacts.crossFlowTimeline.empty() + if (result.artifacts.operationalConflictTimeline.empty() || std::abs( - result.artifacts.crossFlowTimeline.back().timeSeconds - elapsedSeconds) > 1e-9) { - result.artifacts.crossFlowTimeline.push_back({ + result.artifacts.operationalConflictTimeline.back().timeSeconds - elapsedSeconds) > 1e-9) { + result.artifacts.operationalConflictTimeline.push_back({ .timeSeconds = elapsedSeconds, - .peakCrossFlowScore = metrics.snapshot.peakCrossFlowScore, - .activeCrossFlowCellCount = metrics.snapshot.crossFlowCells.size(), + .peakConflictScore = metrics.snapshot.peakConflictScore, + .activeConflictCellCount = metrics.snapshot.operationalConflictCells.size(), + .activeConflictConnectionCount = metrics.snapshot.operationalConflictConnections.size(), }); } } diff --git a/src/domain/ScenarioSimulationSystems.h b/src/domain/ScenarioSimulationSystems.h index 17be99f..b15fbdf 100644 --- a/src/domain/ScenarioSimulationSystems.h +++ b/src/domain/ScenarioSimulationSystems.h @@ -137,16 +137,47 @@ struct ScenarioHazardExposureResource { std::unordered_map hazardsById{}; }; -struct ScenarioCrossFlowCellState { +struct ScenarioOperationalConflictConnectionState { + std::string connectionId{}; + std::string label{}; + std::string floorId{}; + LineSegment2D passage{}; + std::size_t traversalCount{0}; + std::size_t forwardTraversals{0}; + std::size_t reverseTraversals{0}; + std::size_t peakWindowCount{0}; + double currentWindowStartSeconds{0.0}; + std::size_t currentWindowCount{0}; + std::optional peakWindowAtSeconds{}; + double queueExposureAgentSeconds{0.0}; + std::size_t peakQueuedAgents{0}; + std::size_t currentQueueAgents{0}; + std::optional peakQueuedAtSeconds{}; + double observedSpeedSum{0.0}; + std::size_t observedSpeedSamples{0}; + double peakConflictScore{0.0}; + double longestConflictDurationSeconds{0.0}; + std::size_t counterflowEventCount{0}; + double counterflowExposureAgentSeconds{0.0}; + bool conflictActive{false}; + double conflictStartedAtSeconds{0.0}; +}; + +struct ScenarioOperationalConflictCellState { double startedAtSeconds{0.0}; double exposureAgentSeconds{0.0}; - double peakCrossFlowScore{0.0}; + double peakConflictScore{0.0}; std::size_t peakAgentCount{0}; + std::string nearestConnectionId{}; + std::string nearestConnectionLabel{}; }; -struct ScenarioCrossFlowResource { - double totalCrossFlowExposureAgentSeconds{0.0}; - std::unordered_map activeCellsByAddress{}; +struct ScenarioOperationalConflictResource { + bool hasPreviousElapsedSeconds{false}; + double previousElapsedSeconds{0.0}; + double totalConflictExposureAgentSeconds{0.0}; + std::unordered_map activeCellsByAddress{}; + std::unordered_map connectionsById{}; }; struct ScenarioResultArtifactsResource { diff --git a/tests/AlternativeRecommendationServiceTests.cpp b/tests/AlternativeRecommendationServiceTests.cpp index c0c45c9..8104ec5 100644 --- a/tests/AlternativeRecommendationServiceTests.cpp +++ b/tests/AlternativeRecommendationServiceTests.cpp @@ -138,36 +138,60 @@ ScenarioResultArtifacts makeExitUsageArtifacts(double mainRatio = 0.85, double e return artifacts; } -ScenarioRiskSnapshot makeCrossFlowRisk() { +ScenarioRiskSnapshot makeOperationalConflictRisk() { ScenarioRiskSnapshot risk; - risk.peakCrossFlowScore = 0.78; - risk.totalCrossFlowExposureAgentSeconds = 22.5; - risk.crossFlowAgentCount = 7; - risk.crossFlowCells.push_back({ + risk.peakConflictScore = 0.78; + risk.totalConflictExposureAgentSeconds = 22.5; + risk.conflictAgentCount = 7; + risk.operationalConflictCells.push_back({ .center = {.x = 1.0, .y = 0.5}, .cellMin = {.x = 0.0, .y = 0.0}, .cellMax = {.x = 2.0, .y = 2.0}, .floorId = "L1", .movingAgentCount = 7, .peakAgentCount = 7, - .primaryFlowCount = 4, - .crossFlowCount = 3, - .crossFlowRatio = 3.0 / 7.0, + .forwardCount = 4, + .reverseCount = 3, + .counterflowRatio = 3.0 / 7.0, .averageSpeed = 0.55, .speedDropRatio = 0.58, - .crossFlowScore = 0.78, + .conflictScore = 0.78, .durationSeconds = 14.0, .exposureAgentSeconds = 22.5, + .nearestConnectionId = "door-main", + .nearestConnectionLabel = "Main Door", + .oppositionScore = 0.86, + }); + risk.operationalConflictConnections.push_back({ + .connectionId = "door-main", + .label = "Main Door", + .floorId = "L1", + .passage = {.start = {.x = 2.0, .y = 0.0}, .end = {.x = 2.0, .y = 1.0}}, + .nearbyAgentCount = 7, + .movingAgentCount = 7, + .queueAgentCount = 2, + .forwardCount = 4, + .reverseCount = 3, + .counterflowRatio = 3.0 / 7.0, + .averageSpeed = 0.55, + .speedDropRatio = 0.58, + .conflictScore = 0.78, + .durationSeconds = 14.0, + .exposureAgentSeconds = 22.5, + .oppositionScore = 0.86, }); return risk; } -ScenarioResultArtifacts makeCrossFlowArtifacts() { +ScenarioResultArtifacts makeOperationalConflictArtifacts() { ScenarioResultArtifacts artifacts = makeCompletedArtifacts(); - artifacts.crossFlowSummary.peakCrossFlowScore = 0.78; - artifacts.crossFlowSummary.totalCrossFlowExposureAgentSeconds = 22.5; - artifacts.crossFlowSummary.longestCrossFlowDurationSeconds = 14.0; - artifacts.crossFlowSummary.crossFlowHotspotCount = 1; + artifacts.operationalConflictSummary.peakConflictScore = 0.78; + artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds = 22.5; + artifacts.operationalConflictSummary.longestConflictDurationSeconds = 14.0; + artifacts.operationalConflictSummary.conflictConnectionCount = 1; + artifacts.operationalConflictSummary.connectionConcentrationIndex = 0.62; + artifacts.operationalConflictSummary.topConflictConnectionId = "door-main"; + artifacts.operationalConflictSummary.topConflictConnectionLabel = "Main Door"; return artifacts; } @@ -783,52 +807,52 @@ SC_TEST(AlternativeRecommendationService_doesNotAddOneWayOperationForCorridorBot SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::CorridorOneWayFlow)); } -SC_TEST(AlternativeRecommendationService_suppressesOneWayOperationForCrossFlow) { +SC_TEST(AlternativeRecommendationService_suppressesOneWayOperationForOperationalConflict) { const AlternativeRecommendationService service; const auto result = service.recommend({ .layout = makeRecommendationLayout(), .sourceScenario = makeScenario(), - .risk = makeCrossFlowRisk(), - .artifacts = makeCrossFlowArtifacts(), + .risk = makeOperationalConflictRisk(), + .artifacts = makeOperationalConflictArtifacts(), }); - SC_EXPECT_TRUE(hasRiskSignalKind(result, AlternativeRecommendationRiskKind::CrossFlow)); + SC_EXPECT_TRUE(hasRiskSignalKind(result, AlternativeRecommendationRiskKind::OperationalConflict)); SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::CorridorOneWayFlow)); - SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::CrossFlowSeparation)); + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::OperationalConflictSeparation)); } -SC_TEST(AlternativeRecommendationService_reportsCrossFlowRiskWithoutManualOnlyDraft) { +SC_TEST(AlternativeRecommendationService_reportsOperationalConflictRiskWithoutManualOnlyDraft) { const AlternativeRecommendationService service; const auto result = service.recommend({ .layout = makeRecommendationLayout(), .sourceScenario = makeScenario(), - .risk = makeCrossFlowRisk(), - .artifacts = makeCrossFlowArtifacts(), + .risk = makeOperationalConflictRisk(), + .artifacts = makeOperationalConflictArtifacts(), }); const auto signalIt = std::find_if(result.riskSignals.begin(), result.riskSignals.end(), [](const auto& signal) { - return signal.kind == AlternativeRecommendationRiskKind::CrossFlow; + return signal.kind == AlternativeRecommendationRiskKind::OperationalConflict; }); SC_EXPECT_TRUE(signalIt != result.riskSignals.end()); - SC_EXPECT_TRUE(containsEvidenceSource(*signalIt, "ScenarioRiskSnapshot.crossFlowCells")); - SC_EXPECT_TRUE(containsEvidenceSource(*signalIt, "ScenarioResultArtifacts.crossFlowSummary")); - SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::CrossFlowSeparation)); + SC_EXPECT_TRUE(containsEvidenceSource(*signalIt, "ScenarioRiskSnapshot.operationalConflictConnections")); + SC_EXPECT_TRUE(containsEvidenceSource(*signalIt, "ScenarioResultArtifacts.operationalConflictSummary")); + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::OperationalConflictSeparation)); } -SC_TEST(AlternativeRecommendationService_convertsCrossFlowToRouteGuidanceWhenExitDataAllows) { - auto artifacts = makeCrossFlowArtifacts(); +SC_TEST(AlternativeRecommendationService_convertsOperationalConflictToRouteGuidanceWhenExitDataAllows) { + auto artifacts = makeOperationalConflictArtifacts(); artifacts.exitUsage = makeExitUsageArtifacts(0.55, 0.45).exitUsage; const AlternativeRecommendationService service; const auto result = service.recommend({ .layout = makeRecommendationLayout(), .sourceScenario = makeScenario(), - .risk = makeCrossFlowRisk(), + .risk = makeOperationalConflictRisk(), .artifacts = artifacts, }); const auto it = std::find_if(result.candidates.begin(), result.candidates.end(), [](const auto& candidate) { - return candidate.kind == AlternativeRecommendationKind::CrossFlowSeparation; + return candidate.kind == AlternativeRecommendationKind::OperationalConflictSeparation; }); SC_EXPECT_TRUE(it != result.candidates.end()); SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.size(), std::size_t{1}); @@ -842,7 +866,7 @@ SC_TEST(AlternativeRecommendationService_convertsCrossFlowToRouteGuidanceWhenExi SC_EXPECT_TRUE(containsDiffKey(it->recommendedScenario, "control.routeGuidances")); } -SC_TEST(AlternativeRecommendationService_convertsCrossFlowToStagedReleaseWhenNoExitUsageExists) { +SC_TEST(AlternativeRecommendationService_convertsOperationalConflictToStagedReleaseWhenNoExitUsageExists) { auto scenario = makeScenario(); auto second = scenario.population.initialPlacements.front(); second.id = "group-b"; @@ -853,12 +877,12 @@ SC_TEST(AlternativeRecommendationService_convertsCrossFlowToStagedReleaseWhenNoE const auto result = service.recommend({ .layout = makeRecommendationLayout(), .sourceScenario = scenario, - .risk = makeCrossFlowRisk(), - .artifacts = makeCrossFlowArtifacts(), + .risk = makeOperationalConflictRisk(), + .artifacts = makeOperationalConflictArtifacts(), }); const auto it = std::find_if(result.candidates.begin(), result.candidates.end(), [](const auto& candidate) { - return candidate.kind == AlternativeRecommendationKind::CrossFlowSeparation; + return candidate.kind == AlternativeRecommendationKind::OperationalConflictSeparation; }); SC_EXPECT_TRUE(it != result.candidates.end()); SC_EXPECT_TRUE(it->recommendedScenario.control.events.empty()); @@ -869,21 +893,21 @@ SC_TEST(AlternativeRecommendationService_convertsCrossFlowToStagedReleaseWhenNoE SC_EXPECT_TRUE(containsDiffKey(it->recommendedScenario, "population.placements")); } -SC_TEST(AlternativeRecommendationService_skipsManualCrossFlowFallbackWhenExitBalancingExists) { - auto artifacts = makeCrossFlowArtifacts(); +SC_TEST(AlternativeRecommendationService_skipsManualOperationalConflictFallbackWhenExitBalancingExists) { + auto artifacts = makeOperationalConflictArtifacts(); artifacts.exitUsage = makeExitUsageArtifacts(0.90, 0.10).exitUsage; const AlternativeRecommendationService service; const auto result = service.recommend({ .layout = makeRecommendationLayout(), .sourceScenario = makeScenario(), - .risk = makeCrossFlowRisk(), + .risk = makeOperationalConflictRisk(), .artifacts = artifacts, }); SC_EXPECT_EQ(result.candidates.size(), std::size_t{1}); SC_EXPECT_TRUE(result.candidates.front().kind == AlternativeRecommendationKind::ExitUsageBalancing); - SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::CrossFlowSeparation)); + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::OperationalConflictSeparation)); } SC_TEST(AlternativeRecommendationService_addsStagedEvacuationForMissedTimeLimit) { diff --git a/tests/ProjectPersistenceTests.cpp b/tests/ProjectPersistenceTests.cpp index 24d75a1..3922fab 100644 --- a/tests/ProjectPersistenceTests.cpp +++ b/tests/ProjectPersistenceTests.cpp @@ -664,43 +664,68 @@ SC_TEST(ProjectPersistence_preservesImportArtifactsBesideLayoutReview) { SC_EXPECT_EQ(loaded.artifacts.selectedRules.rules.front().tokens.front(), std::string{"A-WALL"}); } -SC_TEST(ProjectPersistence_preservesCrossFlowResultState) { +SC_TEST(ProjectPersistence_preservesOperationalConflictResultState) { QTemporaryDir projectDir; SC_EXPECT_TRUE(projectDir.isValid()); ScenarioDraft scenario; scenario.scenarioId = "scenario-cross-flow"; - scenario.name = "Cross Flow Scenario"; + scenario.name = "Operational Conflict Scenario"; ScenarioRiskSnapshot risk; - risk.peakCrossFlowScore = 0.74; - risk.totalCrossFlowExposureAgentSeconds = 19.5; - risk.crossFlowCells.push_back({ + risk.peakConflictScore = 0.74; + risk.totalConflictExposureAgentSeconds = 19.5; + risk.operationalConflictCells.push_back({ .center = {.x = 1.0, .y = 1.0}, .cellMin = {.x = 0.0, .y = 0.0}, .cellMax = {.x = 2.0, .y = 2.0}, .floorId = "L1", .movingAgentCount = 6, .peakAgentCount = 6, - .primaryFlowCount = 3, - .crossFlowCount = 3, - .crossFlowRatio = 0.5, + .forwardCount = 3, + .reverseCount = 3, + .counterflowRatio = 0.5, .averageSpeed = 0.58, .speedDropRatio = 0.55, - .crossFlowScore = 0.74, + .conflictScore = 0.74, .durationSeconds = 10.0, .exposureAgentSeconds = 19.5, + .nearestConnectionId = "door-main", + .nearestConnectionLabel = "Main Door", + .oppositionScore = 1.0, + }); + risk.operationalConflictConnections.push_back({ + .connectionId = "door-main", + .label = "Main Door", + .floorId = "L1", + .passage = {.start = {.x = 2.0, .y = 0.0}, .end = {.x = 2.0, .y = 1.0}}, + .nearbyAgentCount = 6, + .movingAgentCount = 6, + .queueAgentCount = 2, + .forwardCount = 3, + .reverseCount = 3, + .counterflowRatio = 0.5, + .averageSpeed = 0.58, + .speedDropRatio = 0.55, + .conflictScore = 0.74, + .durationSeconds = 10.0, + .exposureAgentSeconds = 19.5, + .oppositionScore = 1.0, }); ScenarioResultArtifacts artifacts; - artifacts.crossFlowSummary.peakCrossFlowScore = 0.74; - artifacts.crossFlowSummary.totalCrossFlowExposureAgentSeconds = 19.5; - artifacts.crossFlowSummary.longestCrossFlowDurationSeconds = 10.0; - artifacts.crossFlowSummary.crossFlowHotspotCount = 1; - artifacts.crossFlowTimeline.push_back({ + artifacts.operationalConflictSummary.peakConflictScore = 0.74; + artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds = 19.5; + artifacts.operationalConflictSummary.longestConflictDurationSeconds = 10.0; + artifacts.operationalConflictSummary.conflictConnectionCount = 1; + artifacts.operationalConflictSummary.connectionConcentrationIndex = 0.5; + artifacts.operationalConflictSummary.topConflictConnectionId = "door-main"; + artifacts.operationalConflictSummary.topConflictConnectionLabel = "Main Door"; + artifacts.operationalConflictTimeline.push_back({ .timeSeconds = 12.0, - .peakCrossFlowScore = 0.74, - .activeCrossFlowCellCount = 1, + .peakConflictScore = 0.74, + .activeConflictCellCount = 1, + .activeConflictConnectionCount = 1, }); ProjectWorkspaceState workspace; @@ -709,11 +734,11 @@ SC_TEST(ProjectPersistence_preservesCrossFlowResultState) { .scenario = scenario, .risk = risk, .artifacts = artifacts, - .navigationView = SavedResultNavigationView::CrossFlow, + .navigationView = SavedResultNavigationView::OperationalConflict, }; const ProjectMetadata metadata{ - .name = "Cross Flow Persistence", + .name = "Operational Conflict Persistence", .folderPath = projectDir.path(), }; @@ -723,10 +748,14 @@ SC_TEST(ProjectPersistence_preservesCrossFlowResultState) { ProjectWorkspaceState loaded; SC_EXPECT_TRUE(ProjectPersistence::loadProjectWorkspace(metadata, &loaded)); SC_EXPECT_TRUE(loaded.result.has_value()); - SC_EXPECT_TRUE(loaded.result->navigationView == SavedResultNavigationView::CrossFlow); - SC_EXPECT_EQ(loaded.result->risk.crossFlowCells.size(), std::size_t{1}); - SC_EXPECT_NEAR(loaded.result->artifacts.crossFlowSummary.peakCrossFlowScore, 0.74, 1e-9); - SC_EXPECT_EQ(loaded.result->artifacts.crossFlowSummary.crossFlowHotspotCount, std::size_t{1}); - SC_EXPECT_EQ(loaded.result->artifacts.crossFlowTimeline.size(), std::size_t{1}); - SC_EXPECT_EQ(loaded.result->artifacts.crossFlowTimeline.front().activeCrossFlowCellCount, std::size_t{1}); + SC_EXPECT_TRUE(loaded.result->navigationView == SavedResultNavigationView::OperationalConflict); + SC_EXPECT_EQ(loaded.result->risk.operationalConflictCells.size(), std::size_t{1}); + SC_EXPECT_EQ(loaded.result->risk.operationalConflictConnections.size(), std::size_t{1}); + SC_EXPECT_NEAR(loaded.result->risk.operationalConflictCells.front().oppositionScore, 1.0, 1e-9); + SC_EXPECT_NEAR(loaded.result->risk.operationalConflictConnections.front().oppositionScore, 1.0, 1e-9); + SC_EXPECT_NEAR(loaded.result->artifacts.operationalConflictSummary.peakConflictScore, 0.74, 1e-9); + SC_EXPECT_EQ(loaded.result->artifacts.operationalConflictSummary.conflictConnectionCount, std::size_t{1}); + SC_EXPECT_EQ(loaded.result->artifacts.operationalConflictTimeline.size(), std::size_t{1}); + SC_EXPECT_EQ(loaded.result->artifacts.operationalConflictTimeline.front().activeConflictCellCount, std::size_t{1}); + SC_EXPECT_EQ(loaded.result->artifacts.operationalConflictTimeline.front().activeConflictConnectionCount, std::size_t{1}); } diff --git a/tests/ScenarioSimulationSystemsTests.cpp b/tests/ScenarioSimulationSystemsTests.cpp index 038855a..3e5f7f2 100644 --- a/tests/ScenarioSimulationSystemsTests.cpp +++ b/tests/ScenarioSimulationSystemsTests.cpp @@ -294,7 +294,7 @@ class ConfigureDensePersonalSpaceAgentsSystem final : public safecrowd::engine:: } }; -class ConfigureCrossFlowArtifactsSystem final : public safecrowd::engine::EngineSystem { +class ConfigureOperationalConflictArtifactsSystem final : public safecrowd::engine::EngineSystem { public: void configure(safecrowd::engine::EngineWorld& world) override { world.resources().set(safecrowd::domain::ScenarioSimulationClockResource{ @@ -304,31 +304,51 @@ class ConfigureCrossFlowArtifactsSystem final : public safecrowd::engine::Engine }); safecrowd::domain::ScenarioRiskMetricsResource metrics; - metrics.snapshot.peakCrossFlowScore = 0.95; - metrics.snapshot.totalCrossFlowExposureAgentSeconds = 18.0; - metrics.snapshot.crossFlowCells.push_back({ + metrics.snapshot.peakConflictScore = 0.95; + metrics.snapshot.totalConflictExposureAgentSeconds = 18.0; + metrics.snapshot.operationalConflictCells.push_back({ .center = {.x = 1.0, .y = 1.0}, .cellMin = {.x = 0.0, .y = 0.0}, .cellMax = {.x = 2.0, .y = 2.0}, .floorId = "L1", .movingAgentCount = 6, .peakAgentCount = 6, - .primaryFlowCount = 3, - .crossFlowCount = 3, - .crossFlowRatio = 0.5, + .forwardCount = 3, + .reverseCount = 3, + .counterflowRatio = 0.5, .averageSpeed = 0.6, .speedDropRatio = 0.54, - .crossFlowScore = 0.95, + .conflictScore = 0.95, .durationSeconds = 11.0, .exposureAgentSeconds = 18.0, .detectedAtSeconds = 12.0, + .oppositionScore = 1.0, + }); + metrics.snapshot.operationalConflictConnections.push_back({ + .connectionId = "door-main", + .label = "Main Door", + .floorId = "L1", + .passage = {.start = {.x = 2.0, .y = 0.0}, .end = {.x = 2.0, .y = 1.0}}, + .nearbyAgentCount = 6, + .movingAgentCount = 6, + .queueAgentCount = 2, + .forwardCount = 3, + .reverseCount = 3, + .counterflowRatio = 0.5, + .averageSpeed = 0.6, + .speedDropRatio = 0.54, + .conflictScore = 0.95, + .durationSeconds = 11.0, + .exposureAgentSeconds = 18.0, + .detectedAtSeconds = 12.0, + .oppositionScore = 1.0, }); metrics.peakSnapshot = metrics.snapshot; world.resources().set(std::move(metrics)); - safecrowd::domain::ScenarioCrossFlowResource crossFlow; - crossFlow.totalCrossFlowExposureAgentSeconds = 18.0; - world.resources().set(std::move(crossFlow)); + safecrowd::domain::ScenarioOperationalConflictResource operationalConflict; + operationalConflict.totalConflictExposureAgentSeconds = 18.0; + world.resources().set(std::move(operationalConflict)); } void update(safecrowd::engine::EngineWorld&, const safecrowd::engine::EngineStepContext&) override { @@ -4638,6 +4658,239 @@ SC_TEST(ScenarioRiskMetricsSystem_PublishesStalledHotspotAndBottleneckMetrics) { SC_EXPECT_EQ(snapshot.bottlenecks.front().label, std::string{"Room -> Exit"}); } +SC_TEST(ScenarioRiskMetricsSystem_ClassifiesOneWayDoorQueueAsBottleneckOnly) { + std::vector seeds; + for (int index = 0; index < 6; ++index) { + const auto y = -0.25 + (static_cast(index) * 0.1); + seeds.push_back({ + .position = {.value = {.x = 0.78 + (static_cast(index) * 0.02), .y = y}}, + .agent = {.radius = 0.25f, .maxSpeed = 1.0f}, + .velocity = {.value = {}}, + .route = { + .waypoints = {{.x = 1.0, .y = y}}, + .waypointPassages = {{{.x = 1.0, .y = -0.4}, {.x = 1.0, .y = 0.4}}}, + .waypointFromZoneIds = {"room"}, + .waypointZoneIds = {"exit"}, + .waypointConnectionIds = {"room-exit"}, + .nextWaypointIndex = 0, + .currentSegmentStart = {.x = 0.78, .y = y}, + .previousDistanceToWaypoint = 0.22, + .stalledSeconds = 1.0, + .destinationZoneId = "exit", + }, + .status = {}, + }); + } + + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 53, + }); + runtime.addSystem(std::make_unique(std::move(seeds), 10.0)); + runtime.addSystem( + safecrowd::domain::makeScenarioRiskMetricsSystem(straightExitLayout()), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + + runtime.play(); + runtime.stepFrame(0.0); + + const auto& snapshot = + runtime.world().resources().get().snapshot; + SC_EXPECT_EQ(snapshot.bottlenecks.size(), std::size_t{1}); + SC_EXPECT_TRUE(snapshot.operationalConflictCells.empty()); + SC_EXPECT_TRUE(snapshot.operationalConflictConnections.empty()); +} + +SC_TEST(ScenarioRiskMetricsSystem_ClassifiesStoppedBidirectionalIntentAsOperationalConflict) { + std::vector seeds; + for (int index = 0; index < 3; ++index) { + const auto y = -0.15 + (static_cast(index) * 0.15); + seeds.push_back({ + .position = {.value = {.x = 0.85, .y = y}}, + .agent = {.radius = 0.25f, .maxSpeed = 1.0f}, + .velocity = {.value = {}}, + .route = { + .waypoints = {{.x = 1.0, .y = y}}, + .waypointPassages = {{{.x = 1.0, .y = -0.4}, {.x = 1.0, .y = 0.4}}}, + .waypointFromZoneIds = {"room"}, + .waypointZoneIds = {"exit"}, + .waypointConnectionIds = {"room-exit"}, + .nextWaypointIndex = 0, + .currentSegmentStart = {.x = 0.85, .y = y}, + .previousDistanceToWaypoint = 0.15, + .stalledSeconds = 1.0, + .destinationZoneId = "exit", + }, + .status = {}, + }); + seeds.push_back({ + .position = {.value = {.x = 1.15, .y = y}}, + .agent = {.radius = 0.25f, .maxSpeed = 1.0f}, + .velocity = {.value = {}}, + .route = { + .waypoints = {{.x = 1.0, .y = y}}, + .waypointPassages = {{{.x = 1.0, .y = -0.4}, {.x = 1.0, .y = 0.4}}}, + .waypointFromZoneIds = {"exit"}, + .waypointZoneIds = {"room"}, + .waypointConnectionIds = {"room-exit"}, + .nextWaypointIndex = 0, + .currentSegmentStart = {.x = 1.15, .y = y}, + .previousDistanceToWaypoint = 0.15, + .stalledSeconds = 1.0, + .destinationZoneId = "room", + }, + .status = {}, + }); + } + + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 54, + }); + runtime.addSystem(std::make_unique(std::move(seeds), 10.0)); + runtime.addSystem( + safecrowd::domain::makeScenarioRiskMetricsSystem(straightExitLayout()), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + + runtime.play(); + runtime.stepFrame(0.0); + + const auto& snapshot = + runtime.world().resources().get().snapshot; + SC_EXPECT_EQ(snapshot.operationalConflictConnections.size(), std::size_t{1}); + SC_EXPECT_TRUE(!snapshot.operationalConflictCells.empty()); + SC_EXPECT_EQ(snapshot.operationalConflictConnections.front().forwardCount, std::size_t{3}); + SC_EXPECT_EQ(snapshot.operationalConflictConnections.front().reverseCount, std::size_t{3}); + SC_EXPECT_NEAR(snapshot.operationalConflictConnections.front().counterflowRatio, 0.5, 1e-9); + SC_EXPECT_TRUE(snapshot.operationalConflictConnections.front().oppositionScore > 0.9); + SC_EXPECT_TRUE(snapshot.operationalConflictConnections.front().conflictScore > 0.9); + + auto& conflictState = + runtime.world().resources().get().connectionsById.at("room-exit"); + SC_EXPECT_TRUE(conflictState.conflictActive); + SC_EXPECT_EQ(conflictState.currentQueueAgents, std::size_t{6}); + SC_EXPECT_EQ(conflictState.counterflowEventCount, std::size_t{1}); + + auto& query = runtime.world().query(); + for (const auto entity : query.view< + safecrowd::domain::Position, + safecrowd::domain::EvacuationRoute, + safecrowd::domain::EvacuationStatus>()) { + auto& position = query.get(entity); + auto& route = query.get(entity); + position.value = {.x = 8.0 + static_cast(entity.index), .y = 8.0}; + route.currentFloorId.clear(); + route.displayFloorId.clear(); + } + auto& clock = runtime.world().resources().get(); + clock.elapsedSeconds = 2.0; + runtime.stepFrame(0.0); + + const auto& inactiveMetrics = + runtime.world().resources().get().snapshot; + const auto& inactiveState = + runtime.world().resources().get().connectionsById.at("room-exit"); + SC_EXPECT_TRUE(inactiveMetrics.operationalConflictConnections.empty()); + SC_EXPECT_TRUE(!inactiveState.conflictActive); + SC_EXPECT_EQ(inactiveState.currentQueueAgents, std::size_t{0}); + SC_EXPECT_EQ(inactiveState.counterflowEventCount, std::size_t{1}); + + int restoredIndex = 0; + for (const auto entity : query.view< + safecrowd::domain::Position, + safecrowd::domain::EvacuationRoute, + safecrowd::domain::EvacuationStatus>()) { + auto& position = query.get(entity); + auto& route = query.get(entity); + const auto pairIndex = restoredIndex / 2; + const auto y = -0.15 + (static_cast(pairIndex) * 0.15); + const bool forward = (restoredIndex % 2) == 0; + position.value = forward + ? safecrowd::domain::Point2D{.x = 0.85, .y = y} + : safecrowd::domain::Point2D{.x = 1.15, .y = y}; + route.currentFloorId.clear(); + route.displayFloorId.clear(); + ++restoredIndex; + } + clock.elapsedSeconds = 3.0; + runtime.stepFrame(0.0); + + const auto& reactivatedState = + runtime.world().resources().get().connectionsById.at("room-exit"); + SC_EXPECT_TRUE(reactivatedState.conflictActive); + SC_EXPECT_EQ(reactivatedState.currentQueueAgents, std::size_t{6}); + SC_EXPECT_EQ(reactivatedState.counterflowEventCount, std::size_t{2}); +} + +SC_TEST(ScenarioRiskMetricsSystem_DetectsMinorReverseFlowAtDoorAsOperationalConflict) { + std::vector seeds; + for (int index = 0; index < 3; ++index) { + const auto y = -0.18 + (static_cast(index) * 0.18); + seeds.push_back({ + .position = {.value = {.x = 0.86, .y = y}}, + .agent = {.radius = 0.25f, .maxSpeed = 1.0f}, + .velocity = {.value = {}}, + .route = { + .waypoints = {{.x = 1.0, .y = y}}, + .waypointPassages = {{{.x = 1.0, .y = -0.4}, {.x = 1.0, .y = 0.4}}}, + .waypointFromZoneIds = {"room"}, + .waypointZoneIds = {"exit"}, + .waypointConnectionIds = {"room-exit"}, + .nextWaypointIndex = 0, + .currentSegmentStart = {.x = 0.86, .y = y}, + .previousDistanceToWaypoint = 0.14, + .stalledSeconds = 1.0, + .destinationZoneId = "exit", + }, + .status = {}, + }); + } + seeds.push_back({ + .position = {.value = {.x = 1.14, .y = 0.0}}, + .agent = {.radius = 0.25f, .maxSpeed = 1.0f}, + .velocity = {.value = {}}, + .route = { + .waypoints = {{.x = 1.0, .y = 0.0}}, + .waypointPassages = {{{.x = 1.0, .y = -0.4}, {.x = 1.0, .y = 0.4}}}, + .waypointFromZoneIds = {"exit"}, + .waypointZoneIds = {"room"}, + .waypointConnectionIds = {"room-exit"}, + .nextWaypointIndex = 0, + .currentSegmentStart = {.x = 1.14, .y = 0.0}, + .previousDistanceToWaypoint = 0.14, + .stalledSeconds = 1.0, + .destinationZoneId = "room", + }, + .status = {}, + }); + + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 55, + }); + runtime.addSystem(std::make_unique(std::move(seeds), 10.0)); + runtime.addSystem( + safecrowd::domain::makeScenarioRiskMetricsSystem(straightExitLayout()), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + + runtime.play(); + runtime.stepFrame(0.0); + + const auto& snapshot = + runtime.world().resources().get().snapshot; + SC_EXPECT_EQ(snapshot.operationalConflictConnections.size(), std::size_t{1}); + SC_EXPECT_EQ(snapshot.operationalConflictConnections.front().forwardCount, std::size_t{3}); + SC_EXPECT_EQ(snapshot.operationalConflictConnections.front().reverseCount, std::size_t{1}); + SC_EXPECT_NEAR(snapshot.operationalConflictConnections.front().counterflowRatio, 0.25, 1e-9); + SC_EXPECT_TRUE(snapshot.operationalConflictConnections.front().oppositionScore >= 0.5); +} + SC_TEST(ScenarioRiskMetricsSystem_DoesNotPublishPressureHotspotsForLooseClusterInSameCell) { std::vector seeds; for (const auto& point : std::vector{ @@ -5244,13 +5497,13 @@ SC_TEST(ScenarioResultArtifactsSystem_AccumulatesPressurePeakFieldByFloorAndCell SC_EXPECT_TRUE(hasL2Cell); } -SC_TEST(ScenarioResultArtifactsSystem_PublishesCrossFlowSummary) { +SC_TEST(ScenarioResultArtifactsSystem_PublishesOperationalConflictSummary) { safecrowd::engine::EngineRuntime runtime({ .fixedDeltaTime = 1.0 / 30.0, .maxCatchUpSteps = 1, .baseSeed = 52, }); - runtime.addSystem(std::make_unique()); + runtime.addSystem(std::make_unique()); runtime.addSystem( std::make_unique(1.0), {.phase = safecrowd::engine::UpdatePhase::PostSimulation, @@ -5261,15 +5514,16 @@ SC_TEST(ScenarioResultArtifactsSystem_PublishesCrossFlowSummary) { const auto& artifacts = runtime.world().resources().get().artifacts; - SC_EXPECT_NEAR(artifacts.crossFlowSummary.peakCrossFlowScore, 0.95, 1e-9); - SC_EXPECT_NEAR(artifacts.crossFlowSummary.totalCrossFlowExposureAgentSeconds, 18.0, 1e-9); - SC_EXPECT_TRUE(artifacts.crossFlowSummary.peakAtSeconds.has_value()); - SC_EXPECT_NEAR(*artifacts.crossFlowSummary.peakAtSeconds, 12.0, 1e-9); - SC_EXPECT_NEAR(artifacts.crossFlowSummary.longestCrossFlowDurationSeconds, 11.0, 1e-9); - SC_EXPECT_EQ(artifacts.crossFlowSummary.crossFlowHotspotCount, std::size_t{1}); - SC_EXPECT_EQ(artifacts.crossFlowTimeline.size(), std::size_t{1}); - SC_EXPECT_NEAR(artifacts.crossFlowTimeline.front().peakCrossFlowScore, 0.95, 1e-9); - SC_EXPECT_EQ(artifacts.crossFlowTimeline.front().activeCrossFlowCellCount, std::size_t{1}); + SC_EXPECT_NEAR(artifacts.operationalConflictSummary.peakConflictScore, 0.95, 1e-9); + SC_EXPECT_NEAR(artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds, 18.0, 1e-9); + SC_EXPECT_TRUE(artifacts.operationalConflictSummary.peakAtSeconds.has_value()); + SC_EXPECT_NEAR(*artifacts.operationalConflictSummary.peakAtSeconds, 12.0, 1e-9); + SC_EXPECT_NEAR(artifacts.operationalConflictSummary.longestConflictDurationSeconds, 11.0, 1e-9); + SC_EXPECT_EQ(artifacts.operationalConflictSummary.conflictConnectionCount, std::size_t{1}); + SC_EXPECT_EQ(artifacts.operationalConflictTimeline.size(), std::size_t{1}); + SC_EXPECT_NEAR(artifacts.operationalConflictTimeline.front().peakConflictScore, 0.95, 1e-9); + SC_EXPECT_EQ(artifacts.operationalConflictTimeline.front().activeConflictCellCount, std::size_t{1}); + SC_EXPECT_EQ(artifacts.operationalConflictTimeline.front().activeConflictConnectionCount, std::size_t{1}); } SC_TEST(ScenarioRoutePassageCrossed_UsesDoorPlaneNearEndpoint) {