From af2b259c248ea13804d0b23160414abf34b31346 Mon Sep 17 00:00:00 2001 From: muzygosu Date: Sun, 17 May 2026 19:32:48 +0900 Subject: [PATCH] [Application] Add scenario-scoped alternative recommendations --- src/application/ProjectWorkspaceState.h | 1 + src/application/ScenarioBatchResultWidget.cpp | 276 ++++--- src/application/ScenarioBatchResultWidget.h | 5 +- src/application/ScenarioCanvasWidget.cpp | 79 +- src/application/ScenarioResultNavigation.cpp | 98 ++- src/application/ScenarioResultNavigation.h | 7 + src/application/ScenarioResultWidget.cpp | 127 +++ src/application/ScenarioResultWidget.h | 1 + .../AlternativeRecommendationService.cpp | 721 +++++++++++++++++- src/domain/AlternativeRecommendationService.h | 23 + src/domain/DemoFixtureService.cpp | 2 +- .../AlternativeRecommendationServiceTests.cpp | 351 ++++++++- tests/DemoFixtureServiceTests.cpp | 4 +- 13 files changed, 1528 insertions(+), 167 deletions(-) diff --git a/src/application/ProjectWorkspaceState.h b/src/application/ProjectWorkspaceState.h index 04026c0..2163052 100644 --- a/src/application/ProjectWorkspaceState.h +++ b/src/application/ProjectWorkspaceState.h @@ -35,6 +35,7 @@ enum class SavedResultNavigationView { Hotspot, Zone, Groups, + Recommendations, }; struct SavedScenarioState { diff --git a/src/application/ScenarioBatchResultWidget.cpp b/src/application/ScenarioBatchResultWidget.cpp index 4655a49..a5f4240 100644 --- a/src/application/ScenarioBatchResultWidget.cpp +++ b/src/application/ScenarioBatchResultWidget.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -79,6 +78,11 @@ QString formatPercent(std::size_t numerator, std::size_t denominator) { return QString("%1%").arg(ratio * 100.0, 0, 'f', 0); } +bool shouldShowRecommendationEvidence(const safecrowd::domain::AlternativeRecommendationEvidence& item) { + const auto label = QString::fromStdString(item.label); + return !label.startsWith("Risk ") && label != "Critical pressure events"; +} + QString formatPressureScore(double score) { return QString::number(score, 'f', 1); } @@ -126,18 +130,6 @@ QString scenarioRoleLabel(safecrowd::domain::ScenarioRole role) { } } -void clearLayout(QLayout* layout) { - if (layout == nullptr) { - return; - } - while (auto* item = layout->takeAt(0)) { - if (auto* widget = item->widget(); widget != nullptr) { - widget->deleteLater(); - } - delete item; - } -} - std::vector> progressSeries(const SavedScenarioResultState& result) { std::vector> series; if (!result.artifacts.evacuationProgress.empty()) { @@ -681,6 +673,8 @@ ScenarioResultNavigationView resultNavigationViewFromSaved(SavedResultNavigation return ScenarioResultNavigationView::Zone; case SavedResultNavigationView::Groups: return ScenarioResultNavigationView::Groups; + case SavedResultNavigationView::Recommendations: + return ScenarioResultNavigationView::Recommendations; case SavedResultNavigationView::Bottleneck: default: return ScenarioResultNavigationView::Bottleneck; @@ -695,6 +689,8 @@ SavedResultNavigationView savedResultNavigationView(ScenarioResultNavigationView return SavedResultNavigationView::Zone; case ScenarioResultNavigationView::Groups: return SavedResultNavigationView::Groups; + case ScenarioResultNavigationView::Recommendations: + return SavedResultNavigationView::Recommendations; case ScenarioResultNavigationView::Bottleneck: default: return SavedResultNavigationView::Bottleneck; @@ -747,6 +743,9 @@ ScenarioBatchResultWidget::ScenarioBatchResultWidget( } } } + for (int index = 0; index < static_cast(results_.size()); ++index) { + selectedRecommendationIndices_.push_back(index); + } auto* rootLayout = new QVBoxLayout(this); rootLayout->setContentsMargins(0, 0, 0, 0); @@ -979,13 +978,6 @@ QWidget* ScenarioBatchResultWidget::createSummaryPanel() { detailLayout->addWidget(detailLabel_); layout->addWidget(detailCard); - recommendationPanel_ = new QFrame(content); - recommendationPanel_->setStyleSheet(ui::panelStyleSheet()); - auto* recommendationLayout = new QVBoxLayout(recommendationPanel_); - recommendationLayout->setContentsMargins(12, 10, 12, 10); - recommendationLayout->setSpacing(8); - layout->addWidget(recommendationPanel_); - layout->addStretch(1); scrollArea->setWidget(content); @@ -1377,98 +1369,186 @@ void ScenarioBatchResultWidget::refreshPressureComparisonTable() { pressureTable_->resizeRowsToContents(); } -void ScenarioBatchResultWidget::refreshRecommendationPanel() { - if (recommendationPanel_ == nullptr) { +void ScenarioBatchResultWidget::setRecommendationScenarioSelected(int index, bool selected) { + if (index < 0 || index >= static_cast(results_.size())) { return; } - auto* panelLayout = qobject_cast(recommendationPanel_->layout()); - clearLayout(panelLayout); - if (panelLayout == nullptr) { - return; + const auto it = std::find(selectedRecommendationIndices_.begin(), selectedRecommendationIndices_.end(), index); + if (selected && it == selectedRecommendationIndices_.end()) { + selectedRecommendationIndices_.push_back(index); + std::sort(selectedRecommendationIndices_.begin(), selectedRecommendationIndices_.end()); + } else if (!selected && it != selectedRecommendationIndices_.end()) { + selectedRecommendationIndices_.erase(it); } + QTimer::singleShot(0, this, [this]() { + refreshResultNavigationPanel(); + }); +} - panelLayout->addWidget(createLabel("Recommendations", recommendationPanel_, ui::FontRole::SectionTitle)); - if (results_.empty() || currentResultIndex_ < 0 || currentResultIndex_ >= static_cast(results_.size())) { - auto* empty = createLabel("No completed result selected.", recommendationPanel_, ui::FontRole::Caption); - empty->setStyleSheet(ui::mutedTextStyleSheet()); - panelLayout->addWidget(empty); - return; - } +QWidget* ScenarioBatchResultWidget::createBatchRecommendationNavigationPanel() { + auto* panel = new QWidget(shell_); + auto* layout = new QVBoxLayout(panel); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(12); - const auto& selected = results_[static_cast(currentResultIndex_)]; - safecrowd::domain::AlternativeRecommendationRequest request{ - .layout = layout_, - .sourceScenario = selected.scenario, - .risk = selected.risk, - .artifacts = selected.artifacts, - }; - const auto baselineIndex = explicitBaselineResultIndex(); - if (baselineIndex >= 0 && baselineIndex < static_cast(results_.size())) { - request.baselineScenario = results_[static_cast(baselineIndex)].scenario; + auto* header = createLabel("Recommendations", panel, ui::FontRole::SectionTitle); + header->setStyleSheet(ui::mutedTextStyleSheet()); + layout->addWidget(header); + auto* caption = createLabel("Select scenarios to inspect their operational alternatives.", panel, ui::FontRole::Caption); + caption->setStyleSheet(ui::subtleTextStyleSheet()); + layout->addWidget(caption); + + auto* selector = new QFrame(panel); + selector->setStyleSheet(ui::panelStyleSheet()); + auto* selectorLayout = new QVBoxLayout(selector); + selectorLayout->setContentsMargins(14, 12, 14, 12); + selectorLayout->setSpacing(8); + selectorLayout->addWidget(createLabel("Scenarios", selector, ui::FontRole::Body)); + for (int index = 0; index < static_cast(results_.size()); ++index) { + const auto& result = results_[static_cast(index)]; + auto* checkbox = new QCheckBox( + QString("%1\n%2 - %3") + .arg(QString::fromStdString(result.scenario.name)) + .arg(scenarioRoleLabel(result.scenario.role)) + .arg(formatSeconds(finalSeconds(result))), + selector); + checkbox->setFont(ui::font(ui::FontRole::Caption)); + checkbox->setChecked(std::find( + selectedRecommendationIndices_.begin(), + selectedRecommendationIndices_.end(), + index) != selectedRecommendationIndices_.end()); + checkbox->setStyleSheet( + "QCheckBox { color: #344256; spacing: 8px; }" + "QCheckBox::indicator { width: 16px; height: 16px; }"); + selectorLayout->addWidget(checkbox); + connect(checkbox, &QCheckBox::toggled, this, [this, index](bool checked) { + setRecommendationScenarioSelected(index, checked); + }); } + layout->addWidget(selector); - const safecrowd::domain::AlternativeRecommendationService service; - const auto recommendation = service.recommend(request); - if (recommendation.candidates.empty()) { - const auto message = recommendation.blockingReasons.empty() - ? QString("No actionable recommendation for this result.") - : QString::fromStdString(recommendation.blockingReasons.front()); - auto* empty = createLabel(message, recommendationPanel_, ui::FontRole::Caption); + auto* scrollArea = new QScrollArea(panel); + scrollArea->setWidgetResizable(true); + scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + ui::polishScrollArea(scrollArea); + + auto* content = new QWidget(scrollArea); + content->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + auto* contentLayout = new QVBoxLayout(content); + contentLayout->setContentsMargins(0, 0, 10, 0); + contentLayout->setSpacing(12); + + if (selectedRecommendationIndices_.empty()) { + auto* empty = createLabel("Select at least one scenario.", content, ui::FontRole::Caption); empty->setStyleSheet(ui::mutedTextStyleSheet()); - panelLayout->addWidget(empty); - return; + contentLayout->addWidget(empty); } - for (const auto& candidate : recommendation.candidates) { - auto* section = new QWidget(recommendationPanel_); - auto* sectionLayout = new QVBoxLayout(section); - sectionLayout->setContentsMargins(0, 0, 0, 0); - sectionLayout->setSpacing(5); + const auto baselineIndex = explicitBaselineResultIndex(); + const safecrowd::domain::AlternativeRecommendationService service; + for (const auto index : selectedRecommendationIndices_) { + if (index < 0 || index >= static_cast(results_.size())) { + continue; + } + const auto& result = results_[static_cast(index)]; + safecrowd::domain::AlternativeRecommendationRequest request{ + .layout = layout_, + .sourceScenario = result.scenario, + .risk = result.risk, + .artifacts = result.artifacts, + .finalFrame = result.frame, + }; + if (baselineIndex >= 0 && baselineIndex < static_cast(results_.size())) { + request.baselineScenario = results_[static_cast(baselineIndex)].scenario; + } + const auto recommendation = service.recommend(request); - auto* title = createLabel(QString::fromStdString(candidate.title), section, ui::FontRole::Body); + auto* scenarioHeader = new QWidget(content); + auto* scenarioHeaderLayout = new QVBoxLayout(scenarioHeader); + scenarioHeaderLayout->setContentsMargins(0, 0, 0, 0); + scenarioHeaderLayout->setSpacing(2); + auto* title = createLabel(QString::fromStdString(result.scenario.name), scenarioHeader, ui::FontRole::Body); title->setStyleSheet("QLabel { color: #16202b; font-weight: 600; }"); - sectionLayout->addWidget(title); - - auto* summary = createLabel(QString::fromStdString(candidate.summary), section, ui::FontRole::Caption); - summary->setStyleSheet(ui::mutedTextStyleSheet()); - sectionLayout->addWidget(summary); - - auto* source = createLabel( - QString("Result source: %1").arg(QString::fromStdString(candidate.artifactSource)), - section, + scenarioHeaderLayout->addWidget(title); + auto* meta = createLabel( + QString("%1 - %2 - %3 recommendation%4") + .arg(scenarioRoleLabel(result.scenario.role)) + .arg(formatSeconds(finalSeconds(result))) + .arg(static_cast(recommendation.candidates.size())) + .arg(recommendation.candidates.size() == 1 ? "" : "s"), + scenarioHeader, ui::FontRole::Caption); - source->setStyleSheet(ui::mutedTextStyleSheet()); - sectionLayout->addWidget(source); - - for (const auto& item : candidate.evidence) { - auto* evidenceLabel = createLabel( - QString("%1: %2 (%3)") - .arg(QString::fromStdString(item.label), - QString::fromStdString(item.value), - QString::fromStdString(item.source)), - section, - ui::FontRole::Caption); - evidenceLabel->setStyleSheet(ui::mutedTextStyleSheet()); - sectionLayout->addWidget(evidenceLabel); + meta->setStyleSheet(ui::subtleTextStyleSheet()); + scenarioHeaderLayout->addWidget(meta); + contentLayout->addWidget(scenarioHeader); + + if (recommendation.candidates.empty()) { + const auto message = recommendation.blockingReasons.empty() + ? QString("No actionable recommendation for this scenario.") + : QString::fromStdString(recommendation.blockingReasons.front()); + auto* empty = createLabel(message, content, ui::FontRole::Caption); + empty->setStyleSheet(ui::mutedTextStyleSheet()); + contentLayout->addWidget(empty); + continue; } - auto* impact = createLabel( - QString("Expected direction: %1").arg(QString::fromStdString(candidate.expectedImprovement)), - section, - ui::FontRole::Caption); - impact->setStyleSheet(ui::mutedTextStyleSheet()); - sectionLayout->addWidget(impact); - - auto* button = new QPushButton("Create Recommended Scenario", section); - button->setFont(ui::font(ui::FontRole::Body)); - button->setStyleSheet(ui::secondaryButtonStyleSheet()); - sectionLayout->addWidget(button); - connect(button, &QPushButton::clicked, this, [this, scenario = candidate.recommendedScenario]() { - createRecommendedScenario(scenario); - }); + for (const auto& candidate : recommendation.candidates) { + auto* section = new QFrame(content); + section->setStyleSheet(ui::panelStyleSheet()); + section->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); + section->setMinimumWidth(0); + auto* sectionLayout = new QVBoxLayout(section); + sectionLayout->setContentsMargins(14, 12, 14, 12); + sectionLayout->setSpacing(6); + + auto* candidateTitle = createLabel(QString::fromStdString(candidate.title), section, ui::FontRole::Body); + candidateTitle->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + candidateTitle->setStyleSheet("QLabel { color: #16202b; font-weight: 600; }"); + sectionLayout->addWidget(candidateTitle); + + auto* summary = createLabel(QString::fromStdString(candidate.summary), section, ui::FontRole::Caption); + summary->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + summary->setStyleSheet(ui::mutedTextStyleSheet()); + sectionLayout->addWidget(summary); + + for (const auto& item : candidate.evidence) { + if (!shouldShowRecommendationEvidence(item)) { + continue; + } + auto* evidenceLabel = createLabel( + QString("%1: %2") + .arg(QString::fromStdString(item.label), + QString::fromStdString(item.value)), + section, + ui::FontRole::Caption); + evidenceLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + evidenceLabel->setStyleSheet(ui::subtleTextStyleSheet()); + evidenceLabel->setToolTip(QString::fromStdString(item.source)); + sectionLayout->addWidget(evidenceLabel); + } - panelLayout->addWidget(section); + auto* button = new QPushButton("Create Scenario", section); + button->setFont(ui::font(ui::FontRole::Body)); + button->setStyleSheet(ui::secondaryButtonStyleSheet()); + button->setCursor(Qt::PointingHandCursor); + button->setMinimumWidth(0); + button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + button->setToolTip("Create recommended scenario"); + sectionLayout->addWidget(button); + connect(button, &QPushButton::clicked, section, [this, index, scenario = candidate.recommendedScenario]() { + currentResultIndex_ = index; + createRecommendedScenario(scenario); + }); + + contentLayout->addWidget(section); + } } + + contentLayout->addStretch(1); + scrollArea->setWidget(content); + layout->addWidget(scrollArea, 1); + return panel; } void ScenarioBatchResultWidget::refreshResultNavigationPanel() { @@ -1485,6 +1565,11 @@ void ScenarioBatchResultWidget::refreshResultNavigationPanel() { }); const auto& result = results_[static_cast(currentResultIndex_)]; + if (resultNavigationView_ == ScenarioResultNavigationView::Recommendations) { + shell_->setNavigationPanel(createBatchRecommendationNavigationPanel()); + return; + } + auto bottleneckFocusHandler = [this](std::size_t index) { if (results_.empty() || currentResultIndex_ < 0 || currentResultIndex_ >= static_cast(results_.size())) { return; @@ -1552,7 +1637,6 @@ void ScenarioBatchResultWidget::refreshSelectedResult() { static_cast(exitsChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_); } refreshPressureComparisonTable(); - refreshRecommendationPanel(); refreshResultNavigationPanel(); if (detailLabel_ != nullptr) { const auto selectedFinalSeconds = finalSeconds(result); diff --git a/src/application/ScenarioBatchResultWidget.h b/src/application/ScenarioBatchResultWidget.h index f76d7e5..ada79c2 100644 --- a/src/application/ScenarioBatchResultWidget.h +++ b/src/application/ScenarioBatchResultWidget.h @@ -67,15 +67,16 @@ class ScenarioBatchResultWidget : public QWidget { void pauseReplay(); void refreshComparisonSelection(); void refreshPressureComparisonTable(); - void refreshRecommendationPanel(); void refreshResultNavigationPanel(); void refreshSelectedResult(); void rerunBatch(); void seekToTimingMarkerSeconds(double seconds); + void setRecommendationScenarioSelected(int index, bool selected); void setOverlayMode(OverlayMode mode); void showAuthoring(ScenarioAuthoringWidget::InitialState initialState); void showClosestReplayFrameAtSeconds(double seconds); void showReplayFrame(const safecrowd::domain::SimulationFrame& frame); + QWidget* createBatchRecommendationNavigationPanel(); int explicitBaselineResultIndex() const noexcept; int baselineResultIndex() const noexcept; @@ -88,6 +89,7 @@ class ScenarioBatchResultWidget : public QWidget { std::function backToLayoutReviewHandler_{}; int currentResultIndex_{0}; std::vector selectedCompareIndices_{}; + std::vector selectedRecommendationIndices_{}; std::vector replayFrames_{}; int replayFrameIndex_{0}; WorkspaceShell* shell_{nullptr}; @@ -98,7 +100,6 @@ class ScenarioBatchResultWidget : public QWidget { QSlider* replaySlider_{nullptr}; QLabel* replayTimeLabel_{nullptr}; QLabel* detailLabel_{nullptr}; - QWidget* recommendationPanel_{nullptr}; QTableWidget* pressureTable_{nullptr}; std::vector compareCheckBoxes_{}; QWidget* remainingChart_{nullptr}; diff --git a/src/application/ScenarioCanvasWidget.cpp b/src/application/ScenarioCanvasWidget.cpp index 961c394..a526366 100644 --- a/src/application/ScenarioCanvasWidget.cpp +++ b/src/application/ScenarioCanvasWidget.cpp @@ -74,6 +74,7 @@ struct PointBounds { struct OccupantSourceSettings { int agentsPerSpawn{kDefaultSourceAgentsPerSpawn}; + int targetAgentCount{0}; double startSeconds{kDefaultSourceStartSeconds}; double durationSeconds{kDefaultSourceDurationSeconds}; double intervalSeconds{kDefaultSourceIntervalSeconds}; @@ -83,14 +84,15 @@ bool matchesFloor(const std::string& elementFloorId, const QString& floorId) { return floorId.isEmpty() || elementFloorId.empty() || QString::fromStdString(elementFloorId) == floorId; } -int sourceEmissionCount(int agentsPerSpawn, double durationSeconds, double intervalSeconds) { +int sourceEmissionCount(int agentsPerSpawn, double durationSeconds, double intervalSeconds, int targetAgentCount = 0) { if (agentsPerSpawn <= 0 || durationSeconds <= 0.0 || intervalSeconds <= 1e-9) { return 0; } const auto ticks = static_cast( std::floor(std::max(0.0, durationSeconds - 1e-9) / intervalSeconds)) + 1; const auto count = std::max(0, ticks) * static_cast(agentsPerSpawn); - return static_cast(std::min(kMaxSourceOccupantCount, count)); + const auto cappedCount = targetAgentCount > 0 ? std::min(targetAgentCount, count) : count; + return static_cast(std::min(kMaxSourceOccupantCount, cappedCount)); } bool editOccupantSourceSettings( @@ -120,36 +122,49 @@ bool editOccupantSourceSettings( intervalSpin->setSuffix(" sec"); intervalSpin->setValue(std::max(0.1, settings->intervalSeconds)); + auto* startSpin = new QDoubleSpinBox(&dialog); + startSpin->setRange(0.0, 86400.0); + startSpin->setDecimals(1); + startSpin->setSuffix(" sec"); + startSpin->setValue(std::max(0.0, settings->startSeconds)); + auto* durationSpin = new QDoubleSpinBox(&dialog); - durationSpin->setRange(0.1, 1440.0); + durationSpin->setRange(0.1, 86400.0); durationSpin->setDecimals(1); - durationSpin->setSuffix(" min"); - durationSpin->setValue(std::max(0.1, settings->durationSeconds / 60.0)); + durationSpin->setSuffix(" sec"); + durationSpin->setValue(std::max(0.1, settings->durationSeconds)); auto* totalLabel = new QLabel(&dialog); totalLabel->setStyleSheet("QLabel { color: #4f5d6b; }"); const auto refreshSummary = [=]() { const auto total = sourceEmissionCount( peopleSpin->value(), - durationSpin->value() * 60.0, - intervalSpin->value()); - totalLabel->setText(QString("Total emitted: %1 people").arg(total)); + durationSpin->value(), + intervalSpin->value(), + settings->targetAgentCount); + totalLabel->setText(QString("Total emitted: %1 people\nWindow: %2 - %3 sec") + .arg(total) + .arg(startSpin->value(), 0, 'f', 1) + .arg(startSpin->value() + durationSpin->value(), 0, 'f', 1)); }; refreshSummary(); QObject::connect(peopleSpin, qOverload(&QSpinBox::valueChanged), &dialog, refreshSummary); QObject::connect(intervalSpin, qOverload(&QDoubleSpinBox::valueChanged), &dialog, refreshSummary); + QObject::connect(startSpin, qOverload(&QDoubleSpinBox::valueChanged), &dialog, refreshSummary); QObject::connect(durationSpin, qOverload(&QDoubleSpinBox::valueChanged), &dialog, refreshSummary); layout->addWidget(new QLabel("People each time", &dialog), 0, 0); layout->addWidget(peopleSpin, 0, 1); layout->addWidget(new QLabel("Every", &dialog), 1, 0); layout->addWidget(intervalSpin, 1, 1); - layout->addWidget(new QLabel("Duration", &dialog), 2, 0); - layout->addWidget(durationSpin, 2, 1); - layout->addWidget(totalLabel, 3, 0, 1, 2); + layout->addWidget(new QLabel("Start", &dialog), 2, 0); + layout->addWidget(startSpin, 2, 1); + layout->addWidget(new QLabel("Duration", &dialog), 3, 0); + layout->addWidget(durationSpin, 3, 1); + layout->addWidget(totalLabel, 4, 0, 1, 2); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); - layout->addWidget(buttons, 4, 0, 1, 2); + layout->addWidget(buttons, 5, 0, 1, 2); QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); @@ -159,8 +174,9 @@ bool editOccupantSourceSettings( } settings->agentsPerSpawn = peopleSpin->value(); + settings->startSeconds = startSpin->value(); settings->intervalSeconds = intervalSpin->value(); - settings->durationSeconds = durationSpin->value() * 60.0; + settings->durationSeconds = durationSpin->value(); return true; } @@ -2880,7 +2896,8 @@ void ScenarioCanvasWidget::addRouteGuidance(const QPointF& position) { addRouteGuidanceForExitZone(*it); return; } - // If the user clicked inside a non-exit zone, still allow installing guidance by selecting a nearby door. + QMessageBox::information(this, "Route guidance", "Click an exit zone to install guidance."); + return; } constexpr double kPickRadiusPixels = 18.0; @@ -2896,8 +2913,7 @@ void ScenarioCanvasWidget::addRouteGuidance(const QPointF& position) { if (!matchesFloor(candidate.floorId, currentFloorId_)) { continue; } - if (candidate.kind != safecrowd::domain::ConnectionKind::Doorway - && candidate.kind != safecrowd::domain::ConnectionKind::Exit) { + if (candidate.kind != safecrowd::domain::ConnectionKind::Exit) { continue; } const auto halfWidth = std::max(0.0, candidate.effectiveWidth * 0.5); @@ -2910,7 +2926,7 @@ void ScenarioCanvasWidget::addRouteGuidance(const QPointF& position) { } if (connection == nullptr) { - QMessageBox::information(this, "Route guidance", "Click an exit zone or a door to install guidance."); + QMessageBox::information(this, "Route guidance", "Click an exit zone to install guidance."); return; } @@ -2946,20 +2962,23 @@ void ScenarioCanvasWidget::addRouteGuidanceForExitZone(const safecrowd::domain:: } void ScenarioCanvasWidget::addRouteGuidanceForConnection(const safecrowd::domain::Connection2D& connection) { - if (connection.kind != safecrowd::domain::ConnectionKind::Doorway - && connection.kind != safecrowd::domain::ConnectionKind::Exit) { - QMessageBox::information(this, "Route guidance", "This tool can only be used on exit zones or doors."); + if (connection.kind != safecrowd::domain::ConnectionKind::Exit) { + QMessageBox::information(this, "Route guidance", "This tool can only be used on exits."); return; } - for (const auto& existing : routeGuidances_) { - if (!existing.installConnectionId.empty() && existing.installConnectionId == connection.id) { - QMessageBox::information(this, "Route guidance", "Guidance is already installed on this door."); - return; - } - } - const auto exitZoneId = pickNearestExitZoneIdForConnection(layout_, connection); + if (exitZoneId.empty()) { + QMessageBox::information(this, "Route guidance", "No exit zone is connected to this exit."); + return; + } + const auto zoneIt = std::find_if(layout_.zones.begin(), layout_.zones.end(), [&](const auto& zone) { + return zone.id == exitZoneId; + }); + if (zoneIt != layout_.zones.end()) { + addRouteGuidanceForExitZone(*zoneIt); + return; + } safecrowd::domain::RouteGuidanceDraft draft; draft.id = nextRouteGuidanceId().toStdString(); @@ -2967,7 +2986,7 @@ void ScenarioCanvasWidget::addRouteGuidanceForConnection(const safecrowd::domain draft.endSeconds = 0.0; draft.periods.clear(); draft.guidedExitZoneId = exitZoneId; - draft.installConnectionId = connection.id; + draft.installConnectionId.clear(); draft.baseComplianceRate = 0.5; draft.guidanceStrength = 0.55; draft.maxDetourMeters = 20.0; @@ -3143,6 +3162,7 @@ bool ScenarioCanvasWidget::editOccupantSourceById(const QString& sourceId, const OccupantSourceSettings settings{ .agentsPerSpawn = std::max(1, placementIt->sourceAgentsPerSpawn), + .targetAgentCount = placementIt->occupantCount, .startSeconds = placementIt->sourceStartSeconds, .durationSeconds = std::max(0.1, placementIt->sourceEndSeconds - placementIt->sourceStartSeconds), .intervalSeconds = std::max(0.1, placementIt->sourceIntervalSeconds), @@ -3158,7 +3178,8 @@ bool ScenarioCanvasWidget::editOccupantSourceById(const QString& sourceId, const placementIt->occupantCount = sourceEmissionCount( placementIt->sourceAgentsPerSpawn, settings.durationSeconds, - placementIt->sourceIntervalSeconds); + placementIt->sourceIntervalSeconds, + settings.targetAgentCount); emitPlacementsChanged(); update(); return true; diff --git a/src/application/ScenarioResultNavigation.cpp b/src/application/ScenarioResultNavigation.cpp index b1f517e..91e1be1 100644 --- a/src/application/ScenarioResultNavigation.cpp +++ b/src/application/ScenarioResultNavigation.cpp @@ -181,13 +181,21 @@ QIcon makeResultNavigationIcon(const QString& tabId, const QColor& color) { painter.drawRoundedRect(QRectF(11, 11, 22, 22), 4, 4); painter.drawLine(QPointF(22, 11), QPointF(22, 33)); painter.drawLine(QPointF(11, 22), QPointF(33, 22)); - } else { + } else if (tabId == "groups") { painter.drawEllipse(QPointF(22, 14), 5, 5); painter.drawEllipse(QPointF(14, 20), 4, 4); painter.drawEllipse(QPointF(30, 20), 4, 4); painter.drawArc(QRectF(12, 22, 20, 14), 20 * 16, 140 * 16); painter.drawArc(QRectF(5, 26, 18, 10), 30 * 16, 120 * 16); painter.drawArc(QRectF(21, 26, 18, 10), 30 * 16, 120 * 16); + } else { + painter.drawRoundedRect(QRectF(12, 12, 20, 20), 4, 4); + painter.drawLine(QPointF(16, 18), QPointF(24, 18)); + painter.drawLine(QPointF(24, 18), QPointF(21, 15)); + painter.drawLine(QPointF(24, 18), QPointF(21, 21)); + painter.drawLine(QPointF(28, 26), QPointF(20, 26)); + painter.drawLine(QPointF(20, 26), QPointF(23, 23)); + painter.drawLine(QPointF(20, 26), QPointF(23, 29)); } return QIcon(pixmap); @@ -334,6 +342,11 @@ QWidget* createGroupsReportPanel(const safecrowd::domain::ScenarioResultArtifact return parts.panel; } +bool shouldShowRecommendationEvidence(const safecrowd::domain::AlternativeRecommendationEvidence& item) { + const auto label = QString::fromStdString(item.label); + return !label.startsWith("Risk ") && label != "Critical pressure events"; +} + } // namespace std::vector scenarioResultNavigationTabs() { @@ -358,6 +371,11 @@ std::vector scenarioResultNavigationTabs() { .label = "Groups", .icon = makeResultNavigationIcon("groups", QColor("#1f5fae")), }, + { + .id = "recommendations", + .label = "Recommendations", + .icon = makeResultNavigationIcon("recommendations", QColor("#1f5fae")), + }, }; } @@ -369,6 +387,8 @@ QString scenarioResultNavigationTabId(ScenarioResultNavigationView view) { return "zone"; case ScenarioResultNavigationView::Groups: return "groups"; + case ScenarioResultNavigationView::Recommendations: + return "recommendations"; case ScenarioResultNavigationView::Bottleneck: default: return "bottleneck"; @@ -385,6 +405,9 @@ ScenarioResultNavigationView scenarioResultNavigationViewFromTabId(const QString if (tabId == "groups") { return ScenarioResultNavigationView::Groups; } + if (tabId == "recommendations") { + return ScenarioResultNavigationView::Recommendations; + } return ScenarioResultNavigationView::Bottleneck; } @@ -402,10 +425,83 @@ QWidget* createScenarioResultNavigationPanel( return createZoneReportPanel(artifacts, parent); case ScenarioResultNavigationView::Groups: return createGroupsReportPanel(artifacts, parent); + case ScenarioResultNavigationView::Recommendations: + return createResultReportPanel("Recommendations", "Recommended operational changes", parent).panel; case ScenarioResultNavigationView::Bottleneck: default: return createBottleneckReportPanel(risk, std::move(bottleneckFocusHandler), parent); } } +QWidget* createScenarioRecommendationNavigationPanel( + const safecrowd::domain::AlternativeRecommendationResult& recommendation, + std::function createScenarioHandler, + QWidget* parent) { + auto parts = createResultReportPanel("Recommendations", "Operational alternatives for this result", parent); + if (recommendation.candidates.empty()) { + const auto message = recommendation.blockingReasons.empty() + ? QString("No actionable recommendation for this result.") + : QString::fromStdString(recommendation.blockingReasons.front()); + auto* empty = createLabel(message, parts.content, ui::FontRole::Caption); + empty->setStyleSheet(ui::mutedTextStyleSheet()); + parts.contentLayout->addWidget(empty); + parts.contentLayout->addStretch(1); + return parts.panel; + } + + for (const auto& candidate : recommendation.candidates) { + auto* section = new QFrame(parts.content); + section->setStyleSheet(ui::panelStyleSheet()); + section->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); + section->setMinimumWidth(0); + auto* sectionLayout = new QVBoxLayout(section); + sectionLayout->setContentsMargins(14, 12, 14, 12); + sectionLayout->setSpacing(6); + + auto* title = createLabel(QString::fromStdString(candidate.title), section, ui::FontRole::Body); + title->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + title->setStyleSheet("QLabel { color: #16202b; font-weight: 600; }"); + sectionLayout->addWidget(title); + + auto* summary = createLabel(QString::fromStdString(candidate.summary), section, ui::FontRole::Caption); + summary->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + summary->setStyleSheet(ui::mutedTextStyleSheet()); + sectionLayout->addWidget(summary); + + for (const auto& item : candidate.evidence) { + if (!shouldShowRecommendationEvidence(item)) { + continue; + } + auto* evidenceLabel = createLabel( + QString("%1: %2") + .arg(QString::fromStdString(item.label), + QString::fromStdString(item.value)), + section, + ui::FontRole::Caption); + evidenceLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + evidenceLabel->setStyleSheet(ui::subtleTextStyleSheet()); + evidenceLabel->setToolTip(QString::fromStdString(item.source)); + sectionLayout->addWidget(evidenceLabel); + } + + auto* button = new QPushButton("Create Scenario", section); + button->setFont(ui::font(ui::FontRole::Body)); + button->setStyleSheet(ui::secondaryButtonStyleSheet()); + button->setCursor(Qt::PointingHandCursor); + button->setMinimumWidth(0); + button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + button->setToolTip("Create recommended scenario"); + sectionLayout->addWidget(button); + QObject::connect(button, &QPushButton::clicked, section, [createScenarioHandler, scenario = candidate.recommendedScenario]() { + if (createScenarioHandler) { + createScenarioHandler(scenario); + } + }); + + parts.contentLayout->addWidget(section); + } + parts.contentLayout->addStretch(1); + return parts.panel; +} + } // namespace safecrowd::application diff --git a/src/application/ScenarioResultNavigation.h b/src/application/ScenarioResultNavigation.h index 5f1b9f4..b7b7322 100644 --- a/src/application/ScenarioResultNavigation.h +++ b/src/application/ScenarioResultNavigation.h @@ -8,6 +8,7 @@ #include #include "application/WorkspaceShell.h" +#include "domain/AlternativeRecommendationService.h" #include "domain/ScenarioResultArtifacts.h" #include "domain/ScenarioRiskMetrics.h" @@ -18,6 +19,7 @@ enum class ScenarioResultNavigationView { Hotspot, Zone, Groups, + Recommendations, }; std::vector scenarioResultNavigationTabs(); @@ -32,4 +34,9 @@ QWidget* createScenarioResultNavigationPanel( std::function hotspotFocusHandler, QWidget* parent); +QWidget* createScenarioRecommendationNavigationPanel( + const safecrowd::domain::AlternativeRecommendationResult& recommendation, + std::function createScenarioHandler, + QWidget* parent); + } // namespace safecrowd::application diff --git a/src/application/ScenarioResultWidget.cpp b/src/application/ScenarioResultWidget.cpp index f2f11b0..b402c3a 100644 --- a/src/application/ScenarioResultWidget.cpp +++ b/src/application/ScenarioResultWidget.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -42,6 +43,7 @@ #include "application/SimulationCanvasWidget.h" #include "application/UiStyle.h" #include "application/WorkspaceShell.h" +#include "domain/AlternativeRecommendationService.h" namespace safecrowd::application { namespace { @@ -1080,6 +1082,62 @@ ScenarioAuthoringWidget::ScenarioState scenarioStateFromDraft( return state; } +bool scenarioIdExists(const ScenarioAuthoringWidget::InitialState& initial, const std::string& id) { + return std::any_of(initial.scenarios.begin(), initial.scenarios.end(), [&](const auto& scenario) { + return scenario.draft.scenarioId == id; + }); +} + +bool scenarioNameExists(const ScenarioAuthoringWidget::InitialState& initial, const std::string& name) { + return std::any_of(initial.scenarios.begin(), initial.scenarios.end(), [&](const auto& scenario) { + return scenario.draft.name == name; + }); +} + +std::string uniqueScenarioId(const ScenarioAuthoringWidget::InitialState& initial, const std::string& requestedId) { + const auto base = requestedId.empty() ? std::string{"recommended-scenario"} : requestedId; + if (!scenarioIdExists(initial, base)) { + return base; + } + for (int suffix = 2; suffix < 1000; ++suffix) { + auto candidate = base + "-" + std::to_string(suffix); + if (!scenarioIdExists(initial, candidate)) { + return candidate; + } + } + return base + "-copy"; +} + +std::string uniqueScenarioName(const ScenarioAuthoringWidget::InitialState& initial, const std::string& requestedName) { + const auto base = requestedName.empty() ? std::string{"Recommended scenario"} : requestedName; + if (!scenarioNameExists(initial, base)) { + return base; + } + for (int suffix = 2; suffix < 1000; ++suffix) { + auto candidate = base + " " + std::to_string(suffix); + if (!scenarioNameExists(initial, candidate)) { + return candidate; + } + } + return base + " copy"; +} + +std::optional existingScenarioIndexBySourceTemplate( + const ScenarioAuthoringWidget::InitialState& initial, + const std::string& sourceTemplateId) { + if (sourceTemplateId.empty()) { + return std::nullopt; + } + const auto it = std::find_if(initial.scenarios.begin(), initial.scenarios.end(), [&](const auto& scenario) { + return scenario.draft.role == safecrowd::domain::ScenarioRole::Recommended + && scenario.draft.sourceTemplateId == sourceTemplateId; + }); + if (it == initial.scenarios.end()) { + return std::nullopt; + } + return static_cast(std::distance(initial.scenarios.begin(), it)); +} + QWidget* createResultPanel( const safecrowd::domain::ScenarioDraft& scenario, const safecrowd::domain::SimulationFrame& frame, @@ -1233,6 +1291,8 @@ ScenarioResultNavigationView resultNavigationViewFromSaved(SavedResultNavigation return ScenarioResultNavigationView::Zone; case SavedResultNavigationView::Groups: return ScenarioResultNavigationView::Groups; + case SavedResultNavigationView::Recommendations: + return ScenarioResultNavigationView::Recommendations; case SavedResultNavigationView::Bottleneck: default: return ScenarioResultNavigationView::Bottleneck; @@ -1247,6 +1307,8 @@ SavedResultNavigationView savedResultNavigationView(ScenarioResultNavigationView return SavedResultNavigationView::Zone; case ScenarioResultNavigationView::Groups: return SavedResultNavigationView::Groups; + case ScenarioResultNavigationView::Recommendations: + return SavedResultNavigationView::Recommendations; case ScenarioResultNavigationView::Bottleneck: default: return SavedResultNavigationView::Bottleneck; @@ -1375,6 +1437,24 @@ void ScenarioResultWidget::refreshResultNavigationPanel() { refreshResultNavigationPanel(); }); + if (resultNavigationView_ == ScenarioResultNavigationView::Recommendations) { + const safecrowd::domain::AlternativeRecommendationService service; + const auto recommendation = service.recommend({ + .layout = layout_, + .sourceScenario = scenario_, + .risk = risk_, + .artifacts = artifacts_, + .finalFrame = frame_, + }); + shell_->setNavigationPanel(createScenarioRecommendationNavigationPanel( + recommendation, + [this](safecrowd::domain::ScenarioDraft recommendedScenario) { + createRecommendedScenario(std::move(recommendedScenario)); + }, + shell_)); + return; + } + shell_->setNavigationPanel(createScenarioResultNavigationPanel( resultNavigationView_, risk_, @@ -1433,6 +1513,53 @@ void ScenarioResultWidget::rerunScenario() { shell_ = nullptr; } +void ScenarioResultWidget::createRecommendedScenario( + safecrowd::domain::ScenarioDraft recommendedScenario) { + auto* rootLayout = qobject_cast(layout()); + if (rootLayout == nullptr || shell_ == nullptr) { + return; + } + + auto initial = returnAuthoringState_.value_or(ScenarioAuthoringWidget::InitialState{}); + if (initial.scenarios.empty()) { + initial.scenarios.push_back(scenarioStateFromDraft(scenario_, layout_)); + initial.currentScenarioIndex = 0; + initial.navigationView = ScenarioAuthoringWidget::NavigationView::Layout; + } + + if (const auto existingIndex = existingScenarioIndexBySourceTemplate(initial, recommendedScenario.sourceTemplateId); + existingIndex.has_value()) { + initial.currentScenarioIndex = *existingIndex; + initial.rightPanelMode = ScenarioAuthoringWidget::RightPanelMode::Scenario; + } else { + recommendedScenario.scenarioId = uniqueScenarioId(initial, recommendedScenario.scenarioId); + recommendedScenario.name = uniqueScenarioName(initial, recommendedScenario.name); + recommendedScenario.variationDiffKeys = + safecrowd::domain::computeScenarioDiffKeys(scenario_, recommendedScenario); + + auto state = scenarioStateFromDraft(recommendedScenario, layout_); + state.baseScenarioId = QString::fromStdString(scenario_.scenarioId); + state.stagedForRun = false; + initial.scenarios.push_back(std::move(state)); + initial.currentScenarioIndex = static_cast(initial.scenarios.size()) - 1; + initial.rightPanelMode = ScenarioAuthoringWidget::RightPanelMode::Scenario; + } + + auto* authoringWidget = new ScenarioAuthoringWidget( + projectName_, + layout_, + std::move(initial), + saveProjectHandler_, + openProjectHandler_, + backToLayoutReviewHandler_, + this); + + rootLayout->replaceWidget(shell_, authoringWidget); + shell_->hide(); + shell_->deleteLater(); + shell_ = nullptr; +} + void ScenarioResultWidget::navigateToAuthoring(bool showRunPanel) { auto* rootLayout = qobject_cast(layout()); if (rootLayout == nullptr || shell_ == nullptr) { diff --git a/src/application/ScenarioResultWidget.h b/src/application/ScenarioResultWidget.h index a430d64..c9f8ba3 100644 --- a/src/application/ScenarioResultWidget.h +++ b/src/application/ScenarioResultWidget.h @@ -45,6 +45,7 @@ class ScenarioResultWidget : public QWidget { private: void rerunScenario(); void navigateToAuthoring(bool showRunPanel); + void createRecommendedScenario(safecrowd::domain::ScenarioDraft recommendedScenario); void refreshResultNavigationPanel(); QString projectName_{}; diff --git a/src/domain/AlternativeRecommendationService.cpp b/src/domain/AlternativeRecommendationService.cpp index ace51f8..d544830 100644 --- a/src/domain/AlternativeRecommendationService.cpp +++ b/src/domain/AlternativeRecommendationService.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -10,10 +11,18 @@ #include #include +#include "domain/GeometryQueries.h" + namespace safecrowd::domain { namespace { constexpr double kExitImbalanceThreshold = 0.25; +constexpr double kCounterflowCosineThreshold = -0.5; +constexpr double kCounterflowSideRatioThreshold = 0.30; +constexpr double kCounterflowAverageSpeedThreshold = 0.7; +constexpr double kCounterflowSustainedSecondsThreshold = 10.0; +constexpr std::size_t kStagedEvacuationAgentsPerSpawn = 10; +constexpr double kStagedEvacuationIntervalSeconds = 5.0; constexpr double kDefaultGuidanceCompliance = 0.5; constexpr double kDefaultGuidanceStrength = 0.55; constexpr double kDefaultGuidanceMaxDetourMeters = 20.0; @@ -47,6 +56,14 @@ std::string percent(double ratio) { return fixed(std::clamp(ratio, 0.0, 1.0) * 100.0, 0) + "%"; } +AlternativeRecommendationEvidence evidence(std::string label, std::string value, std::string source) { + return { + .label = std::move(label), + .value = std::move(value), + .source = std::move(source), + }; +} + const Zone2D* findZone(const FacilityLayout2D& layout, const std::string& zoneId) { const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [&](const auto& zone) { return zone.id == zoneId; @@ -54,6 +71,11 @@ const Zone2D* findZone(const FacilityLayout2D& layout, const std::string& zoneId return it == layout.zones.end() ? nullptr : &(*it); } +bool zoneIsExit(const FacilityLayout2D& layout, const std::string& zoneId) { + const auto* zone = findZone(layout, zoneId); + return zone != nullptr && zone->kind == ZoneKind::Exit; +} + const Connection2D* findConnection(const FacilityLayout2D& layout, const std::string& connectionId) { const auto it = std::find_if(layout.connections.begin(), layout.connections.end(), [&](const auto& connection) { return connection.id == connectionId; @@ -61,6 +83,53 @@ const Connection2D* findConnection(const FacilityLayout2D& layout, const std::st return it == layout.connections.end() ? nullptr : &(*it); } +bool connectionTouchesExit(const FacilityLayout2D& layout, const Connection2D& connection) { + return connection.kind == ConnectionKind::Exit + || zoneIsExit(layout, connection.fromZoneId) + || zoneIsExit(layout, connection.toZoneId); +} + +bool connectionTouchesZone(const Connection2D& connection, const std::string& zoneId) { + return !zoneId.empty() && (connection.fromZoneId == zoneId || connection.toZoneId == zoneId); +} + +Point2D averagePoint(const std::vector& points) { + if (points.empty()) { + return {}; + } + Point2D result; + for (const auto& point : points) { + result.x += point.x; + result.y += point.y; + } + const auto count = static_cast(points.size()); + result.x /= count; + result.y /= count; + return result; +} + +std::optional sourcePointForPlacement( + const FacilityLayout2D& layout, + const InitialPlacement2D& placement) { + if (!placement.explicitPositions.empty()) { + return averagePoint(placement.explicitPositions); + } + if (placement.area.outline.size() == 1) { + return placement.area.outline.front(); + } + if (!placement.area.outline.empty()) { + return representativePointInPolygon(placement.area).value_or(averagePoint(placement.area.outline)); + } + const auto* zone = findZone(layout, placement.zoneId); + if (zone != nullptr && zone->area.outline.size() == 1) { + return zone->area.outline.front(); + } + if (zone != nullptr && !zone->area.outline.empty()) { + return representativePointInPolygon(zone->area).value_or(averagePoint(zone->area.outline)); + } + return std::nullopt; +} + std::string zoneName(const FacilityLayout2D& layout, const std::string& zoneId) { const auto* zone = findZone(layout, zoneId); if (zone == nullptr) { @@ -93,6 +162,7 @@ bool hasCompletedResultArtifactEvidence(const AlternativeRecommendationRequest& const auto& artifacts = request.artifacts; return artifacts.timingSummary.finalEvacuationTimeSeconds.has_value() || !artifacts.evacuationProgress.empty() + || !artifacts.replayFrames.empty() || !artifacts.exitUsage.empty() || !artifacts.zoneCompletion.empty() || !artifacts.placementCompletion.empty() @@ -100,7 +170,8 @@ bool hasCompletedResultArtifactEvidence(const AlternativeRecommendationRequest& || artifacts.pressureSummary.peakPressureScore > 0.0 || !artifacts.pressureSummary.peakHotspots.empty() || !artifacts.pressureSummary.criticalEvents.empty() - || !artifacts.hazardExposureSummary.hazards.empty(); + || !artifacts.hazardExposureSummary.hazards.empty() + || request.finalFrame.has_value(); } bool containsString(const std::vector& values, const std::string& value) { @@ -179,6 +250,106 @@ bool hasRouteGuidance(const ScenarioDraft& scenario, }); } +bool exitHasBottleneck( + const AlternativeRecommendationRequest& request, + const std::string& exitZoneId) { + return std::any_of(request.risk.bottlenecks.begin(), request.risk.bottlenecks.end(), [&](const auto& bottleneck) { + const auto* connection = findConnection(request.layout, bottleneck.connectionId); + return connection != nullptr + && connectionTouchesExit(request.layout, *connection) + && connectionTouchesZone(*connection, exitZoneId); + }); +} + +bool hasOperationalEvent(const ScenarioDraft& scenario, const std::string& id) { + return std::any_of(scenario.control.events.begin(), scenario.control.events.end(), [&](const auto& event) { + return event.id == id; + }); +} + +bool hasOccupantSource(const ScenarioDraft& scenario, const std::string& id) { + return std::any_of(scenario.population.occupantSources.begin(), scenario.population.occupantSources.end(), [&](const auto& source) { + return source.id == id; + }); +} + +std::size_t placementAgentCount(const InitialPlacement2D& placement) { + return placement.explicitPositions.empty() + ? placement.targetAgentCount + : placement.explicitPositions.size(); +} + +std::size_t stagedEvacuationTickCount(std::size_t targetAgentCount) { + if (targetAgentCount == 0) { + return 0; + } + return (targetAgentCount + kStagedEvacuationAgentsPerSpawn - 1) / kStagedEvacuationAgentsPerSpawn; +} + +OccupantSource2D makeStagedEvacuationSource( + const AlternativeRecommendationRequest& request, + const InitialPlacement2D& placement, + std::size_t targetAgentCount, + Point2D sourcePosition, + double startSeconds) { + const auto tickCount = stagedEvacuationTickCount(targetAgentCount); + OccupantSource2D source; + source.id = "recommendation-source-" + sanitizeId(placement.id.empty() ? placement.zoneId : placement.id); + source.zoneId = placement.zoneId; + source.floorId = placement.floorId; + source.position = sourcePosition; + source.targetAgentCount = targetAgentCount; + source.agentsPerSpawn = std::min(kStagedEvacuationAgentsPerSpawn, targetAgentCount); + source.startSeconds = startSeconds; + source.endSeconds = startSeconds + (kStagedEvacuationIntervalSeconds * static_cast(tickCount)); + source.spawnIntervalSeconds = kStagedEvacuationIntervalSeconds; + source.initialVelocity = placement.initialVelocity; + if (source.floorId.empty()) { + if (const auto* zone = findZone(request.layout, placement.zoneId); zone != nullptr) { + source.floorId = zone->floorId; + } + } + return source; +} + +std::vector makeSequentialStagedEvacuationSources( + const AlternativeRecommendationRequest& request) { + std::vector sources; + sources.reserve(request.sourceScenario.population.initialPlacements.size()); + + double nextStartSeconds = 0.0; + for (const auto& placement : request.sourceScenario.population.initialPlacements) { + const auto targetAgentCount = placementAgentCount(placement); + if (targetAgentCount == 0) { + continue; + } + const auto sourcePosition = sourcePointForPlacement(request.layout, placement); + if (!sourcePosition.has_value()) { + return {}; + } + auto source = makeStagedEvacuationSource(request, placement, targetAgentCount, *sourcePosition, nextStartSeconds); + nextStartSeconds = source.endSeconds; + sources.push_back(std::move(source)); + } + return sources; +} + +std::size_t totalSourceAgents(const std::vector& sources) { + std::size_t total = 0; + for (const auto& source : sources) { + total += source.targetAgentCount; + } + return total; +} + +std::size_t totalSourceTicks(const std::vector& sources) { + std::size_t total = 0; + for (const auto& source : sources) { + total += stagedEvacuationTickCount(source.targetAgentCount); + } + return total; +} + std::vector adjacentExitZoneIdsForConnection( const AlternativeRecommendationRequest& request, const std::string& connectionId) { @@ -216,6 +387,18 @@ RouteGuidanceDraft makeGuidance(const std::string& id, return guidance; } +OperationalEventDraft makeOperationalEvent(const std::string& id, + const std::string& name, + const std::string& triggerSummary, + const std::string& targetSummary) { + OperationalEventDraft event; + event.id = id; + event.name = name; + event.triggerSummary = triggerSummary; + event.targetSummary = targetSummary; + return event; +} + std::string recommendedScenarioId(const ScenarioDraft& source, AlternativeRecommendationKind kind) { const auto sourceId = source.scenarioId.empty() ? "scenario" : source.scenarioId; return sourceId + "-recommended-" + alternativeRecommendationKindId(kind); @@ -280,12 +463,350 @@ std::optional worstBottleneck(const ScenarioRiskSnapsh return it == risk.bottlenecks.end() ? std::nullopt : std::optional{*it}; } -AlternativeRecommendationEvidence evidence(std::string label, std::string value, std::string source) { - return { - .label = std::move(label), - .value = std::move(value), - .source = std::move(source), - }; +AlternativeRecommendationRiskKind bottleneckRiskKind( + const AlternativeRecommendationRequest& request, + const ScenarioBottleneckMetric& bottleneck) { + const auto* connection = findConnection(request.layout, bottleneck.connectionId); + if (connection != nullptr && connectionTouchesExit(request.layout, *connection)) { + return AlternativeRecommendationRiskKind::ExitBottleneck; + } + return AlternativeRecommendationRiskKind::CorridorBottleneck; +} + +std::optional worstBottleneckForKind( + const AlternativeRecommendationRequest& request, + AlternativeRecommendationRiskKind kind) { + std::optional best; + for (const auto& bottleneck : request.risk.bottlenecks) { + if (bottleneck.connectionId.empty() || bottleneckRiskKind(request, bottleneck) != kind) { + continue; + } + if (!best.has_value() || bottleneckLessSevere(*best, bottleneck)) { + best = bottleneck; + } + } + return best; +} + +AlternativeRecommendationRiskSignal makeBottleneckRiskSignal( + const AlternativeRecommendationRequest& request, + const ScenarioBottleneckMetric& bottleneck, + AlternativeRecommendationRiskKind kind) { + AlternativeRecommendationRiskSignal signal; + signal.kind = kind; + signal.severity = static_cast(bottleneck.stalledAgentCount * 10 + bottleneck.nearbyAgentCount); + signal.summary = kind == AlternativeRecommendationRiskKind::ExitBottleneck + ? "Exit bottleneck detected from persisted bottleneck metrics." + : "Corridor bottleneck detected from persisted bottleneck metrics."; + signal.evidence.push_back(evidence( + "Connection", + connectionName(request.layout, bottleneck.connectionId), + "ScenarioRiskSnapshot.bottlenecks")); + signal.evidence.push_back(evidence( + "Stalled / nearby", + std::to_string(bottleneck.stalledAgentCount) + " / " + std::to_string(bottleneck.nearbyAgentCount), + "ScenarioRiskSnapshot.bottlenecks")); + if (bottleneck.averageSpeed > 0.0) { + signal.evidence.push_back(evidence( + "Average speed", + fixed(bottleneck.averageSpeed, 2) + " m/s", + "ScenarioRiskSnapshot.bottlenecks")); + } + if (bottleneck.detectedAtSeconds.has_value()) { + signal.evidence.push_back(evidence( + "Detected at", + fixed(*bottleneck.detectedAtSeconds, 1) + " sec", + "ScenarioRiskSnapshot.bottlenecks")); + } + return signal; +} + +bool hasPressureSignal(const AlternativeRecommendationRequest& request) { + return (request.artifacts.pressureSummary.hotspotScoreThreshold > 0.0 + && request.artifacts.pressureSummary.peakPressureScore >= request.artifacts.pressureSummary.hotspotScoreThreshold) + || !request.artifacts.pressureSummary.peakHotspots.empty() + || !request.artifacts.pressureSummary.criticalEvents.empty() + || !request.risk.pressureHotspots.empty() + || !request.risk.criticalPressureEvents.empty() + || request.risk.criticalPressureAgentCount > 0; +} + +std::optional makePressureRiskSignal( + const AlternativeRecommendationRequest& request) { + if (!hasPressureSignal(request)) { + return std::nullopt; + } + + AlternativeRecommendationRiskSignal signal; + signal.kind = AlternativeRecommendationRiskKind::PressureHotspot; + signal.severity = static_cast(request.artifacts.pressureSummary.peakPressureScore * 10.0) + + static_cast(request.risk.criticalPressureAgentCount * 5); + signal.summary = "Pressure hotspot or critical pressure signal detected."; + if (request.artifacts.pressureSummary.peakPressureScore > 0.0) { + signal.evidence.push_back(evidence( + "Peak pressure", + fixed(request.artifacts.pressureSummary.peakPressureScore, 1), + "ScenarioResultArtifacts.pressureSummary")); + } + if (!request.artifacts.pressureSummary.peakHotspots.empty()) { + signal.evidence.push_back(evidence( + "Pressure hotspots", + std::to_string(request.artifacts.pressureSummary.peakHotspots.size()), + "ScenarioResultArtifacts.pressureSummary.peakHotspots")); + } + if (!request.artifacts.pressureSummary.criticalEvents.empty()) { + signal.evidence.push_back(evidence( + "Critical pressure events", + std::to_string(request.artifacts.pressureSummary.criticalEvents.size()), + "ScenarioResultArtifacts.pressureSummary.criticalEvents")); + } + if (request.risk.criticalPressureAgentCount > 0) { + signal.evidence.push_back(evidence( + "Critical pressure agents", + std::to_string(request.risk.criticalPressureAgentCount), + "ScenarioRiskSnapshot.criticalPressureAgentCount")); + } + return signal; +} + +std::optional finalFrameForRequest(const AlternativeRecommendationRequest& request) { + if (request.finalFrame.has_value()) { + return request.finalFrame; + } + if (!request.artifacts.replayFrames.empty()) { + return request.artifacts.replayFrames.back(); + } + return std::nullopt; +} + +double speedOf(Point2D velocity) { + return std::sqrt(velocity.x * velocity.x + velocity.y * velocity.y); +} + +double dot(Point2D lhs, Point2D rhs) { + return lhs.x * rhs.x + lhs.y * rhs.y; +} + +struct FrameCounterflowObservation { + std::size_t forwardCount{0}; + std::size_t oppositeCount{0}; + std::size_t movingCount{0}; + double averageSpeed{0.0}; +}; + +std::optional counterflowAtFrame(const SimulationFrame& frame) { + std::vector movingAgents; + movingAgents.reserve(frame.agents.size()); + double speedSum = 0.0; + for (const auto& agent : frame.agents) { + const auto speed = speedOf(agent.velocity); + if (speed <= 0.05) { + continue; + } + movingAgents.push_back(agent); + speedSum += speed; + } + if (movingAgents.size() < 2) { + return std::nullopt; + } + + FrameCounterflowObservation best; + for (const auto& anchor : movingAgents) { + const auto anchorSpeed = speedOf(anchor.velocity); + if (anchorSpeed <= 0.0) { + continue; + } + FrameCounterflowObservation candidate; + candidate.movingCount = movingAgents.size(); + candidate.averageSpeed = speedSum / static_cast(movingAgents.size()); + for (const auto& agent : movingAgents) { + const auto agentSpeed = speedOf(agent.velocity); + const auto cosine = dot(anchor.velocity, agent.velocity) / (anchorSpeed * agentSpeed); + if (cosine >= 0.5) { + ++candidate.forwardCount; + } else if (cosine <= kCounterflowCosineThreshold) { + ++candidate.oppositeCount; + } + } + const auto forwardRatio = static_cast(candidate.forwardCount) / static_cast(candidate.movingCount); + const auto oppositeRatio = static_cast(candidate.oppositeCount) / static_cast(candidate.movingCount); + if (forwardRatio >= kCounterflowSideRatioThreshold + && oppositeRatio >= kCounterflowSideRatioThreshold + && candidate.averageSpeed <= kCounterflowAverageSpeedThreshold + && candidate.forwardCount + candidate.oppositeCount > best.forwardCount + best.oppositeCount) { + best = candidate; + } + } + + return best.movingCount == 0 ? std::nullopt : std::optional{best}; +} + +struct SustainedCounterflowObservation { + FrameCounterflowObservation frame{}; + double durationSeconds{0.0}; + double startedAtSeconds{0.0}; + double endedAtSeconds{0.0}; +}; + +std::optional sustainedCounterflowConflict( + const std::vector& frames) { + if (frames.size() < 2) { + return std::nullopt; + } + + std::optional current; + std::optional best; + double previousTimeSeconds = frames.front().elapsedSeconds; + bool previousWasConflict = false; + for (const auto& frame : frames) { + const auto observation = counterflowAtFrame(frame); + if (observation.has_value()) { + if (!current.has_value() || !previousWasConflict) { + current = SustainedCounterflowObservation{ + .frame = *observation, + .durationSeconds = 0.0, + .startedAtSeconds = frame.elapsedSeconds, + .endedAtSeconds = frame.elapsedSeconds, + }; + } else { + current->durationSeconds += std::max(0.0, frame.elapsedSeconds - previousTimeSeconds); + current->endedAtSeconds = frame.elapsedSeconds; + if (observation->forwardCount + observation->oppositeCount + > current->frame.forwardCount + current->frame.oppositeCount) { + current->frame = *observation; + } + } + if (!best.has_value() || current->durationSeconds > best->durationSeconds) { + best = current; + } + previousWasConflict = true; + } else { + previousWasConflict = false; + current.reset(); + } + previousTimeSeconds = frame.elapsedSeconds; + } + + if (best.has_value() && best->durationSeconds >= kCounterflowSustainedSecondsThreshold) { + return best; + } + return std::nullopt; +} + +std::optional makeCounterflowRiskSignal( + const AlternativeRecommendationRequest& request) { + const auto observation = sustainedCounterflowConflict(request.artifacts.replayFrames); + if (!observation.has_value()) { + return std::nullopt; + } + + AlternativeRecommendationRiskSignal signal; + signal.kind = AlternativeRecommendationRiskKind::CounterflowConflict; + signal.severity = static_cast(observation->durationSeconds * 10.0) + + static_cast(observation->frame.forwardCount + observation->frame.oppositeCount); + signal.summary = "Sustained counterflow conflict detected from replay frames."; + signal.evidence.push_back(evidence( + "Opposing flow", + std::to_string(observation->frame.forwardCount) + " vs " + + std::to_string(observation->frame.oppositeCount) + " agents", + "ScenarioResultArtifacts.replayFrames")); + signal.evidence.push_back(evidence( + "Average speed", + fixed(observation->frame.averageSpeed, 2) + " m/s", + "ScenarioResultArtifacts.replayFrames")); + signal.evidence.push_back(evidence( + "Sustained duration", + fixed(observation->durationSeconds, 1) + " sec", + "ScenarioResultArtifacts.replayFrames")); + return signal; +} + +std::optional makeTimeLimitRiskSignal( + const AlternativeRecommendationRequest& request) { + const auto targetSeconds = request.artifacts.timingSummary.targetTimeSeconds > 0.0 + ? request.artifacts.timingSummary.targetTimeSeconds + : request.sourceScenario.execution.timeLimitSeconds; + if (targetSeconds <= 0.0) { + return std::nullopt; + } + + const auto finalFrame = finalFrameForRequest(request); + bool missed = false; + double overrunSeconds = 0.0; + std::size_t remainingAgents = 0; + if (request.artifacts.timingSummary.marginSeconds.has_value() && *request.artifacts.timingSummary.marginSeconds < 0.0) { + missed = true; + overrunSeconds = std::max(overrunSeconds, -*request.artifacts.timingSummary.marginSeconds); + } + if (request.artifacts.timingSummary.finalEvacuationTimeSeconds.has_value() + && *request.artifacts.timingSummary.finalEvacuationTimeSeconds > targetSeconds) { + missed = true; + overrunSeconds = std::max(overrunSeconds, *request.artifacts.timingSummary.finalEvacuationTimeSeconds - targetSeconds); + } + if (finalFrame.has_value() && finalFrame->totalAgentCount > finalFrame->evacuatedAgentCount + && finalFrame->elapsedSeconds + 1e-9 >= targetSeconds) { + missed = true; + remainingAgents = finalFrame->totalAgentCount - finalFrame->evacuatedAgentCount; + } + if (!missed) { + return std::nullopt; + } + + AlternativeRecommendationRiskSignal signal; + signal.kind = AlternativeRecommendationRiskKind::TimeLimitMissed; + signal.severity = static_cast(overrunSeconds) + static_cast(remainingAgents * 10); + signal.summary = "Time limit missed or unevacuated agents remain at the target time."; + signal.evidence.push_back(evidence( + "Target time", + fixed(targetSeconds, 1) + " sec", + request.artifacts.timingSummary.targetTimeSeconds > 0.0 + ? "ScenarioResultArtifacts.timingSummary.targetTimeSeconds" + : "ScenarioDraft.execution.timeLimitSeconds")); + if (overrunSeconds > 0.0) { + signal.evidence.push_back(evidence( + "Overrun", + fixed(overrunSeconds, 1) + " sec", + "ScenarioResultArtifacts.timingSummary")); + } + if (remainingAgents > 0) { + signal.evidence.push_back(evidence( + "Remaining agents", + std::to_string(remainingAgents), + request.finalFrame.has_value() ? "AlternativeRecommendationRequest.finalFrame" : "ScenarioResultArtifacts.replayFrames")); + } + return signal; +} + +std::vector detectRiskSignals( + const AlternativeRecommendationRequest& request) { + std::vector signals; + if (const auto bottleneck = worstBottleneckForKind(request, AlternativeRecommendationRiskKind::ExitBottleneck); + bottleneck.has_value()) { + signals.push_back(makeBottleneckRiskSignal(request, *bottleneck, AlternativeRecommendationRiskKind::ExitBottleneck)); + } + if (const auto bottleneck = worstBottleneckForKind(request, AlternativeRecommendationRiskKind::CorridorBottleneck); + bottleneck.has_value()) { + signals.push_back(makeBottleneckRiskSignal(request, *bottleneck, AlternativeRecommendationRiskKind::CorridorBottleneck)); + } + if (const auto counterflow = makeCounterflowRiskSignal(request); counterflow.has_value()) { + signals.push_back(*counterflow); + } + if (const auto timeLimit = makeTimeLimitRiskSignal(request); timeLimit.has_value()) { + signals.push_back(*timeLimit); + } + if (const auto pressure = makePressureRiskSignal(request); pressure.has_value()) { + signals.push_back(*pressure); + } + return signals; +} + +const AlternativeRecommendationRiskSignal* findRiskSignal( + const std::vector& signals, + AlternativeRecommendationRiskKind kind) { + const auto it = std::find_if(signals.begin(), signals.end(), [&](const auto& signal) { + return signal.kind == kind; + }); + return it == signals.end() ? nullptr : &(*it); } std::optional makeBlockedConnectionCandidate( @@ -311,10 +832,15 @@ std::optional makeBlockedConnectionCandidate candidate.id = "open-" + sanitizeId(*connectionId); candidate.priority = 10; candidate.title = "Reopen blocked connection"; - candidate.summary = "Create a review draft that removes the block on " + connectionName(request.layout, *connectionId) + "."; + candidate.summary = "Remove the block on " + connectionName(request.layout, *connectionId) + "."; candidate.expectedImprovement = "Restores a constrained exit path and can reduce queueing near blocked connectors."; candidate.artifactSource = "ScenarioDraft.control.connectionBlocks + completed result artifacts"; candidate.evidence.push_back(evidence("Blocked connection", connectionName(request.layout, *connectionId), "ScenarioDraft.control.connectionBlocks")); + if (const auto* connection = findConnection(request.layout, *connectionId); connection != nullptr) { + candidate.riskKind = connectionTouchesExit(request.layout, *connection) + ? AlternativeRecommendationRiskKind::ExitBottleneck + : AlternativeRecommendationRiskKind::CorridorBottleneck; + } if (const auto bottleneck = worstBottleneck(request.risk); bottleneck.has_value() && bottleneck->connectionId == *connectionId) { candidate.evidence.push_back(evidence( @@ -340,30 +866,31 @@ std::optional makeBottleneckGuidanceCandidat if (!targetExit.has_value() || targetExit->exitZoneId.empty()) { return std::nullopt; } - if (hasRouteGuidance(request.sourceScenario, targetExit->exitZoneId, bottleneck->connectionId)) { + if (hasRouteGuidance(request.sourceScenario, targetExit->exitZoneId, {})) { return std::nullopt; } auto draft = makeRecommendedDraft( request, AlternativeRecommendationKind::BottleneckBypassGuidance, - "Recommended: guide around " + connectionName(request.layout, bottleneck->connectionId)); + "Recommended: guide to " + zoneName(request.layout, targetExit->exitZoneId)); draft.control.routeGuidances.push_back(makeGuidance( - "recommendation-guidance-" + sanitizeId(bottleneck->connectionId) + "-" + sanitizeId(targetExit->exitZoneId), - targetExit->exitZoneId, - bottleneck->connectionId)); + "recommendation-guidance-" + sanitizeId(targetExit->exitZoneId), + targetExit->exitZoneId)); finalizeDiffKeys(request, draft); AlternativeRecommendationCandidate candidate; candidate.kind = AlternativeRecommendationKind::BottleneckBypassGuidance; candidate.id = "guide-around-" + sanitizeId(bottleneck->connectionId); candidate.priority = 20; - candidate.title = "Guide occupants around bottleneck"; - candidate.summary = "Create a review draft that guides occupants near " - + connectionName(request.layout, bottleneck->connectionId) + " toward " - + zoneName(request.layout, targetExit->exitZoneId) + "."; - candidate.expectedImprovement = "Shifts part of the crowd away from a stalled connector before rerunning the scenario."; + candidate.title = "Guide occupants to another exit"; + candidate.summary = "Install guidance at " + + zoneName(request.layout, targetExit->exitZoneId) + + " instead of placing a guidance marker on " + + connectionName(request.layout, bottleneck->connectionId) + "."; + candidate.expectedImprovement = "Shifts part of the crowd toward an alternate exit before rerunning the scenario."; candidate.artifactSource = "ScenarioRiskSnapshot.bottlenecks + FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage"; + candidate.riskKind = bottleneckRiskKind(request, *bottleneck); candidate.evidence.push_back(evidence( "Bottleneck signal", std::to_string(bottleneck->stalledAgentCount) + " stalled / " @@ -415,7 +942,7 @@ std::optional makeExitBalancingCandidate( candidate.id = "balance-exit-" + sanitizeId(low->exitZoneId); candidate.priority = 30; candidate.title = "Balance exit usage"; - candidate.summary = "Create a review draft that guides a share of occupants toward the underused exit " + candidate.summary = "Guide a share of occupants toward the underused exit " + zoneName(request.layout, low->exitZoneId) + "."; candidate.expectedImprovement = "Reduces dependence on " + zoneName(request.layout, high->exitZoneId) + " and may lower final evacuation time."; @@ -434,21 +961,17 @@ std::optional makeExitBalancingCandidate( std::optional makePressureHotspotCandidate( const AlternativeRecommendationRequest& request) { - const bool hasPressureSignal = - (request.artifacts.pressureSummary.hotspotScoreThreshold > 0.0 - && request.artifacts.pressureSummary.peakPressureScore >= request.artifacts.pressureSummary.hotspotScoreThreshold) - || !request.artifacts.pressureSummary.peakHotspots.empty() - || !request.artifacts.pressureSummary.criticalEvents.empty() - || !request.risk.pressureHotspots.empty() - || !request.risk.criticalPressureEvents.empty() - || request.risk.criticalPressureAgentCount > 0; - if (!hasPressureSignal) { + if (!hasPressureSignal(request)) { + return std::nullopt; + } + if (exitUsageCandidates(request).size() < 2) { return std::nullopt; } const auto targetExit = leastUsedExit(request); if (!targetExit.has_value() || targetExit->exitZoneId.empty() - || hasRouteGuidance(request.sourceScenario, targetExit->exitZoneId, {})) { + || hasRouteGuidance(request.sourceScenario, targetExit->exitZoneId, {}) + || exitHasBottleneck(request, targetExit->exitZoneId)) { return std::nullopt; } if (const auto high = mostUsedExit(request); @@ -472,10 +995,11 @@ std::optional makePressureHotspotCandidate( candidate.id = "relieve-pressure-" + sanitizeId(targetExit->exitZoneId); candidate.priority = 40; candidate.title = "Relieve pressure hotspot"; - candidate.summary = "Create a review draft that guides part of the crowd toward " + candidate.summary = "Guide part of the crowd toward " + zoneName(request.layout, targetExit->exitZoneId) + " to reduce local pressure."; candidate.expectedImprovement = "Moves some agents away from high pressure cells before validating by rerun."; candidate.artifactSource = "ScenarioResultArtifacts.pressureSummary + ScenarioRiskSnapshot pressure signals + FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage"; + candidate.riskKind = AlternativeRecommendationRiskKind::PressureHotspot; if (request.artifacts.pressureSummary.peakPressureScore > 0.0) { candidate.evidence.push_back(evidence( "Peak pressure", @@ -526,6 +1050,111 @@ std::optional makePressureHotspotCandidate( return candidate; } +std::optional makeCounterflowCandidate( + const AlternativeRecommendationRequest& request, + const std::vector& riskSignals) { + const auto* signal = findRiskSignal(riskSignals, AlternativeRecommendationRiskKind::CounterflowConflict); + if (signal == nullptr) { + return std::nullopt; + } + + const std::string eventId = "recommendation-counterflow-separation"; + if (hasOperationalEvent(request.sourceScenario, eventId)) { + return std::nullopt; + } + + auto draft = makeRecommendedDraft( + request, + AlternativeRecommendationKind::CounterflowSeparation, + "Recommended: separate counterflow movements"); + draft.control.events.push_back(makeOperationalEvent( + eventId, + "Separate counterflow movements", + "Sustained opposing movement detected in replay frames", + "Use lane separation, time-separated entry, or exit-before-entry operation.")); + finalizeDiffKeys(request, draft); + + AlternativeRecommendationCandidate candidate; + candidate.kind = AlternativeRecommendationKind::CounterflowSeparation; + candidate.riskKind = AlternativeRecommendationRiskKind::CounterflowConflict; + candidate.id = "separate-counterflow"; + candidate.priority = 35; + candidate.title = "Separate counterflow movements"; + candidate.summary = "Record a lane separation or time-separated entry operation."; + candidate.expectedImprovement = "Reduces head-on movement conflict and lets the revised operation be compared by rerun."; + candidate.artifactSource = "AlternativeRecommendationRiskSignal + ScenarioResultArtifacts.replayFrames"; + candidate.evidence = signal->evidence; + candidate.recommendedScenario = std::move(draft); + return candidate; +} + +std::optional makeStagedEvacuationCandidate( + const AlternativeRecommendationRequest& request, + const std::vector& riskSignals) { + const auto* signal = findRiskSignal(riskSignals, AlternativeRecommendationRiskKind::PressureHotspot); + if (signal == nullptr) { + signal = findRiskSignal(riskSignals, AlternativeRecommendationRiskKind::TimeLimitMissed); + } + if (signal == nullptr) { + return std::nullopt; + } + + const auto stagedSources = makeSequentialStagedEvacuationSources(request); + if (stagedSources.empty()) { + return std::nullopt; + } + for (const auto& source : stagedSources) { + if (hasOccupantSource(request.sourceScenario, source.id)) { + return std::nullopt; + } + } + const auto stagedAgentCount = totalSourceAgents(stagedSources); + const auto tickCount = totalSourceTicks(stagedSources); + if (stagedAgentCount == 0 || tickCount == 0) { + return std::nullopt; + } + const auto finalSpawnSeconds = kStagedEvacuationIntervalSeconds * static_cast(tickCount - 1); + + auto draft = makeRecommendedDraft( + request, + AlternativeRecommendationKind::StagedEvacuation, + "Recommended: stage departure batches"); + draft.population.initialPlacements.clear(); + draft.population.occupantSources.insert( + draft.population.occupantSources.end(), + stagedSources.begin(), + stagedSources.end()); + finalizeDiffKeys(request, draft); + + AlternativeRecommendationCandidate candidate; + candidate.kind = AlternativeRecommendationKind::StagedEvacuation; + candidate.riskKind = signal->kind; + candidate.id = "staged-evacuation-batches"; + candidate.priority = 45; + candidate.title = "Stage groups sequentially"; + candidate.summary = "Release each source group in order: up to " + std::to_string(kStagedEvacuationAgentsPerSpawn) + + " agents every " + fixed(kStagedEvacuationIntervalSeconds, 0) + + " sec, then start the next group instead of releasing " + std::to_string(stagedAgentCount) + " at once."; + candidate.expectedImprovement = "Reduces simultaneous demand at exits or bottlenecks before validating by rerun."; + candidate.artifactSource = "AlternativeRecommendationRiskSignal + ScenarioDraft.population.initialPlacements"; + candidate.evidence = signal->evidence; + candidate.evidence.push_back(evidence( + "Staged groups", + std::to_string(stagedSources.size()) + " groups / " + std::to_string(stagedAgentCount) + " agents", + "ScenarioDraft.population.initialPlacements")); + candidate.evidence.push_back(evidence( + "Release schedule", + "sequential groups, up to " + std::to_string(kStagedEvacuationAgentsPerSpawn) + " agents every " + + fixed(kStagedEvacuationIntervalSeconds, 0) + " sec x " + std::to_string(tickCount) + " batches", + "recommended OccupantSource2D")); + candidate.evidence.push_back(evidence( + "Release window", + "0-" + fixed(finalSpawnSeconds, 0) + " sec", + "recommended OccupantSource2D")); + candidate.recommendedScenario = std::move(draft); + return candidate; +} + } // namespace const char* alternativeRecommendationKindId(AlternativeRecommendationKind kind) noexcept { @@ -538,10 +1167,32 @@ const char* alternativeRecommendationKindId(AlternativeRecommendationKind kind) return "exit-usage-balancing"; case AlternativeRecommendationKind::PressureHotspotRelief: return "pressure-hotspot-relief"; + case AlternativeRecommendationKind::CorridorOneWayFlow: + return "corridor-one-way-flow"; + case AlternativeRecommendationKind::CounterflowSeparation: + return "counterflow-separation"; + case AlternativeRecommendationKind::StagedEvacuation: + return "staged-evacuation"; } return "recommendation"; } +const char* alternativeRecommendationRiskKindId(AlternativeRecommendationRiskKind kind) noexcept { + switch (kind) { + case AlternativeRecommendationRiskKind::ExitBottleneck: + return "exit-bottleneck"; + case AlternativeRecommendationRiskKind::CorridorBottleneck: + return "corridor-bottleneck"; + case AlternativeRecommendationRiskKind::CounterflowConflict: + return "counterflow-conflict"; + case AlternativeRecommendationRiskKind::TimeLimitMissed: + return "time-limit-missed"; + case AlternativeRecommendationRiskKind::PressureHotspot: + return "pressure-hotspot"; + } + return "risk"; +} + AlternativeRecommendationResult AlternativeRecommendationService::recommend( const AlternativeRecommendationRequest& request) const { AlternativeRecommendationResult result; @@ -551,6 +1202,8 @@ AlternativeRecommendationResult AlternativeRecommendationService::recommend( return result; } + result.riskSignals = detectRiskSignals(request); + if (const auto candidate = makeBlockedConnectionCandidate(request); candidate.has_value()) { result.candidates.push_back(*candidate); } @@ -560,9 +1213,15 @@ AlternativeRecommendationResult AlternativeRecommendationService::recommend( if (const auto candidate = makeExitBalancingCandidate(request); candidate.has_value()) { result.candidates.push_back(*candidate); } + if (const auto candidate = makeCounterflowCandidate(request, result.riskSignals); candidate.has_value()) { + result.candidates.push_back(*candidate); + } if (const auto candidate = makePressureHotspotCandidate(request); candidate.has_value()) { result.candidates.push_back(*candidate); } + if (const auto candidate = makeStagedEvacuationCandidate(request, result.riskSignals); candidate.has_value()) { + result.candidates.push_back(*candidate); + } std::sort(result.candidates.begin(), result.candidates.end(), [](const auto& lhs, const auto& rhs) { return lhs.priority < rhs.priority; @@ -570,7 +1229,7 @@ AlternativeRecommendationResult AlternativeRecommendationService::recommend( if (result.candidates.empty()) { result.blockingReasons.push_back( - "No blocked connection, bottleneck, exit imbalance, or pressure hotspot produced an actionable v1 recommendation."); + "No detected risk signal, blocked connection, exit imbalance, or pressure hotspot produced an actionable v1 recommendation."); } return result; diff --git a/src/domain/AlternativeRecommendationService.h b/src/domain/AlternativeRecommendationService.h index 6d18b04..0fb7c20 100644 --- a/src/domain/AlternativeRecommendationService.h +++ b/src/domain/AlternativeRecommendationService.h @@ -8,6 +8,7 @@ #include "domain/ScenarioAuthoring.h" #include "domain/ScenarioResultArtifacts.h" #include "domain/ScenarioRiskMetrics.h" +#include "domain/ScenarioSimulationFrame.h" namespace safecrowd::domain { @@ -16,6 +17,17 @@ enum class AlternativeRecommendationKind { BottleneckBypassGuidance, ExitUsageBalancing, PressureHotspotRelief, + CorridorOneWayFlow, + CounterflowSeparation, + StagedEvacuation, +}; + +enum class AlternativeRecommendationRiskKind { + ExitBottleneck, + CorridorBottleneck, + CounterflowConflict, + TimeLimitMissed, + PressureHotspot, }; struct AlternativeRecommendationEvidence { @@ -34,6 +46,14 @@ struct AlternativeRecommendationCandidate { std::string artifactSource{}; std::vector evidence{}; ScenarioDraft recommendedScenario{}; + std::optional riskKind{}; +}; + +struct AlternativeRecommendationRiskSignal { + AlternativeRecommendationRiskKind kind{AlternativeRecommendationRiskKind::ExitBottleneck}; + int severity{0}; + std::string summary{}; + std::vector evidence{}; }; struct AlternativeRecommendationRequest { @@ -42,9 +62,11 @@ struct AlternativeRecommendationRequest { std::optional baselineScenario{}; ScenarioRiskSnapshot risk{}; ScenarioResultArtifacts artifacts{}; + std::optional finalFrame{}; }; struct AlternativeRecommendationResult { + std::vector riskSignals{}; std::vector candidates{}; std::vector blockingReasons{}; }; @@ -55,5 +77,6 @@ class AlternativeRecommendationService { }; const char* alternativeRecommendationKindId(AlternativeRecommendationKind kind) noexcept; +const char* alternativeRecommendationRiskKindId(AlternativeRecommendationRiskKind kind) noexcept; } // namespace safecrowd::domain diff --git a/src/domain/DemoFixtureService.cpp b/src/domain/DemoFixtureService.cpp index 1292090..24d76a2 100644 --- a/src/domain/DemoFixtureService.cpp +++ b/src/domain/DemoFixtureService.cpp @@ -35,7 +35,7 @@ ScenarioDraft makeTwoFloorEastExitGuidanceAlternative(const ScenarioDraft& basel .endSeconds = 180.0, .periods = {{.startSeconds = 0.0, .endSeconds = 180.0}}, .guidedExitZoneId = Ids::EastExitZoneId, - .installConnectionId = Ids::UpperWestTrainingToCorridorConnectionId, + .installConnectionId = {}, .baseComplianceRate = 0.95, .guidanceStrength = 0.95, .maxDetourMeters = 60.0, diff --git a/tests/AlternativeRecommendationServiceTests.cpp b/tests/AlternativeRecommendationServiceTests.cpp index 7bec94a..045bf94 100644 --- a/tests/AlternativeRecommendationServiceTests.cpp +++ b/tests/AlternativeRecommendationServiceTests.cpp @@ -2,6 +2,7 @@ #include "domain/AlternativeRecommendationService.h" #include +#include #include using namespace safecrowd::domain; @@ -45,6 +46,48 @@ FacilityLayout2D makeRecommendationLayout() { return layout; } +FacilityLayout2D makeCorridorRecommendationLayout() { + auto layout = makeRecommendationLayout(); + layout.zones.push_back({ + .id = "hall-a", + .floorId = "L1", + .kind = ZoneKind::Room, + .label = "Hall A", + }); + layout.connections.push_back({ + .id = "hall-main", + .floorId = "L1", + .kind = ConnectionKind::Opening, + .fromZoneId = "room-a", + .toZoneId = "hall-a", + }); + return layout; +} + +FacilityLayout2D makeSingleExitRecommendationLayout() { + FacilityLayout2D layout; + layout.zones.push_back({ + .id = "room-a", + .floorId = "L1", + .kind = ZoneKind::Room, + .label = "Room A", + }); + layout.zones.push_back({ + .id = "exit-main", + .floorId = "L1", + .kind = ZoneKind::Exit, + .label = "Main Exit", + }); + layout.connections.push_back({ + .id = "door-main", + .floorId = "L1", + .kind = ConnectionKind::Exit, + .fromZoneId = "room-a", + .toZoneId = "exit-main", + }); + return layout; +} + ScenarioDraft makeScenario() { ScenarioDraft scenario; scenario.scenarioId = "scenario-1"; @@ -53,6 +96,8 @@ ScenarioDraft makeScenario() { InitialPlacement2D placement; placement.id = "group-a"; placement.zoneId = "room-a"; + placement.floorId = "L1"; + placement.area.outline = {{.x = 0.5, .y = 0.5}}; placement.targetAgentCount = 20; scenario.population.initialPlacements.push_back(placement); scenario.execution.timeLimitSeconds = 120.0; @@ -83,6 +128,32 @@ ScenarioResultArtifacts makeExitUsageArtifacts(double mainRatio = 0.85, double e return artifacts; } +ScenarioResultArtifacts makeCounterflowArtifacts(double endSeconds = 10.0) { + ScenarioResultArtifacts artifacts = makeCompletedArtifacts(); + for (int second = 0; second <= static_cast(endSeconds); ++second) { + SimulationFrame frame; + frame.elapsedSeconds = static_cast(second); + frame.totalAgentCount = 6; + frame.evacuatedAgentCount = 0; + for (std::uint64_t index = 0; index < 3; ++index) { + frame.agents.push_back({ + .id = index + 1, + .position = {static_cast(index), 0.0}, + .velocity = {0.3, 0.0}, + .floorId = "L1", + }); + frame.agents.push_back({ + .id = index + 10, + .position = {static_cast(index), 1.0}, + .velocity = {-0.3, 0.0}, + .floorId = "L1", + }); + } + artifacts.replayFrames.push_back(std::move(frame)); + } + return artifacts; +} + ScenarioResultArtifacts makeSingleExitUsageArtifacts( std::string exitZoneId, std::string exitLabel, @@ -106,6 +177,14 @@ bool hasCandidateKind( }); } +bool hasRiskSignalKind( + const AlternativeRecommendationResult& result, + AlternativeRecommendationRiskKind kind) { + return std::any_of(result.riskSignals.begin(), result.riskSignals.end(), [&](const auto& signal) { + return signal.kind == kind; + }); +} + bool containsEvidenceLabel( const AlternativeRecommendationCandidate& candidate, const std::string& label) { @@ -314,7 +393,7 @@ SC_TEST(AlternativeRecommendationService_skipsExitBalancingBelowThreshold) { SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::ExitUsageBalancing)); } -SC_TEST(AlternativeRecommendationService_addsBottleneckGuidanceAtBottleneckConnection) { +SC_TEST(AlternativeRecommendationService_addsBottleneckGuidanceAtExit) { ScenarioRiskSnapshot risk; risk.bottlenecks.push_back({ .connectionId = "door-main", @@ -336,10 +415,35 @@ SC_TEST(AlternativeRecommendationService_addsBottleneckGuidanceAtBottleneckConne SC_EXPECT_TRUE(it != result.candidates.end()); SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.size(), std::size_t{1}); SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.front().guidedExitZoneId, std::string{"exit-east"}); - SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.front().installConnectionId, std::string{"door-main"}); + SC_EXPECT_TRUE(it->recommendedScenario.control.routeGuidances.front().installConnectionId.empty()); SC_EXPECT_TRUE(containsDiffKey(it->recommendedScenario, "control.routeGuidances")); } +SC_TEST(AlternativeRecommendationService_installsCorridorBottleneckGuidanceAtExitOnly) { + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "hall-main", + .nearbyAgentCount = 8, + .stalledAgentCount = 5, + }); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeCorridorRecommendationLayout(), + .sourceScenario = makeScenario(), + .risk = risk, + .artifacts = makeExitUsageArtifacts(), + }); + + const auto it = std::find_if(result.candidates.begin(), result.candidates.end(), [](const auto& candidate) { + return candidate.kind == AlternativeRecommendationKind::BottleneckBypassGuidance; + }); + SC_EXPECT_TRUE(it != result.candidates.end()); + SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.size(), std::size_t{1}); + SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.front().guidedExitZoneId, std::string{"exit-east"}); + SC_EXPECT_TRUE(it->recommendedScenario.control.routeGuidances.front().installConnectionId.empty()); +} + SC_TEST(AlternativeRecommendationService_guidesBottleneckAwayFromAdjacentLeastUsedExit) { ScenarioRiskSnapshot risk; risk.bottlenecks.push_back({ @@ -362,7 +466,7 @@ SC_TEST(AlternativeRecommendationService_guidesBottleneckAwayFromAdjacentLeastUs SC_EXPECT_TRUE(it != result.candidates.end()); SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.size(), std::size_t{1}); SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.front().guidedExitZoneId, std::string{"exit-main"}); - SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.front().installConnectionId, std::string{"door-east"}); + SC_EXPECT_TRUE(it->recommendedScenario.control.routeGuidances.front().installConnectionId.empty()); SC_EXPECT_TRUE(containsEvidenceLabel(*it, "Excluded adjacent exits")); SC_EXPECT_TRUE(containsEvidenceSource(*it, "least-used non-adjacent exit from FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage")); } @@ -391,7 +495,7 @@ SC_TEST(AlternativeRecommendationService_guidesBottleneckTowardUnusedNonAdjacent SC_EXPECT_TRUE(it != result.candidates.end()); SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.size(), std::size_t{1}); SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.front().guidedExitZoneId, std::string{"exit-main"}); - SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.front().installConnectionId, std::string{"door-east"}); + SC_EXPECT_TRUE(it->recommendedScenario.control.routeGuidances.front().installConnectionId.empty()); SC_EXPECT_TRUE(containsEvidenceSource(*it, "least-used non-adjacent exit from FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage")); } @@ -467,6 +571,45 @@ SC_TEST(AlternativeRecommendationService_requiresExitUsageForPressureHotspotReli SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::PressureHotspotRelief)); } +SC_TEST(AlternativeRecommendationService_skipsPressureHotspotReliefForSingleExitLayout) { + auto artifacts = makeSingleExitUsageArtifacts("exit-main", "Main Exit", 20, 1.0); + artifacts.pressureSummary.hotspotScoreThreshold = 4.0; + artifacts.pressureSummary.peakPressureScore = 8.7; + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeSingleExitRecommendationLayout(), + .sourceScenario = makeScenario(), + .artifacts = artifacts, + }); + + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::PressureHotspotRelief)); +} + +SC_TEST(AlternativeRecommendationService_skipsPressureHotspotReliefTowardBottleneckExit) { + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "door-east", + .nearbyAgentCount = 10, + .stalledAgentCount = 4, + .averageSpeed = 0.2, + }); + + auto artifacts = makeExitUsageArtifacts(0.55, 0.45); + artifacts.pressureSummary.hotspotScoreThreshold = 4.0; + artifacts.pressureSummary.peakPressureScore = 8.7; + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = makeScenario(), + .risk = risk, + .artifacts = artifacts, + }); + + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::PressureHotspotRelief)); +} + SC_TEST(AlternativeRecommendationService_usesRiskPressureEvidenceWhenArtifactPeakMissing) { ScenarioRiskSnapshot risk; risk.criticalPressureAgentCount = 4; @@ -487,6 +630,206 @@ SC_TEST(AlternativeRecommendationService_usesRiskPressureEvidenceWhenArtifactPea SC_EXPECT_TRUE(!containsEvidenceLabel(*it, "Peak pressure")); } +SC_TEST(AlternativeRecommendationService_reportsExitAndCorridorBottleneckRiskSignals) { + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "door-main", + .nearbyAgentCount = 8, + .stalledAgentCount = 5, + .averageSpeed = 0.2, + }); + risk.bottlenecks.push_back({ + .connectionId = "hall-main", + .nearbyAgentCount = 6, + .stalledAgentCount = 4, + .averageSpeed = 0.3, + }); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeCorridorRecommendationLayout(), + .sourceScenario = makeScenario(), + .risk = risk, + .artifacts = makeCompletedArtifacts(), + }); + + SC_EXPECT_TRUE(hasRiskSignalKind(result, AlternativeRecommendationRiskKind::ExitBottleneck)); + SC_EXPECT_TRUE(hasRiskSignalKind(result, AlternativeRecommendationRiskKind::CorridorBottleneck)); +} + +SC_TEST(AlternativeRecommendationService_doesNotAddOneWayOperationForCorridorBottleneckAlone) { + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "hall-main", + .nearbyAgentCount = 6, + .stalledAgentCount = 4, + .averageSpeed = 0.3, + }); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeCorridorRecommendationLayout(), + .sourceScenario = makeScenario(), + .risk = risk, + .artifacts = makeCompletedArtifacts(), + }); + + SC_EXPECT_TRUE(hasRiskSignalKind(result, AlternativeRecommendationRiskKind::CorridorBottleneck)); + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::CorridorOneWayFlow)); +} + +SC_TEST(AlternativeRecommendationService_suppressesOneWayOperationForCounterflowConflict) { + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = makeScenario(), + .artifacts = makeCounterflowArtifacts(), + }); + + SC_EXPECT_TRUE(hasRiskSignalKind(result, AlternativeRecommendationRiskKind::CounterflowConflict)); + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::CorridorOneWayFlow)); + SC_EXPECT_TRUE(hasCandidateKind(result, AlternativeRecommendationKind::CounterflowSeparation)); +} + +SC_TEST(AlternativeRecommendationService_detectsSustainedCounterflowConflict) { + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = makeScenario(), + .artifacts = makeCounterflowArtifacts(), + }); + + const auto it = std::find_if(result.candidates.begin(), result.candidates.end(), [](const auto& candidate) { + return candidate.kind == AlternativeRecommendationKind::CounterflowSeparation; + }); + SC_EXPECT_TRUE(hasRiskSignalKind(result, AlternativeRecommendationRiskKind::CounterflowConflict)); + SC_EXPECT_TRUE(it != result.candidates.end()); + SC_EXPECT_TRUE(it->riskKind.has_value() && *it->riskKind == AlternativeRecommendationRiskKind::CounterflowConflict); + SC_EXPECT_EQ(it->recommendedScenario.control.events.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsDiffKey(it->recommendedScenario, "control.events")); +} + +SC_TEST(AlternativeRecommendationService_ignoresTransientCounterflowConflict) { + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = makeScenario(), + .artifacts = makeCounterflowArtifacts(5.0), + }); + + SC_EXPECT_TRUE(!hasRiskSignalKind(result, AlternativeRecommendationRiskKind::CounterflowConflict)); + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::CounterflowSeparation)); +} + +SC_TEST(AlternativeRecommendationService_addsStagedEvacuationForMissedTimeLimit) { + auto scenario = makeScenario(); + scenario.execution.timeLimitSeconds = 120.0; + auto artifacts = makeCompletedArtifacts(); + artifacts.timingSummary.targetTimeSeconds = 120.0; + artifacts.timingSummary.finalEvacuationTimeSeconds = 132.0; + artifacts.timingSummary.marginSeconds = -12.0; + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = scenario, + .artifacts = artifacts, + }); + + const auto it = std::find_if(result.candidates.begin(), result.candidates.end(), [](const auto& candidate) { + return candidate.kind == AlternativeRecommendationKind::StagedEvacuation; + }); + SC_EXPECT_TRUE(hasRiskSignalKind(result, AlternativeRecommendationRiskKind::TimeLimitMissed)); + SC_EXPECT_TRUE(it != result.candidates.end()); + SC_EXPECT_TRUE(it->riskKind.has_value() && *it->riskKind == AlternativeRecommendationRiskKind::TimeLimitMissed); + SC_EXPECT_TRUE(it->recommendedScenario.population.initialPlacements.empty()); + SC_EXPECT_EQ(it->recommendedScenario.population.occupantSources.size(), std::size_t{1}); + const auto& source = it->recommendedScenario.population.occupantSources.front(); + SC_EXPECT_EQ(source.targetAgentCount, std::size_t{20}); + SC_EXPECT_EQ(source.agentsPerSpawn, std::size_t{10}); + SC_EXPECT_NEAR(source.spawnIntervalSeconds, 5.0, 1e-9); + SC_EXPECT_NEAR(source.startSeconds, 0.0, 1e-9); + SC_EXPECT_NEAR(source.endSeconds, 10.0, 1e-9); + SC_EXPECT_TRUE(containsDiffKey(it->recommendedScenario, "population.placements")); +} + +SC_TEST(AlternativeRecommendationService_stagesLargeInitialPlacementForPressureHotspot) { + auto scenario = makeScenario(); + scenario.population.initialPlacements.front().targetAgentCount = 100; + + auto artifacts = makeSingleExitUsageArtifacts("exit-main", "Main Exit", 100, 1.0); + artifacts.pressureSummary.hotspotScoreThreshold = 4.0; + artifacts.pressureSummary.peakPressureScore = 8.7; + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeSingleExitRecommendationLayout(), + .sourceScenario = scenario, + .artifacts = artifacts, + }); + + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::PressureHotspotRelief)); + const auto it = std::find_if(result.candidates.begin(), result.candidates.end(), [](const auto& candidate) { + return candidate.kind == AlternativeRecommendationKind::StagedEvacuation; + }); + SC_EXPECT_TRUE(hasRiskSignalKind(result, AlternativeRecommendationRiskKind::PressureHotspot)); + SC_EXPECT_TRUE(it != result.candidates.end()); + SC_EXPECT_TRUE(it->riskKind.has_value() && *it->riskKind == AlternativeRecommendationRiskKind::PressureHotspot); + SC_EXPECT_TRUE(it->recommendedScenario.population.initialPlacements.empty()); + SC_EXPECT_EQ(it->recommendedScenario.population.occupantSources.size(), std::size_t{1}); + const auto& source = it->recommendedScenario.population.occupantSources.front(); + SC_EXPECT_EQ(source.zoneId, std::string{"room-a"}); + SC_EXPECT_EQ(source.floorId, std::string{"L1"}); + SC_EXPECT_EQ(source.targetAgentCount, std::size_t{100}); + SC_EXPECT_EQ(source.agentsPerSpawn, std::size_t{10}); + SC_EXPECT_NEAR(source.spawnIntervalSeconds, 5.0, 1e-9); + SC_EXPECT_NEAR(source.startSeconds, 0.0, 1e-9); + SC_EXPECT_NEAR(source.endSeconds, 50.0, 1e-9); + SC_EXPECT_TRUE(containsEvidenceLabel(*it, "Release schedule")); + SC_EXPECT_TRUE(containsDiffKey(it->recommendedScenario, "population.placements")); +} + +SC_TEST(AlternativeRecommendationService_stagesAllInitialPlacementsInOrder) { + auto scenario = makeScenario(); + scenario.population.initialPlacements.front().targetAgentCount = 25; + InitialPlacement2D second = scenario.population.initialPlacements.front(); + second.id = "group-b"; + second.area.outline = {{.x = 1.5, .y = 0.5}}; + second.targetAgentCount = 25; + scenario.population.initialPlacements.push_back(second); + + auto artifacts = makeSingleExitUsageArtifacts("exit-main", "Main Exit", 50, 1.0); + artifacts.pressureSummary.hotspotScoreThreshold = 4.0; + artifacts.pressureSummary.peakPressureScore = 8.7; + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeSingleExitRecommendationLayout(), + .sourceScenario = scenario, + .artifacts = artifacts, + }); + + const auto it = std::find_if(result.candidates.begin(), result.candidates.end(), [](const auto& candidate) { + return candidate.kind == AlternativeRecommendationKind::StagedEvacuation; + }); + SC_EXPECT_TRUE(it != result.candidates.end()); + SC_EXPECT_TRUE(it->recommendedScenario.population.initialPlacements.empty()); + SC_EXPECT_EQ(it->recommendedScenario.population.occupantSources.size(), std::size_t{2}); + const auto& firstSource = it->recommendedScenario.population.occupantSources.front(); + const auto& secondSource = it->recommendedScenario.population.occupantSources.back(); + SC_EXPECT_EQ(firstSource.id, std::string{"recommendation-source-group-a"}); + SC_EXPECT_EQ(secondSource.id, std::string{"recommendation-source-group-b"}); + SC_EXPECT_EQ(firstSource.targetAgentCount, std::size_t{25}); + SC_EXPECT_EQ(secondSource.targetAgentCount, std::size_t{25}); + SC_EXPECT_EQ(firstSource.agentsPerSpawn, std::size_t{10}); + SC_EXPECT_EQ(secondSource.agentsPerSpawn, std::size_t{10}); + SC_EXPECT_NEAR(firstSource.startSeconds, 0.0, 1e-9); + SC_EXPECT_NEAR(firstSource.endSeconds, 15.0, 1e-9); + SC_EXPECT_NEAR(secondSource.startSeconds, 15.0, 1e-9); + SC_EXPECT_NEAR(secondSource.endSeconds, 30.0, 1e-9); + SC_EXPECT_TRUE(containsEvidenceLabel(*it, "Release window")); +} + SC_TEST(AlternativeRecommendationService_sortsBlockedReliefBeforeGuidance) { auto scenario = makeScenario(); scenario.control.connectionBlocks.push_back({ diff --git a/tests/DemoFixtureServiceTests.cpp b/tests/DemoFixtureServiceTests.cpp index 76f05c0..08a3833 100644 --- a/tests/DemoFixtureServiceTests.cpp +++ b/tests/DemoFixtureServiceTests.cpp @@ -241,9 +241,7 @@ SC_TEST(DemoFixtureServiceBuildsTwoFloorEvacuationFixture) { SC_EXPECT_EQ( fixture.alternativeScenario.control.routeGuidances.front().guidedExitZoneId, std::string(Ids::EastExitZoneId)); - SC_EXPECT_EQ( - fixture.alternativeScenario.control.routeGuidances.front().installConnectionId, - std::string(Ids::UpperWestTrainingToCorridorConnectionId)); + SC_EXPECT_TRUE(fixture.alternativeScenario.control.routeGuidances.front().installConnectionId.empty()); SC_EXPECT_TRUE(containsDiffKey(fixture.alternativeScenario, "control.routeGuidances")); safecrowd::domain::ImportValidationService validator;