Skip to content

Commit dc325d2

Browse files
committed
Add pressure risk summaries and overlays
1 parent 0687e70 commit dc325d2

12 files changed

Lines changed: 1391 additions & 15 deletions

src/application/ScenarioBatchResultWidget.cpp

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include <QComboBox>
1313
#include <QEvent>
1414
#include <QFrame>
15+
#include <QHeaderView>
1516
#include <QHBoxLayout>
1617
#include <QLabel>
1718
#include <QMouseEvent>
@@ -24,6 +25,8 @@
2425
#include <QSizePolicy>
2526
#include <QSlider>
2627
#include <QTabWidget>
28+
#include <QTableWidget>
29+
#include <QTableWidgetItem>
2730
#include <QTimer>
2831
#include <QVBoxLayout>
2932

@@ -73,6 +76,41 @@ QString formatPercent(std::size_t numerator, std::size_t denominator) {
7376
return QString("%1%").arg(ratio * 100.0, 0, 'f', 0);
7477
}
7578

79+
QString formatPressureScore(double score) {
80+
return QString::number(score, 'f', 1);
81+
}
82+
83+
QTableWidget* createComparisonTable(const QStringList& headers, QWidget* parent) {
84+
auto* table = new QTableWidget(0, headers.size(), parent);
85+
table->setHorizontalHeaderLabels(headers);
86+
table->verticalHeader()->setVisible(false);
87+
table->horizontalHeader()->setStretchLastSection(true);
88+
table->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
89+
table->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
90+
table->setWordWrap(false);
91+
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
92+
table->setSelectionMode(QAbstractItemView::NoSelection);
93+
table->setFocusPolicy(Qt::NoFocus);
94+
table->setAlternatingRowColors(true);
95+
table->setStyleSheet(
96+
"QTableWidget { background: #ffffff; border: 1px solid #d7e0ea; gridline-color: #e4ebf3; }"
97+
"QHeaderView::section { background: #eef3f8; border: 0; border-bottom: 1px solid #d7e0ea; padding: 6px; color: #4f5d6b; }"
98+
"QTableWidget::item { padding: 6px; }");
99+
return table;
100+
}
101+
102+
QTableWidgetItem* tableItem(const QString& text, bool emphasized = false) {
103+
auto* item = new QTableWidgetItem(text);
104+
item->setFlags(item->flags() & ~Qt::ItemIsEditable);
105+
if (emphasized) {
106+
auto font = item->font();
107+
font.setBold(true);
108+
item->setFont(font);
109+
item->setBackground(QColor("#eef5ff"));
110+
}
111+
return item;
112+
}
113+
76114
QString scenarioRoleLabel(safecrowd::domain::ScenarioRole role) {
77115
switch (role) {
78116
case safecrowd::domain::ScenarioRole::Baseline:
@@ -658,6 +696,7 @@ QWidget* ScenarioBatchResultWidget::createCanvasPanel() {
658696

659697
overlayCombo_ = new QComboBox(selectorBar);
660698
overlayCombo_->addItem("Density", static_cast<int>(OverlayMode::Density));
699+
overlayCombo_->addItem("Pressure", static_cast<int>(OverlayMode::Pressure));
661700
overlayCombo_->addItem("Hotspots", static_cast<int>(OverlayMode::Hotspots));
662701
overlayCombo_->addItem("Bottlenecks", static_cast<int>(OverlayMode::Bottlenecks));
663702
overlayCombo_->addItem("None", static_cast<int>(OverlayMode::None));
@@ -704,13 +743,15 @@ QWidget* ScenarioBatchResultWidget::createCanvasPanel() {
704743
auto* tabs = new QTabWidget(graphPanel);
705744
remainingChart_ = new ComparisonGraphWidget(ComparisonGraphMode::Remaining, tabs);
706745
exitsChart_ = new ComparisonGraphWidget(ComparisonGraphMode::Exits, tabs);
746+
pressureTable_ = createComparisonTable({"Scenario", "Peak score", "Exposed / Critical", "Hotspots", "Events", "Peak at"}, tabs);
707747
static_cast<ComparisonGraphWidget*>(remainingChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_);
708748
static_cast<ComparisonGraphWidget*>(exitsChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_);
709749
static_cast<ComparisonGraphWidget*>(remainingChart_)->setTimingMarkerActivatedHandler([this](double seconds) {
710750
seekToTimingMarkerSeconds(seconds);
711751
});
712752
tabs->addTab(remainingChart_, "Remaining");
713753
tabs->addTab(exitsChart_, "Exits");
754+
tabs->addTab(pressureTable_, "Pressure");
714755
graphLayout->addWidget(tabs, 1);
715756
layout->addWidget(graphPanel, 1);
716757

@@ -769,7 +810,7 @@ QWidget* ScenarioBatchResultWidget::createSummaryPanel() {
769810
layout->setContentsMargins(0, 0, 0, 0);
770811
layout->setSpacing(12);
771812

772-
auto* intro = createLabel("Choose which completed scenarios appear together in the Remaining and Exits graphs.", content, ui::FontRole::Caption);
813+
auto* intro = createLabel("Choose which completed scenarios appear together in the comparison graphs and pressure summary table.", content, ui::FontRole::Caption);
773814
intro->setStyleSheet(ui::mutedTextStyleSheet());
774815
layout->addWidget(intro);
775816

@@ -921,6 +962,12 @@ void ScenarioBatchResultWidget::applyReplayFrameData(const safecrowd::domain::Si
921962
? result.artifacts.densitySummary.peakCells
922963
: result.artifacts.densitySummary.peakField.cells,
923964
result.artifacts.densitySummary.highDensityThresholdPeoplePerSquareMeter);
965+
canvas_->setPressureOverlay(result.artifacts.pressureSummary.peakField.cells.empty()
966+
? result.artifacts.pressureSummary.peakCells
967+
: result.artifacts.pressureSummary.peakField.cells,
968+
std::max(
969+
result.artifacts.pressureSummary.hotspotScoreThreshold,
970+
result.artifacts.pressureSummary.peakPressureScore));
924971
applyOverlayModeToCanvas();
925972
}
926973
if (replaySlider_ != nullptr && replaySlider_->value() != replayFrameIndex_) {
@@ -946,6 +993,9 @@ void ScenarioBatchResultWidget::applyOverlayModeToCanvas() {
946993
return;
947994
}
948995
switch (overlayMode_) {
996+
case OverlayMode::Pressure:
997+
canvas_->setResultOverlayMode(ResultOverlayMode::Pressure);
998+
break;
949999
case OverlayMode::Hotspots:
9501000
canvas_->setResultOverlayMode(ResultOverlayMode::Hotspots);
9511001
break;
@@ -1069,6 +1119,44 @@ void ScenarioBatchResultWidget::refreshComparisonSelection() {
10691119
if (exitsChart_ != nullptr) {
10701120
static_cast<ComparisonGraphWidget*>(exitsChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_);
10711121
}
1122+
refreshPressureComparisonTable();
1123+
}
1124+
1125+
void ScenarioBatchResultWidget::refreshPressureComparisonTable() {
1126+
if (pressureTable_ == nullptr) {
1127+
return;
1128+
}
1129+
1130+
std::vector<int> visibleIndices = selectedCompareIndices_;
1131+
if (visibleIndices.empty() && currentResultIndex_ >= 0 && currentResultIndex_ < static_cast<int>(results_.size())) {
1132+
visibleIndices.push_back(currentResultIndex_);
1133+
}
1134+
1135+
pressureTable_->setRowCount(static_cast<int>(visibleIndices.size()));
1136+
for (int row = 0; row < static_cast<int>(visibleIndices.size()); ++row) {
1137+
const auto index = visibleIndices[static_cast<std::size_t>(row)];
1138+
if (index < 0 || index >= static_cast<int>(results_.size())) {
1139+
continue;
1140+
}
1141+
1142+
const auto& result = results_[static_cast<std::size_t>(index)];
1143+
const auto& summary = result.artifacts.pressureSummary;
1144+
const bool emphasized = index == currentResultIndex_;
1145+
pressureTable_->setItem(row, 0, tableItem(QString::fromStdString(result.scenario.name), emphasized));
1146+
pressureTable_->setItem(row, 1, tableItem(formatPressureScore(summary.peakPressureScore), emphasized));
1147+
pressureTable_->setItem(
1148+
row,
1149+
2,
1150+
tableItem(
1151+
QString("%1 / %2")
1152+
.arg(static_cast<int>(summary.peakExposedAgentCount))
1153+
.arg(static_cast<int>(summary.peakCriticalAgentCount)),
1154+
emphasized));
1155+
pressureTable_->setItem(row, 3, tableItem(QString::number(static_cast<int>(summary.peakHotspots.size())), emphasized));
1156+
pressureTable_->setItem(row, 4, tableItem(QString::number(static_cast<int>(summary.criticalEvents.size())), emphasized));
1157+
pressureTable_->setItem(row, 5, tableItem(formatSeconds(summary.peakAtSeconds), emphasized));
1158+
}
1159+
pressureTable_->resizeRowsToContents();
10721160
}
10731161

10741162
void ScenarioBatchResultWidget::refreshResultNavigationPanel() {
@@ -1151,6 +1239,7 @@ void ScenarioBatchResultWidget::refreshSelectedResult() {
11511239
if (exitsChart_ != nullptr) {
11521240
static_cast<ComparisonGraphWidget*>(exitsChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_);
11531241
}
1242+
refreshPressureComparisonTable();
11541243
refreshResultNavigationPanel();
11551244
if (detailLabel_ != nullptr) {
11561245
const auto selectedFinalSeconds = finalSeconds(result);
@@ -1159,7 +1248,7 @@ void ScenarioBatchResultWidget::refreshSelectedResult() {
11591248
? QString("Baseline")
11601249
: formatDeltaSeconds(selectedFinalSeconds - finalSeconds(results_[static_cast<std::size_t>(baselineIndex)])))
11611250
: QString("No baseline");
1162-
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")
1251+
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")
11631252
.arg(QString::fromStdString(result.scenario.name))
11641253
.arg(scenarioRoleLabel(result.scenario.role))
11651254
.arg(formatSeconds(selectedFinalSeconds))
@@ -1170,6 +1259,11 @@ void ScenarioBatchResultWidget::refreshSelectedResult() {
11701259
.arg(safecrowd::domain::scenarioRiskLevelLabel(result.risk.completionRisk))
11711260
.arg(static_cast<int>(result.risk.hotspots.size()))
11721261
.arg(static_cast<int>(result.risk.bottlenecks.size()))
1262+
.arg(static_cast<int>(result.artifacts.pressureSummary.peakHotspots.size()))
1263+
.arg(static_cast<int>(result.artifacts.pressureSummary.peakCriticalAgentCount))
1264+
.arg(static_cast<int>(result.artifacts.pressureSummary.criticalEvents.size()))
1265+
.arg(formatPressureScore(result.artifacts.pressureSummary.peakPressureScore))
1266+
.arg(formatSeconds(result.artifacts.pressureSummary.peakAtSeconds))
11731267
.arg(formatSeconds(result.artifacts.timingSummary.t90Seconds))
11741268
.arg(formatSeconds(result.artifacts.timingSummary.t95Seconds)));
11751269
}

src/application/ScenarioBatchResultWidget.h

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class QCheckBox;
1818
class QComboBox;
1919
class QPushButton;
2020
class QSlider;
21+
class QTableWidget;
2122
class QTimer;
2223

2324
namespace safecrowd::application {
@@ -45,9 +46,10 @@ class ScenarioBatchResultWidget : public QWidget {
4546
private:
4647
enum class OverlayMode {
4748
Density = 0,
48-
Hotspots = 1,
49-
Bottlenecks = 2,
50-
None = 3,
49+
Pressure = 1,
50+
Hotspots = 2,
51+
Bottlenecks = 3,
52+
None = 4,
5153
};
5254

5355
QWidget* createCanvasPanel();
@@ -61,6 +63,7 @@ class ScenarioBatchResultWidget : public QWidget {
6163
void navigateToAuthoring();
6264
void pauseReplay();
6365
void refreshComparisonSelection();
66+
void refreshPressureComparisonTable();
6467
void refreshResultNavigationPanel();
6568
void refreshSelectedResult();
6669
void rerunBatch();
@@ -89,6 +92,7 @@ class ScenarioBatchResultWidget : public QWidget {
8992
QSlider* replaySlider_{nullptr};
9093
QLabel* replayTimeLabel_{nullptr};
9194
QLabel* detailLabel_{nullptr};
95+
QTableWidget* pressureTable_{nullptr};
9296
std::vector<QCheckBox*> compareCheckBoxes_{};
9397
QWidget* remainingChart_{nullptr};
9498
QWidget* exitsChart_{nullptr};

src/application/ScenarioResultWidget.cpp

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
#include <QTimer>
3434
#include <QToolButton>
3535
#include <QVBoxLayout>
36+
#include <QWidget>
3637

3738
#include "application/ScenarioAuthoringWidget.h"
3839
#include "application/ScenarioCanvasWidget.h"
@@ -71,6 +72,10 @@ QString formatDensity(double density) {
7172
return QString("%1 / m2").arg(density, 0, 'f', 1);
7273
}
7374

75+
QString formatPressureScore(double score) {
76+
return QString::number(score, 'f', 1);
77+
}
78+
7479
QString formatPercent(double ratio) {
7580
return QString("%1%").arg(std::clamp(ratio, 0.0, 1.0) * 100.0, 0, 'f', 0);
7681
}
@@ -372,6 +377,60 @@ class DensityLegendWidget final : public QWidget {
372377
double peakDensity_{0.0};
373378
};
374379

380+
class PressureLegendWidget final : public QWidget {
381+
public:
382+
explicit PressureLegendWidget(
383+
const safecrowd::domain::PressureSummary& summary,
384+
QWidget* parent = nullptr)
385+
: QWidget(parent),
386+
threshold_(summary.hotspotScoreThreshold),
387+
peakScore_(summary.peakPressureScore) {
388+
setFixedSize(300, 34);
389+
setToolTip("Pressure heatmap scale uses the pressure hotspot score threshold and the observed peak score.");
390+
}
391+
392+
protected:
393+
void paintEvent(QPaintEvent* event) override {
394+
(void)event;
395+
396+
QPainter painter(this);
397+
painter.setRenderHint(QPainter::Antialiasing, true);
398+
399+
const QRectF ramp(0, 4, width(), 9);
400+
QLinearGradient gradient(ramp.left(), ramp.center().y(), ramp.right(), ramp.center().y());
401+
gradient.setColorAt(0.0, QColor("#facc15"));
402+
gradient.setColorAt(0.25, QColor("#f97316"));
403+
gradient.setColorAt(0.55, QColor("#ef4444"));
404+
gradient.setColorAt(1.0, QColor("#991b1b"));
405+
painter.setPen(Qt::NoPen);
406+
painter.setBrush(gradient);
407+
painter.drawRoundedRect(ramp, 4, 4);
408+
409+
painter.setFont(ui::font(ui::FontRole::Caption));
410+
painter.setPen(QColor("#687789"));
411+
if (peakScore_ > 0.0 && threshold_ > 0.0) {
412+
const auto peakX = ramp.left()
413+
+ (std::clamp(peakScore_ / threshold_, 0.0, 1.0) * ramp.width());
414+
painter.setPen(QPen(QColor("#405063"), 1));
415+
painter.drawLine(QPointF(peakX, ramp.top() - 2), QPointF(peakX, ramp.bottom() + 2));
416+
painter.setPen(QColor("#687789"));
417+
}
418+
painter.drawText(QRectF(0, 16, 40, 16), Qt::AlignLeft | Qt::AlignVCenter, "0");
419+
painter.drawText(
420+
QRectF(42, 16, 178, 16),
421+
Qt::AlignCenter,
422+
QString("Hotspot %1+").arg(threshold_, 0, 'f', 1));
423+
painter.drawText(
424+
QRectF(width() - 98, 16, 98, 16),
425+
Qt::AlignRight | Qt::AlignVCenter,
426+
QString("Peak %1").arg(peakScore_, 0, 'f', 1));
427+
}
428+
429+
private:
430+
double threshold_{0.0};
431+
double peakScore_{0.0};
432+
};
433+
375434
QFrame* createMetricCard(const QString& title, const QString& value, QWidget* parent, const QString& tooltip = {}) {
376435
auto* card = new QFrame(parent);
377436
card->setStyleSheet(ui::panelStyleSheet());
@@ -400,6 +459,8 @@ QString resultCriteriaTooltip(const safecrowd::domain::ScenarioResultArtifacts&
400459
return QStringList{
401460
QString("High density: %1 / m2 or higher for accumulated duration.")
402461
.arg(artifacts.densitySummary.highDensityThresholdPeoplePerSquareMeter, 0, 'f', 1),
462+
QString("Pressure hotspot: score %1 or higher in a crowded cell.")
463+
.arg(artifacts.pressureSummary.hotspotScoreThreshold, 0, 'f', 1),
403464
safecrowd::domain::scenarioStalledDefinition(),
404465
safecrowd::domain::scenarioBottleneckDefinition(),
405466
}.join("\n\n");
@@ -744,23 +805,36 @@ QWidget* createResultCanvasPanel(
744805
overlayLabel->setStyleSheet(ui::mutedTextStyleSheet());
745806
auto* overlayCombo = new QComboBox(overlayBar);
746807
overlayCombo->addItem("Peak Density", static_cast<int>(ResultOverlayMode::Density));
808+
overlayCombo->addItem("Pressure", static_cast<int>(ResultOverlayMode::Pressure));
747809
overlayCombo->addItem("Bottlenecks", static_cast<int>(ResultOverlayMode::Bottlenecks));
748810
overlayCombo->addItem("Hotspots", static_cast<int>(ResultOverlayMode::Hotspots));
749811
overlayCombo->addItem("None", static_cast<int>(ResultOverlayMode::None));
750812
overlayCombo->setToolTip("Switch between result map overlays.");
751813
overlayLayout->addWidget(overlayLabel);
752814
overlayLayout->addWidget(overlayCombo);
753815
overlayLayout->addSpacing(10);
754-
overlayLayout->addWidget(new DensityLegendWidget(artifacts.densitySummary, overlayBar));
816+
auto* densityLegend = new DensityLegendWidget(artifacts.densitySummary, overlayBar);
817+
auto* pressureLegend = new PressureLegendWidget(artifacts.pressureSummary, overlayBar);
818+
pressureLegend->setVisible(false);
819+
overlayLayout->addWidget(densityLegend);
820+
overlayLayout->addWidget(pressureLegend);
755821
overlayLayout->addStretch(1);
756822
layout->addWidget(overlayBar);
757823
canvas->setDensityOverlay(artifacts.densitySummary.peakField.cells.empty()
758824
? artifacts.densitySummary.peakCells
759825
: artifacts.densitySummary.peakField.cells,
760826
artifacts.densitySummary.highDensityThresholdPeoplePerSquareMeter);
827+
canvas->setPressureOverlay(artifacts.pressureSummary.peakField.cells.empty()
828+
? artifacts.pressureSummary.peakCells
829+
: artifacts.pressureSummary.peakField.cells,
830+
std::max(
831+
artifacts.pressureSummary.hotspotScoreThreshold,
832+
artifacts.pressureSummary.peakPressureScore));
761833
canvas->setResultOverlayMode(ResultOverlayMode::Density);
762-
QObject::connect(overlayCombo, &QComboBox::currentIndexChanged, panel, [canvas, overlayCombo](int index) {
834+
QObject::connect(overlayCombo, &QComboBox::currentIndexChanged, panel, [canvas, overlayCombo, densityLegend, pressureLegend](int index) {
763835
const auto mode = static_cast<ResultOverlayMode>(overlayCombo->itemData(index).toInt());
836+
densityLegend->setVisible(mode == ResultOverlayMode::Density);
837+
pressureLegend->setVisible(mode == ResultOverlayMode::Pressure);
764838
canvas->setResultOverlayMode(mode);
765839
});
766840
layout->addWidget(canvas, 1);
@@ -893,6 +967,14 @@ QWidget* createResultPanel(
893967
const auto slowestGroup = artifacts.placementCompletion.empty()
894968
? QString("Pending")
895969
: QString::fromStdString(artifacts.placementCompletion.front().placementId);
970+
const auto peakPressureTooltip = QString(
971+
"Highest pressure hotspot score observed during the run.%1%2")
972+
.arg(artifacts.pressureSummary.peakAtSeconds.has_value()
973+
? QString("\n\nPeak at %1 sec.").arg(*artifacts.pressureSummary.peakAtSeconds, 0, 'f', 1)
974+
: QString())
975+
.arg(artifacts.pressureSummary.peakCell.has_value()
976+
? QString("\nCell floor: %1").arg(QString::fromStdString(artifacts.pressureSummary.peakCell->floorId))
977+
: QString());
896978
metricsGrid->addWidget(createMetricCard(
897979
"Completion",
898980
formatSecondsValue(completionTime),
@@ -939,6 +1021,27 @@ QWidget* createResultPanel(
9391021
formatOptionalSeconds(artifacts.timingSummary.t95Seconds),
9401022
panel,
9411023
"Time at which 95% of occupants completed evacuation."), 4, 0);
1024+
metricsGrid->addWidget(createMetricCard(
1025+
"Peak Pressure",
1026+
formatPressureScore(artifacts.pressureSummary.peakPressureScore),
1027+
panel,
1028+
peakPressureTooltip), 4, 1);
1029+
metricsGrid->addWidget(createMetricCard(
1030+
"Pressure Hotspots",
1031+
QString::number(static_cast<int>(artifacts.pressureSummary.peakHotspots.size())),
1032+
panel,
1033+
"Peak number of stored pressure hotspot locations from the run."), 5, 0);
1034+
metricsGrid->addWidget(createMetricCard(
1035+
"Pressure Events",
1036+
QString::number(static_cast<int>(artifacts.pressureSummary.criticalEvents.size())),
1037+
panel,
1038+
"Stored sustained critical pressure events that met the duration and agent-count thresholds."), 5, 1);
1039+
metricsGrid->addWidget(createMetricCard(
1040+
"Critical Pressure",
1041+
QString("%1 agents").arg(static_cast<int>(artifacts.pressureSummary.peakCriticalAgentCount)),
1042+
panel,
1043+
QString("Peak simultaneously critical agents during the run.\nExposed peak: %1 agents.")
1044+
.arg(static_cast<int>(artifacts.pressureSummary.peakExposedAgentCount))), 6, 0);
9421045
layout->addLayout(metricsGrid);
9431046
layout->addStretch(1);
9441047

0 commit comments

Comments
 (0)