diff --git a/src/application/ProjectWorkspaceState.h b/src/application/ProjectWorkspaceState.h index dea70ff..daa29b1 100644 --- a/src/application/ProjectWorkspaceState.h +++ b/src/application/ProjectWorkspaceState.h @@ -36,6 +36,7 @@ enum class SavedResultNavigationView { Zone, Groups, Recommendations, + OperationalConflict, }; struct SavedScenarioState { diff --git a/src/application/ResultArtifactsCodec.cpp b/src/application/ResultArtifactsCodec.cpp index 6b89b96..6167e63 100644 --- a/src/application/ResultArtifactsCodec.cpp +++ b/src/application/ResultArtifactsCodec.cpp @@ -119,10 +119,205 @@ safecrowd::domain::ScenarioBottleneckMetric bottleneckFromJson(const QJsonObject return bottleneck; } +QJsonObject operationalConflictCellToJson(const safecrowd::domain::ScenarioOperationalConflictCellMetric& cell) { + QJsonObject object; + object["center"] = pointArray(cell.center); + object["cellMin"] = pointArray(cell.cellMin); + object["cellMax"] = pointArray(cell.cellMax); + object["floorId"] = QString::fromStdString(cell.floorId); + object["movingAgentCount"] = static_cast(cell.movingAgentCount); + object["peakAgentCount"] = static_cast(cell.peakAgentCount); + 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["conflictScore"] = cell.conflictScore; + object["durationSeconds"] = cell.durationSeconds; + object["exposureAgentSeconds"] = cell.exposureAgentSeconds; + object["nearestConnectionId"] = QString::fromStdString(cell.nearestConnectionId); + object["nearestConnectionLabel"] = QString::fromStdString(cell.nearestConnectionLabel); + object["detectedAtSeconds"] = optionalDoubleToJson(cell.detectedAtSeconds); + if (cell.detectionFrame.has_value()) { + object["detectionFrame"] = simulationFrameToJson(*cell.detectionFrame); + } + return object; +} + +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()), + .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(), + .nearestConnectionId = object.value("nearestConnectionId").toString().toStdString(), + .nearestConnectionLabel = object.value("nearestConnectionLabel").toString().toStdString(), + }; + cell.detectedAtSeconds = optionalDoubleFromJson(object.value("detectedAtSeconds")); + if (object.value("detectionFrame").isObject()) { + cell.detectionFrame = simulationFrameFromJson(object.value("detectionFrame").toObject()); + } + return cell; +} + +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["passage"] = lineToJson(connection.passage); + 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["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 = lineFromJson(object.value("passage").toObject()), + .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.detectedAtSeconds = optionalDoubleFromJson(object.value("detectedAtSeconds")); + if (object.value("detectionFrame").isObject()) { + connection.detectionFrame = simulationFrameFromJson(object.value("detectionFrame").toObject()); + } + return connection; +} + +QJsonObject connectionUsageMetricToJson(const safecrowd::domain::ConnectionUsageMetric& connection) { + QJsonObject object; + object["connectionId"] = QString::fromStdString(connection.connectionId); + object["label"] = QString::fromStdString(connection.label); + object["floorId"] = QString::fromStdString(connection.floorId); + object["traversalCount"] = static_cast(connection.traversalCount); + object["usageRatio"] = connection.usageRatio; + object["peakWindowCount"] = static_cast(connection.peakWindowCount); + object["peakAtSeconds"] = optionalDoubleToJson(connection.peakAtSeconds); + object["forwardTraversals"] = static_cast(connection.forwardTraversals); + object["reverseTraversals"] = static_cast(connection.reverseTraversals); + object["queueExposureAgentSeconds"] = connection.queueExposureAgentSeconds; + object["peakQueuedAgents"] = static_cast(connection.peakQueuedAgents); + object["averageObservedSpeed"] = connection.averageObservedSpeed; + object["peakConflictScore"] = connection.peakConflictScore; + object["longestConflictDurationSeconds"] = connection.longestConflictDurationSeconds; + object["counterflowEventCount"] = static_cast(connection.counterflowEventCount); + return object; +} + +safecrowd::domain::ConnectionUsageMetric connectionUsageMetricFromJson(const QJsonObject& object) { + safecrowd::domain::ConnectionUsageMetric connection; + connection.connectionId = object.value("connectionId").toString().toStdString(); + connection.label = object.value("label").toString().toStdString(); + connection.floorId = object.value("floorId").toString().toStdString(); + connection.traversalCount = static_cast(object.value("traversalCount").toInteger()); + connection.usageRatio = object.value("usageRatio").toDouble(); + connection.peakWindowCount = static_cast(object.value("peakWindowCount").toInteger()); + connection.peakAtSeconds = optionalDoubleFromJson(object.value("peakAtSeconds")); + connection.forwardTraversals = static_cast(object.value("forwardTraversals").toInteger()); + connection.reverseTraversals = static_cast(object.value("reverseTraversals").toInteger()); + connection.queueExposureAgentSeconds = object.value("queueExposureAgentSeconds").toDouble(); + connection.peakQueuedAgents = static_cast(object.value("peakQueuedAgents").toInteger()); + connection.averageObservedSpeed = object.value("averageObservedSpeed").toDouble(); + connection.peakConflictScore = object.value("peakConflictScore").toDouble(); + connection.longestConflictDurationSeconds = object.value("longestConflictDurationSeconds").toDouble(); + connection.counterflowEventCount = static_cast(object.value("counterflowEventCount").toInteger()); + return connection; +} + +QJsonObject operationalConflictTimelineSampleToJson(const safecrowd::domain::OperationalConflictTimelineSample& sample) { + QJsonObject object; + object["timeSeconds"] = sample.timeSeconds; + object["peakConflictScore"] = sample.peakConflictScore; + object["activeConflictCellCount"] = static_cast(sample.activeConflictCellCount); + object["activeConflictConnectionCount"] = static_cast(sample.activeConflictConnectionCount); + object["queuedAgentsNearConnections"] = static_cast(sample.queuedAgentsNearConnections); + return object; +} + +safecrowd::domain::OperationalConflictTimelineSample operationalConflictTimelineSampleFromJson(const QJsonObject& object) { + return { + .timeSeconds = object.value("timeSeconds").toDouble(), + .peakConflictScore = object.value("peakConflictScore").toDouble(), + .activeConflictCellCount = static_cast(object.value("activeConflictCellCount").toInteger()), + .activeConflictConnectionCount = static_cast(object.value("activeConflictConnectionCount").toInteger()), + .queuedAgentsNearConnections = static_cast(object.value("queuedAgentsNearConnections").toInteger()), + }; +} + +QJsonObject operationalConflictSummaryToJson(const safecrowd::domain::OperationalConflictSummary& summary) { + QJsonObject object; + object["peakConflictScore"] = summary.peakConflictScore; + object["peakAtSeconds"] = optionalDoubleToJson(summary.peakAtSeconds); + object["totalConflictExposureAgentSeconds"] = summary.totalConflictExposureAgentSeconds; + object["longestConflictDurationSeconds"] = summary.longestConflictDurationSeconds; + object["counterflowHotspotCount"] = static_cast(summary.counterflowHotspotCount); + object["conflictConnectionCount"] = static_cast(summary.conflictConnectionCount); + object["connectionConcentrationIndex"] = summary.connectionConcentrationIndex; + object["peakQueuedAgents"] = static_cast(summary.peakQueuedAgents); + object["topConflictConnectionId"] = QString::fromStdString(summary.topConflictConnectionId); + object["topConflictConnectionLabel"] = QString::fromStdString(summary.topConflictConnectionLabel); + return object; +} + +safecrowd::domain::OperationalConflictSummary operationalConflictSummaryFromJson(const QJsonObject& object) { + safecrowd::domain::OperationalConflictSummary summary; + summary.peakConflictScore = object.value("peakConflictScore").toDouble(); + summary.peakAtSeconds = optionalDoubleFromJson(object.value("peakAtSeconds")); + summary.totalConflictExposureAgentSeconds = object.value("totalConflictExposureAgentSeconds").toDouble(); + summary.longestConflictDurationSeconds = object.value("longestConflictDurationSeconds").toDouble(); + summary.counterflowHotspotCount = static_cast(object.value("counterflowHotspotCount").toInteger()); + summary.conflictConnectionCount = static_cast(object.value("conflictConnectionCount").toInteger()); + summary.connectionConcentrationIndex = object.value("connectionConcentrationIndex").toDouble(); + summary.peakQueuedAgents = static_cast(object.value("peakQueuedAgents").toInteger()); + summary.topConflictConnectionId = object.value("topConflictConnectionId").toString().toStdString(); + summary.topConflictConnectionLabel = object.value("topConflictConnectionLabel").toString().toStdString(); + return summary; +} + QJsonObject riskSnapshotToJson(const safecrowd::domain::ScenarioRiskSnapshot& risk) { QJsonObject object; object["completionRisk"] = static_cast(risk.completionRisk); object["stalledAgentCount"] = static_cast(risk.stalledAgentCount); + object["pressureExposedAgentCount"] = static_cast(risk.pressureExposedAgentCount); + object["criticalPressureAgentCount"] = static_cast(risk.criticalPressureAgentCount); + 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)); @@ -133,6 +328,16 @@ QJsonObject riskSnapshotToJson(const safecrowd::domain::ScenarioRiskSnapshot& ri bottlenecks.append(bottleneckToJson(bottleneck)); } object["bottlenecks"] = bottlenecks; + QJsonArray operationalConflictCells; + for (const auto& cell : risk.operationalConflictCells) { + operationalConflictCells.append(operationalConflictCellToJson(cell)); + } + object["operationalConflictCells"] = operationalConflictCells; + QJsonArray operationalConflictConnections; + for (const auto& connection : risk.operationalConflictConnections) { + operationalConflictConnections.append(operationalConflictConnectionToJson(connection)); + } + object["operationalConflictConnections"] = operationalConflictConnections; return object; } @@ -140,12 +345,23 @@ safecrowd::domain::ScenarioRiskSnapshot riskSnapshotFromJson(const QJsonObject& safecrowd::domain::ScenarioRiskSnapshot risk; risk.completionRisk = static_cast(object.value("completionRisk").toInt()); risk.stalledAgentCount = static_cast(object.value("stalledAgentCount").toInteger()); + risk.pressureExposedAgentCount = static_cast(object.value("pressureExposedAgentCount").toInteger()); + risk.criticalPressureAgentCount = static_cast(object.value("criticalPressureAgentCount").toInteger()); + 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())); } for (const auto& value : object.value("bottlenecks").toArray()) { risk.bottlenecks.push_back(bottleneckFromJson(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; } @@ -354,6 +570,20 @@ QJsonObject resultArtifactsToJson(const safecrowd::domain::ScenarioResultArtifac } object["placementCompletion"] = placementCompletion; + object["operationalConflictSummary"] = operationalConflictSummaryToJson(artifacts.operationalConflictSummary); + + QJsonArray connectionUsage; + for (const auto& connection : artifacts.connectionUsage) { + connectionUsage.append(connectionUsageMetricToJson(connection)); + } + object["connectionUsage"] = connectionUsage; + + QJsonArray operationalConflictTimeline; + for (const auto& sample : artifacts.operationalConflictTimeline) { + operationalConflictTimeline.append(operationalConflictTimelineSampleToJson(sample)); + } + object["operationalConflictTimeline"] = operationalConflictTimeline; + return object; } @@ -398,6 +628,17 @@ safecrowd::domain::ScenarioResultArtifacts resultArtifactsFromJson(const QJsonOb for (const auto& value : object.value("placementCompletion").toArray()) { artifacts.placementCompletion.push_back(placementCompletionMetricFromJson(value.toObject())); } + if (object.value("operationalConflictSummary").isObject()) { + artifacts.operationalConflictSummary = + operationalConflictSummaryFromJson(object.value("operationalConflictSummary").toObject()); + } + for (const auto& value : object.value("connectionUsage").toArray()) { + artifacts.connectionUsage.push_back(connectionUsageMetricFromJson(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 2cb9d05..9d7852d 100644 --- a/src/application/ScenarioBatchResultWidget.cpp +++ b/src/application/ScenarioBatchResultWidget.cpp @@ -831,6 +831,8 @@ std::optional existingScenarioIndexBySourceTemplate( ScenarioResultNavigationView resultNavigationViewFromSaved(SavedResultNavigationView view) { switch (view) { + case SavedResultNavigationView::OperationalConflict: + return ScenarioResultNavigationView::OperationalConflict; case SavedResultNavigationView::Hotspot: return ScenarioResultNavigationView::Hotspot; case SavedResultNavigationView::Zone: @@ -847,6 +849,8 @@ ScenarioResultNavigationView resultNavigationViewFromSaved(SavedResultNavigation SavedResultNavigationView savedResultNavigationView(ScenarioResultNavigationView view) { switch (view) { + case ScenarioResultNavigationView::OperationalConflict: + return SavedResultNavigationView::OperationalConflict; case ScenarioResultNavigationView::Hotspot: return SavedResultNavigationView::Hotspot; case ScenarioResultNavigationView::Zone: @@ -968,6 +972,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("Operational Conflicts", static_cast(OverlayMode::OperationalConflicts)); overlayCombo_->addItem("None", static_cast(OverlayMode::None)); overlayCombo_->setCurrentIndex(0); selectorLayout->addWidget(overlayCombo_); @@ -1377,6 +1382,9 @@ void ScenarioBatchResultWidget::applySelectedResultStaticCanvasState() { canvas_->setEnvironmentHazards(result.scenario.environment.hazards); canvas_->setHotspotOverlay(result.risk.hotspots); canvas_->setBottleneckOverlay(result.risk.bottlenecks); + canvas_->setOperationalConflictOverlay( + result.risk.operationalConflictCells, + result.risk.operationalConflictConnections); canvas_->setDensityOverlay( result.artifacts.densitySummary.peakField.cells.empty() ? result.artifacts.densitySummary.peakCells @@ -1406,6 +1414,9 @@ void ScenarioBatchResultWidget::applyOverlayModeToCanvas() { case OverlayMode::Bottlenecks: canvas_->setResultOverlayMode(ResultOverlayMode::Bottlenecks); break; + case OverlayMode::OperationalConflicts: + canvas_->setResultOverlayMode(ResultOverlayMode::OperationalConflicts); + break; case OverlayMode::None: canvas_->setResultOverlayMode(ResultOverlayMode::None); break; @@ -1844,6 +1855,42 @@ void ScenarioBatchResultWidget::refreshResultNavigationPanel() { canvas_->focusBottleneck(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.operationalConflictCells.size()) { + setOverlayMode(OverlayMode::OperationalConflicts); + const auto& cell = selected.risk.operationalConflictCells[index]; + if (cell.detectionFrame.has_value()) { + showReplayFrame(*cell.detectionFrame); + } else if (cell.detectedAtSeconds.has_value()) { + showClosestReplayFrameAtSeconds(*cell.detectedAtSeconds); + } + } + if (canvas_ != nullptr) { + 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::OperationalConflicts); + 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) { if (results_.empty() || currentResultIndex_ < 0 || currentResultIndex_ >= static_cast(results_.size())) { return; @@ -1868,6 +1915,8 @@ void ScenarioBatchResultWidget::refreshResultNavigationPanel() { result.risk, result.artifacts, std::move(bottleneckFocusHandler), + std::move(operationalConflictCellFocusHandler), + std::move(operationalConflictConnectionFocusHandler), std::move(hotspotFocusHandler), shell_)); } @@ -1918,7 +1967,21 @@ void ScenarioBatchResultWidget::refreshSelectedResult() { .arg(formatPressureScore(result.artifacts.pressureSummary.peakPressureScore)) .arg(formatSeconds(result.artifacts.pressureSummary.peakAtSeconds)) .arg(formatSeconds(result.artifacts.timingSummary.t90Seconds)) - .arg(formatSeconds(result.artifacts.timingSummary.t95Seconds))); + .arg(formatSeconds(result.artifacts.timingSummary.t95Seconds)) + + QString("\nOperational conflict: %1 score / %2 connections / HHI %3") + .arg(result.artifacts.operationalConflictSummary.peakConflictScore, 0, 'f', 2) + .arg(static_cast(result.artifacts.operationalConflictSummary.conflictConnectionCount)) + .arg(result.artifacts.operationalConflictSummary.connectionConcentrationIndex, 0, 'f', 2) + + QString("\nConflict exposure: %1 agent-sec | Top connection: %2") + .arg(result.artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds, 0, 'f', 1) + .arg((result.artifacts.operationalConflictSummary.peakConflictScore <= 0.0 + && result.artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds <= 0.0 + && result.artifacts.operationalConflictSummary.conflictConnectionCount == 0) + ? QString("None") + : QString::fromStdString( + result.artifacts.operationalConflictSummary.topConflictConnectionLabel.empty() + ? result.artifacts.operationalConflictSummary.topConflictConnectionId + : result.artifacts.operationalConflictSummary.topConflictConnectionLabel))); } } diff --git a/src/application/ScenarioBatchResultWidget.h b/src/application/ScenarioBatchResultWidget.h index 56808da..7b266d3 100644 --- a/src/application/ScenarioBatchResultWidget.h +++ b/src/application/ScenarioBatchResultWidget.h @@ -50,7 +50,8 @@ class ScenarioBatchResultWidget : public QWidget { Pressure = 1, Hotspots = 2, Bottlenecks = 3, - None = 4, + OperationalConflicts = 4, + None = 5, }; QWidget* createCanvasPanel(); diff --git a/src/application/ScenarioResultNavigation.cpp b/src/application/ScenarioResultNavigation.cpp index 91e1be1..358d7bc 100644 --- a/src/application/ScenarioResultNavigation.cpp +++ b/src/application/ScenarioResultNavigation.cpp @@ -34,6 +34,10 @@ QString formatOptionalSeconds(const std::optional& seconds) { return seconds.has_value() ? QString("%1 sec").arg(*seconds, 0, 'f', 1) : QString("Pending"); } +QString formatPercent(double ratio) { + return QString("%1%").arg(std::clamp(ratio, 0.0, 1.0) * 100.0, 0, 'f', 0); +} + QLabel* createReportSectionHeader(const QString& text, QWidget* parent) { auto* label = createLabel(text, parent, ui::FontRole::SectionTitle); label->setStyleSheet(ui::mutedTextStyleSheet()); @@ -127,6 +131,62 @@ QPushButton* createHotspotRowButton( return button; } +QPushButton* createOperationalConflictCellRowButton( + const safecrowd::domain::ScenarioOperationalConflictCellMetric& cell, + std::size_t index, + QWidget* parent) { + QStringList lines{ + QString("%1. Conflict score %2") + .arg(static_cast(index + 1)) + .arg(cell.conflictScore, 0, 'f', 2), + QString("Counterflow %1 | %2 vs %3 movers") + .arg(formatPercent(cell.counterflowRatio)) + .arg(static_cast(cell.forwardCount)) + .arg(static_cast(cell.reverseCount)), + QString("Duration %1 sec | Speed %2 m/s") + .arg(cell.durationSeconds, 0, 'f', 1) + .arg(cell.averageSpeed, 0, 'f', 2), + }; + if (!cell.nearestConnectionLabel.empty() || !cell.nearestConnectionId.empty()) { + lines.push_back(QString("Nearest connection: %1") + .arg(QString::fromStdString( + !cell.nearestConnectionLabel.empty() ? cell.nearestConnectionLabel : cell.nearestConnectionId))); + } + 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 conflict hotspot 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.label : connection.connectionId)), + QString("Score %1 | Counterflow %2") + .arg(connection.conflictScore, 0, 'f', 2) + .arg(formatPercent(connection.counterflowRatio)), + QString("Queue %1 | Duration %2 sec | Speed %3 m/s") + .arg(static_cast(connection.queueAgentCount)) + .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 conflict connection on the canvas.") + .arg(safecrowd::domain::scenarioOperationalConflictDefinition())); + return button; +} + QFrame* createLegendSwatch(const QColor& color, QWidget* parent) { auto* swatch = new QFrame(parent); swatch->setFixedSize(22, 12); @@ -172,6 +232,15 @@ QIcon makeResultNavigationIcon(const QString& tabId, const QColor& color) { painter.drawLine(QPointF(32, 32), QPointF(24, 23)); painter.setBrush(color); painter.drawEllipse(QPointF(22, 22), 2.8, 2.8); + } else if (tabId == "operational-conflict") { + painter.drawLine(QPointF(12, 22), QPointF(32, 22)); + painter.drawLine(QPointF(22, 12), QPointF(22, 32)); + painter.drawLine(QPointF(17, 17), QPointF(13, 13)); + painter.drawLine(QPointF(17, 17), QPointF(13, 21)); + painter.drawLine(QPointF(27, 27), QPointF(31, 23)); + painter.drawLine(QPointF(27, 27), QPointF(31, 31)); + painter.setBrush(color); + painter.drawEllipse(QPointF(22, 22), 2.8, 2.8); } else if (tabId == "hotspot") { painter.drawEllipse(QPointF(22, 22), 12, 12); painter.drawEllipse(QPointF(22, 22), 7, 7); @@ -342,6 +411,91 @@ QWidget* createGroupsReportPanel(const safecrowd::domain::ScenarioResultArtifact return parts.panel; } +QWidget* createOperationalConflictReportPanel( + const safecrowd::domain::ScenarioRiskSnapshot& risk, + const safecrowd::domain::ScenarioResultArtifacts& artifacts, + std::function operationalConflictCellFocusHandler, + std::function operationalConflictConnectionFocusHandler, + QWidget* parent) { + auto parts = createResultReportPanel("Operational Conflict", "Counterflow and concentrated connector load", parent); + auto* summaryHeader = createReportSectionHeader("Summary", parts.content); + summaryHeader->setToolTip(safecrowd::domain::scenarioOperationalConflictDefinition()); + parts.contentLayout->addWidget(summaryHeader); + parts.contentLayout->addWidget(createReportInfoRow({ + QString("Peak conflict score: %1") + .arg(artifacts.operationalConflictSummary.peakConflictScore, 0, 'f', 2), + QString("Total exposure: %1 agent-sec") + .arg(artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds, 0, 'f', 1), + QString("Longest duration: %1 sec") + .arg(artifacts.operationalConflictSummary.longestConflictDurationSeconds, 0, 'f', 1), + QString("Conflict connections: %1 | Peak queued: %2") + .arg(static_cast(artifacts.operationalConflictSummary.conflictConnectionCount)) + .arg(static_cast(artifacts.operationalConflictSummary.peakQueuedAgents)), + QString("Connection concentration: %1") + .arg(artifacts.operationalConflictSummary.connectionConcentrationIndex, 0, 'f', 2), + }, parts.content)); + + auto* cellHeader = createReportSectionHeader("Conflict Cells", parts.content); + parts.contentLayout->addWidget(cellHeader); + 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.operationalConflictCells.size(); ++index) { + auto* row = createOperationalConflictCellRowButton(risk.operationalConflictCells[index], index, parts.content); + QObject::connect(row, &QPushButton::clicked, parts.content, [operationalConflictCellFocusHandler, index]() { + if (operationalConflictCellFocusHandler) { + operationalConflictCellFocusHandler(index); + } + }); + parts.contentLayout->addWidget(row); + } + } + + auto* connectionHeader = createReportSectionHeader("Conflict 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, index]() { + if (operationalConflictConnectionFocusHandler) { + operationalConflictConnectionFocusHandler(index); + } + }); + parts.contentLayout->addWidget(row); + } + } + + if (!artifacts.connectionUsage.empty()) { + auto* usageHeader = createReportSectionHeader("Top Connection Load", parts.content); + parts.contentLayout->addWidget(usageHeader); + const auto usageCount = std::min(3, artifacts.connectionUsage.size()); + for (std::size_t index = 0; index < usageCount; ++index) { + const auto& metric = artifacts.connectionUsage[index]; + parts.contentLayout->addWidget(createReportInfoRow({ + QString::fromStdString(!metric.label.empty() ? metric.label : metric.connectionId), + QString("Traversals %1 | Share %2") + .arg(static_cast(metric.traversalCount)) + .arg(formatPercent(metric.usageRatio)), + QString("Peak window %1 | Queue exposure %2 agent-sec") + .arg(static_cast(metric.peakWindowCount)) + .arg(metric.queueExposureAgentSeconds, 0, 'f', 1), + }, parts.content)); + } + } + + parts.contentLayout->addStretch(1); + return parts.panel; +} + bool shouldShowRecommendationEvidence(const safecrowd::domain::AlternativeRecommendationEvidence& item) { const auto label = QString::fromStdString(item.label); return !label.startsWith("Risk ") && label != "Critical pressure events"; @@ -356,6 +510,11 @@ std::vector scenarioResultNavigationTabs() { .label = "Bottleneck", .icon = makeResultNavigationIcon("bottleneck", QColor("#1f5fae")), }, + { + .id = "operational-conflict", + .label = "Operational Conflict", + .icon = makeResultNavigationIcon("operational-conflict", QColor("#1f5fae")), + }, { .id = "hotspot", .label = "Hotspot", @@ -381,6 +540,8 @@ std::vector scenarioResultNavigationTabs() { QString scenarioResultNavigationTabId(ScenarioResultNavigationView view) { switch (view) { + case ScenarioResultNavigationView::OperationalConflict: + return "operational-conflict"; case ScenarioResultNavigationView::Hotspot: return "hotspot"; case ScenarioResultNavigationView::Zone: @@ -396,6 +557,9 @@ QString scenarioResultNavigationTabId(ScenarioResultNavigationView view) { } ScenarioResultNavigationView scenarioResultNavigationViewFromTabId(const QString& tabId) { + if (tabId == "operational-conflict") { + return ScenarioResultNavigationView::OperationalConflict; + } if (tabId == "hotspot") { return ScenarioResultNavigationView::Hotspot; } @@ -416,9 +580,18 @@ QWidget* createScenarioResultNavigationPanel( const safecrowd::domain::ScenarioRiskSnapshot& risk, const safecrowd::domain::ScenarioResultArtifacts& artifacts, std::function bottleneckFocusHandler, + std::function operationalConflictCellFocusHandler, + std::function operationalConflictConnectionFocusHandler, std::function hotspotFocusHandler, QWidget* parent) { switch (view) { + case ScenarioResultNavigationView::OperationalConflict: + return createOperationalConflictReportPanel( + risk, + artifacts, + std::move(operationalConflictCellFocusHandler), + std::move(operationalConflictConnectionFocusHandler), + parent); case ScenarioResultNavigationView::Hotspot: return createHotspotReportPanel(risk, std::move(hotspotFocusHandler), parent); case ScenarioResultNavigationView::Zone: diff --git a/src/application/ScenarioResultNavigation.h b/src/application/ScenarioResultNavigation.h index b7b7322..c7c8b7c 100644 --- a/src/application/ScenarioResultNavigation.h +++ b/src/application/ScenarioResultNavigation.h @@ -16,6 +16,7 @@ namespace safecrowd::application { enum class ScenarioResultNavigationView { Bottleneck, + OperationalConflict, Hotspot, Zone, Groups, @@ -31,6 +32,8 @@ QWidget* createScenarioResultNavigationPanel( const safecrowd::domain::ScenarioRiskSnapshot& risk, const safecrowd::domain::ScenarioResultArtifacts& artifacts, std::function bottleneckFocusHandler, + std::function operationalConflictCellFocusHandler, + std::function operationalConflictConnectionFocusHandler, std::function hotspotFocusHandler, QWidget* parent); diff --git a/src/application/ScenarioResultWidget.cpp b/src/application/ScenarioResultWidget.cpp index c6c4aa1..e27a0aa 100644 --- a/src/application/ScenarioResultWidget.cpp +++ b/src/application/ScenarioResultWidget.cpp @@ -567,6 +567,7 @@ QString resultCriteriaTooltip(const safecrowd::domain::ScenarioResultArtifacts& .arg(artifacts.pressureSummary.hotspotScoreThreshold, 0, 'f', 1), safecrowd::domain::scenarioStalledDefinition(), safecrowd::domain::scenarioBottleneckDefinition(), + safecrowd::domain::scenarioOperationalConflictDefinition(), }.join("\n\n"); } @@ -912,6 +913,7 @@ QWidget* createResultCanvasPanel( overlayCombo->addItem("Peak Density", static_cast(ResultOverlayMode::Density)); overlayCombo->addItem("Pressure", static_cast(ResultOverlayMode::Pressure)); overlayCombo->addItem("Bottlenecks", static_cast(ResultOverlayMode::Bottlenecks)); + overlayCombo->addItem("Operational Conflicts", static_cast(ResultOverlayMode::OperationalConflicts)); overlayCombo->addItem("Hotspots", static_cast(ResultOverlayMode::Hotspots)); overlayCombo->addItem("None", static_cast(ResultOverlayMode::None)); overlayCombo->setToolTip("Switch between result map overlays."); @@ -1175,6 +1177,18 @@ QWidget* createResultPanel( const auto slowestGroup = artifacts.placementCompletion.empty() ? QString("Pending") : QString::fromStdString(artifacts.placementCompletion.front().placementId); + const bool hasOperationalConflict = + artifacts.operationalConflictSummary.peakConflictScore > 0.0 + || artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds > 0.0 + || artifacts.operationalConflictSummary.conflictConnectionCount > 0; + const auto topConflictFull = !hasOperationalConflict + ? QString("None") + : (artifacts.operationalConflictSummary.topConflictConnectionLabel.empty() + ? (artifacts.operationalConflictSummary.topConflictConnectionId.empty() + ? QString("None") + : QString::fromStdString(artifacts.operationalConflictSummary.topConflictConnectionId)) + : QString::fromStdString(artifacts.operationalConflictSummary.topConflictConnectionLabel)); + const auto topConflict = compactBottleneckLabel(topConflictFull); const auto peakPressureTooltip = QString( "Highest pressure hotspot score observed during the run.%1%2") .arg(artifacts.pressureSummary.peakAtSeconds.has_value() @@ -1183,6 +1197,12 @@ QWidget* createResultPanel( .arg(artifacts.pressureSummary.peakCell.has_value() ? QString("\nCell floor: %1").arg(QString::fromStdString(artifacts.pressureSummary.peakCell->floorId)) : QString()); + const auto conflictTooltip = QString( + "%1\n\nPeak score: %2\nExposure: %3 agent-sec\nLongest duration: %4 sec") + .arg(safecrowd::domain::scenarioOperationalConflictDefinition()) + .arg(artifacts.operationalConflictSummary.peakConflictScore, 0, 'f', 2) + .arg(artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds, 0, 'f', 1) + .arg(artifacts.operationalConflictSummary.longestConflictDurationSeconds, 0, 'f', 1); metricsGrid->addWidget(createMetricCard( "Completion", formatSecondsValue(completionTime), @@ -1251,6 +1271,23 @@ QWidget* createResultPanel( panel, QString("Peak simultaneously critical agents during the run.\nExposed peak: %1 agents.") .arg(static_cast(artifacts.pressureSummary.peakExposedAgentCount))), 6, 0); + metricsGrid->addWidget(createMetricCard( + "Peak Conflict", + QString::number(artifacts.operationalConflictSummary.peakConflictScore, 'f', 2), + panel, + conflictTooltip), 6, 1); + metricsGrid->addWidget(createMetricCard( + "Conflict Exposure", + QString("%1 agent-sec").arg(artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds, 0, 'f', 1), + panel, + conflictTooltip), 7, 0); + metricsGrid->addWidget(createMetricCard( + "Top Conflict", + topConflict, + panel, + QString("%1\n\nTop connection: %2") + .arg(conflictTooltip) + .arg(topConflictFull)), 7, 1); layout->addLayout(metricsGrid); layout->addStretch(1); @@ -1285,6 +1322,8 @@ QWidget* createResultPanel( ScenarioResultNavigationView resultNavigationViewFromSaved(SavedResultNavigationView view) { switch (view) { + case SavedResultNavigationView::OperationalConflict: + return ScenarioResultNavigationView::OperationalConflict; case SavedResultNavigationView::Hotspot: return ScenarioResultNavigationView::Hotspot; case SavedResultNavigationView::Zone: @@ -1301,6 +1340,8 @@ ScenarioResultNavigationView resultNavigationViewFromSaved(SavedResultNavigation SavedResultNavigationView savedResultNavigationView(ScenarioResultNavigationView view) { switch (view) { + case ScenarioResultNavigationView::OperationalConflict: + return SavedResultNavigationView::OperationalConflict; case ScenarioResultNavigationView::Hotspot: return SavedResultNavigationView::Hotspot; case ScenarioResultNavigationView::Zone: @@ -1365,6 +1406,7 @@ ScenarioResultWidget::ScenarioResultWidget( canvas->setEnvironmentHazards(scenario_.environment.hazards); canvas->setHotspotOverlay(risk_.hotspots); canvas->setBottleneckOverlay(risk_.bottlenecks); + canvas->setOperationalConflictOverlay(risk_.operationalConflictCells, risk_.operationalConflictConnections); ResultReplayControls* replayControls = nullptr; std::function applyResultOverlayMode; shell_->setCanvas(createResultCanvasPanel( @@ -1390,6 +1432,38 @@ ScenarioResultWidget::ScenarioResultWidget( } canvas->focusBottleneck(index); }; + operationalConflictCellFocusHandler_ = [this, canvas, replayControls, applyResultOverlayMode](std::size_t index) { + if (index < risk_.operationalConflictCells.size() && replayControls != nullptr) { + const auto& cell = risk_.operationalConflictCells[index]; + if (cell.detectionFrame.has_value()) { + replayControls->showFrame(*cell.detectionFrame); + } else if (cell.detectedAtSeconds.has_value()) { + replayControls->showClosestFrameAtSeconds(*cell.detectedAtSeconds); + } + } + if (applyResultOverlayMode) { + applyResultOverlayMode(ResultOverlayMode::OperationalConflicts); + } else { + canvas->setResultOverlayMode(ResultOverlayMode::OperationalConflicts); + } + canvas->focusOperationalConflictCell(index); + }; + operationalConflictConnectionFocusHandler_ = [this, canvas, replayControls, applyResultOverlayMode](std::size_t index) { + if (index < risk_.operationalConflictConnections.size() && replayControls != nullptr) { + const auto& connection = risk_.operationalConflictConnections[index]; + if (connection.detectionFrame.has_value()) { + replayControls->showFrame(*connection.detectionFrame); + } else if (connection.detectedAtSeconds.has_value()) { + replayControls->showClosestFrameAtSeconds(*connection.detectedAtSeconds); + } + } + if (applyResultOverlayMode) { + applyResultOverlayMode(ResultOverlayMode::OperationalConflicts); + } else { + canvas->setResultOverlayMode(ResultOverlayMode::OperationalConflicts); + } + canvas->focusOperationalConflictConnection(index); + }; hotspotFocusHandler_ = [this, canvas, replayControls, applyResultOverlayMode](std::size_t index) { if (index < risk_.hotspots.size() && replayControls != nullptr) { const auto& hotspot = risk_.hotspots[index]; @@ -1460,6 +1534,8 @@ void ScenarioResultWidget::refreshResultNavigationPanel() { risk_, artifacts_, bottleneckFocusHandler_, + operationalConflictCellFocusHandler_, + operationalConflictConnectionFocusHandler_, hotspotFocusHandler_, shell_)); } diff --git a/src/application/ScenarioResultWidget.h b/src/application/ScenarioResultWidget.h index c9f8ba3..0bfe39f 100644 --- a/src/application/ScenarioResultWidget.h +++ b/src/application/ScenarioResultWidget.h @@ -59,6 +59,8 @@ class ScenarioResultWidget : public QWidget { std::function openProjectHandler_{}; std::function backToLayoutReviewHandler_{}; std::function bottleneckFocusHandler_{}; + std::function operationalConflictCellFocusHandler_{}; + std::function operationalConflictConnectionFocusHandler_{}; std::function hotspotFocusHandler_{}; ScenarioResultNavigationView resultNavigationView_{ScenarioResultNavigationView::Bottleneck}; WorkspaceShell* shell_{nullptr}; diff --git a/src/application/SimulationCanvasWidget.cpp b/src/application/SimulationCanvasWidget.cpp index 3489dad..1cf4901 100644 --- a/src/application/SimulationCanvasWidget.cpp +++ b/src/application/SimulationCanvasWidget.cpp @@ -31,12 +31,16 @@ constexpr double kAgentMarkerRadius = 5.0; constexpr double kDefaultHotspotCellSize = 1.5; constexpr double kHotspotFocusZoom = 2.8; constexpr double kBottleneckFocusZoom = 2.4; +constexpr double kOperationalConflictCellFocusZoom = 2.8; +constexpr double kOperationalConflictConnectionFocusZoom = 2.5; constexpr double kDensityInfluenceRadiusMultiplier = 1.75; constexpr double kDensityMinimumScreenRadius = 14.0; constexpr double kDefaultDensityScaleMaxPeoplePerSquareMeter = 4.0; constexpr double kPressureInfluenceRadiusMultiplier = 1.45; constexpr double kPressureMinimumScreenRadius = 12.0; constexpr double kDefaultPressureScaleMaxScore = 1.0; +constexpr double kOperationalConflictInfluenceRadiusMultiplier = 1.3; +constexpr double kOperationalConflictMinimumScreenRadius = 16.0; constexpr int kHotspotMinCoreAlpha = 72; constexpr int kHotspotMaxCoreAlpha = 190; constexpr int kFloorSelectorMargin = 14; @@ -173,6 +177,57 @@ QColor pressureHeatmapColor(double ratio, int alpha) { return QColor(153, 27, 27, 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); + } + if (t < 0.65) { + return QColor(249, 115, 22, alpha); + } + return QColor(220, 38, 38, alpha); +} + +safecrowd::domain::Point2D lineSegmentCenter(const safecrowd::domain::LineSegment2D& line) { + return { + .x = (line.start.x + line.end.x) * 0.5, + .y = (line.start.y + line.end.y) * 0.5, + }; +} + +void drawArrowHead(QPainter& painter, const QPointF& tip, const QPointF& tail, const QColor& color, double size) { + const auto line = QLineF(tail, tip); + if (line.length() <= 1e-6) { + return; + } + const auto angle = std::atan2(line.dy(), line.dx()); + const QPointF left( + tip.x() - (std::cos(angle - 0.45) * size), + tip.y() - (std::sin(angle - 0.45) * size)); + const QPointF right( + tip.x() - (std::cos(angle + 0.45) * size), + tip.y() - (std::sin(angle + 0.45) * size)); + painter.save(); + painter.setPen(QPen(color, std::max(1.8, size * 0.32), Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawLine(tip, left); + painter.drawLine(tip, right); + painter.restore(); +} + +void drawBidirectionalFlowMarker(QPainter& painter, const QPointF& start, const QPointF& end, const QColor& color) { + const QLineF segment(start, end); + if (segment.length() <= 1e-6) { + return; + } + const auto direction = QPointF(segment.dx() / segment.length(), segment.dy() / segment.length()); + const auto center = segment.pointAt(0.5); + const auto forwardTip = center + (direction * std::min(18.0, segment.length() * 0.28)); + const auto reverseTip = center - (direction * std::min(18.0, segment.length() * 0.28)); + const auto markerSize = std::clamp(segment.length() * 0.12, 6.0, 10.0); + drawArrowHead(painter, forwardTip, center, color, markerSize); + drawArrowHead(painter, reverseTip, center, color, markerSize); +} + QString formatScheduleTooltip(const safecrowd::domain::ConnectionBlockDraft& block) { if (block.connectionId.empty()) { return {}; @@ -607,6 +662,23 @@ void SimulationCanvasWidget::setBottleneckOverlay(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(); +} + void SimulationCanvasWidget::setResultOverlayMode(ResultOverlayMode mode) { if (overlayMode_ == mode) { return; @@ -626,6 +698,8 @@ void SimulationCanvasWidget::focusHotspot(std::size_t index) { } focusedHotspotIndex_ = index; focusedBottleneckIndex_.reset(); + focusedOperationalConflictCellIndex_.reset(); + focusedOperationalConflictConnectionIndex_.reset(); invalidateOverlayCache(); focusWorldPoint(hotspotOverlay_[index].center, std::max(camera_.zoom(), kHotspotFocusZoom)); } @@ -641,12 +715,50 @@ void SimulationCanvasWidget::focusBottleneck(std::size_t index) { } focusedBottleneckIndex_ = index; focusedHotspotIndex_.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::focusOperationalConflictCell(std::size_t index) { + if (index >= operationalConflictCellOverlay_.size()) { + return; + } + + const auto& cell = operationalConflictCellOverlay_[index]; + if (!cell.floorId.empty() && cell.floorId != currentFloorId_) { + setCurrentFloorId(cell.floorId, true); + } + focusedOperationalConflictCellIndex_ = index; + focusedOperationalConflictConnectionIndex_.reset(); + focusedHotspotIndex_.reset(); + focusedBottleneckIndex_.reset(); + invalidateOverlayCache(); + 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( + lineSegmentCenter(connection.passage), + std::max(camera_.zoom(), kOperationalConflictConnectionFocusZoom)); +} + bool SimulationCanvasWidget::eventFilter(QObject* watched, QEvent* event) { (void)watched; camera_.handleGlobalKeyEvent(event); @@ -986,6 +1098,8 @@ void SimulationCanvasWidget::refreshOverlayCache(const LayoutCanvasBounds& bound drawHotspotOverlay(painter, transform); } else if (overlayMode_ == ResultOverlayMode::Bottlenecks) { drawBottleneckOverlay(painter, transform); + } else if (overlayMode_ == ResultOverlayMode::OperationalConflicts) { + drawOperationalConflictOverlay(painter, transform); } overlayCacheSize_ = currentSize; @@ -1479,6 +1593,117 @@ void SimulationCanvasWidget::drawBottleneckOverlay(QPainter& painter, const Layo painter.restore(); } +void SimulationCanvasWidget::drawOperationalConflictOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const { + if (operationalConflictCellOverlay_.empty() && operationalConflictConnectionOverlay_.empty()) { + return; + } + + painter.save(); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); + + QPainterPath walkableClip; + for (const auto& zone : layout_.zones) { + if (!matchesFloor(zone.floorId, currentFloorId_)) { + continue; + } + walkableClip.addPath(layoutCanvasPolygonPath(zone.area, transform)); + } + if (!walkableClip.isEmpty()) { + painter.setClipPath(walkableClip); + } + + std::vector visibleCells; + visibleCells.reserve(operationalConflictCellOverlay_.size()); + for (const auto& cell : operationalConflictCellOverlay_) { + if (!matchesFloor(cell.floorId, currentFloorId_)) { + continue; + } + 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->conflictScore - rhs->conflictScore) > 1e-9) { + return lhs->conflictScore < rhs->conflictScore; + } + return lhs->movingAgentCount < rhs->movingAgentCount; + }); + + painter.setPen(Qt::NoPen); + for (const auto* cell : visibleCells) { + const auto center = transform.map(cell->center); + const auto cellWidth = cell->cellMax.x > cell->cellMin.x + ? cell->cellMax.x - cell->cellMin.x + : safecrowd::domain::kScenarioOperationalConflictCellSize; + const auto cellHeight = cell->cellMax.y > cell->cellMin.y + ? cell->cellMax.y - cell->cellMin.y + : safecrowd::domain::kScenarioOperationalConflictCellSize; + const auto influenceRadiusWorld = + std::max(cellWidth, cellHeight) * kOperationalConflictInfluenceRadiusMultiplier; + const auto radiusAnchor = transform.map({ + .x = cell->center.x + influenceRadiusWorld, + .y = cell->center.y, + }); + const auto radius = std::max( + kOperationalConflictMinimumScreenRadius, + std::hypot(radiusAnchor.x() - center.x(), radiusAnchor.y() - center.y())); + 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, 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 (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); + painter.setPen(Qt::NoPen); + } + } + + 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 && connection.queueAgentCount == 0) { + continue; + } + const auto focused = focusedOperationalConflictConnectionIndex_.has_value() + && *focusedOperationalConflictConnectionIndex_ == index; + const auto intensity = std::clamp(std::max(connection.conflictScore, connection.speedDropRatio), 0.0, 1.0); + const auto color = focused + ? QColor(153, 27, 27, 240) + : operationalConflictHeatmapColor(intensity, 210); + const auto width = focused ? 6.0 : (3.0 + (2.2 * intensity)); + const auto start = transform.map(connection.passage.start); + const auto end = transform.map(connection.passage.end); + painter.setPen(QPen(color, width, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawLine(start, end); + drawBidirectionalFlowMarker(painter, start, end, color); + + if (connection.queueAgentCount > 0) { + const auto center = transform.map(lineSegmentCenter(connection.passage)); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(120, 53, 15, focused ? 220 : 170)); + painter.drawEllipse(center, 5.2 + std::min(connection.queueAgentCount, 4), 5.2 + std::min(connection.queueAgentCount, 4)); + painter.setBrush(Qt::NoBrush); + } + } + + painter.restore(); +} + bool SimulationCanvasWidget::switchFloorByWheel(QWheelEvent* event) { if (event == nullptr || !(event->modifiers() & Qt::ControlModifier) diff --git a/src/application/SimulationCanvasWidget.h b/src/application/SimulationCanvasWidget.h index 398d513..6057bcb 100644 --- a/src/application/SimulationCanvasWidget.h +++ b/src/application/SimulationCanvasWidget.h @@ -33,6 +33,7 @@ enum class ResultOverlayMode { Pressure, Hotspots, Bottlenecks, + OperationalConflicts, }; class SimulationCanvasWidget : public QWidget { @@ -52,9 +53,14 @@ class SimulationCanvasWidget : public QWidget { double scaleMaxPressureScore = 1.0); void setHotspotOverlay(std::vector hotspots); void setBottleneckOverlay(std::vector bottlenecks); + 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 focusOperationalConflictCell(std::size_t index); + void focusOperationalConflictConnection(std::size_t index); protected: bool eventFilter(QObject* watched, QEvent* event) override; @@ -84,6 +90,7 @@ class SimulationCanvasWidget : public QWidget { void drawPressureOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawHotspotOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawBottleneckOverlay(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(); @@ -100,9 +107,13 @@ class SimulationCanvasWidget : public QWidget { double pressureScaleMaxScore_{1.0}; std::vector hotspotOverlay_{}; std::vector bottleneckOverlay_{}; + std::vector operationalConflictCellOverlay_{}; + std::vector operationalConflictConnectionOverlay_{}; ResultOverlayMode overlayMode_{ResultOverlayMode::None}; std::optional focusedHotspotIndex_{}; std::optional focusedBottleneckIndex_{}; + 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 40735e9..45f6d9c 100644 --- a/src/domain/AlternativeRecommendationService.cpp +++ b/src/domain/AlternativeRecommendationService.cpp @@ -834,6 +834,85 @@ std::optional sustainedCounterflowConflict( std::optional makeCounterflowRiskSignal( const AlternativeRecommendationInput& request) { + if (!request.risk.operationalConflictConnections.empty() + || !request.risk.operationalConflictCells.empty() + || request.artifacts.operationalConflictSummary.peakConflictScore > 0.0) { + AlternativeRecommendationRiskSignal signal; + signal.kind = AlternativeRecommendationRiskKind::CounterflowConflict; + signal.summary = "Operational conflict detected from counterflow and connector-load metrics."; + + 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.operationalConflictConnections.empty()) { + const auto& connection = request.risk.operationalConflictConnections.front(); + severity += static_cast(connection.forwardCount + connection.reverseCount); + signal.evidence.push_back(evidence( + "Conflict connection", + connectionName(request.layout, connection.connectionId), + "ScenarioRiskSnapshot.operationalConflictConnections")); + signal.evidence.push_back(evidence( + "Opposing flow", + std::to_string(connection.forwardCount) + " vs " + + std::to_string(connection.reverseCount) + " agents", + "ScenarioRiskSnapshot.operationalConflictConnections")); + signal.evidence.push_back(evidence( + "Conflict duration", + fixed(connection.durationSeconds, 1) + " sec", + "ScenarioRiskSnapshot.operationalConflictConnections")); + signal.evidence.push_back(evidence( + "Average speed", + fixed(connection.averageSpeed, 2) + " m/s", + "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( + "Opposing flow", + std::to_string(cell.forwardCount) + " vs " + + std::to_string(cell.reverseCount) + " agents", + "ScenarioRiskSnapshot.operationalConflictCells")); + signal.evidence.push_back(evidence( + "Conflict duration", + fixed(cell.durationSeconds, 1) + " sec", + "ScenarioRiskSnapshot.operationalConflictCells")); + signal.evidence.push_back(evidence( + "Average speed", + fixed(cell.averageSpeed, 2) + " m/s", + "ScenarioRiskSnapshot.operationalConflictCells")); + if (!cell.nearestConnectionId.empty()) { + signal.evidence.push_back(evidence( + "Nearest connection", + connectionName(request.layout, cell.nearestConnectionId), + "ScenarioRiskSnapshot.operationalConflictCells")); + } + } + + signal.evidence.push_back(evidence( + "Peak conflict score", + fixed(request.artifacts.operationalConflictSummary.peakConflictScore, 2), + "ScenarioResultArtifacts.operationalConflictSummary")); + signal.evidence.push_back(evidence( + "Conflict exposure", + fixed(request.artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds, 1) + " agent-sec", + "ScenarioResultArtifacts.operationalConflictSummary")); + signal.evidence.push_back(evidence( + "Conflict connections", + std::to_string(request.artifacts.operationalConflictSummary.conflictConnectionCount), + "ScenarioResultArtifacts.operationalConflictSummary")); + if (!request.artifacts.connectionUsage.empty()) { + signal.evidence.push_back(evidence( + "Connection concentration", + fixed(request.artifacts.operationalConflictSummary.connectionConcentrationIndex, 2), + "ScenarioResultArtifacts.connectionUsage")); + } + + signal.severity = static_cast(std::round(severity)); + return signal; + } + const auto observation = sustainedCounterflowConflict(request.artifacts.replayFrames); if (!observation.has_value()) { return std::nullopt; @@ -1209,7 +1288,7 @@ std::optional makeCounterflowCandidate( draft.control.events.push_back(makeOperationalEvent( eventId, "Separate counterflow movements", - "Sustained opposing movement detected in replay frames", + signal->summary, "Use lane separation, time-separated entry, or exit-before-entry operation.")); finalizeDiffKeys(request, draft); @@ -1221,7 +1300,7 @@ std::optional makeCounterflowCandidate( candidate.title = "Separate counterflow movements"; candidate.summary = "Record a lane separation or time-separated entry operation."; candidate.expectedImprovement = "Reduces head-on movement conflict and lets the revised operation be compared by rerun."; - candidate.artifactSource = "AlternativeRecommendationRiskSignal + ScenarioResultArtifacts.replayFrames"; + candidate.artifactSource = "AlternativeRecommendationRiskSignal + operational conflict metrics"; candidate.evidence = signal->evidence; candidate.recommendedScenario = std::move(draft); return candidate; diff --git a/src/domain/ScenarioResultArtifacts.h b/src/domain/ScenarioResultArtifacts.h index bb181c0..4f803cd 100644 --- a/src/domain/ScenarioResultArtifacts.h +++ b/src/domain/ScenarioResultArtifacts.h @@ -113,6 +113,45 @@ struct HazardExposureSummary { std::vector hazards{}; }; +struct ConnectionUsageMetric { + std::string connectionId{}; + std::string label{}; + std::string floorId{}; + std::size_t traversalCount{0}; + double usageRatio{0.0}; + std::size_t peakWindowCount{0}; + std::optional peakAtSeconds{}; + std::size_t forwardTraversals{0}; + std::size_t reverseTraversals{0}; + double queueExposureAgentSeconds{0.0}; + std::size_t peakQueuedAgents{0}; + double averageObservedSpeed{0.0}; + double peakConflictScore{0.0}; + double longestConflictDurationSeconds{0.0}; + std::size_t counterflowEventCount{0}; +}; + +struct OperationalConflictTimelineSample { + double timeSeconds{0.0}; + double peakConflictScore{0.0}; + std::size_t activeConflictCellCount{0}; + std::size_t activeConflictConnectionCount{0}; + std::size_t queuedAgentsNearConnections{0}; +}; + +struct OperationalConflictSummary { + double peakConflictScore{0.0}; + std::optional peakAtSeconds{}; + double totalConflictExposureAgentSeconds{0.0}; + double longestConflictDurationSeconds{0.0}; + std::size_t counterflowHotspotCount{0}; + std::size_t conflictConnectionCount{0}; + double connectionConcentrationIndex{0.0}; + std::size_t peakQueuedAgents{0}; + std::string topConflictConnectionId{}; + std::string topConflictConnectionLabel{}; +}; + struct ExitUsageMetric { std::string exitZoneId{}; std::string exitLabel{}; @@ -147,6 +186,9 @@ struct ScenarioResultArtifacts { DensitySummary densitySummary{}; PressureSummary pressureSummary{}; HazardExposureSummary hazardExposureSummary{}; + OperationalConflictSummary operationalConflictSummary{}; + std::vector connectionUsage{}; + 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 880e6be..33acbba 100644 --- a/src/domain/ScenarioRiskMetrics.cpp +++ b/src/domain/ScenarioRiskMetrics.cpp @@ -17,7 +17,7 @@ const char* scenarioRiskLevelLabel(ScenarioRiskLevel level) noexcept { const char* scenarioRiskDefinition() noexcept { return "Low when evacuation is complete or no active risk is detected. " "Medium when elapsed time reaches 50% of the limit, stalled active agents reach 15%, " - "any hotspot/pressure hotspot/bottleneck is detected, or any active agent reaches " + "any hotspot/pressure hotspot/bottleneck/operational conflict is detected, or any active agent reaches " "critical pressure exposure. " "High when elapsed time reaches 80% of the limit, stalled active agents reach 35%, " "a critical pressure event is sustained, or two or more bottlenecks are detected."; @@ -42,6 +42,13 @@ const char* scenarioBottleneckDefinition() noexcept { "are within 1.25 m and at least one is stalled or average speed is low."; } +const char* scenarioOperationalConflictDefinition() noexcept { + return "Operational conflict highlights counterflow and connector concentration. " + "Conflict cells use a 2.0 m grid derived from Pathfinder's 4 m^2 measurement-region " + "influence area, connect to passages within 1.41 m, and compare observed speed against " + "Pathfinder's 1.30 m/s mean and 0.97 m/s minimum walking speeds."; +} + bool scenarioAgentStalled(double speedMetersPerSecond, double routeStalledSeconds) noexcept { return speedMetersPerSecond <= kScenarioStalledSpeedThreshold || routeStalledSeconds >= kScenarioStalledSecondsThreshold; diff --git a/src/domain/ScenarioRiskMetrics.h b/src/domain/ScenarioRiskMetrics.h index f5c49c0..db4cab7 100644 --- a/src/domain/ScenarioRiskMetrics.h +++ b/src/domain/ScenarioRiskMetrics.h @@ -37,6 +37,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 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; enum class ScenarioRiskLevel { Low, @@ -102,16 +112,62 @@ struct ScenarioBottleneckMetric { std::optional detectionFrame{}; }; +struct ScenarioOperationalConflictCellMetric { + Point2D center{}; + Point2D cellMin{}; + Point2D cellMax{}; + std::string floorId{}; + std::size_t movingAgentCount{0}; + std::size_t peakAgentCount{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::string nearestConnectionId{}; + std::string nearestConnectionLabel{}; + std::optional detectedAtSeconds{}; + std::optional detectionFrame{}; +}; + +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{}; +}; + struct ScenarioRiskSnapshot { ScenarioRiskLevel completionRisk{ScenarioRiskLevel::Low}; std::size_t stalledAgentCount{0}; std::size_t pressureExposedAgentCount{0}; std::size_t criticalPressureAgentCount{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 operationalConflictCells{}; + std::vector operationalConflictConnections{}; }; const char* scenarioRiskLevelLabel(ScenarioRiskLevel level) noexcept; @@ -120,6 +176,7 @@ const char* scenarioStalledDefinition() noexcept; const char* scenarioHotspotDefinition() noexcept; const char* scenarioPressureHotspotDefinition() noexcept; const char* scenarioBottleneckDefinition() 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 1e59b91..c0b0e5f 100644 --- a/src/domain/ScenarioRiskMetricsSystem.cpp +++ b/src/domain/ScenarioRiskMetricsSystem.cpp @@ -4,6 +4,7 @@ #include "domain/ScenarioSimulationInternal.h" #include +#include #include #include #include @@ -24,6 +25,9 @@ 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 kMaxReportedOperationalConflictCells = 5; +constexpr std::size_t kMaxReportedOperationalConflictConnections = 5; +constexpr double kOperationalConflictDirectionEpsilon = 1e-6; template void sortAndTrimTop(std::vector& values, std::size_t maxCount, Compare compare) { @@ -46,6 +50,16 @@ struct RiskCellAccumulator { std::vector entities{}; }; +struct OperationalConflictCellAccumulator { + Point2D positionSum{}; + Point2D cellMin{}; + Point2D cellMax{}; + std::string floorId{}; + std::size_t movingAgentCount{0}; + double speedSum{0.0}; + std::array directionCounts{}; +}; + struct ActiveAgentContext { engine::Entity entity{}; std::uint64_t agentId{0}; @@ -56,6 +70,21 @@ struct ActiveAgentContext { bool stalled{false}; }; +struct OperationalConflictCellAddress { + SpatialCell cell{}; + std::string floorId{}; +}; + +struct OperationalConflictObservation { + std::size_t movingAgentCount{0}; + std::size_t forwardCount{0}; + std::size_t reverseCount{0}; + double averageSpeed{0.0}; + double counterflowRatio{0.0}; + double speedDropRatio{0.0}; + double conflictScore{0.0}; +}; + struct ActivePressureFeedbackContext { engine::Entity entity{}; std::uint64_t agentId{0}; @@ -106,6 +135,26 @@ Point2D riskCellMax(const RiskCellAddress& cell) { return spatialCellMax(cell.cell, kScenarioHotspotCellSize); } +OperationalConflictCellAddress operationalConflictCellAddress(const Point2D& point, const std::string& floorId) { + return { + .cell = spatialCellFor(point, kScenarioOperationalConflictCellSize), + .floorId = floorId, + }; +} + +long long operationalConflictCellKey(const OperationalConflictCellAddress& cell) { + const auto cellKey = spatialKey(cell.cell); + return cellKey ^ (static_cast(std::hash{}(cell.floorId)) << 1); +} + +Point2D operationalConflictCellMin(const OperationalConflictCellAddress& cell) { + return spatialCellMin(cell.cell, kScenarioOperationalConflictCellSize); +} + +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); } @@ -218,6 +267,48 @@ bool isBottleneckSetWorse( return lhs.averageSpeed < rhs.averageSpeed; } +bool isOperationalConflictCellSetWorse( + 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.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); } @@ -305,6 +396,104 @@ double localDensityRatio(std::size_t nearbyCount) { return densityPeoplePerSquareMeter / kPressureHighDensityThresholdPeoplePerSquareMeter; } +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(kScenarioOperationalConflictDirectionBinCount)))); + return std::min(kScenarioOperationalConflictDirectionBinCount - 1, bin); +} + +std::array makeOperationalConflictDirectionVectors() { + constexpr double kTau = 6.28318530717958647692; + std::array vectors{}; + for (std::size_t index = 0; index < vectors.size(); ++index) { + const auto angle = ((static_cast(index) + 0.5) + / static_cast(kScenarioOperationalConflictDirectionBinCount)) * kTau; + vectors[index] = { + .x = std::cos(angle), + .y = std::sin(angle), + }; + } + return vectors; +} + +double operationalConflictDirectionCosine(std::size_t lhs, std::size_t rhs) { + static const auto directions = makeOperationalConflictDirectionVectors(); + return dot(directions[lhs], directions[rhs]); +} + +double operationalConflictSpeedDropRatio(double averageSpeed) { + return std::clamp( + 1.0 - (averageSpeed / std::max(1e-9, kScenarioOperationalConflictReferenceSpeedMetersPerSecond)), + 0.0, + 1.0); +} + +double operationalConflictScore(double counterflowRatio, double averageSpeed) { + const auto speedDropRatio = operationalConflictSpeedDropRatio(averageSpeed); + return std::clamp((counterflowRatio * 0.65) + (speedDropRatio * 0.35), 0.0, 1.0); +} + +std::optional detectOperationalConflict( + const std::array& directionCounts, + std::size_t movingAgentCount, + double averageSpeed) { + if (movingAgentCount < kScenarioOperationalConflictMinMovingAgents) { + return std::nullopt; + } + if (averageSpeed > kScenarioOperationalConflictMinimumExpectedSpeedMetersPerSecond + kOperationalConflictDirectionEpsilon) { + return std::nullopt; + } + + OperationalConflictObservation best; + best.averageSpeed = averageSpeed; + best.movingAgentCount = movingAgentCount; + for (std::size_t anchorBin = 0; anchorBin < kScenarioOperationalConflictDirectionBinCount; ++anchorBin) { + if (directionCounts[anchorBin] == 0) { + continue; + } + + OperationalConflictObservation candidate; + candidate.averageSpeed = averageSpeed; + candidate.movingAgentCount = movingAgentCount; + for (std::size_t bin = 0; bin < kScenarioOperationalConflictDirectionBinCount; ++bin) { + if (directionCounts[bin] == 0) { + continue; + } + const auto cosine = operationalConflictDirectionCosine(anchorBin, bin); + if (cosine >= 0.5) { + candidate.forwardCount += directionCounts[bin]; + } else if (cosine <= kScenarioOperationalConflictCosineThreshold) { + candidate.reverseCount += directionCounts[bin]; + } + } + + const auto forwardRatio = static_cast(candidate.forwardCount) + / static_cast(candidate.movingAgentCount); + const auto reverseRatio = static_cast(candidate.reverseCount) + / static_cast(candidate.movingAgentCount); + if (forwardRatio < kScenarioOperationalConflictSideRatioThreshold + || reverseRatio < kScenarioOperationalConflictSideRatioThreshold) { + continue; + } + + candidate.counterflowRatio = std::min(forwardRatio, reverseRatio); + candidate.speedDropRatio = operationalConflictSpeedDropRatio(averageSpeed); + candidate.conflictScore = operationalConflictScore(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.conflictScore <= 0.0 ? std::nullopt : std::optional{best}; +} + double pressureFeedbackLevel(double compressionForce, double exposureSeconds, bool critical) { if (critical) { return 1.0; @@ -356,7 +545,10 @@ ScenarioRiskLevel completionRiskLevel( std::size_t pressureHotspotCount, std::size_t criticalPressureAgentCount, std::size_t criticalPressureEventCount, - std::size_t bottleneckCount) { + std::size_t bottleneckCount, + std::size_t operationalConflictCellCount, + std::size_t operationalConflictConnectionCount, + double peakConflictScore) { if (totalAgentCount == 0 || evacuatedAgentCount >= totalAgentCount) { return ScenarioRiskLevel::Low; } @@ -372,7 +564,9 @@ ScenarioRiskLevel completionRiskLevel( if (elapsedRatio >= 0.8 || stalledRatio >= 0.35 || criticalPressureEventCount > 0 - || bottleneckCount >= 2) { + || bottleneckCount >= 2 + || peakConflictScore >= 0.75 + || operationalConflictConnectionCount >= 2) { return ScenarioRiskLevel::High; } if (elapsedRatio >= 0.5 @@ -380,7 +574,10 @@ ScenarioRiskLevel completionRiskLevel( || hotspotCount > 0 || pressureHotspotCount > 0 || criticalPressureAgentCount > 0 - || bottleneckCount > 0) { + || bottleneckCount > 0 + || operationalConflictCellCount > 0 + || operationalConflictConnectionCount > 0 + || peakConflictScore >= 0.45) { return ScenarioRiskLevel::Medium; } return ScenarioRiskLevel::Low; @@ -670,6 +867,7 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { void configure(engine::EngineWorld& world) override { world.resources().set(ScenarioRiskMetricsResource{}); world.resources().set(ScenarioPressureTrackingResource{}); + world.resources().set(ScenarioOperationalConflictResource{}); } void update(engine::EngineWorld& world, const engine::EngineStepContext& step) override { @@ -753,6 +951,15 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { collectPressureHotspots(snapshot, query, cells); collectCriticalPressureEvents(snapshot, cells, clock.elapsedSeconds, deltaSeconds, pressureTracking); collectBottlenecks(snapshot, activeAgents, *pressureIndex, activeLayout); + collectOperationalConflicts( + snapshot, + activeAgents, + *pressureIndex, + activeLayout, + query, + clock, + deltaSeconds, + resources.get()); snapshot.completionRisk = completionRiskLevel( clock, @@ -763,11 +970,16 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { snapshot.pressureHotspots.size(), snapshot.criticalPressureAgentCount, snapshot.criticalPressureEvents.size(), - snapshot.bottlenecks.size()); + snapshot.bottlenecks.size(), + snapshot.operationalConflictCells.size(), + snapshot.operationalConflictConnections.size(), + snapshot.peakConflictScore); if (!snapshot.hotspots.empty() || !snapshot.pressureHotspots.empty() || !snapshot.criticalPressureEvents.empty() - || !snapshot.bottlenecks.empty()) { + || !snapshot.bottlenecks.empty() + || !snapshot.operationalConflictCells.empty() + || !snapshot.operationalConflictConnections.empty()) { attachDetectionState(snapshot, captureSimulationFrame(query, clock), clock.elapsedSeconds); } @@ -789,6 +1001,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.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; } @@ -804,6 +1021,16 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { if (isBottleneckSetWorse(current.bottlenecks, peak.bottlenecks)) { peak.bottlenecks = current.bottlenecks; } + if (isOperationalConflictCellSetWorse( + current.operationalConflictCells, + peak.operationalConflictCells)) { + peak.operationalConflictCells = current.operationalConflictCells; + } + if (isOperationalConflictConnectionSetWorse( + current.operationalConflictConnections, + peak.operationalConflictConnections)) { + peak.operationalConflictConnections = current.operationalConflictConnections; + } } void attachDetectionState( @@ -826,6 +1053,14 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { bottleneck.detectedAtSeconds = elapsedSeconds; bottleneck.detectionFrame = frame; } + for (auto& cell : snapshot.operationalConflictCells) { + cell.detectedAtSeconds = elapsedSeconds; + cell.detectionFrame = frame; + } + for (auto& connection : snapshot.operationalConflictConnections) { + connection.detectedAtSeconds = elapsedSeconds; + connection.detectionFrame = frame; + } } double updatePressureTracking( @@ -1279,6 +1514,313 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { }); } + void collectOperationalConflicts( + ScenarioRiskSnapshot& snapshot, + const std::vector& activeAgents, + const ScenarioAgentSpatialIndexResource& spatialIndex, + const FacilityLayout2D& layout, + engine::WorldQuery& query, + const ScenarioSimulationClockResource& clock, + double deltaSeconds, + ScenarioOperationalConflictResource& operationalConflict) const { + (void)query; + operationalConflict.previousElapsedSeconds = clock.elapsedSeconds; + operationalConflict.hasPreviousElapsedSeconds = true; + + std::unordered_map activeAgentIndices; + activeAgentIndices.reserve(activeAgents.size()); + for (std::size_t index = 0; index < activeAgents.size(); ++index) { + activeAgentIndices.emplace(activeAgents[index].agentId, index); + } + + std::unordered_map cells; + cells.reserve(activeAgents.size()); + for (const auto& agent : activeAgents) { + const auto speed = lengthOf(agent.velocity); + if (speed <= 0.05) { + continue; + } + + const auto address = operationalConflictCellAddress(agent.position, agent.floorId); + auto& cell = cells[operationalConflictCellKey(address)]; + if (cell.movingAgentCount == 0) { + 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[operationalConflictDirectionBinForVelocity(agent.velocity)]; + } + + 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.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) { + continue; + } + + const auto averageSpeed = cell.speedSum / static_cast(cell.movingAgentCount); + const auto observation = detectOperationalConflict( + cell.directionCounts, + cell.movingAgentCount, + averageSpeed); + if (!observation.has_value()) { + continue; + } + + activeCellKeys.insert(cellKey); + const auto [stateIt, inserted] = operationalConflict.activeCellsByAddress.try_emplace(cellKey); + auto& state = stateIt->second; + if (inserted) { + state.startedAtSeconds = clock.elapsedSeconds; + } + state.exposureAgentSeconds += static_cast(observation->movingAgentCount) * std::max(0.0, deltaSeconds); + state.peakConflictScore = std::max(state.peakConflictScore, observation->conflictScore); + state.peakAgentCount = std::max(state.peakAgentCount, observation->movingAgentCount); + + const auto count = static_cast(cell.movingAgentCount); + const Point2D center{ + .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.conflictAgentCount += observation->movingAgentCount; + snapshot.peakConflictScore = std::max(snapshot.peakConflictScore, observation->conflictScore); + snapshot.totalConflictExposureAgentSeconds += state.exposureAgentSeconds; + snapshot.operationalConflictCells.push_back({ + .center = center, + .cellMin = cell.cellMin, + .cellMax = cell.cellMax, + .floorId = cell.floorId, + .movingAgentCount = observation->movingAgentCount, + .peakAgentCount = state.peakAgentCount, + .forwardCount = observation->forwardCount, + .reverseCount = observation->reverseCount, + .counterflowRatio = observation->counterflowRatio, + .averageSpeed = observation->averageSpeed, + .speedDropRatio = observation->speedDropRatio, + .conflictScore = state.peakConflictScore, + .durationSeconds = durationSeconds, + .exposureAgentSeconds = state.exposureAgentSeconds, + .nearestConnectionId = state.nearestConnectionId, + .nearestConnectionLabel = state.nearestConnectionLabel, + }); + } + + for (auto it = operationalConflict.activeCellsByAddress.begin(); + it != operationalConflict.activeCellsByAddress.end();) { + if (activeCellKeys.contains(it->first)) { + ++it; + continue; + } + it = operationalConflict.activeCellsByAddress.erase(it); + } + + 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) - kScenarioOperationalConflictInfluenceRadius, + .y = std::min(passage.start.y, passage.end.y) - kScenarioOperationalConflictInfluenceRadius, + }; + const Point2D maxPoint{ + .x = std::max(passage.start.x, passage.end.x) + kScenarioOperationalConflictInfluenceRadius, + .y = std::max(passage.start.y, passage.end.y) + kScenarioOperationalConflictInfluenceRadius, + }; + 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; + }; + + for (const auto& connection : layout.connections) { + if (connection.directionality == TravelDirection::Closed) { + continue; + } + + auto& state = operationalConflict.connectionsById[connection.id]; + state.connectionId = connection.id; + state.label = connectionLabel(layout, connection); + state.floorId = connectionFloorId(layout, connection); + state.passage = connection.centerSpan; + + std::size_t nearbyAgentCount = 0; + std::size_t queueAgentCount = 0; + double speedSum = 0.0; + std::array directionCounts{}; + std::size_t movingAgentCount = 0; + double movingSpeedSum = 0.0; + + for (const auto entity : nearbyEntitiesForConnection(state.floorId, connection.centerSpan)) { + const auto activeIt = activeAgentIndices.find(entity.index); + if (activeIt == activeAgentIndices.end()) { + continue; + } + const auto& agent = activeAgents[activeIt->second]; + if (agent.floorId != state.floorId) { + continue; + } + const auto distanceToConnection = distanceBetween( + agent.position, + closestPointOnSegment(agent.position, connection.centerSpan.start, connection.centerSpan.end)); + if (distanceToConnection > kScenarioOperationalConflictInfluenceRadius) { + continue; + } + + const auto speed = lengthOf(agent.velocity); + ++nearbyAgentCount; + speedSum += speed; + if (speed <= kScenarioOperationalConflictQueueSpeedThreshold + 1e-9) { + ++queueAgentCount; + } + if (speed > 0.05) { + ++movingAgentCount; + movingSpeedSum += speed; + ++directionCounts[operationalConflictDirectionBinForVelocity(agent.velocity)]; + } + } + + if (nearbyAgentCount > 0) { + state.observedSpeedSum += speedSum / static_cast(nearbyAgentCount); + ++state.observedSpeedSamples; + } + if (queueAgentCount > 0) { + state.queueExposureAgentSeconds += static_cast(queueAgentCount) * std::max(0.0, deltaSeconds); + } + state.currentQueueAgents = queueAgentCount; + if (queueAgentCount > state.peakQueuedAgents) { + state.peakQueuedAgents = queueAgentCount; + state.peakQueuedAtSeconds = clock.elapsedSeconds; + } + + std::optional observation; + if (movingAgentCount > 0) { + observation = detectOperationalConflict( + directionCounts, + movingAgentCount, + movingSpeedSum / static_cast(movingAgentCount)); + } + + if (!observation.has_value()) { + 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(observation->movingAgentCount) * std::max(0.0, deltaSeconds); + + snapshot.peakConflictScore = std::max(snapshot.peakConflictScore, observation->conflictScore); + snapshot.operationalConflictConnections.push_back({ + .connectionId = state.connectionId, + .label = state.label, + .floorId = state.floorId, + .passage = state.passage, + .nearbyAgentCount = nearbyAgentCount, + .movingAgentCount = observation->movingAgentCount, + .queueAgentCount = queueAgentCount, + .forwardCount = observation->forwardCount, + .reverseCount = observation->reverseCount, + .counterflowRatio = observation->counterflowRatio, + .averageSpeed = observation->averageSpeed, + .speedDropRatio = observation->speedDropRatio, + .conflictScore = state.peakConflictScore, + .durationSeconds = durationSeconds, + .exposureAgentSeconds = state.counterflowExposureAgentSeconds, + }); + } + + 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; + }); + 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; + }); + } + FacilityLayout2D layout_{}; }; diff --git a/src/domain/ScenarioSimulationMotionSystem.cpp b/src/domain/ScenarioSimulationMotionSystem.cpp index b66e1a2..ec92698 100644 --- a/src/domain/ScenarioSimulationMotionSystem.cpp +++ b/src/domain/ScenarioSimulationMotionSystem.cpp @@ -70,7 +70,11 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { if (!resources.contains()) { resources.set(ScenarioEnvironmentReactionResource{}); } + if (!resources.contains()) { + resources.set(ScenarioOperationalConflictResource{}); + } auto* reactions = &resources.get(); + auto* operationalConflict = &resources.get(); const auto* activeHazards = resources.contains() ? &resources.get() : nullptr; @@ -87,9 +91,15 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { reactions, activeHazards, sharedSpatialIndex); - advanceRoutesForCurrentZones(query, activeEntities_, layoutCache); + advanceRoutesForCurrentZones(query, activeEntities_, layoutCache, operationalConflict, clock.elapsedSeconds); replanBlockedExitRoutes(query, activeEntities_, layoutCache, clock.elapsedSeconds, layoutRevision, reactions); - advanceRoutesForWaypointProgress(query, 0.0, activeEntities_, layoutCache); + advanceRoutesForWaypointProgress( + query, + 0.0, + activeEntities_, + layoutCache, + operationalConflict, + clock.elapsedSeconds); replanBlockedRouteSegments(query, activeEntities_, layoutCache, clock.elapsedSeconds, layoutRevision); replanHazardAwareExitRoutes(query, activeEntities_, layoutCache, clock.elapsedSeconds, reactions, activeHazards); updateAgentPhysicsFloorIds(query, layoutCache, activeEntities_); @@ -209,7 +219,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { static_cast(agent.radius)); const auto distance = distanceBetween(position.value, target); if (!verticalTransition && !transitionWaypoint && distance <= kArrivalEpsilon) { - const auto advance = advanceRouteWaypoint(layoutCache, route, agent, target); + const auto advance = advanceRouteWaypoint( + layoutCache, + route, + agent, + target, + operationalConflict, + clock.elapsedSeconds); position.value = advance.position; velocity.value = {}; continue; @@ -337,14 +353,30 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { updateDisplayFloor(route, nextPosition); } - advanceVerticalRoutesAtPortal(query, activeEntities_, layoutCache); + advanceVerticalRoutesAtPortal( + query, + activeEntities_, + layoutCache, + operationalConflict, + clock.elapsedSeconds); updateAgentPhysicsFloorIds(query, layoutCache, activeEntities_); resolveAgentOverlaps(query, activeEntities_, layoutCache); - advanceRoutesForCurrentZones(query, activeEntities_, layoutCache); - advanceRoutesForWaypointProgress(query, clampedDelta, activeEntities_, layoutCache); + advanceRoutesForCurrentZones(query, activeEntities_, layoutCache, operationalConflict, clock.elapsedSeconds); + advanceRoutesForWaypointProgress( + query, + clampedDelta, + activeEntities_, + layoutCache, + operationalConflict, + clock.elapsedSeconds); updateAgentPhysicsFloorIds(query, layoutCache, activeEntities_); resolveAgentOverlaps(query, activeEntities_, layoutCache); - advanceVerticalRoutesAtPortal(query, activeEntities_, layoutCache); + advanceVerticalRoutesAtPortal( + query, + activeEntities_, + layoutCache, + operationalConflict, + clock.elapsedSeconds); updateAgentPhysicsFloorIds(query, layoutCache, activeEntities_); const auto pendingScheduledSpawns = resources.contains() ? resources.get().pendingCount @@ -969,16 +1001,90 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { return true; } + void recordConnectionTraversal( + const ScenarioLayoutCacheResource& layoutCache, + const EvacuationRoute& route, + std::size_t reachedIndex, + ScenarioOperationalConflictResource* operationalConflict, + double elapsedSeconds) const { + if (operationalConflict == nullptr + || reachedIndex >= route.waypointConnectionIds.size()) { + return; + } + + const auto& connectionId = route.waypointConnectionIds[reachedIndex]; + if (connectionId.empty()) { + return; + } + + const auto connectionIndexIt = layoutCache.connectionIndices.find(connectionId); + if (connectionIndexIt == layoutCache.connectionIndices.end() + || connectionIndexIt->second >= layoutCache.layout.connections.size()) { + return; + } + + const auto& connection = layoutCache.layout.connections[connectionIndexIt->second]; + auto& state = operationalConflict->connectionsById[connection.id]; + state.connectionId = connection.id; + state.label = connection.id; + const auto* fromZone = findCachedZone(layoutCache, connection.fromZoneId); + const auto* toZone = findCachedZone(layoutCache, connection.toZoneId); + if (fromZone != nullptr && toZone != nullptr) { + const auto fromLabel = fromZone->label.empty() ? fromZone->id : fromZone->label; + const auto toLabel = toZone->label.empty() ? toZone->id : toZone->label; + state.label = fromLabel + " -> " + toLabel; + } + state.floorId = !connection.floorId.empty() + ? connection.floorId + : cachedFloorIdForZone(layoutCache, connection.fromZoneId); + state.passage = connection.centerSpan; + ++state.traversalCount; + + const auto fromZoneId = reachedIndex < route.waypointFromZoneIds.size() + ? route.waypointFromZoneIds[reachedIndex] + : std::string{}; + const auto toZoneId = reachedIndex < route.waypointZoneIds.size() + ? route.waypointZoneIds[reachedIndex] + : std::string{}; + if (!fromZoneId.empty() && !toZoneId.empty()) { + if (connection.fromZoneId == fromZoneId && connection.toZoneId == toZoneId) { + ++state.forwardTraversals; + } else if (connection.toZoneId == fromZoneId && connection.fromZoneId == toZoneId) { + ++state.reverseTraversals; + } + } + + if (state.currentWindowCount == 0 + || elapsedSeconds - state.currentWindowStartSeconds >= kScenarioOperationalConflictWindowSeconds) { + state.currentWindowStartSeconds = elapsedSeconds; + state.currentWindowCount = 1; + } else { + ++state.currentWindowCount; + } + if (state.currentWindowCount >= state.peakWindowCount) { + state.peakWindowCount = state.currentWindowCount; + state.peakWindowAtSeconds = elapsedSeconds; + } + } + RouteAdvanceResult advanceRouteWaypoint( const ScenarioLayoutCacheResource& layoutCache, EvacuationRoute& route, const Agent& agent, - const Point2D& reachedPoint) const { + const Point2D& reachedPoint, + ScenarioOperationalConflictResource* operationalConflict, + double elapsedSeconds) const { if (route.nextWaypointIndex >= route.waypoints.size()) { return {.position = reachedPoint, .advanced = false}; } const auto reachedIndex = route.nextWaypointIndex; + recordConnectionTraversal( + layoutCache, + route, + reachedIndex, + operationalConflict, + elapsedSeconds); const auto transitionStartPoint = route.currentSegmentStart; const auto completedVerticalTransition = reachedIndex < route.waypointVerticalTransitions.size() && route.waypointVerticalTransitions[reachedIndex]; @@ -1073,7 +1179,9 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { engine::WorldQuery& query, double deltaSeconds, const std::vector& entities, - const ScenarioLayoutCacheResource& layoutCache) const { + const ScenarioLayoutCacheResource& layoutCache, + ScenarioOperationalConflictResource* operationalConflict, + double elapsedSeconds) const { for (const auto entity : entities) { const auto& status = query.get(entity); if (status.evacuated) { @@ -1091,7 +1199,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { ? verticalPassageCrossed(layoutCache, route, position.value, agent.radius) : routePassageCrossed(cachedLayoutForFloor(layoutCache, route.currentFloorId), route, position.value, agent.radius); if (passageCrossed) { - const auto advance = advanceRouteWaypoint(layoutCache, route, agent, position.value); + const auto advance = advanceRouteWaypoint( + layoutCache, + route, + agent, + position.value, + operationalConflict, + elapsedSeconds); position.value = advance.position; if (advance.advanced) { continue; @@ -1105,7 +1219,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { const auto distance = distanceToRouteWaypoint(route, position.value); if (!verticalTransition && !transitionWaypoint && distance <= kArrivalEpsilon) { - const auto advance = advanceRouteWaypoint(layoutCache, route, agent, target); + const auto advance = advanceRouteWaypoint( + layoutCache, + route, + agent, + target, + operationalConflict, + elapsedSeconds); position.value = advance.position; if (advance.advanced) { continue; @@ -1118,7 +1238,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { if (!verticalTransition && !transitionWaypoint && projection >= segmentLengthSquared - kWaypointCrossingEpsilon) { - const auto advance = advanceRouteWaypoint(layoutCache, route, agent, position.value); + const auto advance = advanceRouteWaypoint( + layoutCache, + route, + agent, + position.value, + operationalConflict, + elapsedSeconds); position.value = advance.position; if (advance.advanced) { continue; @@ -1132,7 +1258,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { segment, segmentLengthSquared, projection)) { - const auto advance = advanceRouteWaypoint(layoutCache, route, agent, position.value); + const auto advance = advanceRouteWaypoint( + layoutCache, + route, + agent, + position.value, + operationalConflict, + elapsedSeconds); position.value = advance.position; if (advance.advanced) { continue; @@ -1172,7 +1304,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { && segmentLengthSquared > 1e-9) { const auto projection = dot(position.value - route.currentSegmentStart, segment); if (projection > segmentLengthSquared * 0.45) { - const auto advance = advanceRouteWaypoint(layoutCache, route, agent, position.value); + const auto advance = advanceRouteWaypoint( + layoutCache, + route, + agent, + position.value, + operationalConflict, + elapsedSeconds); position.value = advance.position; if (advance.advanced) { continue; @@ -1190,7 +1328,9 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { void advanceVerticalRoutesAtPortal( engine::WorldQuery& query, const std::vector& entities, - const ScenarioLayoutCacheResource& layoutCache) const { + const ScenarioLayoutCacheResource& layoutCache, + ScenarioOperationalConflictResource* operationalConflict, + double elapsedSeconds) const { for (const auto entity : entities) { const auto& status = query.get(entity); if (status.evacuated) { @@ -1205,7 +1345,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { break; } - const auto advance = advanceRouteWaypoint(layoutCache, route, agent, position.value); + const auto advance = advanceRouteWaypoint( + layoutCache, + route, + agent, + position.value, + operationalConflict, + elapsedSeconds); position.value = advance.position; if (!advance.advanced) { break; @@ -1217,7 +1363,9 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { void advanceRoutesForCurrentZones( engine::WorldQuery& query, const std::vector& entities, - const ScenarioLayoutCacheResource& layoutCache) const { + const ScenarioLayoutCacheResource& layoutCache, + ScenarioOperationalConflictResource* operationalConflict, + double elapsedSeconds) const { for (const auto entity : entities) { const auto& status = query.get(entity); if (status.evacuated) { @@ -1253,7 +1401,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { break; } while (route.nextWaypointIndex <= matchedIndex && route.nextWaypointIndex < route.waypoints.size()) { - const auto advance = advanceRouteWaypoint(layoutCache, route, agent, position.value); + const auto advance = advanceRouteWaypoint( + layoutCache, + route, + agent, + position.value, + operationalConflict, + elapsedSeconds); position.value = advance.position; if (!advance.advanced) { break; diff --git a/src/domain/ScenarioSimulationSystems.cpp b/src/domain/ScenarioSimulationSystems.cpp index 1783404..d3df502 100644 --- a/src/domain/ScenarioSimulationSystems.cpp +++ b/src/domain/ScenarioSimulationSystems.cpp @@ -1630,6 +1630,136 @@ void ScenarioResultArtifactsSystem::update(engine::EngineWorld& world, const eng }); } + result.artifacts.operationalConflictSummary = {}; + result.artifacts.connectionUsage.clear(); + if (resources.contains()) { + const auto& operationalConflict = resources.get(); + std::size_t totalTraversals = 0; + for (const auto& [_, state] : operationalConflict.connectionsById) { + totalTraversals += state.traversalCount; + } + + result.artifacts.connectionUsage.reserve(operationalConflict.connectionsById.size()); + for (const auto& [_, state] : operationalConflict.connectionsById) { + ConnectionUsageMetric metric; + metric.connectionId = state.connectionId; + metric.label = state.label; + metric.floorId = state.floorId; + metric.traversalCount = state.traversalCount; + metric.usageRatio = totalTraversals == 0 + ? 0.0 + : static_cast(state.traversalCount) / static_cast(totalTraversals); + metric.peakWindowCount = state.peakWindowCount; + metric.peakAtSeconds = state.peakWindowAtSeconds; + metric.forwardTraversals = state.forwardTraversals; + metric.reverseTraversals = state.reverseTraversals; + metric.queueExposureAgentSeconds = state.queueExposureAgentSeconds; + metric.peakQueuedAgents = state.peakQueuedAgents; + metric.averageObservedSpeed = state.observedSpeedSamples == 0 + ? 0.0 + : state.observedSpeedSum / static_cast(state.observedSpeedSamples); + metric.peakConflictScore = state.peakConflictScore; + metric.longestConflictDurationSeconds = state.longestConflictDurationSeconds; + metric.counterflowEventCount = state.counterflowEventCount; + result.artifacts.connectionUsage.push_back(std::move(metric)); + } + std::sort(result.artifacts.connectionUsage.begin(), result.artifacts.connectionUsage.end(), [](const auto& lhs, const auto& rhs) { + if (std::fabs(lhs.peakConflictScore - rhs.peakConflictScore) > 1e-9) { + return lhs.peakConflictScore > rhs.peakConflictScore; + } + if (lhs.traversalCount != rhs.traversalCount) { + return lhs.traversalCount > rhs.traversalCount; + } + return lhs.connectionId < rhs.connectionId; + }); + + auto& summary = result.artifacts.operationalConflictSummary; + double concentrationIndex = 0.0; + for (const auto& metric : result.artifacts.connectionUsage) { + concentrationIndex += metric.usageRatio * metric.usageRatio; + summary.longestConflictDurationSeconds = + std::max(summary.longestConflictDurationSeconds, metric.longestConflictDurationSeconds); + summary.peakQueuedAgents = std::max(summary.peakQueuedAgents, metric.peakQueuedAgents); + } + summary.connectionConcentrationIndex = concentrationIndex; + summary.conflictConnectionCount = static_cast(std::count_if( + result.artifacts.connectionUsage.begin(), + result.artifacts.connectionUsage.end(), + [](const auto& metric) { + return metric.peakConflictScore > 0.0 || metric.counterflowEventCount > 0; + })); + + const auto topConflictIt = std::max_element( + result.artifacts.connectionUsage.begin(), + result.artifacts.connectionUsage.end(), + [](const auto& lhs, const auto& rhs) { + if (std::fabs(lhs.peakConflictScore - rhs.peakConflictScore) > 1e-9) { + return lhs.peakConflictScore < rhs.peakConflictScore; + } + return lhs.longestConflictDurationSeconds < rhs.longestConflictDurationSeconds; + }); + if (topConflictIt != result.artifacts.connectionUsage.end()) { + summary.topConflictConnectionId = topConflictIt->connectionId; + summary.topConflictConnectionLabel = topConflictIt->label; + summary.peakConflictScore = topConflictIt->peakConflictScore; + } + } + + if (resources.contains()) { + const auto& metrics = resources.get(); + result.artifacts.operationalConflictSummary.peakConflictScore = + std::max( + result.artifacts.operationalConflictSummary.peakConflictScore, + metrics.peakSnapshot.peakConflictScore); + result.artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds = + std::max( + result.artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds, + metrics.peakSnapshot.totalConflictExposureAgentSeconds); + result.artifacts.operationalConflictSummary.counterflowHotspotCount = + std::max( + result.artifacts.operationalConflictSummary.counterflowHotspotCount, + metrics.peakSnapshot.operationalConflictCells.size()); + + if (!metrics.peakSnapshot.operationalConflictCells.empty()) { + result.artifacts.operationalConflictSummary.peakAtSeconds = + metrics.peakSnapshot.operationalConflictCells.front().detectedAtSeconds; + if (result.artifacts.operationalConflictSummary.topConflictConnectionId.empty()) { + result.artifacts.operationalConflictSummary.topConflictConnectionId = + metrics.peakSnapshot.operationalConflictCells.front().nearestConnectionId; + result.artifacts.operationalConflictSummary.topConflictConnectionLabel = + metrics.peakSnapshot.operationalConflictCells.front().nearestConnectionLabel; + } + } else if (!metrics.peakSnapshot.operationalConflictConnections.empty()) { + result.artifacts.operationalConflictSummary.peakAtSeconds = + metrics.peakSnapshot.operationalConflictConnections.front().detectedAtSeconds; + if (result.artifacts.operationalConflictSummary.topConflictConnectionId.empty()) { + result.artifacts.operationalConflictSummary.topConflictConnectionId = + metrics.peakSnapshot.operationalConflictConnections.front().connectionId; + result.artifacts.operationalConflictSummary.topConflictConnectionLabel = + metrics.peakSnapshot.operationalConflictConnections.front().label; + } + } + + if (result.artifacts.operationalConflictTimeline.empty() + || std::abs( + result.artifacts.operationalConflictTimeline.back().timeSeconds - elapsedSeconds) > 1e-9) { + std::size_t queuedAgents = 0; + if (resources.contains()) { + const auto& operationalConflict = resources.get(); + for (const auto& [_, state] : operationalConflict.connectionsById) { + queuedAgents += state.currentQueueAgents; + } + } + result.artifacts.operationalConflictTimeline.push_back({ + .timeSeconds = elapsedSeconds, + .peakConflictScore = metrics.snapshot.peakConflictScore, + .activeConflictCellCount = metrics.snapshot.operationalConflictCells.size(), + .activeConflictConnectionCount = metrics.snapshot.operationalConflictConnections.size(), + .queuedAgentsNearConnections = queuedAgents, + }); + } + } + result.artifacts.evacuationProgress.push_back({ .timeSeconds = elapsedSeconds, .evacuatedCount = evacuatedCount, diff --git a/src/domain/ScenarioSimulationSystems.h b/src/domain/ScenarioSimulationSystems.h index aa722d1..ff93f60 100644 --- a/src/domain/ScenarioSimulationSystems.h +++ b/src/domain/ScenarioSimulationSystems.h @@ -137,6 +137,48 @@ struct ScenarioHazardExposureResource { std::unordered_map hazardsById{}; }; +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 peakConflictScore{0.0}; + std::size_t peakAgentCount{0}; + std::string nearestConnectionId{}; + std::string nearestConnectionLabel{}; +}; + +struct ScenarioOperationalConflictResource { + bool hasPreviousElapsedSeconds{false}; + double previousElapsedSeconds{0.0}; + std::unordered_map activeCellsByAddress{}; + std::unordered_map connectionsById{}; +}; + struct ScenarioResultArtifactsResource { ScenarioResultArtifacts artifacts{}; std::size_t lastRecordedEvacuatedCount{static_cast(-1)}; diff --git a/tests/AlternativeRecommendationServiceTests.cpp b/tests/AlternativeRecommendationServiceTests.cpp index a59641a..b2ec763 100644 --- a/tests/AlternativeRecommendationServiceTests.cpp +++ b/tests/AlternativeRecommendationServiceTests.cpp @@ -206,6 +206,77 @@ ScenarioResultArtifacts makeCrossFloorOpposingFlowArtifacts(double endSeconds = return artifacts; } +ScenarioRiskSnapshot makeOperationalConflictRisk() { + ScenarioRiskSnapshot risk; + risk.completionRisk = ScenarioRiskLevel::Medium; + 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, + .forwardCount = 4, + .reverseCount = 3, + .counterflowRatio = 3.0 / 7.0, + .averageSpeed = 0.55, + .speedDropRatio = 0.58, + .conflictScore = 0.78, + .durationSeconds = 14.0, + .exposureAgentSeconds = 22.5, + .nearestConnectionId = "door-main", + .nearestConnectionLabel = "Main Door", + }); + risk.operationalConflictConnections.push_back({ + .connectionId = "door-main", + .label = "Main Door", + .floorId = "L1", + .nearbyAgentCount = 7, + .movingAgentCount = 7, + .queueAgentCount = 3, + .forwardCount = 4, + .reverseCount = 3, + .counterflowRatio = 3.0 / 7.0, + .averageSpeed = 0.55, + .speedDropRatio = 0.58, + .conflictScore = 0.78, + .durationSeconds = 14.0, + .exposureAgentSeconds = 22.5, + }); + return risk; +} + +ScenarioResultArtifacts makeOperationalConflictArtifacts() { + ScenarioResultArtifacts artifacts = makeCompletedArtifacts(); + 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"; + artifacts.connectionUsage.push_back({ + .connectionId = "door-main", + .label = "Main Door", + .floorId = "L1", + .traversalCount = 10, + .usageRatio = 0.62, + .peakWindowCount = 5, + .forwardTraversals = 6, + .reverseTraversals = 4, + .queueExposureAgentSeconds = 5.0, + .peakQueuedAgents = 3, + .averageObservedSpeed = 0.63, + .peakConflictScore = 0.78, + .longestConflictDurationSeconds = 14.0, + .counterflowEventCount = 2, + }); + return artifacts; +} + ScenarioResultArtifacts makeSingleExitUsageArtifacts( std::string exitZoneId, std::string exitLabel, @@ -761,6 +832,25 @@ SC_TEST(AlternativeRecommendationService_detectsSustainedCounterflowConflict) { SC_EXPECT_TRUE(containsDiffKey(it->recommendedScenario, "control.events")); } +SC_TEST(AlternativeRecommendationService_prefersOperationalConflictMetricsOverReplayHeuristics) { + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = makeScenario(), + .risk = makeOperationalConflictRisk(), + .artifacts = makeOperationalConflictArtifacts(), + }); + + const auto it = std::find_if(result.candidates.begin(), result.candidates.end(), [](const auto& candidate) { + return candidate.kind == AlternativeRecommendationKind::CounterflowSeparation; + }); + SC_EXPECT_TRUE(hasRiskSignalKind(result, AlternativeRecommendationRiskKind::CounterflowConflict)); + SC_EXPECT_TRUE(it != result.candidates.end()); + SC_EXPECT_TRUE(containsEvidenceSource(*it, "ScenarioRiskSnapshot.operationalConflictConnections")); + SC_EXPECT_TRUE(containsEvidenceSource(*it, "ScenarioResultArtifacts.operationalConflictSummary")); + SC_EXPECT_EQ(it->recommendedScenario.control.events.size(), std::size_t{1}); +} + SC_TEST(AlternativeRecommendationService_ignoresSeparatedOpposingFlows) { const AlternativeRecommendationService service; const auto result = service.recommend({ diff --git a/tests/ProjectPersistenceTests.cpp b/tests/ProjectPersistenceTests.cpp index cc631b4..8beb058 100644 --- a/tests/ProjectPersistenceTests.cpp +++ b/tests/ProjectPersistenceTests.cpp @@ -162,3 +162,110 @@ SC_TEST(ProjectPersistence_preservesImportArtifactsBesideLayoutReview) { SC_EXPECT_EQ(loaded.artifacts.selectedRules.rules.size(), std::size_t{1}); SC_EXPECT_EQ(loaded.artifacts.selectedRules.rules.front().tokens.front(), std::string{"A-WALL"}); } + +SC_TEST(ProjectPersistence_preservesOperationalConflictResultState) { + QTemporaryDir projectDir; + SC_EXPECT_TRUE(projectDir.isValid()); + + ScenarioDraft scenario; + scenario.scenarioId = "scenario-conflict"; + scenario.name = "Operational Conflict Scenario"; + + ScenarioRiskSnapshot risk; + risk.completionRisk = ScenarioRiskLevel::Medium; + 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, + .forwardCount = 3, + .reverseCount = 3, + .counterflowRatio = 0.5, + .averageSpeed = 0.58, + .speedDropRatio = 0.55, + .conflictScore = 0.74, + .durationSeconds = 10.0, + .exposureAgentSeconds = 19.5, + .nearestConnectionId = "door-main", + .nearestConnectionLabel = "Main Door", + }); + risk.operationalConflictConnections.push_back({ + .connectionId = "door-main", + .label = "Main Door", + .floorId = "L1", + .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, + }); + + ScenarioResultArtifacts artifacts; + artifacts.operationalConflictSummary.peakConflictScore = 0.74; + artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds = 19.5; + artifacts.operationalConflictSummary.longestConflictDurationSeconds = 10.0; + artifacts.operationalConflictSummary.conflictConnectionCount = 1; + artifacts.operationalConflictSummary.connectionConcentrationIndex = 0.61; + artifacts.operationalConflictSummary.topConflictConnectionId = "door-main"; + artifacts.operationalConflictSummary.topConflictConnectionLabel = "Main Door"; + artifacts.connectionUsage.push_back({ + .connectionId = "door-main", + .label = "Main Door", + .floorId = "L1", + .traversalCount = 8, + .usageRatio = 0.61, + .peakWindowCount = 4, + .forwardTraversals = 5, + .reverseTraversals = 3, + .queueExposureAgentSeconds = 3.5, + .peakQueuedAgents = 2, + .averageObservedSpeed = 0.64, + .peakConflictScore = 0.74, + .longestConflictDurationSeconds = 10.0, + .counterflowEventCount = 2, + }); + artifacts.operationalConflictTimeline.push_back({ + .timeSeconds = 12.0, + .peakConflictScore = 0.74, + .activeConflictCellCount = 1, + .activeConflictConnectionCount = 1, + .queuedAgentsNearConnections = 2, + }); + + ProjectWorkspaceState workspace; + workspace.activeView = ProjectWorkspaceView::ScenarioResult; + workspace.result = SavedScenarioResultState{ + .scenario = scenario, + .risk = risk, + .artifacts = artifacts, + .navigationView = SavedResultNavigationView::OperationalConflict, + }; + + const ProjectMetadata metadata{ + .name = "Operational Conflict Persistence", + .folderPath = projectDir.path(), + }; + + QString errorMessage; + SC_EXPECT_TRUE(ProjectPersistence::saveProjectWorkspace(metadata, workspace, &errorMessage)); + + ProjectWorkspaceState loaded; + SC_EXPECT_TRUE(ProjectPersistence::loadProjectWorkspace(metadata, &loaded)); + SC_EXPECT_TRUE(loaded.result.has_value()); + 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->artifacts.operationalConflictSummary.peakConflictScore, 0.74, 1e-9); + SC_EXPECT_EQ(loaded.result->artifacts.connectionUsage.size(), std::size_t{1}); + SC_EXPECT_EQ(loaded.result->artifacts.operationalConflictTimeline.size(), std::size_t{1}); +} diff --git a/tests/ScenarioSimulationSystemsTests.cpp b/tests/ScenarioSimulationSystemsTests.cpp index 8a007f2..1b6a3f2 100644 --- a/tests/ScenarioSimulationSystemsTests.cpp +++ b/tests/ScenarioSimulationSystemsTests.cpp @@ -231,6 +231,91 @@ class ConfigureDenseActiveAgentsSystem final : public safecrowd::engine::EngineS } }; +class ConfigureOperationalConflictArtifactsSystem final : public safecrowd::engine::EngineSystem { +public: + void configure(safecrowd::engine::EngineWorld& world) override { + world.resources().set(safecrowd::domain::ScenarioSimulationClockResource{ + .elapsedSeconds = 12.0, + .timeLimitSeconds = 30.0, + .complete = false, + }); + + safecrowd::domain::ScenarioRiskMetricsResource metrics; + metrics.snapshot.peakConflictScore = 0.72; + 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, + .forwardCount = 3, + .reverseCount = 3, + .counterflowRatio = 0.5, + .averageSpeed = 0.6, + .speedDropRatio = 0.54, + .conflictScore = 0.72, + .durationSeconds = 11.0, + .exposureAgentSeconds = 18.0, + .nearestConnectionId = "door-main", + .nearestConnectionLabel = "Main Door", + .detectedAtSeconds = 12.0, + }); + metrics.snapshot.operationalConflictConnections.push_back({ + .connectionId = "door-main", + .label = "Main Door", + .floorId = "L1", + .passage = {{.x = 1.0, .y = 0.0}, {.x = 1.0, .y = 2.0}}, + .nearbyAgentCount = 6, + .movingAgentCount = 6, + .queueAgentCount = 2, + .forwardCount = 3, + .reverseCount = 3, + .counterflowRatio = 0.5, + .averageSpeed = 0.6, + .speedDropRatio = 0.54, + .conflictScore = 0.72, + .durationSeconds = 11.0, + .exposureAgentSeconds = 18.0, + .detectedAtSeconds = 12.0, + }); + metrics.peakSnapshot = metrics.snapshot; + metrics.peakSnapshot.totalConflictExposureAgentSeconds = 18.0; + world.resources().set(std::move(metrics)); + + safecrowd::domain::ScenarioOperationalConflictResource conflicts; + conflicts.connectionsById.emplace("door-main", safecrowd::domain::ScenarioOperationalConflictConnectionState{ + .connectionId = "door-main", + .label = "Main Door", + .floorId = "L1", + .passage = {{.x = 1.0, .y = 0.0}, {.x = 1.0, .y = 2.0}}, + .traversalCount = 10, + .forwardTraversals = 6, + .reverseTraversals = 4, + .peakWindowCount = 5, + .currentWindowStartSeconds = 10.0, + .currentWindowCount = 3, + .peakWindowAtSeconds = 12.0, + .queueExposureAgentSeconds = 4.5, + .peakQueuedAgents = 3, + .currentQueueAgents = 2, + .peakQueuedAtSeconds = 12.0, + .observedSpeedSum = 6.0, + .observedSpeedSamples = 10, + .peakConflictScore = 0.72, + .longestConflictDurationSeconds = 11.0, + .counterflowEventCount = 2, + .counterflowExposureAgentSeconds = 18.0, + .conflictActive = true, + .conflictStartedAtSeconds = 1.0, + }); + world.resources().set(std::move(conflicts)); + } + + void update(safecrowd::engine::EngineWorld&, const safecrowd::engine::EngineStepContext&) override { + } +}; + class ConfigureQuietAgentWithPriorPressureFeedbackSystem final : public safecrowd::engine::EngineSystem { public: void configure(safecrowd::engine::EngineWorld& world) override { @@ -4235,6 +4320,36 @@ SC_TEST(ScenarioResultArtifactsSystem_AccumulatesPressurePeakFieldByFloorAndCell SC_EXPECT_TRUE(hasL2Cell); } +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(1.0), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + + runtime.play(); + runtime.stepFrame(0.0); + + const auto& artifacts = + runtime.world().resources().get().artifacts; + SC_EXPECT_NEAR(artifacts.operationalConflictSummary.peakConflictScore, 0.72, 1e-9); + SC_EXPECT_NEAR(artifacts.operationalConflictSummary.totalConflictExposureAgentSeconds, 18.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.operationalConflictSummary.topConflictConnectionId, std::string{"door-main"}); + SC_EXPECT_EQ(artifacts.connectionUsage.size(), std::size_t{1}); + SC_EXPECT_EQ(artifacts.connectionUsage.front().traversalCount, std::size_t{10}); + SC_EXPECT_EQ(artifacts.connectionUsage.front().counterflowEventCount, std::size_t{2}); + SC_EXPECT_EQ(artifacts.operationalConflictTimeline.size(), std::size_t{1}); + SC_EXPECT_EQ(artifacts.operationalConflictTimeline.front().activeConflictCellCount, std::size_t{1}); + SC_EXPECT_EQ(artifacts.operationalConflictTimeline.front().queuedAgentsNearConnections, std::size_t{2}); +} + SC_TEST(ScenarioRoutePassageCrossed_UsesDoorPlaneNearEndpoint) { safecrowd::domain::FacilityLayout2D layout; layout.zones.push_back({