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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/application/ProjectWorkspaceState.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ enum class SavedResultNavigationView {
Zone,
Groups,
Recommendations,
OperationalConflict,
};

struct SavedScenarioState {
Expand Down
241 changes: 241 additions & 0 deletions src/application/ResultArtifactsCodec.cpp

Large diffs are not rendered by default.

65 changes: 64 additions & 1 deletion src/application/ScenarioBatchResultWidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,8 @@ std::optional<int> existingScenarioIndexBySourceTemplate(

ScenarioResultNavigationView resultNavigationViewFromSaved(SavedResultNavigationView view) {
switch (view) {
case SavedResultNavigationView::OperationalConflict:
return ScenarioResultNavigationView::OperationalConflict;
case SavedResultNavigationView::Hotspot:
return ScenarioResultNavigationView::Hotspot;
case SavedResultNavigationView::Zone:
Expand All @@ -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:
Expand Down Expand Up @@ -968,6 +972,7 @@ QWidget* ScenarioBatchResultWidget::createCanvasPanel() {
overlayCombo_->addItem("Pressure", static_cast<int>(OverlayMode::Pressure));
overlayCombo_->addItem("Hotspots", static_cast<int>(OverlayMode::Hotspots));
overlayCombo_->addItem("Bottlenecks", static_cast<int>(OverlayMode::Bottlenecks));
overlayCombo_->addItem("Operational Conflicts", static_cast<int>(OverlayMode::OperationalConflicts));
overlayCombo_->addItem("None", static_cast<int>(OverlayMode::None));
overlayCombo_->setCurrentIndex(0);
selectorLayout->addWidget(overlayCombo_);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<int>(results_.size())) {
return;
}
const auto& selected = results_[static_cast<std::size_t>(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<int>(results_.size())) {
return;
}
const auto& selected = results_[static_cast<std::size_t>(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<int>(results_.size())) {
return;
Expand All @@ -1868,6 +1915,8 @@ void ScenarioBatchResultWidget::refreshResultNavigationPanel() {
result.risk,
result.artifacts,
std::move(bottleneckFocusHandler),
std::move(operationalConflictCellFocusHandler),
std::move(operationalConflictConnectionFocusHandler),
std::move(hotspotFocusHandler),
shell_));
}
Expand Down Expand Up @@ -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<int>(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)));
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/application/ScenarioBatchResultWidget.h
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ class ScenarioBatchResultWidget : public QWidget {
Pressure = 1,
Hotspots = 2,
Bottlenecks = 3,
None = 4,
OperationalConflicts = 4,
None = 5,
};

QWidget* createCanvasPanel();
Expand Down
173 changes: 173 additions & 0 deletions src/application/ScenarioResultNavigation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ QString formatOptionalSeconds(const std::optional<double>& 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());
Expand Down Expand Up @@ -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<int>(index + 1))
.arg(cell.conflictScore, 0, 'f', 2),
QString("Counterflow %1 | %2 vs %3 movers")
.arg(formatPercent(cell.counterflowRatio))
.arg(static_cast<int>(cell.forwardCount))
.arg(static_cast<int>(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<int>(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<int>(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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<void(std::size_t)> operationalConflictCellFocusHandler,
std::function<void(std::size_t)> 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<int>(artifacts.operationalConflictSummary.conflictConnectionCount))
.arg(static_cast<int>(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<std::size_t>(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<int>(metric.traversalCount))
.arg(formatPercent(metric.usageRatio)),
QString("Peak window %1 | Queue exposure %2 agent-sec")
.arg(static_cast<int>(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";
Expand All @@ -356,6 +510,11 @@ std::vector<WorkspaceNavigationTab> scenarioResultNavigationTabs() {
.label = "Bottleneck",
.icon = makeResultNavigationIcon("bottleneck", QColor("#1f5fae")),
},
{
.id = "operational-conflict",
.label = "Operational Conflict",
.icon = makeResultNavigationIcon("operational-conflict", QColor("#1f5fae")),
},
{
.id = "hotspot",
.label = "Hotspot",
Expand All @@ -381,6 +540,8 @@ std::vector<WorkspaceNavigationTab> scenarioResultNavigationTabs() {

QString scenarioResultNavigationTabId(ScenarioResultNavigationView view) {
switch (view) {
case ScenarioResultNavigationView::OperationalConflict:
return "operational-conflict";
case ScenarioResultNavigationView::Hotspot:
return "hotspot";
case ScenarioResultNavigationView::Zone:
Expand All @@ -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;
}
Expand All @@ -416,9 +580,18 @@ QWidget* createScenarioResultNavigationPanel(
const safecrowd::domain::ScenarioRiskSnapshot& risk,
const safecrowd::domain::ScenarioResultArtifacts& artifacts,
std::function<void(std::size_t)> bottleneckFocusHandler,
std::function<void(std::size_t)> operationalConflictCellFocusHandler,
std::function<void(std::size_t)> operationalConflictConnectionFocusHandler,
std::function<void(std::size_t)> 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:
Expand Down
3 changes: 3 additions & 0 deletions src/application/ScenarioResultNavigation.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ namespace safecrowd::application {

enum class ScenarioResultNavigationView {
Bottleneck,
OperationalConflict,
Hotspot,
Zone,
Groups,
Expand All @@ -31,6 +32,8 @@ QWidget* createScenarioResultNavigationPanel(
const safecrowd::domain::ScenarioRiskSnapshot& risk,
const safecrowd::domain::ScenarioResultArtifacts& artifacts,
std::function<void(std::size_t)> bottleneckFocusHandler,
std::function<void(std::size_t)> operationalConflictCellFocusHandler,
std::function<void(std::size_t)> operationalConflictConnectionFocusHandler,
std::function<void(std::size_t)> hotspotFocusHandler,
QWidget* parent);

Expand Down
Loading
Loading