diff --git a/src/application/ScenarioBatchResultWidget.cpp b/src/application/ScenarioBatchResultWidget.cpp index 49050d7..f020815 100644 --- a/src/application/ScenarioBatchResultWidget.cpp +++ b/src/application/ScenarioBatchResultWidget.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,8 @@ #include #include #include +#include +#include #include #include @@ -73,6 +76,41 @@ QString formatPercent(std::size_t numerator, std::size_t denominator) { return QString("%1%").arg(ratio * 100.0, 0, 'f', 0); } +QString formatPressureScore(double score) { + return QString::number(score, 'f', 1); +} + +QTableWidget* createComparisonTable(const QStringList& headers, QWidget* parent) { + auto* table = new QTableWidget(0, headers.size(), parent); + table->setHorizontalHeaderLabels(headers); + table->verticalHeader()->setVisible(false); + table->horizontalHeader()->setStretchLastSection(true); + table->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + table->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + table->setWordWrap(false); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSelectionMode(QAbstractItemView::NoSelection); + table->setFocusPolicy(Qt::NoFocus); + table->setAlternatingRowColors(true); + table->setStyleSheet( + "QTableWidget { background: #ffffff; border: 1px solid #d7e0ea; gridline-color: #e4ebf3; }" + "QHeaderView::section { background: #eef3f8; border: 0; border-bottom: 1px solid #d7e0ea; padding: 6px; color: #4f5d6b; }" + "QTableWidget::item { padding: 6px; }"); + return table; +} + +QTableWidgetItem* tableItem(const QString& text, bool emphasized = false) { + auto* item = new QTableWidgetItem(text); + item->setFlags(item->flags() & ~Qt::ItemIsEditable); + if (emphasized) { + auto font = item->font(); + font.setBold(true); + item->setFont(font); + item->setBackground(QColor("#eef5ff")); + } + return item; +} + QString scenarioRoleLabel(safecrowd::domain::ScenarioRole role) { switch (role) { case safecrowd::domain::ScenarioRole::Baseline: @@ -658,6 +696,7 @@ QWidget* ScenarioBatchResultWidget::createCanvasPanel() { overlayCombo_ = new QComboBox(selectorBar); overlayCombo_->addItem("Density", static_cast(OverlayMode::Density)); + overlayCombo_->addItem("Pressure", static_cast(OverlayMode::Pressure)); overlayCombo_->addItem("Hotspots", static_cast(OverlayMode::Hotspots)); overlayCombo_->addItem("Bottlenecks", static_cast(OverlayMode::Bottlenecks)); overlayCombo_->addItem("None", static_cast(OverlayMode::None)); @@ -704,6 +743,7 @@ QWidget* ScenarioBatchResultWidget::createCanvasPanel() { auto* tabs = new QTabWidget(graphPanel); remainingChart_ = new ComparisonGraphWidget(ComparisonGraphMode::Remaining, tabs); exitsChart_ = new ComparisonGraphWidget(ComparisonGraphMode::Exits, tabs); + pressureTable_ = createComparisonTable({"Scenario", "Peak score", "Exposed / Critical", "Hotspots", "Events", "Peak at"}, tabs); static_cast(remainingChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_); static_cast(exitsChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_); static_cast(remainingChart_)->setTimingMarkerActivatedHandler([this](double seconds) { @@ -711,6 +751,7 @@ QWidget* ScenarioBatchResultWidget::createCanvasPanel() { }); tabs->addTab(remainingChart_, "Remaining"); tabs->addTab(exitsChart_, "Exits"); + tabs->addTab(pressureTable_, "Pressure"); graphLayout->addWidget(tabs, 1); layout->addWidget(graphPanel, 1); @@ -769,7 +810,7 @@ QWidget* ScenarioBatchResultWidget::createSummaryPanel() { layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(12); - auto* intro = createLabel("Choose which completed scenarios appear together in the Remaining and Exits graphs.", content, ui::FontRole::Caption); + auto* intro = createLabel("Choose which completed scenarios appear together in the comparison graphs and pressure summary table.", content, ui::FontRole::Caption); intro->setStyleSheet(ui::mutedTextStyleSheet()); layout->addWidget(intro); @@ -921,6 +962,12 @@ void ScenarioBatchResultWidget::applyReplayFrameData(const safecrowd::domain::Si ? result.artifacts.densitySummary.peakCells : result.artifacts.densitySummary.peakField.cells, result.artifacts.densitySummary.highDensityThresholdPeoplePerSquareMeter); + canvas_->setPressureOverlay(result.artifacts.pressureSummary.peakField.cells.empty() + ? result.artifacts.pressureSummary.peakCells + : result.artifacts.pressureSummary.peakField.cells, + std::max( + result.artifacts.pressureSummary.hotspotScoreThreshold, + result.artifacts.pressureSummary.peakPressureScore)); applyOverlayModeToCanvas(); } if (replaySlider_ != nullptr && replaySlider_->value() != replayFrameIndex_) { @@ -946,6 +993,9 @@ void ScenarioBatchResultWidget::applyOverlayModeToCanvas() { return; } switch (overlayMode_) { + case OverlayMode::Pressure: + canvas_->setResultOverlayMode(ResultOverlayMode::Pressure); + break; case OverlayMode::Hotspots: canvas_->setResultOverlayMode(ResultOverlayMode::Hotspots); break; @@ -1069,6 +1119,44 @@ void ScenarioBatchResultWidget::refreshComparisonSelection() { if (exitsChart_ != nullptr) { static_cast(exitsChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_); } + refreshPressureComparisonTable(); +} + +void ScenarioBatchResultWidget::refreshPressureComparisonTable() { + if (pressureTable_ == nullptr) { + return; + } + + std::vector visibleIndices = selectedCompareIndices_; + if (visibleIndices.empty() && currentResultIndex_ >= 0 && currentResultIndex_ < static_cast(results_.size())) { + visibleIndices.push_back(currentResultIndex_); + } + + pressureTable_->setRowCount(static_cast(visibleIndices.size())); + for (int row = 0; row < static_cast(visibleIndices.size()); ++row) { + const auto index = visibleIndices[static_cast(row)]; + if (index < 0 || index >= static_cast(results_.size())) { + continue; + } + + const auto& result = results_[static_cast(index)]; + const auto& summary = result.artifacts.pressureSummary; + const bool emphasized = index == currentResultIndex_; + pressureTable_->setItem(row, 0, tableItem(QString::fromStdString(result.scenario.name), emphasized)); + pressureTable_->setItem(row, 1, tableItem(formatPressureScore(summary.peakPressureScore), emphasized)); + pressureTable_->setItem( + row, + 2, + tableItem( + QString("%1 / %2") + .arg(static_cast(summary.peakExposedAgentCount)) + .arg(static_cast(summary.peakCriticalAgentCount)), + emphasized)); + pressureTable_->setItem(row, 3, tableItem(QString::number(static_cast(summary.peakHotspots.size())), emphasized)); + pressureTable_->setItem(row, 4, tableItem(QString::number(static_cast(summary.criticalEvents.size())), emphasized)); + pressureTable_->setItem(row, 5, tableItem(formatSeconds(summary.peakAtSeconds), emphasized)); + } + pressureTable_->resizeRowsToContents(); } void ScenarioBatchResultWidget::refreshResultNavigationPanel() { @@ -1151,6 +1239,7 @@ void ScenarioBatchResultWidget::refreshSelectedResult() { if (exitsChart_ != nullptr) { static_cast(exitsChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_); } + refreshPressureComparisonTable(); refreshResultNavigationPanel(); if (detailLabel_ != nullptr) { const auto selectedFinalSeconds = finalSeconds(result); @@ -1159,7 +1248,7 @@ void ScenarioBatchResultWidget::refreshSelectedResult() { ? QString("Baseline") : formatDeltaSeconds(selectedFinalSeconds - finalSeconds(results_[static_cast(baselineIndex)]))) : QString("No baseline"); - detailLabel_->setText(QString("%1 (%2)\nFinal: %3\nDelta vs baseline: %4\nEvacuated: %5 / %6 (%7)\nRisk: %8\nHotspots: %9\nBottlenecks: %10\nT90 / T95: %11 / %12") + detailLabel_->setText(QString("%1 (%2)\nFinal: %3\nDelta vs baseline: %4\nEvacuated: %5 / %6 (%7)\nRisk: %8\nHotspots: %9\nBottlenecks: %10\nPressure hotspots: %11\nCritical pressure: %12 agents / %13 events\nPeak pressure: %14 at %15\nT90 / T95: %16 / %17") .arg(QString::fromStdString(result.scenario.name)) .arg(scenarioRoleLabel(result.scenario.role)) .arg(formatSeconds(selectedFinalSeconds)) @@ -1170,6 +1259,11 @@ void ScenarioBatchResultWidget::refreshSelectedResult() { .arg(safecrowd::domain::scenarioRiskLevelLabel(result.risk.completionRisk)) .arg(static_cast(result.risk.hotspots.size())) .arg(static_cast(result.risk.bottlenecks.size())) + .arg(static_cast(result.artifacts.pressureSummary.peakHotspots.size())) + .arg(static_cast(result.artifacts.pressureSummary.peakCriticalAgentCount)) + .arg(static_cast(result.artifacts.pressureSummary.criticalEvents.size())) + .arg(formatPressureScore(result.artifacts.pressureSummary.peakPressureScore)) + .arg(formatSeconds(result.artifacts.pressureSummary.peakAtSeconds)) .arg(formatSeconds(result.artifacts.timingSummary.t90Seconds)) .arg(formatSeconds(result.artifacts.timingSummary.t95Seconds))); } diff --git a/src/application/ScenarioBatchResultWidget.h b/src/application/ScenarioBatchResultWidget.h index 9b70585..378e503 100644 --- a/src/application/ScenarioBatchResultWidget.h +++ b/src/application/ScenarioBatchResultWidget.h @@ -18,6 +18,7 @@ class QCheckBox; class QComboBox; class QPushButton; class QSlider; +class QTableWidget; class QTimer; namespace safecrowd::application { @@ -45,9 +46,10 @@ class ScenarioBatchResultWidget : public QWidget { private: enum class OverlayMode { Density = 0, - Hotspots = 1, - Bottlenecks = 2, - None = 3, + Pressure = 1, + Hotspots = 2, + Bottlenecks = 3, + None = 4, }; QWidget* createCanvasPanel(); @@ -61,6 +63,7 @@ class ScenarioBatchResultWidget : public QWidget { void navigateToAuthoring(); void pauseReplay(); void refreshComparisonSelection(); + void refreshPressureComparisonTable(); void refreshResultNavigationPanel(); void refreshSelectedResult(); void rerunBatch(); @@ -89,6 +92,7 @@ class ScenarioBatchResultWidget : public QWidget { QSlider* replaySlider_{nullptr}; QLabel* replayTimeLabel_{nullptr}; QLabel* detailLabel_{nullptr}; + QTableWidget* pressureTable_{nullptr}; std::vector compareCheckBoxes_{}; QWidget* remainingChart_{nullptr}; QWidget* exitsChart_{nullptr}; diff --git a/src/application/ScenarioResultWidget.cpp b/src/application/ScenarioResultWidget.cpp index 18bc287..faf93e2 100644 --- a/src/application/ScenarioResultWidget.cpp +++ b/src/application/ScenarioResultWidget.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include "application/ScenarioAuthoringWidget.h" #include "application/ScenarioCanvasWidget.h" @@ -71,6 +72,99 @@ QString formatDensity(double density) { return QString("%1 / m2").arg(density, 0, 'f', 1); } +QString formatPressureScore(double score) { + return QString::number(score, 'f', 1); +} + +QString simplifyLocationLabel(QString text) { + text = text.simplified(); + if (text.isEmpty()) { + return text; + } + + QStringList words = text.split(' ', Qt::SkipEmptyParts); + static const QStringList genericSuffixes{ + "Room", + "Area", + "Zone", + "Passage", + "Corridor", + "Hallway", + "Hall", + "Lobby", + "Section", + }; + + while (words.size() > 1 && genericSuffixes.contains(words.back(), Qt::CaseInsensitive)) { + words.removeLast(); + } + return words.join(' '); +} + +QString compactWords(QString text, int maxCharactersPerSide) { + text = text.simplified(); + if (text.size() <= maxCharactersPerSide) { + return text; + } + + const auto words = text.split(' ', Qt::SkipEmptyParts); + if (words.size() >= 2) { + QString compact = words.front(); + for (int index = 1; index < words.size(); ++index) { + const auto candidate = compact + ' ' + words[index]; + if (candidate.size() > maxCharactersPerSide) { + break; + } + compact = candidate; + } + if (compact.size() <= maxCharactersPerSide) { + return compact; + } + } + + if (maxCharactersPerSide <= 3) { + return text.left(std::max(1, maxCharactersPerSide)); + } + return text.left(maxCharactersPerSide - 3).trimmed() + QStringLiteral("..."); +} + +QString compactBottleneckLabel(QString label) { + label = label.simplified(); + if (label.isEmpty()) { + return label; + } + + constexpr int kCompactLabelLimit = 18; + if (label.size() <= kCompactLabelLimit) { + return label.replace(QStringLiteral("->"), QStringLiteral(">")); + } + + const auto segments = label.split(QStringLiteral("->"), Qt::SkipEmptyParts); + if (segments.size() != 2) { + const auto simplified = simplifyLocationLabel(label); + return compactWords(simplified, kCompactLabelLimit); + } + + auto from = simplifyLocationLabel(segments[0]); + auto to = simplifyLocationLabel(segments[1]); + QString compact = QStringLiteral("%1 > %2").arg(from, to); + if (compact.size() <= kCompactLabelLimit) { + return compact; + } + + const int sideBudget = std::max(5, (kCompactLabelLimit - 3) / 2); + from = compactWords(from, sideBudget); + to = compactWords(to, sideBudget); + compact = QStringLiteral("%1 > %2").arg(from, to); + if (compact.size() <= kCompactLabelLimit) { + return compact; + } + + return QStringLiteral("%1 > %2") + .arg(compactWords(from, sideBudget - 1)) + .arg(compactWords(to, sideBudget - 1)); +} + QString formatPercent(double ratio) { return QString("%1%").arg(std::clamp(ratio, 0.0, 1.0) * 100.0, 0, 'f', 0); } @@ -372,6 +466,60 @@ class DensityLegendWidget final : public QWidget { double peakDensity_{0.0}; }; +class PressureLegendWidget final : public QWidget { +public: + explicit PressureLegendWidget( + const safecrowd::domain::PressureSummary& summary, + QWidget* parent = nullptr) + : QWidget(parent), + threshold_(summary.hotspotScoreThreshold), + peakScore_(summary.peakPressureScore) { + setFixedSize(300, 34); + setToolTip("Pressure heatmap scale uses the pressure hotspot score threshold and the observed peak score."); + } + +protected: + void paintEvent(QPaintEvent* event) override { + (void)event; + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + + const QRectF ramp(0, 4, width(), 9); + QLinearGradient gradient(ramp.left(), ramp.center().y(), ramp.right(), ramp.center().y()); + gradient.setColorAt(0.0, QColor("#facc15")); + gradient.setColorAt(0.25, QColor("#f97316")); + gradient.setColorAt(0.55, QColor("#ef4444")); + gradient.setColorAt(1.0, QColor("#991b1b")); + painter.setPen(Qt::NoPen); + painter.setBrush(gradient); + painter.drawRoundedRect(ramp, 4, 4); + + painter.setFont(ui::font(ui::FontRole::Caption)); + painter.setPen(QColor("#687789")); + if (peakScore_ > 0.0 && threshold_ > 0.0) { + const auto peakX = ramp.left() + + (std::clamp(peakScore_ / threshold_, 0.0, 1.0) * ramp.width()); + painter.setPen(QPen(QColor("#405063"), 1)); + painter.drawLine(QPointF(peakX, ramp.top() - 2), QPointF(peakX, ramp.bottom() + 2)); + painter.setPen(QColor("#687789")); + } + painter.drawText(QRectF(0, 16, 40, 16), Qt::AlignLeft | Qt::AlignVCenter, "0"); + painter.drawText( + QRectF(42, 16, 178, 16), + Qt::AlignCenter, + QString("Hotspot %1+").arg(threshold_, 0, 'f', 1)); + painter.drawText( + QRectF(width() - 98, 16, 98, 16), + Qt::AlignRight | Qt::AlignVCenter, + QString("Peak %1").arg(peakScore_, 0, 'f', 1)); + } + +private: + double threshold_{0.0}; + double peakScore_{0.0}; +}; + QFrame* createMetricCard(const QString& title, const QString& value, QWidget* parent, const QString& tooltip = {}) { auto* card = new QFrame(parent); card->setStyleSheet(ui::panelStyleSheet()); @@ -386,7 +534,20 @@ QFrame* createMetricCard(const QString& title, const QString& value, QWidget* pa auto* titleLabel = createLabel(title, card, ui::FontRole::Caption); titleLabel->setStyleSheet(ui::mutedTextStyleSheet()); auto* valueLabel = createLabel(value, card, ui::FontRole::SectionTitle); - valueLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + const bool longValue = value.size() >= 12 || value.contains('>') || value.contains(' '); + auto valueFont = longValue + ? ui::font(ui::FontRole::Caption) + : valueLabel->font(); + if (longValue) { + valueFont.setWeight(QFont::DemiBold); + } else { + valueFont.setPointSize(std::max(11, valueFont.pointSize() - 1)); + } + valueLabel->setFont(valueFont); + valueLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::MinimumExpanding); + valueLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop); + const auto minimumLines = longValue ? 2 : 1; + valueLabel->setMinimumHeight((QFontMetrics(valueFont).lineSpacing() * minimumLines) + 4); if (!tooltip.isEmpty()) { titleLabel->setToolTip(tooltip); valueLabel->setToolTip(tooltip); @@ -400,6 +561,8 @@ QString resultCriteriaTooltip(const safecrowd::domain::ScenarioResultArtifacts& return QStringList{ QString("High density: %1 / m2 or higher for accumulated duration.") .arg(artifacts.densitySummary.highDensityThresholdPeoplePerSquareMeter, 0, 'f', 1), + QString("Pressure hotspot: score %1 or higher in a crowded cell.") + .arg(artifacts.pressureSummary.hotspotScoreThreshold, 0, 'f', 1), safecrowd::domain::scenarioStalledDefinition(), safecrowd::domain::scenarioBottleneckDefinition(), }.join("\n\n"); @@ -744,6 +907,7 @@ QWidget* createResultCanvasPanel( overlayLabel->setStyleSheet(ui::mutedTextStyleSheet()); auto* overlayCombo = new QComboBox(overlayBar); overlayCombo->addItem("Peak Density", static_cast(ResultOverlayMode::Density)); + overlayCombo->addItem("Pressure", static_cast(ResultOverlayMode::Pressure)); overlayCombo->addItem("Bottlenecks", static_cast(ResultOverlayMode::Bottlenecks)); overlayCombo->addItem("Hotspots", static_cast(ResultOverlayMode::Hotspots)); overlayCombo->addItem("None", static_cast(ResultOverlayMode::None)); @@ -751,16 +915,28 @@ QWidget* createResultCanvasPanel( overlayLayout->addWidget(overlayLabel); overlayLayout->addWidget(overlayCombo); overlayLayout->addSpacing(10); - overlayLayout->addWidget(new DensityLegendWidget(artifacts.densitySummary, overlayBar)); + auto* densityLegend = new DensityLegendWidget(artifacts.densitySummary, overlayBar); + auto* pressureLegend = new PressureLegendWidget(artifacts.pressureSummary, overlayBar); + pressureLegend->setVisible(false); + overlayLayout->addWidget(densityLegend); + overlayLayout->addWidget(pressureLegend); overlayLayout->addStretch(1); layout->addWidget(overlayBar); canvas->setDensityOverlay(artifacts.densitySummary.peakField.cells.empty() ? artifacts.densitySummary.peakCells : artifacts.densitySummary.peakField.cells, artifacts.densitySummary.highDensityThresholdPeoplePerSquareMeter); + canvas->setPressureOverlay(artifacts.pressureSummary.peakField.cells.empty() + ? artifacts.pressureSummary.peakCells + : artifacts.pressureSummary.peakField.cells, + std::max( + artifacts.pressureSummary.hotspotScoreThreshold, + artifacts.pressureSummary.peakPressureScore)); canvas->setResultOverlayMode(ResultOverlayMode::Density); - QObject::connect(overlayCombo, &QComboBox::currentIndexChanged, panel, [canvas, overlayCombo](int index) { + QObject::connect(overlayCombo, &QComboBox::currentIndexChanged, panel, [canvas, overlayCombo, densityLegend, pressureLegend](int index) { const auto mode = static_cast(overlayCombo->itemData(index).toInt()); + densityLegend->setVisible(mode == ResultOverlayMode::Density); + pressureLegend->setVisible(mode == ResultOverlayMode::Pressure); canvas->setResultOverlayMode(mode); }); layout->addWidget(canvas, 1); @@ -887,12 +1063,21 @@ QWidget* createResultPanel( metricsGrid->setColumnStretch(0, 1); metricsGrid->setColumnStretch(1, 1); const auto completionTime = resultCompletionTime(frame, artifacts); - const auto worstBottleneck = risk.bottlenecks.empty() + const auto worstBottleneckFull = risk.bottlenecks.empty() ? QString("None") : QString::fromStdString(risk.bottlenecks.front().label); + const auto worstBottleneck = compactBottleneckLabel(worstBottleneckFull); const auto slowestGroup = artifacts.placementCompletion.empty() ? QString("Pending") : QString::fromStdString(artifacts.placementCompletion.front().placementId); + const auto peakPressureTooltip = QString( + "Highest pressure hotspot score observed during the run.%1%2") + .arg(artifacts.pressureSummary.peakAtSeconds.has_value() + ? QString("\n\nPeak at %1 sec.").arg(*artifacts.pressureSummary.peakAtSeconds, 0, 'f', 1) + : QString()) + .arg(artifacts.pressureSummary.peakCell.has_value() + ? QString("\nCell floor: %1").arg(QString::fromStdString(artifacts.pressureSummary.peakCell->floorId)) + : QString()); metricsGrid->addWidget(createMetricCard( "Completion", formatSecondsValue(completionTime), @@ -912,7 +1097,8 @@ QWidget* createResultPanel( "Bottleneck", worstBottleneck, panel, - QString("Worst bottleneck observed during the run.\n\n%1") + QString("Worst bottleneck observed during the run.\n\nFull label: %1\n\n%2") + .arg(worstBottleneckFull) .arg(safecrowd::domain::scenarioBottleneckDefinition())), 1, 1); metricsGrid->addWidget(createMetricCard( "Slowest", @@ -939,6 +1125,27 @@ QWidget* createResultPanel( formatOptionalSeconds(artifacts.timingSummary.t95Seconds), panel, "Time at which 95% of occupants completed evacuation."), 4, 0); + metricsGrid->addWidget(createMetricCard( + "Peak Pressure", + formatPressureScore(artifacts.pressureSummary.peakPressureScore), + panel, + peakPressureTooltip), 4, 1); + metricsGrid->addWidget(createMetricCard( + "Pressure Hotspots", + QString::number(static_cast(artifacts.pressureSummary.peakHotspots.size())), + panel, + "Peak number of stored pressure hotspot locations from the run."), 5, 0); + metricsGrid->addWidget(createMetricCard( + "Pressure Events", + QString::number(static_cast(artifacts.pressureSummary.criticalEvents.size())), + panel, + "Stored sustained critical pressure events that met the duration and agent-count thresholds."), 5, 1); + metricsGrid->addWidget(createMetricCard( + "Critical Pressure", + QString("%1 agents").arg(static_cast(artifacts.pressureSummary.peakCriticalAgentCount)), + panel, + QString("Peak simultaneously critical agents during the run.\nExposed peak: %1 agents.") + .arg(static_cast(artifacts.pressureSummary.peakExposedAgentCount))), 6, 0); layout->addLayout(metricsGrid); layout->addStretch(1); diff --git a/src/application/SimulationCanvasWidget.cpp b/src/application/SimulationCanvasWidget.cpp index 825d301..aee2ecf 100644 --- a/src/application/SimulationCanvasWidget.cpp +++ b/src/application/SimulationCanvasWidget.cpp @@ -30,6 +30,9 @@ constexpr double kBottleneckFocusZoom = 2.4; 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 int kHotspotMinCoreAlpha = 72; constexpr int kHotspotMaxCoreAlpha = 190; constexpr int kFloorSelectorMargin = 14; @@ -110,6 +113,20 @@ QColor densityHeatmapColor(double ratio, int alpha) { return QColor(220, 38, 38, alpha); } +QColor pressureHeatmapColor(double ratio, int alpha) { + const auto t = std::clamp(ratio, 0.0, 1.0); + if (t < 0.25) { + return QColor(250, 204, 21, alpha); + } + if (t < 0.55) { + return QColor(249, 115, 22, alpha); + } + if (t < 0.8) { + return QColor(239, 68, 68, alpha); + } + return QColor(153, 27, 27, alpha); +} + QString formatScheduleTooltip(const safecrowd::domain::ConnectionBlockDraft& block) { if (block.connectionId.empty()) { return {}; @@ -406,6 +423,17 @@ void SimulationCanvasWidget::setDensityOverlay( update(); } +void SimulationCanvasWidget::setPressureOverlay( + std::vector pressureCells, + double scaleMaxPressureScore) { + pressureOverlay_ = std::move(pressureCells); + pressureScaleMaxScore_ = + std::isfinite(scaleMaxPressureScore) && scaleMaxPressureScore > 0.0 + ? scaleMaxPressureScore + : kDefaultPressureScaleMaxScore; + update(); +} + void SimulationCanvasWidget::setHotspotOverlay(std::vector hotspots) { hotspotOverlay_ = std::move(hotspots); if (focusedHotspotIndex_.has_value() && *focusedHotspotIndex_ >= hotspotOverlay_.size()) { @@ -615,6 +643,8 @@ void SimulationCanvasWidget::paintEvent(QPaintEvent* event) { drawRouteGuidanceOverlay(painter, transform); if (overlayMode_ == ResultOverlayMode::Density) { drawDensityOverlay(painter, transform); + } else if (overlayMode_ == ResultOverlayMode::Pressure) { + drawPressureOverlay(painter, transform); } else if (overlayMode_ == ResultOverlayMode::Hotspots) { drawHotspotOverlay(painter, transform); } else if (overlayMode_ == ResultOverlayMode::Bottlenecks) { @@ -877,6 +907,83 @@ void SimulationCanvasWidget::drawDensityOverlay(QPainter& painter, const LayoutC painter.restore(); } +void SimulationCanvasWidget::drawPressureOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const { + if (pressureOverlay_.empty()) { + return; + } + + std::vector visibleCells; + visibleCells.reserve(pressureOverlay_.size()); + for (const auto& cell : pressureOverlay_) { + if (!matchesFloor(cell.floorId, currentFloorId_)) { + continue; + } + if (cell.pressureScore <= 0.0) { + continue; + } + visibleCells.push_back(&cell); + } + if (visibleCells.empty()) { + return; + } + + const auto scaleMax = + std::isfinite(pressureScaleMaxScore_) && pressureScaleMaxScore_ > 0.0 + ? pressureScaleMaxScore_ + : kDefaultPressureScaleMaxScore; + std::sort(visibleCells.begin(), visibleCells.end(), [](const auto* lhs, const auto* rhs) { + if (lhs->pressureScore != rhs->pressureScore) { + return lhs->pressureScore < rhs->pressureScore; + } + return lhs->intrudingPairCount < rhs->intrudingPairCount; + }); + + painter.save(); + painter.setPen(Qt::NoPen); + 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); + } + + 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 + : kDefaultHotspotCellSize; + const auto cellHeight = cell->cellMax.y > cell->cellMin.y + ? cell->cellMax.y - cell->cellMin.y + : kDefaultHotspotCellSize; + const auto influenceRadiusWorld = + std::max(cellWidth, cellHeight) * kPressureInfluenceRadiusMultiplier; + const auto radiusAnchor = transform.map({ + .x = cell->center.x + influenceRadiusWorld, + .y = cell->center.y, + }); + const auto radius = std::max( + kPressureMinimumScreenRadius, + std::hypot(radiusAnchor.x() - center.x(), radiusAnchor.y() - center.y())); + const auto intensity = std::clamp(cell->pressureScore / scaleMax, 0.0, 1.0); + const auto coreAlpha = 62 + static_cast(138.0 * intensity); + const auto coreColor = pressureHeatmapColor(intensity, std::clamp(coreAlpha, 62, 200)); + const auto middleColor = pressureHeatmapColor(intensity, static_cast(coreAlpha * 0.46)); + + QRadialGradient gradient(center, radius); + gradient.setColorAt(0.0, coreColor); + gradient.setColorAt(0.34, middleColor); + gradient.setColorAt(1.0, pressureHeatmapColor(intensity, 0)); + painter.setBrush(gradient); + painter.drawEllipse(center, radius, radius); + } + painter.restore(); +} + void SimulationCanvasWidget::drawHotspotOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const { if (hotspotOverlay_.empty()) { return; diff --git a/src/application/SimulationCanvasWidget.h b/src/application/SimulationCanvasWidget.h index 400c98f..8c0a2e2 100644 --- a/src/application/SimulationCanvasWidget.h +++ b/src/application/SimulationCanvasWidget.h @@ -30,6 +30,7 @@ namespace safecrowd::application { enum class ResultOverlayMode { None, Density, + Pressure, Hotspots, Bottlenecks, }; @@ -45,6 +46,9 @@ class SimulationCanvasWidget : public QWidget { void setDensityOverlay( std::vector densityCells, double scaleMaxPeoplePerSquareMeter = 4.0); + void setPressureOverlay( + std::vector pressureCells, + double scaleMaxPressureScore = 1.0); void setHotspotOverlay(std::vector hotspots); void setBottleneckOverlay(std::vector bottlenecks); void setResultOverlayMode(ResultOverlayMode mode); @@ -73,6 +77,7 @@ class SimulationCanvasWidget : public QWidget { void drawConnectionBlockOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawRouteGuidanceOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawDensityOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; + void drawPressureOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawHotspotOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; void drawBottleneckOverlay(QPainter& painter, const LayoutCanvasTransform& transform) const; bool switchFloorByWheel(QWheelEvent* event); @@ -86,6 +91,8 @@ class SimulationCanvasWidget : public QWidget { std::vector routeGuidances_{}; std::vector densityOverlay_{}; double densityScaleMaxPeoplePerSquareMeter_{4.0}; + std::vector pressureOverlay_{}; + double pressureScaleMaxScore_{1.0}; std::vector hotspotOverlay_{}; std::vector bottleneckOverlay_{}; ResultOverlayMode overlayMode_{ResultOverlayMode::None}; diff --git a/src/domain/ScenarioResultArtifacts.h b/src/domain/ScenarioResultArtifacts.h index 2bf2352..0e9aa32 100644 --- a/src/domain/ScenarioResultArtifacts.h +++ b/src/domain/ScenarioResultArtifacts.h @@ -6,6 +6,7 @@ #include #include "domain/Geometry2D.h" +#include "domain/ScenarioRiskMetrics.h" #include "domain/ScenarioSimulationFrame.h" namespace safecrowd::domain { @@ -55,6 +56,42 @@ struct DensitySummary { DensityFieldSnapshot peakField{}; }; +struct PressureCellMetric { + Point2D center{}; + Point2D cellMin{}; + Point2D cellMax{}; + std::string floorId{}; + std::size_t agentCount{0}; + std::size_t intrudingPairCount{0}; + double densityPeoplePerSquareMeter{0.0}; + double pressureScore{0.0}; +}; + +struct PressureFieldSnapshot { + double timeSeconds{0.0}; + double cellSizeMeters{0.0}; + std::vector cells{}; +}; + +struct PressureSummary { + double cellSizeMeters{0.0}; + double hotspotScoreThreshold{0.0}; + double criticalCompressionForceThreshold{0.0}; + double criticalExposureThresholdSeconds{0.0}; + double criticalEventDurationThresholdSeconds{0.0}; + std::size_t criticalEventAgentThreshold{0}; + double peakPressureScore{0.0}; + std::optional peakAtSeconds{}; + std::optional peakCell{}; + std::size_t peakExposedAgentCount{0}; + std::size_t peakCriticalAgentCount{0}; + std::vector peakCells{}; + PressureFieldSnapshot peakField{}; + std::vector peakHotspots{}; + std::vector peakAgents{}; + std::vector criticalEvents{}; +}; + struct ExitUsageMetric { std::string exitZoneId{}; std::string exitLabel{}; @@ -87,6 +124,7 @@ struct ScenarioResultArtifacts { std::vector replayFrames{}; EvacuationTimingSummary timingSummary{}; DensitySummary densitySummary{}; + PressureSummary pressureSummary{}; std::vector exitUsage{}; std::vector zoneCompletion{}; std::vector placementCompletion{}; diff --git a/src/domain/ScenarioRiskMetrics.cpp b/src/domain/ScenarioRiskMetrics.cpp index ae21ac3..3ea2118 100644 --- a/src/domain/ScenarioRiskMetrics.cpp +++ b/src/domain/ScenarioRiskMetrics.cpp @@ -17,9 +17,10 @@ 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%, " - "or any hotspot/bottleneck is detected. " + "any hotspot/pressure hotspot/bottleneck is detected, or any active agent reaches " + "critical pressure exposure. " "High when elapsed time reaches 80% of the limit, stalled active agents reach 35%, " - "or two or more bottlenecks are detected."; + "a critical pressure event is sustained, or two or more bottlenecks are detected."; } const char* scenarioStalledDefinition() noexcept { @@ -31,6 +32,11 @@ const char* scenarioHotspotDefinition() noexcept { return "A hotspot is a 1.5 m by 1.5 m cell containing at least 5 active agents."; } +const char* scenarioPressureHotspotDefinition() noexcept { + return "A pressure hotspot is a 1.5 m by 1.5 m cell containing at least 5 active agents " + "with overlapping personal-space intrusion between nearby occupants."; +} + const char* scenarioBottleneckDefinition() noexcept { return "A bottleneck is reported around an open connection when at least 3 active agents " "are within 1.25 m and at least one is stalled or average speed is low."; diff --git a/src/domain/ScenarioRiskMetrics.h b/src/domain/ScenarioRiskMetrics.h index 094cb3b..b3bfad1 100644 --- a/src/domain/ScenarioRiskMetrics.h +++ b/src/domain/ScenarioRiskMetrics.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -14,6 +15,12 @@ inline constexpr double kScenarioStalledSpeedThreshold = 0.12; inline constexpr double kScenarioStalledSecondsThreshold = 0.75; inline constexpr double kScenarioHotspotCellSize = 1.5; inline constexpr std::size_t kScenarioHotspotAgentThreshold = 5; +inline constexpr std::size_t kScenarioPressureHotspotAgentThreshold = 5; +inline constexpr double kScenarioPressureScoreThreshold = 1.0; +inline constexpr double kScenarioCriticalPressureForceThreshold = 0.5; +inline constexpr double kScenarioCriticalPressureExposureThresholdSeconds = 2.0; +inline constexpr double kScenarioCriticalPressureEventDurationThresholdSeconds = 1.0; +inline constexpr std::size_t kScenarioCriticalPressureEventAgentThreshold = 2; inline constexpr double kScenarioBottleneckRadius = 1.25; inline constexpr std::size_t kScenarioBottleneckAgentThreshold = 3; @@ -33,6 +40,42 @@ struct ScenarioCongestionHotspot { std::optional detectionFrame{}; }; +struct ScenarioPressureHotspot { + Point2D center{}; + Point2D cellMin{}; + Point2D cellMax{}; + std::string floorId{}; + std::size_t agentCount{0}; + std::size_t intrudingPairCount{0}; + double densityPeoplePerSquareMeter{0.0}; + double pressureScore{0.0}; + std::optional detectedAtSeconds{}; + std::optional detectionFrame{}; +}; + +struct ScenarioPressureAgentMetric { + std::uint64_t agentId{0}; + Point2D position{}; + std::string floorId{}; + double compressionForce{0.0}; + double exposureSeconds{0.0}; + bool critical{false}; +}; + +struct ScenarioCriticalPressureEvent { + Point2D center{}; + Point2D cellMin{}; + Point2D cellMax{}; + std::string floorId{}; + std::size_t exposedAgentCount{0}; + std::size_t criticalAgentCount{0}; + double pressureScore{0.0}; + double startedAtSeconds{0.0}; + double durationSeconds{0.0}; + std::optional detectedAtSeconds{}; + std::optional detectionFrame{}; +}; + struct ScenarioBottleneckMetric { std::string connectionId{}; std::string label{}; @@ -48,7 +91,12 @@ struct ScenarioBottleneckMetric { struct ScenarioRiskSnapshot { ScenarioRiskLevel completionRisk{ScenarioRiskLevel::Low}; std::size_t stalledAgentCount{0}; + std::size_t pressureExposedAgentCount{0}; + std::size_t criticalPressureAgentCount{0}; std::vector hotspots{}; + std::vector pressureHotspots{}; + std::vector pressureAgents{}; + std::vector criticalPressureEvents{}; std::vector bottlenecks{}; }; @@ -56,6 +104,7 @@ const char* scenarioRiskLevelLabel(ScenarioRiskLevel level) noexcept; const char* scenarioRiskDefinition() noexcept; const char* scenarioStalledDefinition() noexcept; const char* scenarioHotspotDefinition() noexcept; +const char* scenarioPressureHotspotDefinition() noexcept; const char* scenarioBottleneckDefinition() noexcept; bool scenarioAgentStalled(double speedMetersPerSecond, double routeStalledSeconds) noexcept; diff --git a/src/domain/ScenarioRiskMetricsSystem.cpp b/src/domain/ScenarioRiskMetricsSystem.cpp index 907e413..3d5202e 100644 --- a/src/domain/ScenarioRiskMetricsSystem.cpp +++ b/src/domain/ScenarioRiskMetricsSystem.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include namespace safecrowd::domain { @@ -15,7 +16,11 @@ namespace { using namespace simulation_internal; constexpr std::size_t kMaxReportedHotspots = 5; +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 double kScenarioPressureScoreThreshold = 1.0; struct RiskCellAccumulator { Point2D positionSum{}; @@ -23,6 +28,31 @@ struct RiskCellAccumulator { Point2D cellMax{}; std::string floorId{}; std::size_t agentCount{0}; + std::vector entities{}; +}; + +struct ActiveAgentContext { + engine::Entity entity{}; + std::uint64_t agentId{0}; + Point2D position{}; + std::string floorId{}; + double radius{0.25}; +}; + +struct ScenarioPressureAgentTrackingState { + double currentForce{0.0}; + double exposureSeconds{0.0}; +}; + +struct ScenarioActiveCriticalPressureEventState { + double startedAtSeconds{0.0}; +}; + +struct ScenarioPressureTrackingResource { + bool hasPreviousElapsedSeconds{false}; + double previousElapsedSeconds{0.0}; + std::unordered_map agentStates{}; + std::unordered_map activeEvents{}; }; struct RiskCellAddress { @@ -89,6 +119,69 @@ bool isHotspotSetWorse( return candidate.front().agentCount > currentPeak.front().agentCount; } +bool isPressureHotspotSetWorse( + 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.pressureScore - rhs.pressureScore) > 1e-9) { + return lhs.pressureScore > rhs.pressureScore; + } + if (lhs.intrudingPairCount != rhs.intrudingPairCount) { + return lhs.intrudingPairCount > rhs.intrudingPairCount; + } + return lhs.agentCount > rhs.agentCount; +} + +bool isPressureAgentSetWorse( + 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 (lhs.critical != rhs.critical) { + return lhs.critical; + } + if (std::fabs(lhs.exposureSeconds - rhs.exposureSeconds) > 1e-9) { + return lhs.exposureSeconds > rhs.exposureSeconds; + } + return lhs.compressionForce > rhs.compressionForce; +} + +bool isCriticalPressureEventSetWorse( + 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 (lhs.criticalAgentCount != rhs.criticalAgentCount) { + return lhs.criticalAgentCount > rhs.criticalAgentCount; + } + if (std::fabs(lhs.durationSeconds - rhs.durationSeconds) > 1e-9) { + return lhs.durationSeconds > rhs.durationSeconds; + } + return lhs.pressureScore > rhs.pressureScore; +} + bool isBottleneckSetWorse( const std::vector& candidate, const std::vector& currentPeak) { @@ -110,12 +203,60 @@ bool isBottleneckSetWorse( return lhs.averageSpeed < rhs.averageSpeed; } +double distancePointToSegment(const Point2D& point, const Point2D& start, const Point2D& end) { + const auto dx = end.x - start.x; + const auto dy = end.y - start.y; + const auto lengthSquared = (dx * dx) + (dy * dy); + if (lengthSquared <= 1e-9) { + return distanceBetween(point, start); + } + + const auto t = std::clamp( + (((point.x - start.x) * dx) + ((point.y - start.y) * dy)) / lengthSquared, + 0.0, + 1.0); + const Point2D projection{ + .x = start.x + (t * dx), + .y = start.y + (t * dy), + }; + return distanceBetween(point, projection); +} + +bool barrierMatchesFloor(const Barrier2D& barrier, const std::string& floorId) { + return barrier.floorId.empty() || floorId.empty() || barrier.floorId == floorId; +} + +double barrierCompression(const Barrier2D& barrier, const Point2D& position, double radius) { + if (!barrier.blocksMovement || barrier.geometry.vertices.size() < 2) { + return 0.0; + } + + double force = 0.0; + const auto& vertices = barrier.geometry.vertices; + for (std::size_t index = 0; index + 1 < vertices.size(); ++index) { + const auto distance = distancePointToSegment(position, vertices[index], vertices[index + 1]); + if (distance < radius) { + force += radius - distance; + } + } + if (barrier.geometry.closed) { + const auto distance = distancePointToSegment(position, vertices.back(), vertices.front()); + if (distance < radius) { + force += radius - distance; + } + } + return force; +} + ScenarioRiskLevel completionRiskLevel( const ScenarioSimulationClockResource& clock, std::size_t totalAgentCount, std::size_t evacuatedAgentCount, std::size_t stalledAgentCount, std::size_t hotspotCount, + std::size_t pressureHotspotCount, + std::size_t criticalPressureAgentCount, + std::size_t criticalPressureEventCount, std::size_t bottleneckCount) { if (totalAgentCount == 0 || evacuatedAgentCount >= totalAgentCount) { return ScenarioRiskLevel::Low; @@ -129,10 +270,18 @@ ScenarioRiskLevel completionRiskLevel( ? static_cast(stalledAgentCount) / static_cast(activeAgentCount) : 0.0; - if (elapsedRatio >= 0.8 || stalledRatio >= 0.35 || bottleneckCount >= 2) { + if (elapsedRatio >= 0.8 + || stalledRatio >= 0.35 + || criticalPressureEventCount > 0 + || bottleneckCount >= 2) { return ScenarioRiskLevel::High; } - if (elapsedRatio >= 0.5 || stalledRatio >= 0.15 || hotspotCount > 0 || bottleneckCount > 0) { + if (elapsedRatio >= 0.5 + || stalledRatio >= 0.15 + || hotspotCount > 0 + || pressureHotspotCount > 0 + || criticalPressureAgentCount > 0 + || bottleneckCount > 0) { return ScenarioRiskLevel::Medium; } return ScenarioRiskLevel::Low; @@ -183,6 +332,7 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { void configure(engine::EngineWorld& world) override { world.resources().set(ScenarioRiskMetricsResource{}); + world.resources().set(ScenarioPressureTrackingResource{}); } void update(engine::EngineWorld& world, const engine::EngineStepContext& step) override { @@ -198,6 +348,7 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { std::size_t totalAgentCount = 0; std::size_t evacuatedAgentCount = 0; + std::vector activeAgents; std::unordered_map cells; cells.reserve(entities.size()); @@ -217,6 +368,13 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { } const auto floorId = agentDisplayFloorId(route); + activeAgents.push_back({ + .entity = entity, + .agentId = entity.index, + .position = position.value, + .floorId = floorId, + .radius = static_cast(query.get(entity).radius), + }); const auto address = riskCellAddress(position.value, floorId); auto& cell = cells[riskCellKey(address)]; if (cell.agentCount == 0) { @@ -226,23 +384,40 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { } cell.positionSum = cell.positionSum + position.value; ++cell.agentCount; + cell.entities.push_back(entity); } - collectHotspots(snapshot, cells); - collectBottlenecks(snapshot, query, entities, activeLayout); - ScenarioSimulationClockResource clock; if (resources.contains()) { clock = resources.get(); } + auto& pressureTracking = resources.get(); + const auto deltaSeconds = updatePressureTracking( + snapshot, + activeLayout, + activeAgents, + clock.elapsedSeconds, + pressureTracking); + + collectHotspots(snapshot, cells); + collectPressureHotspots(snapshot, query, cells); + collectCriticalPressureEvents(snapshot, cells, clock.elapsedSeconds, deltaSeconds, pressureTracking); + collectBottlenecks(snapshot, query, entities, activeLayout); + snapshot.completionRisk = completionRiskLevel( clock, totalAgentCount, evacuatedAgentCount, snapshot.stalledAgentCount, snapshot.hotspots.size(), + snapshot.pressureHotspots.size(), + snapshot.criticalPressureAgentCount, + snapshot.criticalPressureEvents.size(), snapshot.bottlenecks.size()); - if (!snapshot.hotspots.empty() || !snapshot.bottlenecks.empty()) { + if (!snapshot.hotspots.empty() + || !snapshot.pressureHotspots.empty() + || !snapshot.criticalPressureEvents.empty() + || !snapshot.bottlenecks.empty()) { attachDetectionState(snapshot, captureSimulationFrame(query, clock), clock.elapsedSeconds); } @@ -262,9 +437,20 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { peak.completionRisk = current.completionRisk; } peak.stalledAgentCount = std::max(peak.stalledAgentCount, current.stalledAgentCount); + peak.pressureExposedAgentCount = std::max(peak.pressureExposedAgentCount, current.pressureExposedAgentCount); + peak.criticalPressureAgentCount = std::max(peak.criticalPressureAgentCount, current.criticalPressureAgentCount); if (isHotspotSetWorse(current.hotspots, peak.hotspots)) { peak.hotspots = current.hotspots; } + if (isPressureHotspotSetWorse(current.pressureHotspots, peak.pressureHotspots)) { + peak.pressureHotspots = current.pressureHotspots; + } + if (isPressureAgentSetWorse(current.pressureAgents, peak.pressureAgents)) { + peak.pressureAgents = current.pressureAgents; + } + if (isCriticalPressureEventSetWorse(current.criticalPressureEvents, peak.criticalPressureEvents)) { + peak.criticalPressureEvents = current.criticalPressureEvents; + } if (isBottleneckSetWorse(current.bottlenecks, peak.bottlenecks)) { peak.bottlenecks = current.bottlenecks; } @@ -278,12 +464,117 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { hotspot.detectedAtSeconds = elapsedSeconds; hotspot.detectionFrame = frame; } + for (auto& hotspot : snapshot.pressureHotspots) { + hotspot.detectedAtSeconds = elapsedSeconds; + hotspot.detectionFrame = frame; + } + for (auto& event : snapshot.criticalPressureEvents) { + event.detectedAtSeconds = elapsedSeconds; + event.detectionFrame = frame; + } for (auto& bottleneck : snapshot.bottlenecks) { bottleneck.detectedAtSeconds = elapsedSeconds; bottleneck.detectionFrame = frame; } } + double updatePressureTracking( + ScenarioRiskSnapshot& snapshot, + const FacilityLayout2D& layout, + const std::vector& activeAgents, + double elapsedSeconds, + ScenarioPressureTrackingResource& tracking) const { + const auto deltaSeconds = tracking.hasPreviousElapsedSeconds + ? std::max(0.0, elapsedSeconds - tracking.previousElapsedSeconds) + : 0.0; + tracking.previousElapsedSeconds = elapsedSeconds; + tracking.hasPreviousElapsedSeconds = true; + + std::vector forces(activeAgents.size(), 0.0); + for (std::size_t lhsIndex = 0; lhsIndex < activeAgents.size(); ++lhsIndex) { + for (std::size_t rhsIndex = lhsIndex + 1; rhsIndex < activeAgents.size(); ++rhsIndex) { + if (activeAgents[lhsIndex].floorId != activeAgents[rhsIndex].floorId) { + continue; + } + const auto distance = distanceBetween(activeAgents[lhsIndex].position, activeAgents[rhsIndex].position); + const auto combinedRadius = activeAgents[lhsIndex].radius + activeAgents[rhsIndex].radius; + if (distance >= combinedRadius) { + continue; + } + + const auto overlap = combinedRadius - distance; + forces[lhsIndex] += overlap; + forces[rhsIndex] += overlap; + } + } + + for (std::size_t index = 0; index < activeAgents.size(); ++index) { + for (const auto& barrier : layout.barriers) { + if (!barrierMatchesFloor(barrier, activeAgents[index].floorId)) { + continue; + } + forces[index] += barrierCompression( + barrier, + activeAgents[index].position, + activeAgents[index].radius); + } + } + + std::unordered_set activeAgentIds; + activeAgentIds.reserve(activeAgents.size()); + snapshot.pressureAgents.reserve(activeAgents.size()); + for (std::size_t index = 0; index < activeAgents.size(); ++index) { + const auto& context = activeAgents[index]; + activeAgentIds.insert(context.agentId); + auto& state = tracking.agentStates[context.agentId]; + state.currentForce = forces[index]; + if (state.currentForce > kScenarioCriticalPressureForceThreshold) { + state.exposureSeconds += deltaSeconds; + } + + const bool critical = + state.currentForce > kScenarioCriticalPressureForceThreshold + && state.exposureSeconds >= kScenarioCriticalPressureExposureThresholdSeconds; + if (state.exposureSeconds > 0.0 || state.currentForce > kScenarioCriticalPressureForceThreshold) { + ++snapshot.pressureExposedAgentCount; + snapshot.pressureAgents.push_back({ + .agentId = context.agentId, + .position = context.position, + .floorId = context.floorId, + .compressionForce = state.currentForce, + .exposureSeconds = state.exposureSeconds, + .critical = critical, + }); + } + if (critical) { + ++snapshot.criticalPressureAgentCount; + } + } + + for (auto it = tracking.agentStates.begin(); it != tracking.agentStates.end();) { + if (activeAgentIds.contains(it->first)) { + ++it; + continue; + } + it = tracking.agentStates.erase(it); + } + + std::sort(snapshot.pressureAgents.begin(), snapshot.pressureAgents.end(), [](const auto& lhs, const auto& rhs) { + if (lhs.critical != rhs.critical) { + return lhs.critical; + } + if (std::fabs(lhs.exposureSeconds - rhs.exposureSeconds) > 1e-9) { + return lhs.exposureSeconds > rhs.exposureSeconds; + } + return lhs.compressionForce > rhs.compressionForce; + }); + if (snapshot.pressureAgents.size() > kMaxReportedPressureAgents) { + snapshot.pressureAgents.resize(kMaxReportedPressureAgents); + } + + return deltaSeconds; + } + void collectHotspots( ScenarioRiskSnapshot& snapshot, const std::unordered_map& cells) const { @@ -310,6 +601,166 @@ class ScenarioRiskMetricsSystem final : public engine::EngineSystem { } } + void collectPressureHotspots( + ScenarioRiskSnapshot& snapshot, + engine::WorldQuery& query, + const std::unordered_map& cells) const { + snapshot.pressureHotspots.reserve(cells.size()); + const auto cellArea = kScenarioHotspotCellSize * kScenarioHotspotCellSize; + + for (const auto& [_, cell] : cells) { + if (cell.agentCount < kScenarioPressureHotspotAgentThreshold) { + continue; + } + + double pressureScore = 0.0; + std::size_t intrudingPairCount = 0; + for (std::size_t lhsIndex = 0; lhsIndex < cell.entities.size(); ++lhsIndex) { + const auto lhsEntity = cell.entities[lhsIndex]; + const auto& lhsPosition = query.get(lhsEntity); + const auto& lhsAgent = query.get(lhsEntity); + for (std::size_t rhsIndex = lhsIndex + 1; rhsIndex < cell.entities.size(); ++rhsIndex) { + const auto rhsEntity = cell.entities[rhsIndex]; + const auto& rhsPosition = query.get(rhsEntity); + const auto& rhsAgent = query.get(rhsEntity); + const auto comfortDistance = + static_cast(lhsAgent.radius + rhsAgent.radius) + kPersonalSpaceBuffer; + if (comfortDistance <= 1e-9) { + continue; + } + const auto distance = distanceBetween(lhsPosition.value, rhsPosition.value); + if (distance >= comfortDistance) { + continue; + } + + pressureScore += (comfortDistance - distance) / comfortDistance; + ++intrudingPairCount; + } + } + + if (pressureScore < kScenarioPressureScoreThreshold || intrudingPairCount == 0) { + continue; + } + + const auto count = static_cast(cell.agentCount); + snapshot.pressureHotspots.push_back({ + .center = {.x = cell.positionSum.x / count, .y = cell.positionSum.y / count}, + .cellMin = cell.cellMin, + .cellMax = cell.cellMax, + .floorId = cell.floorId, + .agentCount = cell.agentCount, + .intrudingPairCount = intrudingPairCount, + .densityPeoplePerSquareMeter = cellArea <= 1e-9 ? 0.0 : count / cellArea, + .pressureScore = pressureScore, + }); + } + + std::sort(snapshot.pressureHotspots.begin(), snapshot.pressureHotspots.end(), [](const auto& lhs, const auto& rhs) { + if (std::fabs(lhs.pressureScore - rhs.pressureScore) > 1e-9) { + return lhs.pressureScore > rhs.pressureScore; + } + if (lhs.intrudingPairCount != rhs.intrudingPairCount) { + return lhs.intrudingPairCount > rhs.intrudingPairCount; + } + return lhs.agentCount > rhs.agentCount; + }); + if (snapshot.pressureHotspots.size() > kMaxReportedPressureHotspots) { + snapshot.pressureHotspots.resize(kMaxReportedPressureHotspots); + } + } + + void collectCriticalPressureEvents( + ScenarioRiskSnapshot& snapshot, + const std::unordered_map& cells, + double elapsedSeconds, + double deltaSeconds, + ScenarioPressureTrackingResource& tracking) const { + std::unordered_set candidateEventKeys; + candidateEventKeys.reserve(cells.size()); + + for (const auto& [cellKey, cell] : cells) { + std::size_t exposedAgentCount = 0; + std::size_t criticalAgentCount = 0; + double pressureScore = 0.0; + + for (const auto entity : cell.entities) { + const auto stateIt = tracking.agentStates.find(entity.index); + if (stateIt == tracking.agentStates.end()) { + continue; + } + const auto& state = stateIt->second; + const bool exposed = + state.exposureSeconds > 0.0 + || state.currentForce > kScenarioCriticalPressureForceThreshold; + const bool critical = + state.currentForce > kScenarioCriticalPressureForceThreshold + && state.exposureSeconds >= kScenarioCriticalPressureExposureThresholdSeconds; + if (exposed) { + ++exposedAgentCount; + } + if (critical) { + ++criticalAgentCount; + } + pressureScore += state.currentForce; + } + + if (criticalAgentCount < kScenarioCriticalPressureEventAgentThreshold) { + continue; + } + + candidateEventKeys.insert(cellKey); + auto [eventIt, inserted] = tracking.activeEvents.try_emplace( + cellKey, + ScenarioActiveCriticalPressureEventState{.startedAtSeconds = elapsedSeconds}); + if (inserted) { + eventIt->second.startedAtSeconds = elapsedSeconds; + } + + const auto durationSeconds = std::max( + 0.0, + (elapsedSeconds - eventIt->second.startedAtSeconds) + deltaSeconds); + if (durationSeconds < kScenarioCriticalPressureEventDurationThresholdSeconds) { + continue; + } + + const auto count = static_cast(cell.agentCount); + snapshot.criticalPressureEvents.push_back({ + .center = count <= 0.0 + ? Point2D{} + : Point2D{.x = cell.positionSum.x / count, .y = cell.positionSum.y / count}, + .cellMin = cell.cellMin, + .cellMax = cell.cellMax, + .floorId = cell.floorId, + .exposedAgentCount = exposedAgentCount, + .criticalAgentCount = criticalAgentCount, + .pressureScore = pressureScore, + .startedAtSeconds = eventIt->second.startedAtSeconds, + .durationSeconds = durationSeconds, + }); + } + + for (auto it = tracking.activeEvents.begin(); it != tracking.activeEvents.end();) { + if (candidateEventKeys.contains(it->first)) { + ++it; + continue; + } + it = tracking.activeEvents.erase(it); + } + + std::sort(snapshot.criticalPressureEvents.begin(), snapshot.criticalPressureEvents.end(), [](const auto& lhs, const auto& rhs) { + if (lhs.criticalAgentCount != rhs.criticalAgentCount) { + return lhs.criticalAgentCount > rhs.criticalAgentCount; + } + if (std::fabs(lhs.durationSeconds - rhs.durationSeconds) > 1e-9) { + return lhs.durationSeconds > rhs.durationSeconds; + } + return lhs.pressureScore > rhs.pressureScore; + }); + if (snapshot.criticalPressureEvents.size() > kMaxReportedCriticalPressureEvents) { + snapshot.criticalPressureEvents.resize(kMaxReportedCriticalPressureEvents); + } + } + std::string zoneDisplayName(const FacilityLayout2D& layout, const std::string& zoneId) const { const auto* zone = findZone(layout, zoneId); if (zone == nullptr) { diff --git a/src/domain/ScenarioSimulationSystems.cpp b/src/domain/ScenarioSimulationSystems.cpp index 01cfb09..2024ffd 100644 --- a/src/domain/ScenarioSimulationSystems.cpp +++ b/src/domain/ScenarioSimulationSystems.cpp @@ -20,6 +20,10 @@ namespace { constexpr double kReplaySampleIntervalSeconds = 0.5; constexpr std::size_t kMaxReplayFrames = 600; constexpr std::size_t kMaxResultDensityCells = 5; +constexpr std::size_t kMaxResultPressureCells = 5; +constexpr std::size_t kMaxResultPressureHotspots = 5; +constexpr std::size_t kMaxResultPressureAgents = 5; +constexpr std::size_t kMaxResultCriticalPressureEvents = 5; constexpr double kHighDensityThresholdPeoplePerSquareMeter = 4.0; struct SpatialCell { @@ -37,6 +41,7 @@ struct DensityCellAccumulator { SpatialCell cell{}; std::string floorId{}; std::size_t agentCount{0}; + std::vector entities{}; }; long long spatialKey(const SpatialCell& cell) { @@ -145,6 +150,96 @@ DensityCellMetric densityMetricFromCell( }; } +PressureCellMetric pressureMetricFromCell( + engine::WorldQuery& query, + const DensityCellAccumulator& cell, + double cellSize) { + const auto count = static_cast(cell.agentCount); + const auto min = cellMin(cell.cell, cellSize); + const auto max = cellMax(cell.cell, cellSize); + PressureCellMetric metric{ + .center = cell.agentCount == 0 + ? Point2D{.x = (min.x + max.x) * 0.5, .y = (min.y + max.y) * 0.5} + : Point2D{.x = cell.positionSum.x / count, .y = cell.positionSum.y / count}, + .cellMin = min, + .cellMax = max, + .floorId = cell.floorId, + .agentCount = cell.agentCount, + .intrudingPairCount = 0, + .densityPeoplePerSquareMeter = cellSize <= 0.0 + ? 0.0 + : count / (cellSize * cellSize), + .pressureScore = 0.0, + }; + + for (std::size_t lhsIndex = 0; lhsIndex < cell.entities.size(); ++lhsIndex) { + const auto lhsEntity = cell.entities[lhsIndex]; + const auto& lhsPosition = query.get(lhsEntity); + const auto& lhsAgent = query.get(lhsEntity); + for (std::size_t rhsIndex = lhsIndex + 1; rhsIndex < cell.entities.size(); ++rhsIndex) { + const auto rhsEntity = cell.entities[rhsIndex]; + const auto& rhsPosition = query.get(rhsEntity); + const auto& rhsAgent = query.get(rhsEntity); + const auto comfortDistance = + static_cast(lhsAgent.radius + rhsAgent.radius) + simulation_internal::kPersonalSpaceBuffer; + if (comfortDistance <= 1e-9) { + continue; + } + const auto distance = distanceBetween(lhsPosition.value, rhsPosition.value); + if (distance >= comfortDistance) { + continue; + } + + metric.pressureScore += (comfortDistance - distance) / comfortDistance; + ++metric.intrudingPairCount; + } + } + + return metric; +} + +bool isPressureCellWorse(const PressureCellMetric& candidate, const PressureCellMetric& current) { + if (std::fabs(candidate.pressureScore - current.pressureScore) > 1e-9) { + return candidate.pressureScore > current.pressureScore; + } + if (candidate.intrudingPairCount != current.intrudingPairCount) { + return candidate.intrudingPairCount > current.intrudingPairCount; + } + return candidate.agentCount > current.agentCount; +} + +bool isPressureHotspotWorse(const ScenarioPressureHotspot& candidate, const ScenarioPressureHotspot& current) { + if (std::fabs(candidate.pressureScore - current.pressureScore) > 1e-9) { + return candidate.pressureScore > current.pressureScore; + } + if (candidate.intrudingPairCount != current.intrudingPairCount) { + return candidate.intrudingPairCount > current.intrudingPairCount; + } + return candidate.agentCount > current.agentCount; +} + +bool isPressureAgentWorse(const ScenarioPressureAgentMetric& candidate, const ScenarioPressureAgentMetric& current) { + if (candidate.critical != current.critical) { + return candidate.critical; + } + if (std::fabs(candidate.exposureSeconds - current.exposureSeconds) > 1e-9) { + return candidate.exposureSeconds > current.exposureSeconds; + } + return candidate.compressionForce > current.compressionForce; +} + +bool isCriticalPressureEventWorse( + const ScenarioCriticalPressureEvent& candidate, + const ScenarioCriticalPressureEvent& current) { + if (candidate.criticalAgentCount != current.criticalAgentCount) { + return candidate.criticalAgentCount > current.criticalAgentCount; + } + if (std::fabs(candidate.durationSeconds - current.durationSeconds) > 1e-9) { + return candidate.durationSeconds > current.durationSeconds; + } + return candidate.pressureScore > current.pressureScore; +} + bool intervalContains(const ConnectionBlockIntervalDraft& interval, double timeSeconds) { const auto start = std::max(0.0, interval.startSeconds); const auto end = std::max(start, interval.endSeconds); @@ -643,6 +738,7 @@ void ScenarioResultArtifactsSystem::update(engine::EngineWorld& world, const eng .y = cell.positionSum.y + position.value.y, }; ++cell.agentCount; + cell.entities.push_back(entity); } std::vector densityMetrics; @@ -703,6 +799,163 @@ void ScenarioResultArtifactsSystem::update(engine::EngineWorld& world, const eng result.densityTrackingInitialized = true; result.lastDensitySampleTimeSeconds = elapsedSeconds; + auto& pressureSummary = result.artifacts.pressureSummary; + pressureSummary.cellSizeMeters = kScenarioHotspotCellSize; + pressureSummary.hotspotScoreThreshold = kScenarioPressureScoreThreshold; + pressureSummary.criticalCompressionForceThreshold = kScenarioCriticalPressureForceThreshold; + pressureSummary.criticalExposureThresholdSeconds = kScenarioCriticalPressureExposureThresholdSeconds; + pressureSummary.criticalEventDurationThresholdSeconds = kScenarioCriticalPressureEventDurationThresholdSeconds; + pressureSummary.criticalEventAgentThreshold = kScenarioCriticalPressureEventAgentThreshold; + + std::vector currentPressureMetrics; + currentPressureMetrics.reserve(densityCells.size()); + for (const auto& [key, cell] : densityCells) { + auto metric = pressureMetricFromCell(query, cell, kScenarioHotspotCellSize); + if (metric.intrudingPairCount == 0 || metric.pressureScore <= 0.0) { + continue; + } + currentPressureMetrics.push_back(metric); + auto& peakMetric = result.peakPressureCellsByAddress[key]; + if (peakMetric.pressureScore <= 0.0 || isPressureCellWorse(metric, peakMetric)) { + peakMetric = std::move(metric); + } + } + + std::sort(currentPressureMetrics.begin(), currentPressureMetrics.end(), [](const auto& lhs, const auto& rhs) { + if (std::fabs(lhs.pressureScore - rhs.pressureScore) > 1e-9) { + return lhs.pressureScore > rhs.pressureScore; + } + if (lhs.intrudingPairCount != rhs.intrudingPairCount) { + return lhs.intrudingPairCount > rhs.intrudingPairCount; + } + return lhs.agentCount > rhs.agentCount; + }); + + std::vector cumulativePeakPressureMetrics; + cumulativePeakPressureMetrics.reserve(result.peakPressureCellsByAddress.size()); + for (const auto& [_, cell] : result.peakPressureCellsByAddress) { + cumulativePeakPressureMetrics.push_back(cell); + } + std::sort(cumulativePeakPressureMetrics.begin(), cumulativePeakPressureMetrics.end(), [](const auto& lhs, const auto& rhs) { + if (std::fabs(lhs.pressureScore - rhs.pressureScore) > 1e-9) { + return lhs.pressureScore > rhs.pressureScore; + } + if (lhs.intrudingPairCount != rhs.intrudingPairCount) { + return lhs.intrudingPairCount > rhs.intrudingPairCount; + } + return lhs.agentCount > rhs.agentCount; + }); + + pressureSummary.peakField = { + .timeSeconds = elapsedSeconds, + .cellSizeMeters = kScenarioHotspotCellSize, + .cells = cumulativePeakPressureMetrics, + }; + pressureSummary.peakCells = cumulativePeakPressureMetrics; + if (pressureSummary.peakCells.size() > kMaxResultPressureCells) { + pressureSummary.peakCells.resize(kMaxResultPressureCells); + } + + if (!currentPressureMetrics.empty()) { + if (!pressureSummary.peakCell.has_value() + || isPressureCellWorse(currentPressureMetrics.front(), *pressureSummary.peakCell)) { + pressureSummary.peakPressureScore = currentPressureMetrics.front().pressureScore; + pressureSummary.peakAtSeconds = elapsedSeconds; + pressureSummary.peakCell = currentPressureMetrics.front(); + } + } + + if (resources.contains()) { + const auto& metrics = resources.get(); + pressureSummary.peakExposedAgentCount = + std::max(pressureSummary.peakExposedAgentCount, metrics.peakSnapshot.pressureExposedAgentCount); + pressureSummary.peakCriticalAgentCount = + std::max(pressureSummary.peakCriticalAgentCount, metrics.peakSnapshot.criticalPressureAgentCount); + + for (const auto& hotspot : metrics.snapshot.pressureHotspots) { + const DensityCellAddress address{ + .cell = spatialCellFor(hotspot.center, kScenarioHotspotCellSize), + .floorId = hotspot.floorId, + }; + auto& peakHotspot = result.peakPressureHotspotsByAddress[densitySpatialKey(address)]; + if (peakHotspot.pressureScore <= 0.0 || isPressureHotspotWorse(hotspot, peakHotspot)) { + peakHotspot = hotspot; + } + } + + for (const auto& agent : metrics.snapshot.pressureAgents) { + auto [peakAgentIt, inserted] = result.peakPressureAgentsById.try_emplace(agent.agentId, agent); + if (!inserted && isPressureAgentWorse(agent, peakAgentIt->second)) { + peakAgentIt->second = agent; + } + } + + for (const auto& event : metrics.snapshot.criticalPressureEvents) { + const DensityCellAddress address{ + .cell = spatialCellFor(event.center, kScenarioHotspotCellSize), + .floorId = event.floorId, + }; + auto& peakEvent = result.peakCriticalPressureEventsByAddress[densitySpatialKey(address)]; + if (peakEvent.pressureScore <= 0.0 || isCriticalPressureEventWorse(event, peakEvent)) { + peakEvent = event; + } + } + } + + pressureSummary.peakHotspots.clear(); + pressureSummary.peakHotspots.reserve(result.peakPressureHotspotsByAddress.size()); + for (const auto& [_, hotspot] : result.peakPressureHotspotsByAddress) { + pressureSummary.peakHotspots.push_back(hotspot); + } + std::sort(pressureSummary.peakHotspots.begin(), pressureSummary.peakHotspots.end(), [](const auto& lhs, const auto& rhs) { + if (std::fabs(lhs.pressureScore - rhs.pressureScore) > 1e-9) { + return lhs.pressureScore > rhs.pressureScore; + } + if (lhs.intrudingPairCount != rhs.intrudingPairCount) { + return lhs.intrudingPairCount > rhs.intrudingPairCount; + } + return lhs.agentCount > rhs.agentCount; + }); + if (pressureSummary.peakHotspots.size() > kMaxResultPressureHotspots) { + pressureSummary.peakHotspots.resize(kMaxResultPressureHotspots); + } + + pressureSummary.peakAgents.clear(); + pressureSummary.peakAgents.reserve(result.peakPressureAgentsById.size()); + for (const auto& [_, agent] : result.peakPressureAgentsById) { + pressureSummary.peakAgents.push_back(agent); + } + std::sort(pressureSummary.peakAgents.begin(), pressureSummary.peakAgents.end(), [](const auto& lhs, const auto& rhs) { + if (lhs.critical != rhs.critical) { + return lhs.critical; + } + if (std::fabs(lhs.exposureSeconds - rhs.exposureSeconds) > 1e-9) { + return lhs.exposureSeconds > rhs.exposureSeconds; + } + return lhs.compressionForce > rhs.compressionForce; + }); + if (pressureSummary.peakAgents.size() > kMaxResultPressureAgents) { + pressureSummary.peakAgents.resize(kMaxResultPressureAgents); + } + + pressureSummary.criticalEvents.clear(); + pressureSummary.criticalEvents.reserve(result.peakCriticalPressureEventsByAddress.size()); + for (const auto& [_, event] : result.peakCriticalPressureEventsByAddress) { + pressureSummary.criticalEvents.push_back(event); + } + std::sort(pressureSummary.criticalEvents.begin(), pressureSummary.criticalEvents.end(), [](const auto& lhs, const auto& rhs) { + if (lhs.criticalAgentCount != rhs.criticalAgentCount) { + return lhs.criticalAgentCount > rhs.criticalAgentCount; + } + if (std::fabs(lhs.durationSeconds - rhs.durationSeconds) > 1e-9) { + return lhs.durationSeconds > rhs.durationSeconds; + } + return lhs.pressureScore > rhs.pressureScore; + }); + if (pressureSummary.criticalEvents.size() > kMaxResultCriticalPressureEvents) { + pressureSummary.criticalEvents.resize(kMaxResultCriticalPressureEvents); + } + const auto shouldRecordSample = result.artifacts.evacuationProgress.empty() || evacuatedCount != result.lastRecordedEvacuatedCount diff --git a/src/domain/ScenarioSimulationSystems.h b/src/domain/ScenarioSimulationSystems.h index b547a52..c381298 100644 --- a/src/domain/ScenarioSimulationSystems.h +++ b/src/domain/ScenarioSimulationSystems.h @@ -70,6 +70,10 @@ struct ScenarioResultArtifactsResource { bool densityTrackingInitialized{false}; double lastDensitySampleTimeSeconds{0.0}; std::unordered_map peakDensityCellsByAddress{}; + std::unordered_map peakPressureCellsByAddress{}; + std::unordered_map peakPressureHotspotsByAddress{}; + std::unordered_map peakPressureAgentsById{}; + std::unordered_map peakCriticalPressureEventsByAddress{}; }; struct ScenarioTimingKeyframesResource { diff --git a/tests/ScenarioSimulationSystemsTests.cpp b/tests/ScenarioSimulationSystemsTests.cpp index 8db79d3..900b4f2 100644 --- a/tests/ScenarioSimulationSystemsTests.cpp +++ b/tests/ScenarioSimulationSystemsTests.cpp @@ -1442,11 +1442,129 @@ SC_TEST(ScenarioRiskMetricsSystem_PublishesStalledHotspotAndBottleneckMetrics) { SC_EXPECT_NEAR(snapshot.hotspots.front().cellMin.y, 0.0, 1e-9); SC_EXPECT_NEAR(snapshot.hotspots.front().cellMax.x, 1.5, 1e-9); SC_EXPECT_NEAR(snapshot.hotspots.front().cellMax.y, 1.5, 1e-9); + SC_EXPECT_TRUE(!snapshot.pressureHotspots.empty()); + SC_EXPECT_EQ(snapshot.pressureHotspots.front().agentCount, std::size_t{5}); + SC_EXPECT_TRUE(snapshot.pressureHotspots.front().intrudingPairCount > 0); + SC_EXPECT_TRUE(snapshot.pressureHotspots.front().pressureScore >= 1.0); SC_EXPECT_TRUE(!snapshot.bottlenecks.empty()); SC_EXPECT_EQ(snapshot.bottlenecks.front().label, std::string{"Room -> Exit"}); SC_EXPECT_EQ(snapshot.completionRisk, safecrowd::domain::ScenarioRiskLevel::High); } +SC_TEST(ScenarioRiskMetricsSystem_DoesNotPublishPressureHotspotsForLooseClusterInSameCell) { + std::vector seeds; + for (const auto& point : std::vector{ + {0.10, 0.10}, + {1.30, 0.10}, + {0.10, 1.30}, + {1.30, 1.30}, + {0.75, 0.75}, + }) { + seeds.push_back({ + .position = {.value = point}, + .agent = {.radius = 0.25f, .maxSpeed = 1.0f}, + .velocity = {.value = {}}, + .route = { + .waypoints = {{.x = 1.0, .y = 0.0}}, + .waypointPassages = {{{.x = 1.0, .y = -0.4}, {.x = 1.0, .y = 0.4}}}, + .waypointFromZoneIds = {"room"}, + .waypointZoneIds = {"exit"}, + .nextWaypointIndex = 0, + .currentSegmentStart = point, + .previousDistanceToWaypoint = 0.25, + .stalledSeconds = 1.0, + .destinationZoneId = "exit", + }, + .status = {}, + }); + } + + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 37, + }); + runtime.addSystem(std::make_unique(std::move(seeds), 10.0)); + runtime.addSystem( + safecrowd::domain::makeScenarioRiskMetricsSystem(straightExitLayout()), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + + runtime.play(); + runtime.stepFrame(0.0); + + const auto& snapshot = + runtime.world().resources().get().snapshot; + SC_EXPECT_TRUE(!snapshot.hotspots.empty()); + SC_EXPECT_TRUE(snapshot.pressureHotspots.empty()); +} + +SC_TEST(ScenarioRiskMetricsSystem_AccumulatesCompressionExposureAndCriticalPressureAgents) { + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 38, + }); + runtime.addSystem(std::make_unique()); + runtime.addSystem( + safecrowd::domain::makeScenarioRiskMetricsSystem(straightExitLayout()), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + + runtime.play(); + runtime.stepFrame(0.0); + auto& clock = runtime.world().resources().get(); + clock.elapsedSeconds = 2.0; + runtime.stepFrame(0.0); + clock.elapsedSeconds = 3.0; + runtime.stepFrame(0.0); + + const auto& snapshot = + runtime.world().resources().get().snapshot; + SC_EXPECT_TRUE(snapshot.pressureExposedAgentCount > 0); + SC_EXPECT_TRUE(snapshot.criticalPressureAgentCount > 0); + SC_EXPECT_TRUE(!snapshot.pressureAgents.empty()); + SC_EXPECT_TRUE(snapshot.pressureAgents.front().critical); + SC_EXPECT_TRUE(snapshot.pressureAgents.front().compressionForce > 0.5); + SC_EXPECT_TRUE(snapshot.pressureAgents.front().exposureSeconds >= 2.0); +} + +SC_TEST(ScenarioRiskMetricsSystem_PublishesCriticalPressureEventAfterSustainedExposure) { + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 39, + }); + runtime.addSystem(std::make_unique()); + runtime.addSystem( + safecrowd::domain::makeScenarioRiskMetricsSystem(straightExitLayout()), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + + runtime.play(); + runtime.stepFrame(0.0); + auto& clock = runtime.world().resources().get(); + clock.elapsedSeconds = 2.0; + runtime.stepFrame(0.0); + { + const auto& snapshot = + runtime.world().resources().get().snapshot; + SC_EXPECT_TRUE(snapshot.criticalPressureEvents.empty()); + } + + clock.elapsedSeconds = 3.0; + runtime.stepFrame(0.0); + + const auto& snapshot = + runtime.world().resources().get().snapshot; + SC_EXPECT_TRUE(snapshot.criticalPressureAgentCount >= 2); + SC_EXPECT_TRUE(!snapshot.criticalPressureEvents.empty()); + SC_EXPECT_TRUE(snapshot.criticalPressureEvents.front().criticalAgentCount >= 2); + SC_EXPECT_TRUE(snapshot.criticalPressureEvents.front().durationSeconds >= 1.0); + SC_EXPECT_TRUE(snapshot.criticalPressureEvents.front().pressureScore > 0.0); + SC_EXPECT_EQ(snapshot.completionRisk, safecrowd::domain::ScenarioRiskLevel::High); +} + SC_TEST(ScenarioRiskMetricsSystem_DoesNotMergeHotspotsAcrossFloors) { safecrowd::engine::EngineRuntime runtime({ .fixedDeltaTime = 1.0 / 30.0, @@ -1465,6 +1583,7 @@ SC_TEST(ScenarioRiskMetricsSystem_DoesNotMergeHotspotsAcrossFloors) { const auto& snapshot = runtime.world().resources().get().snapshot; SC_EXPECT_TRUE(snapshot.hotspots.empty()); + SC_EXPECT_TRUE(snapshot.pressureHotspots.empty()); } SC_TEST(ScenarioRiskMetricsSystem_FiltersBottlenecksByConnectionFloor) { @@ -1532,6 +1651,8 @@ SC_TEST(ScenarioRiskMetricsSystem_UsesDisplayFloorForVirtualPhysicsBuckets) { runtime.world().resources().get().snapshot; SC_EXPECT_TRUE(!snapshot.hotspots.empty()); SC_EXPECT_EQ(snapshot.hotspots.front().floorId, std::string{"L2"}); + SC_EXPECT_TRUE(!snapshot.pressureHotspots.empty()); + SC_EXPECT_EQ(snapshot.pressureHotspots.front().floorId, std::string{"L2"}); SC_EXPECT_TRUE(!snapshot.bottlenecks.empty()); SC_EXPECT_EQ(snapshot.bottlenecks.front().floorId, std::string{"L2"}); } @@ -1581,13 +1702,54 @@ SC_TEST(ScenarioRiskMetricsSystem_PreservesPeakMetricsAfterAllAgentsEvacuate) { const auto& metrics = runtime.world().resources().get(); SC_EXPECT_TRUE(metrics.snapshot.hotspots.empty()); + SC_EXPECT_TRUE(metrics.snapshot.pressureHotspots.empty()); SC_EXPECT_TRUE(metrics.snapshot.bottlenecks.empty()); SC_EXPECT_TRUE(!metrics.peakSnapshot.hotspots.empty()); + SC_EXPECT_TRUE(!metrics.peakSnapshot.pressureHotspots.empty()); SC_EXPECT_TRUE(!metrics.peakSnapshot.bottlenecks.empty()); SC_EXPECT_EQ(metrics.peakSnapshot.stalledAgentCount, std::size_t{5}); SC_EXPECT_EQ(metrics.peakSnapshot.completionRisk, safecrowd::domain::ScenarioRiskLevel::High); } +SC_TEST(ScenarioRiskMetricsSystem_PreservesPeakCriticalPressureMetricsAfterAgentsEvacuate) { + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 40, + }); + runtime.addSystem(std::make_unique()); + runtime.addSystem( + safecrowd::domain::makeScenarioRiskMetricsSystem(straightExitLayout()), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + + runtime.play(); + runtime.stepFrame(0.0); + auto& clock = runtime.world().resources().get(); + clock.elapsedSeconds = 2.0; + runtime.stepFrame(0.0); + clock.elapsedSeconds = 3.0; + runtime.stepFrame(0.0); + + auto& query = runtime.world().query(); + for (const auto entity : query.view()) { + query.get(entity).evacuated = true; + } + clock.elapsedSeconds = 4.0; + runtime.stepFrame(0.0); + + const auto& metrics = + runtime.world().resources().get(); + SC_EXPECT_EQ(metrics.snapshot.pressureExposedAgentCount, std::size_t{0}); + SC_EXPECT_EQ(metrics.snapshot.criticalPressureAgentCount, std::size_t{0}); + SC_EXPECT_TRUE(metrics.snapshot.pressureAgents.empty()); + SC_EXPECT_TRUE(metrics.snapshot.criticalPressureEvents.empty()); + SC_EXPECT_TRUE(metrics.peakSnapshot.pressureExposedAgentCount > 0); + SC_EXPECT_TRUE(metrics.peakSnapshot.criticalPressureAgentCount > 0); + SC_EXPECT_TRUE(!metrics.peakSnapshot.pressureAgents.empty()); + SC_EXPECT_TRUE(!metrics.peakSnapshot.criticalPressureEvents.empty()); +} + SC_TEST(ScenarioResultArtifactsSystem_PublishesEvacuationCurveAndPercentiles) { safecrowd::engine::EngineRuntime runtime({ .fixedDeltaTime = 1.0 / 30.0, @@ -1707,6 +1869,104 @@ SC_TEST(ScenarioResultArtifactsSystem_AccumulatesDensityPeakFieldByFloorAndCell) SC_EXPECT_TRUE(hasL2Cell); } +SC_TEST(ScenarioResultArtifactsSystem_PublishesPressureSummary) { + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 41, + }); + runtime.addSystem(std::make_unique()); + runtime.addSystem( + safecrowd::domain::makeScenarioRiskMetricsSystem(straightExitLayout()), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .order = 10, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + runtime.addSystem( + std::make_unique(1.0), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .order = 20, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + + runtime.play(); + runtime.stepFrame(0.0); + auto& clock = runtime.world().resources().get(); + clock.elapsedSeconds = 2.0; + runtime.stepFrame(0.0); + clock.elapsedSeconds = 3.0; + runtime.stepFrame(0.0); + + const auto& summary = + runtime.world().resources().get().artifacts.pressureSummary; + SC_EXPECT_NEAR(summary.cellSizeMeters, safecrowd::domain::kScenarioHotspotCellSize, 1e-9); + SC_EXPECT_NEAR(summary.hotspotScoreThreshold, safecrowd::domain::kScenarioPressureScoreThreshold, 1e-9); + SC_EXPECT_NEAR(summary.criticalCompressionForceThreshold, safecrowd::domain::kScenarioCriticalPressureForceThreshold, 1e-9); + SC_EXPECT_NEAR(summary.criticalExposureThresholdSeconds, safecrowd::domain::kScenarioCriticalPressureExposureThresholdSeconds, 1e-9); + SC_EXPECT_NEAR(summary.criticalEventDurationThresholdSeconds, safecrowd::domain::kScenarioCriticalPressureEventDurationThresholdSeconds, 1e-9); + SC_EXPECT_EQ(summary.criticalEventAgentThreshold, safecrowd::domain::kScenarioCriticalPressureEventAgentThreshold); + SC_EXPECT_TRUE(summary.peakPressureScore > 0.0); + SC_EXPECT_TRUE(summary.peakAtSeconds.has_value()); + SC_EXPECT_TRUE(summary.peakCell.has_value()); + SC_EXPECT_TRUE(!summary.peakCells.empty()); + SC_EXPECT_TRUE(!summary.peakField.cells.empty()); + SC_EXPECT_TRUE(!summary.peakHotspots.empty()); + SC_EXPECT_TRUE(!summary.peakAgents.empty()); + SC_EXPECT_TRUE(summary.peakAgents.front().critical); + SC_EXPECT_TRUE(summary.peakExposedAgentCount > 0); + SC_EXPECT_TRUE(summary.peakCriticalAgentCount > 0); + SC_EXPECT_TRUE(!summary.criticalEvents.empty()); + SC_EXPECT_TRUE(summary.criticalEvents.front().durationSeconds >= 1.0); +} + +SC_TEST(ScenarioResultArtifactsSystem_AccumulatesPressurePeakFieldByFloorAndCell) { + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 42, + }); + runtime.addSystem(std::make_unique()); + runtime.addSystem( + safecrowd::domain::makeScenarioRiskMetricsSystem(straightExitLayout()), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .order = 10, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + runtime.addSystem( + std::make_unique(1.0), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .order = 20, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + + runtime.play(); + runtime.stepFrame(0.0); + + auto& query = runtime.world().query(); + int movedIndex = 0; + for (const auto entity : query.view< + safecrowd::domain::Position, + safecrowd::domain::EvacuationRoute, + safecrowd::domain::EvacuationStatus>()) { + auto& position = query.get(entity); + auto& route = query.get(entity); + position.value = {.x = 3.1 + (0.04 * static_cast(movedIndex)), .y = 0.1}; + route.currentFloorId = "L1"; + route.displayFloorId = "L1"; + ++movedIndex; + } + auto& clock = runtime.world().resources().get(); + clock.elapsedSeconds = 2.0; + runtime.stepFrame(0.0); + + const auto& cells = + runtime.world().resources().get().artifacts.pressureSummary.peakField.cells; + const auto hasL1Cell = std::any_of(cells.begin(), cells.end(), [](const auto& cell) { + return cell.floorId == "L1"; + }); + const auto hasL2Cell = std::any_of(cells.begin(), cells.end(), [](const auto& cell) { + return cell.floorId == "L2"; + }); + SC_EXPECT_TRUE(hasL1Cell); + SC_EXPECT_TRUE(hasL2Cell); +} + SC_TEST(ScenarioRoutePassageCrossed_UsesDoorPlaneNearEndpoint) { safecrowd::domain::FacilityLayout2D layout; layout.zones.push_back({