diff --git a/CMakeLists.txt b/CMakeLists.txt index 49e1c97..554af9b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,6 +82,8 @@ add_library(safecrowd_domain STATIC src/domain/GeometryQueries.h src/domain/GeometryQueries.cpp src/domain/PopulationSpec.h + src/domain/AlternativeRecommendationService.h + src/domain/AlternativeRecommendationService.cpp src/domain/ScenarioAuthoring.h src/domain/ScenarioAuthoring.cpp src/domain/ScenarioBatchRunner.h @@ -158,6 +160,7 @@ if (BUILD_TESTING) tests/EngineIntegrationTests.cpp tests/ResourceStoreTests.cpp tests/DeterministicRngTests.cpp + tests/AlternativeRecommendationServiceTests.cpp tests/ScenarioSimulationRunnerTests.cpp tests/ScenarioAuthoringTests.cpp tests/ScenarioBatchRunnerTests.cpp @@ -174,6 +177,19 @@ if (BUILD_TESTING) safecrowd_domain ) + if (SAFECROWD_BUILD_APP) + target_sources(safecrowd_tests + PRIVATE + tests/ProjectPersistenceTests.cpp + src/application/ProjectPersistence.cpp + ) + + target_link_libraries(safecrowd_tests + PRIVATE + Qt6::Core + ) + endif() + configure_project_target(safecrowd_tests) add_test(NAME safecrowd_tests COMMAND safecrowd_tests) diff --git a/docs/UI.md b/docs/UI.md index 617506b..3e41002 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -347,6 +347,9 @@ stage된 baseline 시나리오를 실제로 실행하고 진행 상태를 보는 - 하단 graph panel에 Remaining, Exits, Compare 탭 제공 - Exits 탭에 출구별 이용 인원, 비율, 마지막 통과 시각 표시 - Compare 탭은 v1 placeholder이며 baseline/alternative 비교 계산은 후속 Analysis Workspace 범위 +- Batch Result 우측 패널에 완료 결과 아티팩트 기반 Recommendations v1 제공 + - 차단 해제, 출구 분산 유도, 병목/압박 hotspot 완화 후보를 표시 + - `Create Recommended Scenario`는 `Recommended` 시나리오 초안만 만들고 자동 재실행하지 않음 - 상세 탭에 Zones, Groups, Criteria 표시 - Zones 탭에 구역별 초기 인원, 대피 인원, 마지막 완료시각 표시 - Groups 탭에 배치 그룹별 초기 인원, 대피 인원, 마지막 완료시각 표시 @@ -554,7 +557,7 @@ stage된 baseline 시나리오를 실제로 실행하고 진행 상태를 보는 - [ ] Variation Summary 제공 - [ ] Heatmap Selector 제공 - [ ] Comparison View 제공 -- [ ] Recommendation Drawer 제공 +- [x] Recommendation Drawer v1 제공 - [ ] Export Dialog 제공 ## 7. 문서 유지 규칙 diff --git a/docs/alternative-recommendation-evidence.md b/docs/alternative-recommendation-evidence.md index e9624a5..914dfb3 100644 --- a/docs/alternative-recommendation-evidence.md +++ b/docs/alternative-recommendation-evidence.md @@ -11,7 +11,6 @@ --- - @@ -189,4 +188,3 @@ - “문헌상 특정 조건에서 개선 사례가 보고됨” - “현재 시나리오에서도 효과가 있는지는 재시뮬레이션으로 검증 필요” - “장애물/분리대는 잘못 배치하면 오히려 악화될 수 있음” - diff --git a/docs/alternative-recommendation-plan.md b/docs/alternative-recommendation-plan.md index 379569b..ffca399 100644 --- a/docs/alternative-recommendation-plan.md +++ b/docs/alternative-recommendation-plan.md @@ -12,23 +12,20 @@ - 판단기준 측정 구역(출구 앞 2M)에 밀도가 2명/㎡ 이상있을때, 10초 이상 대기 상태가 지속되고, - - + - 복도에서 병목 - 판단기준 복도 실제 통과 시간이 정상 예상 통과 시간의 2배 이상이고, 평균속도가 0.5이하 상태가 10초 이상 지속되면 복도 병목으로 판단한다. - - + - 제한시간 초과/ 미대피 - 판단기준 제한시간동안 모든인원이 대피하지 못했을때. - - + + - 양방향 흐름 충돌 - 판단기준 120도 이상 반대 방향 흐름, 양쪽 인원 각각 30% 이상, 속도 0.7m/s 이하가 10초 이상 - ### 대안추천 예상값 @@ -69,5 +66,3 @@ |제한시간 초과 / 미대피 증가|과부하 출구 인원을 다른 출구로 분산|가장 혼잡한 E1 구역 지연시간 180초 감소| |제한시간 초과 / 미대피 증가|여유 구역 E2/E3로 부하 이전|E2/E3 시간 증가는 30초 내외로 제한| |제한시간 초과 / 미대피 증가|단계적 출발 / 지연 대기 전략|병목 전방 동시 압력 감소, 미대피 발생 억제| - - diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index c0aee29..893f560 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -576,6 +576,23 @@ QLabel* createRoleBadge(const QString& text, bool alternative, QWidget* parent) return badge; } +QString scenarioRoleLabel(safecrowd::domain::ScenarioRole role) { + switch (role) { + case safecrowd::domain::ScenarioRole::Baseline: + return "Baseline"; + case safecrowd::domain::ScenarioRole::Recommended: + return "Recommended"; + case safecrowd::domain::ScenarioRole::Alternative: + default: + return "Alternative"; + } +} + +bool scenarioRoleHasBaselineDiff(safecrowd::domain::ScenarioRole role) { + return role == safecrowd::domain::ScenarioRole::Alternative + || role == safecrowd::domain::ScenarioRole::Recommended; +} + void addMetaRow(QVBoxLayout* layout, const QString& label, const QString& value, QWidget* parent) { auto* row = new QWidget(parent); auto* rowLayout = new QHBoxLayout(row); @@ -1223,7 +1240,7 @@ void ScenarioAuthoringWidget::createScenarioWithName(const QString& name, int so scenario.crowdPlacements = source.crowdPlacements; scenario.startText = source.startText; scenario.destinationText = source.destinationText; - scenario.baseScenarioId = source.draft.role == safecrowd::domain::ScenarioRole::Alternative + scenario.baseScenarioId = scenarioRoleHasBaselineDiff(source.draft.role) ? source.baseScenarioId : QString::fromStdString(source.draft.scenarioId); scenario.stagedForRun = false; @@ -1356,10 +1373,10 @@ void ScenarioAuthoringWidget::refreshInspector() { if (!hasScenario) { addStatusMessage(panelLayout, "No scenario selected", scenarioOverviewPanel_); } else { - const bool alternative = scenario->draft.role == safecrowd::domain::ScenarioRole::Alternative; + const bool variation = scenarioRoleHasBaselineDiff(scenario->draft.role); panelLayout->addWidget(createRoleBadge( - alternative ? "Alternative" : "Baseline", - alternative, + scenarioRoleLabel(scenario->draft.role), + variation, scenarioOverviewPanel_)); auto* nameLabel = createLabel( @@ -1377,7 +1394,7 @@ void ScenarioAuthoringWidget::refreshInspector() { addMetaRow(panelLayout, "Blocked", QString::number(static_cast(scenario->draft.control.connectionBlocks.size())), scenarioOverviewPanel_); addMetaRow(panelLayout, "Start", scenario->startText, scenarioOverviewPanel_); addMetaRow(panelLayout, "Destination", scenario->destinationText, scenarioOverviewPanel_); - if (alternative && !scenario->baseScenarioId.isEmpty()) { + if (variation && !scenario->baseScenarioId.isEmpty()) { addMetaRow(panelLayout, "Based on", scenario->baseScenarioId, scenarioOverviewPanel_); } } @@ -1397,7 +1414,7 @@ void ScenarioAuthoringWidget::refreshInspector() { } else if (scenario->draft.role == safecrowd::domain::ScenarioRole::Baseline) { addStatusMessage(panelLayout, "Baseline scenario", scenarioDiffPanel_); } else if (scenario->baseScenarioId.isEmpty()) { - addStatusMessage(panelLayout, "Alternative scenario / no baseline link", scenarioDiffPanel_); + addStatusMessage(panelLayout, "Variation scenario / no baseline link", scenarioDiffPanel_); } else { const auto baseId = scenario->baseScenarioId.toStdString(); const auto baselineIt = std::find_if(scenarios_.begin(), scenarios_.end(), [&](const auto& candidate) { @@ -1452,8 +1469,8 @@ void ScenarioAuthoringWidget::refreshInspector() { if (!stagedScenario.stagedForRun || !scenarioHasOccupants(stagedScenario)) { continue; } - const auto role = stagedScenario.draft.role == safecrowd::domain::ScenarioRole::Baseline ? "Baseline" : "Alternative"; - lines << QString("- %1 (%2)").arg(QString::fromStdString(stagedScenario.draft.name), role); + lines << QString("- %1 (%2)") + .arg(QString::fromStdString(stagedScenario.draft.name), scenarioRoleLabel(stagedScenario.draft.role)); } } stagedScenariosLabel_->setText(lines.join('\n')); @@ -1698,8 +1715,8 @@ void ScenarioAuthoringWidget::refreshScenarioSwitcher() { scenarioSwitcher_->blockSignals(true); scenarioSwitcher_->clear(); for (const auto& scenario : scenarios_) { - const auto role = scenario.draft.role == safecrowd::domain::ScenarioRole::Baseline ? "Baseline" : "Alternative"; - scenarioSwitcher_->addItem(QString("%1 (%2)").arg(QString::fromStdString(scenario.draft.name), role)); + scenarioSwitcher_->addItem(QString("%1 (%2)") + .arg(QString::fromStdString(scenario.draft.name), scenarioRoleLabel(scenario.draft.role))); } scenarioSwitcher_->setCurrentIndex(currentScenarioIndex_); scenarioSwitcher_->blockSignals(false); @@ -1822,8 +1839,7 @@ void ScenarioAuthoringWidget::recomputeDependentVariationDiffKeys(const QString& } void ScenarioAuthoringWidget::recomputeVariationDiffKeysIfAlternative(ScenarioState& scenario) const { - if (scenario.draft.role != safecrowd::domain::ScenarioRole::Alternative - || scenario.baseScenarioId.isEmpty()) { + if (!scenarioRoleHasBaselineDiff(scenario.draft.role) || scenario.baseScenarioId.isEmpty()) { scenario.draft.variationDiffKeys.clear(); return; } @@ -2009,8 +2025,8 @@ QWidget* ScenarioAuthoringWidget::createScenarioPanel() { if (!scenario.stagedForRun || !scenarioHasOccupants(scenario)) { continue; } - const auto role = scenario.draft.role == safecrowd::domain::ScenarioRole::Baseline ? "Baseline" : "Alternative"; - lines << QString("- %1 (%2)").arg(QString::fromStdString(scenario.draft.name), role); + lines << QString("- %1 (%2)") + .arg(QString::fromStdString(scenario.draft.name), scenarioRoleLabel(scenario.draft.role)); } } stagedScenariosLabel_->setText(lines.join('\n')); diff --git a/src/application/ScenarioBatchResultWidget.cpp b/src/application/ScenarioBatchResultWidget.cpp index 5a9f062..4655a49 100644 --- a/src/application/ScenarioBatchResultWidget.cpp +++ b/src/application/ScenarioBatchResultWidget.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -35,6 +37,7 @@ #include "application/SimulationCanvasWidget.h" #include "application/UiStyle.h" #include "application/WorkspaceShell.h" +#include "domain/AlternativeRecommendationService.h" #include "domain/ScenarioAuthoring.h" namespace safecrowd::application { @@ -115,12 +118,26 @@ QString scenarioRoleLabel(safecrowd::domain::ScenarioRole role) { switch (role) { case safecrowd::domain::ScenarioRole::Baseline: return "Baseline"; + case safecrowd::domain::ScenarioRole::Recommended: + return "Recommended"; case safecrowd::domain::ScenarioRole::Alternative: default: return "Alternative"; } } +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()) { @@ -600,6 +617,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)); +} + ScenarioResultNavigationView resultNavigationViewFromSaved(SavedResultNavigationView view) { switch (view) { case SavedResultNavigationView::Hotspot: @@ -905,6 +978,14 @@ QWidget* ScenarioBatchResultWidget::createSummaryPanel() { detailLabel_->setStyleSheet(ui::mutedTextStyleSheet()); 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); @@ -929,10 +1010,6 @@ QWidget* ScenarioBatchResultWidget::createSummaryPanel() { } void ScenarioBatchResultWidget::navigateToAuthoring() { - auto* rootLayout = qobject_cast(layout()); - if (rootLayout == nullptr || shell_ == nullptr) { - return; - } pauseReplay(); auto initial = returnAuthoringState_.value_or(ScenarioAuthoringWidget::InitialState{}); @@ -953,11 +1030,19 @@ void ScenarioBatchResultWidget::navigateToAuthoring() { } } initial.rightPanelMode = ScenarioAuthoringWidget::RightPanelMode::Scenario; + showAuthoring(std::move(initial)); +} + +void ScenarioBatchResultWidget::showAuthoring(ScenarioAuthoringWidget::InitialState initialState) { + auto* rootLayout = qobject_cast(layout()); + if (rootLayout == nullptr || shell_ == nullptr) { + return; + } auto* authoringWidget = new ScenarioAuthoringWidget( projectName_, layout_, - std::move(initial), + std::move(initialState), saveProjectHandler_, openProjectHandler_, backToLayoutReviewHandler_, @@ -969,6 +1054,67 @@ void ScenarioBatchResultWidget::navigateToAuthoring() { canvas_ = nullptr; } +void ScenarioBatchResultWidget::createRecommendedScenario( + safecrowd::domain::ScenarioDraft recommendedScenario) { + pauseReplay(); + + auto initial = returnAuthoringState_.value_or(ScenarioAuthoringWidget::InitialState{}); + if (initial.scenarios.empty()) { + for (const auto& result : results_) { + initial.scenarios.push_back(scenarioStateFromDraft(result.scenario, layout_)); + } + } + + if (const auto existingIndex = existingScenarioIndexBySourceTemplate(initial, recommendedScenario.sourceTemplateId); + existingIndex.has_value()) { + initial.currentScenarioIndex = *existingIndex; + initial.rightPanelMode = ScenarioAuthoringWidget::RightPanelMode::Scenario; + showAuthoring(std::move(initial)); + return; + } + + recommendedScenario.scenarioId = uniqueScenarioId(initial, recommendedScenario.scenarioId); + recommendedScenario.name = uniqueScenarioName(initial, recommendedScenario.name); + + QString baseScenarioId; + if (currentResultIndex_ >= 0 && currentResultIndex_ < static_cast(results_.size())) { + const auto& source = results_[static_cast(currentResultIndex_)].scenario; + const auto sourceIt = std::find_if(initial.scenarios.begin(), initial.scenarios.end(), [&](const auto& scenario) { + return scenario.draft.scenarioId == source.scenarioId; + }); + if (sourceIt != initial.scenarios.end() && !sourceIt->baseScenarioId.isEmpty()) { + baseScenarioId = sourceIt->baseScenarioId; + } else if (source.role == safecrowd::domain::ScenarioRole::Baseline) { + baseScenarioId = QString::fromStdString(source.scenarioId); + } + } + if (baseScenarioId.isEmpty()) { + const auto baselineIndex = explicitBaselineResultIndex(); + if (baselineIndex >= 0 && baselineIndex < static_cast(results_.size())) { + baseScenarioId = QString::fromStdString(results_[static_cast(baselineIndex)].scenario.scenarioId); + } + } + + if (!baseScenarioId.isEmpty()) { + const auto baseId = baseScenarioId.toStdString(); + const auto baselineIt = std::find_if(initial.scenarios.begin(), initial.scenarios.end(), [&](const auto& scenario) { + return scenario.draft.scenarioId == baseId; + }); + if (baselineIt != initial.scenarios.end()) { + recommendedScenario.variationDiffKeys = + safecrowd::domain::computeScenarioDiffKeys(baselineIt->draft, recommendedScenario); + } + } + + auto state = scenarioStateFromDraft(recommendedScenario, layout_); + state.baseScenarioId = baseScenarioId; + state.stagedForRun = false; + initial.scenarios.push_back(std::move(state)); + initial.currentScenarioIndex = static_cast(initial.scenarios.size()) - 1; + initial.rightPanelMode = ScenarioAuthoringWidget::RightPanelMode::Scenario; + showAuthoring(std::move(initial)); +} + void ScenarioBatchResultWidget::pauseReplay() { if (replayTimer_ != nullptr) { replayTimer_->stop(); @@ -1231,6 +1377,100 @@ void ScenarioBatchResultWidget::refreshPressureComparisonTable() { pressureTable_->resizeRowsToContents(); } +void ScenarioBatchResultWidget::refreshRecommendationPanel() { + if (recommendationPanel_ == nullptr) { + return; + } + auto* panelLayout = qobject_cast(recommendationPanel_->layout()); + clearLayout(panelLayout); + if (panelLayout == nullptr) { + return; + } + + 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; + } + + 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; + } + + 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); + empty->setStyleSheet(ui::mutedTextStyleSheet()); + panelLayout->addWidget(empty); + return; + } + + 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); + + auto* title = createLabel(QString::fromStdString(candidate.title), section, 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, + 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); + } + + 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); + }); + + panelLayout->addWidget(section); + } +} + void ScenarioBatchResultWidget::refreshResultNavigationPanel() { if (shell_ == nullptr || results_.empty() || currentResultIndex_ < 0 || currentResultIndex_ >= static_cast(results_.size())) { return; @@ -1312,6 +1552,7 @@ void ScenarioBatchResultWidget::refreshSelectedResult() { static_cast(exitsChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_); } refreshPressureComparisonTable(); + refreshRecommendationPanel(); refreshResultNavigationPanel(); if (detailLabel_ != nullptr) { const auto selectedFinalSeconds = finalSeconds(result); @@ -1341,12 +1582,20 @@ void ScenarioBatchResultWidget::refreshSelectedResult() { } } -int ScenarioBatchResultWidget::baselineResultIndex() const noexcept { +int ScenarioBatchResultWidget::explicitBaselineResultIndex() const noexcept { for (int index = 0; index < static_cast(results_.size()); ++index) { if (results_[static_cast(index)].scenario.role == safecrowd::domain::ScenarioRole::Baseline) { return index; } } + return -1; +} + +int ScenarioBatchResultWidget::baselineResultIndex() const noexcept { + const auto explicitIndex = explicitBaselineResultIndex(); + if (explicitIndex >= 0) { + return explicitIndex; + } return results_.empty() ? -1 : 0; } diff --git a/src/application/ScenarioBatchResultWidget.h b/src/application/ScenarioBatchResultWidget.h index b46ada1..f76d7e5 100644 --- a/src/application/ScenarioBatchResultWidget.h +++ b/src/application/ScenarioBatchResultWidget.h @@ -55,6 +55,7 @@ class ScenarioBatchResultWidget : public QWidget { QWidget* createCanvasPanel(); QWidget* createSummaryPanel(); + void createRecommendedScenario(safecrowd::domain::ScenarioDraft recommendedScenario); void advanceReplay(); void applyReplayFrame(int frameIndex); void applyReplayFrameData(const safecrowd::domain::SimulationFrame& frame, int sliderIndex); @@ -66,13 +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 setOverlayMode(OverlayMode mode); + void showAuthoring(ScenarioAuthoringWidget::InitialState initialState); void showClosestReplayFrameAtSeconds(double seconds); void showReplayFrame(const safecrowd::domain::SimulationFrame& frame); + int explicitBaselineResultIndex() const noexcept; int baselineResultIndex() const noexcept; QString projectName_{}; @@ -94,6 +98,7 @@ 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/domain/AlternativeRecommendationService.cpp b/src/domain/AlternativeRecommendationService.cpp new file mode 100644 index 0000000..ace51f8 --- /dev/null +++ b/src/domain/AlternativeRecommendationService.cpp @@ -0,0 +1,579 @@ +#include "domain/AlternativeRecommendationService.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace safecrowd::domain { +namespace { + +constexpr double kExitImbalanceThreshold = 0.25; +constexpr double kDefaultGuidanceCompliance = 0.5; +constexpr double kDefaultGuidanceStrength = 0.55; +constexpr double kDefaultGuidanceMaxDetourMeters = 20.0; + +std::string sanitizeId(std::string value) { + for (auto& ch : value) { + const auto c = static_cast(ch); + if (!std::isalnum(c)) { + ch = '-'; + } + } + value.erase(std::unique(value.begin(), value.end(), [](char lhs, char rhs) { + return lhs == '-' && rhs == '-'; + }), value.end()); + while (!value.empty() && value.front() == '-') { + value.erase(value.begin()); + } + while (!value.empty() && value.back() == '-') { + value.pop_back(); + } + return value.empty() ? "item" : value; +} + +std::string fixed(double value, int precision = 1) { + std::ostringstream stream; + stream << std::fixed << std::setprecision(precision) << value; + return stream.str(); +} + +std::string percent(double ratio) { + return fixed(std::clamp(ratio, 0.0, 1.0) * 100.0, 0) + "%"; +} + +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; + }); + return it == layout.zones.end() ? nullptr : &(*it); +} + +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; + }); + return it == layout.connections.end() ? nullptr : &(*it); +} + +std::string zoneName(const FacilityLayout2D& layout, const std::string& zoneId) { + const auto* zone = findZone(layout, zoneId); + if (zone == nullptr) { + return zoneId.empty() ? "unknown zone" : zoneId; + } + return zone->label.empty() ? zone->id : zone->label + " (" + zone->id + ")"; +} + +std::string connectionName(const FacilityLayout2D& layout, const std::string& connectionId) { + const auto* connection = findConnection(layout, connectionId); + if (connection == nullptr) { + return connectionId.empty() ? "unknown connection" : connectionId; + } + return connection->id + " (" + zoneName(layout, connection->fromZoneId) + " -> " + + zoneName(layout, connection->toZoneId) + ")"; +} + +std::string zoneNameList(const FacilityLayout2D& layout, const std::vector& zoneIds) { + std::string value; + for (const auto& zoneId : zoneIds) { + if (!value.empty()) { + value += ", "; + } + value += zoneName(layout, zoneId); + } + return value.empty() ? "none" : value; +} + +bool hasCompletedResultArtifactEvidence(const AlternativeRecommendationRequest& request) { + const auto& artifacts = request.artifacts; + return artifacts.timingSummary.finalEvacuationTimeSeconds.has_value() + || !artifacts.evacuationProgress.empty() + || !artifacts.exitUsage.empty() + || !artifacts.zoneCompletion.empty() + || !artifacts.placementCompletion.empty() + || artifacts.densitySummary.peakDensityPeoplePerSquareMeter > 0.0 + || artifacts.pressureSummary.peakPressureScore > 0.0 + || !artifacts.pressureSummary.peakHotspots.empty() + || !artifacts.pressureSummary.criticalEvents.empty() + || !artifacts.hazardExposureSummary.hazards.empty(); +} + +bool containsString(const std::vector& values, const std::string& value) { + return std::find(values.begin(), values.end(), value) != values.end(); +} + +bool exitUsageContainsZone(const ScenarioResultArtifacts& artifacts, const std::string& zoneId) { + return std::any_of(artifacts.exitUsage.begin(), artifacts.exitUsage.end(), [&](const auto& usage) { + return usage.exitZoneId == zoneId; + }); +} + +std::vector exitUsageCandidates(const AlternativeRecommendationRequest& request) { + if (request.artifacts.exitUsage.empty()) { + return {}; + } + + auto candidates = request.artifacts.exitUsage; + for (const auto& zone : request.layout.zones) { + if (zone.kind != ZoneKind::Exit || exitUsageContainsZone(request.artifacts, zone.id)) { + continue; + } + candidates.push_back({ + .exitZoneId = zone.id, + .exitLabel = zone.label.empty() ? zone.id : zone.label, + .floorId = zone.floorId, + .evacuatedCount = 0, + .usageRatio = 0.0, + }); + } + return candidates; +} + +std::optional leastUsedExit( + const AlternativeRecommendationRequest& request, + const std::vector& excludedExitZoneIds = {}) { + const auto candidates = exitUsageCandidates(request); + if (candidates.empty()) { + return std::nullopt; + } + + std::optional best; + for (const auto& usage : candidates) { + if (containsString(excludedExitZoneIds, usage.exitZoneId)) { + continue; + } + if (!best.has_value() + || usage.usageRatio < best->usageRatio + || (usage.usageRatio == best->usageRatio && usage.evacuatedCount < best->evacuatedCount)) { + best = usage; + } + } + return best; +} + +std::optional mostUsedExit(const AlternativeRecommendationRequest& request) { + const auto candidates = exitUsageCandidates(request); + const auto it = std::max_element( + candidates.begin(), + candidates.end(), + [](const auto& lhs, const auto& rhs) { + if (lhs.usageRatio == rhs.usageRatio) { + return lhs.evacuatedCount < rhs.evacuatedCount; + } + return lhs.usageRatio < rhs.usageRatio; + }); + return it == candidates.end() ? std::nullopt : std::optional{*it}; +} + +bool hasRouteGuidance(const ScenarioDraft& scenario, + const std::string& guidedExitZoneId, + const std::string& installConnectionId) { + return std::any_of(scenario.control.routeGuidances.begin(), scenario.control.routeGuidances.end(), [&](const auto& guidance) { + return guidance.guidedExitZoneId == guidedExitZoneId + && guidance.installConnectionId == installConnectionId; + }); +} + +std::vector adjacentExitZoneIdsForConnection( + const AlternativeRecommendationRequest& request, + const std::string& connectionId) { + const auto* connection = findConnection(request.layout, connectionId); + if (connection == nullptr) { + return {}; + } + + std::vector exitZoneIds; + const auto addIfExit = [&](const std::string& zoneId) { + if (zoneId.empty() || containsString(exitZoneIds, zoneId)) { + return; + } + const auto* zone = findZone(request.layout, zoneId); + if ((zone != nullptr && zone->kind == ZoneKind::Exit) + || exitUsageContainsZone(request.artifacts, zoneId)) { + exitZoneIds.push_back(zoneId); + } + }; + addIfExit(connection->fromZoneId); + addIfExit(connection->toZoneId); + return exitZoneIds; +} + +RouteGuidanceDraft makeGuidance(const std::string& id, + const std::string& guidedExitZoneId, + const std::string& installConnectionId = {}) { + RouteGuidanceDraft guidance; + guidance.id = id; + guidance.guidedExitZoneId = guidedExitZoneId; + guidance.installConnectionId = installConnectionId; + guidance.baseComplianceRate = kDefaultGuidanceCompliance; + guidance.guidanceStrength = kDefaultGuidanceStrength; + guidance.maxDetourMeters = kDefaultGuidanceMaxDetourMeters; + return guidance; +} + +std::string recommendedScenarioId(const ScenarioDraft& source, AlternativeRecommendationKind kind) { + const auto sourceId = source.scenarioId.empty() ? "scenario" : source.scenarioId; + return sourceId + "-recommended-" + alternativeRecommendationKindId(kind); +} + +ScenarioDraft makeRecommendedDraft(const AlternativeRecommendationRequest& request, + AlternativeRecommendationKind kind, + const std::string& name) { + ScenarioDraft draft = request.sourceScenario; + draft.scenarioId = recommendedScenarioId(request.sourceScenario, kind); + draft.name = name; + draft.role = ScenarioRole::Recommended; + draft.sourceTemplateId = "recommendation:" + std::string(alternativeRecommendationKindId(kind)) + + ":" + (request.sourceScenario.scenarioId.empty() ? "source" : request.sourceScenario.scenarioId); + draft.blockingIssues.clear(); + return draft; +} + +void finalizeDiffKeys(const AlternativeRecommendationRequest& request, ScenarioDraft& draft) { + if (request.baselineScenario.has_value()) { + draft.variationDiffKeys = computeScenarioDiffKeys(*request.baselineScenario, draft); + } else { + draft.variationDiffKeys = computeScenarioDiffKeys(request.sourceScenario, draft); + } +} + +bool sourceHasConnectionBlock(const ScenarioDraft& scenario, const std::string& connectionId) { + return std::any_of(scenario.control.connectionBlocks.begin(), scenario.control.connectionBlocks.end(), [&](const auto& block) { + return block.connectionId == connectionId; + }); +} + +bool bottleneckLessSevere(const ScenarioBottleneckMetric& lhs, const ScenarioBottleneckMetric& rhs) { + if (lhs.stalledAgentCount == rhs.stalledAgentCount) { + return lhs.nearbyAgentCount < rhs.nearbyAgentCount; + } + return lhs.stalledAgentCount < rhs.stalledAgentCount; +} + +std::optional blockedConnectionToRelieve(const AlternativeRecommendationRequest& request) { + if (request.sourceScenario.control.connectionBlocks.empty()) { + return std::nullopt; + } + + std::optional best; + for (const auto& bottleneck : request.risk.bottlenecks) { + if (!bottleneck.connectionId.empty() + && sourceHasConnectionBlock(request.sourceScenario, bottleneck.connectionId)) { + if (!best.has_value() || bottleneckLessSevere(*best, bottleneck)) { + best = bottleneck; + } + } + } + return best.has_value() ? std::optional{best->connectionId} : std::nullopt; +} + +std::optional worstBottleneck(const ScenarioRiskSnapshot& risk) { + if (risk.bottlenecks.empty()) { + return std::nullopt; + } + const auto it = std::max_element(risk.bottlenecks.begin(), risk.bottlenecks.end(), bottleneckLessSevere); + 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), + }; +} + +std::optional makeBlockedConnectionCandidate( + const AlternativeRecommendationRequest& request) { + const auto connectionId = blockedConnectionToRelieve(request); + if (!connectionId.has_value()) { + return std::nullopt; + } + + auto draft = makeRecommendedDraft( + request, + AlternativeRecommendationKind::BlockedConnectionRelief, + "Recommended: reopen " + connectionName(request.layout, *connectionId)); + draft.control.connectionBlocks.erase( + std::remove_if(draft.control.connectionBlocks.begin(), draft.control.connectionBlocks.end(), [&](const auto& block) { + return block.connectionId == *connectionId; + }), + draft.control.connectionBlocks.end()); + finalizeDiffKeys(request, draft); + + AlternativeRecommendationCandidate candidate; + candidate.kind = AlternativeRecommendationKind::BlockedConnectionRelief; + 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.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 bottleneck = worstBottleneck(request.risk); + bottleneck.has_value() && bottleneck->connectionId == *connectionId) { + candidate.evidence.push_back(evidence( + "Bottleneck signal", + std::to_string(bottleneck->stalledAgentCount) + " stalled / " + + std::to_string(bottleneck->nearbyAgentCount) + " nearby", + "ScenarioRiskSnapshot.bottlenecks")); + } + candidate.recommendedScenario = std::move(draft); + return candidate; +} + +std::optional makeBottleneckGuidanceCandidate( + const AlternativeRecommendationRequest& request) { + const auto bottleneck = worstBottleneck(request.risk); + if (!bottleneck.has_value() || bottleneck->connectionId.empty()) { + return std::nullopt; + } + const auto adjacentExitZoneIds = adjacentExitZoneIdsForConnection(request, bottleneck->connectionId); + const auto targetExit = leastUsedExit( + request, + adjacentExitZoneIds); + if (!targetExit.has_value() || targetExit->exitZoneId.empty()) { + return std::nullopt; + } + if (hasRouteGuidance(request.sourceScenario, targetExit->exitZoneId, bottleneck->connectionId)) { + return std::nullopt; + } + + auto draft = makeRecommendedDraft( + request, + AlternativeRecommendationKind::BottleneckBypassGuidance, + "Recommended: guide around " + connectionName(request.layout, bottleneck->connectionId)); + draft.control.routeGuidances.push_back(makeGuidance( + "recommendation-guidance-" + sanitizeId(bottleneck->connectionId) + "-" + sanitizeId(targetExit->exitZoneId), + targetExit->exitZoneId, + bottleneck->connectionId)); + 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.artifactSource = "ScenarioRiskSnapshot.bottlenecks + FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage"; + candidate.evidence.push_back(evidence( + "Bottleneck signal", + std::to_string(bottleneck->stalledAgentCount) + " stalled / " + + std::to_string(bottleneck->nearbyAgentCount) + " nearby", + "ScenarioRiskSnapshot.bottlenecks")); + if (!adjacentExitZoneIds.empty()) { + candidate.evidence.push_back(evidence( + "Excluded adjacent exits", + zoneNameList(request.layout, adjacentExitZoneIds), + "FacilityLayout2D connection endpoints + ScenarioResultArtifacts.exitUsage")); + } + candidate.evidence.push_back(evidence( + "Guided exit", + zoneName(request.layout, targetExit->exitZoneId), + adjacentExitZoneIds.empty() + ? "least-used exit from FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage" + : "least-used non-adjacent exit from FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage")); + candidate.recommendedScenario = std::move(draft); + return candidate; +} + +std::optional makeExitBalancingCandidate( + const AlternativeRecommendationRequest& request) { + if (exitUsageCandidates(request).size() < 2) { + return std::nullopt; + } + const auto low = leastUsedExit(request); + const auto high = mostUsedExit(request); + if (!low.has_value() || !high.has_value() || low->exitZoneId.empty() || high->exitZoneId.empty()) { + return std::nullopt; + } + if (low->exitZoneId == high->exitZoneId + || high->usageRatio - low->usageRatio < kExitImbalanceThreshold + || hasRouteGuidance(request.sourceScenario, low->exitZoneId, {})) { + return std::nullopt; + } + + auto draft = makeRecommendedDraft( + request, + AlternativeRecommendationKind::ExitUsageBalancing, + "Recommended: balance exit usage toward " + zoneName(request.layout, low->exitZoneId)); + draft.control.routeGuidances.push_back(makeGuidance( + "recommendation-guidance-exit-" + sanitizeId(low->exitZoneId), + low->exitZoneId)); + finalizeDiffKeys(request, draft); + + AlternativeRecommendationCandidate candidate; + candidate.kind = AlternativeRecommendationKind::ExitUsageBalancing; + 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 " + + zoneName(request.layout, low->exitZoneId) + "."; + candidate.expectedImprovement = "Reduces dependence on " + zoneName(request.layout, high->exitZoneId) + + " and may lower final evacuation time."; + candidate.artifactSource = "FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage"; + candidate.evidence.push_back(evidence( + "Most used exit", + zoneName(request.layout, high->exitZoneId) + " at " + percent(high->usageRatio), + "ScenarioResultArtifacts.exitUsage")); + candidate.evidence.push_back(evidence( + "Underused exit", + zoneName(request.layout, low->exitZoneId) + " at " + percent(low->usageRatio), + "FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage")); + candidate.recommendedScenario = std::move(draft); + return candidate; +} + +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) { + return std::nullopt; + } + + const auto targetExit = leastUsedExit(request); + if (!targetExit.has_value() || targetExit->exitZoneId.empty() + || hasRouteGuidance(request.sourceScenario, targetExit->exitZoneId, {})) { + return std::nullopt; + } + if (const auto high = mostUsedExit(request); + high.has_value() + && high->exitZoneId != targetExit->exitZoneId + && high->usageRatio - targetExit->usageRatio >= kExitImbalanceThreshold) { + return std::nullopt; + } + + auto draft = makeRecommendedDraft( + request, + AlternativeRecommendationKind::PressureHotspotRelief, + "Recommended: relieve pressure toward " + zoneName(request.layout, targetExit->exitZoneId)); + draft.control.routeGuidances.push_back(makeGuidance( + "recommendation-guidance-pressure-" + sanitizeId(targetExit->exitZoneId), + targetExit->exitZoneId)); + finalizeDiffKeys(request, draft); + + AlternativeRecommendationCandidate candidate; + candidate.kind = AlternativeRecommendationKind::PressureHotspotRelief; + 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 " + + 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"; + if (request.artifacts.pressureSummary.peakPressureScore > 0.0) { + candidate.evidence.push_back(evidence( + "Peak pressure", + fixed(request.artifacts.pressureSummary.peakPressureScore, 1), + "ScenarioResultArtifacts.pressureSummary")); + } + if (request.artifacts.pressureSummary.peakCell.has_value()) { + candidate.evidence.push_back(evidence( + "Peak cell floor", + request.artifacts.pressureSummary.peakCell->floorId, + "ScenarioResultArtifacts.pressureSummary.peakCell")); + } + if (!request.artifacts.pressureSummary.peakHotspots.empty()) { + candidate.evidence.push_back(evidence( + "Pressure hotspots", + std::to_string(request.artifacts.pressureSummary.peakHotspots.size()), + "ScenarioResultArtifacts.pressureSummary.peakHotspots")); + } + if (!request.artifacts.pressureSummary.criticalEvents.empty()) { + candidate.evidence.push_back(evidence( + "Critical pressure events", + std::to_string(request.artifacts.pressureSummary.criticalEvents.size()), + "ScenarioResultArtifacts.pressureSummary.criticalEvents")); + } + if (!request.risk.pressureHotspots.empty()) { + candidate.evidence.push_back(evidence( + "Risk pressure hotspots", + std::to_string(request.risk.pressureHotspots.size()), + "ScenarioRiskSnapshot.pressureHotspots")); + } + if (!request.risk.criticalPressureEvents.empty()) { + candidate.evidence.push_back(evidence( + "Risk critical pressure events", + std::to_string(request.risk.criticalPressureEvents.size()), + "ScenarioRiskSnapshot.criticalPressureEvents")); + } + if (request.risk.criticalPressureAgentCount > 0) { + candidate.evidence.push_back(evidence( + "Critical pressure agents", + std::to_string(request.risk.criticalPressureAgentCount), + "ScenarioRiskSnapshot.criticalPressureAgentCount")); + } + candidate.evidence.push_back(evidence( + "Guided exit", + zoneName(request.layout, targetExit->exitZoneId), + "least-used exit from FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage")); + candidate.recommendedScenario = std::move(draft); + return candidate; +} + +} // namespace + +const char* alternativeRecommendationKindId(AlternativeRecommendationKind kind) noexcept { + switch (kind) { + case AlternativeRecommendationKind::BlockedConnectionRelief: + return "blocked-connection-relief"; + case AlternativeRecommendationKind::BottleneckBypassGuidance: + return "bottleneck-bypass-guidance"; + case AlternativeRecommendationKind::ExitUsageBalancing: + return "exit-usage-balancing"; + case AlternativeRecommendationKind::PressureHotspotRelief: + return "pressure-hotspot-relief"; + } + return "recommendation"; +} + +AlternativeRecommendationResult AlternativeRecommendationService::recommend( + const AlternativeRecommendationRequest& request) const { + AlternativeRecommendationResult result; + if (!hasCompletedResultArtifactEvidence(request)) { + result.blockingReasons.push_back( + "Completed scenario result artifacts are required before SafeCrowd can recommend an operational draft."); + return result; + } + + if (const auto candidate = makeBlockedConnectionCandidate(request); candidate.has_value()) { + result.candidates.push_back(*candidate); + } + if (const auto candidate = makeBottleneckGuidanceCandidate(request); candidate.has_value()) { + result.candidates.push_back(*candidate); + } + if (const auto candidate = makeExitBalancingCandidate(request); candidate.has_value()) { + result.candidates.push_back(*candidate); + } + if (const auto candidate = makePressureHotspotCandidate(request); 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; + }); + + if (result.candidates.empty()) { + result.blockingReasons.push_back( + "No blocked connection, bottleneck, exit imbalance, or pressure hotspot produced an actionable v1 recommendation."); + } + + return result; +} + +} // namespace safecrowd::domain diff --git a/src/domain/AlternativeRecommendationService.h b/src/domain/AlternativeRecommendationService.h new file mode 100644 index 0000000..6d18b04 --- /dev/null +++ b/src/domain/AlternativeRecommendationService.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include + +#include "domain/FacilityLayout2D.h" +#include "domain/ScenarioAuthoring.h" +#include "domain/ScenarioResultArtifacts.h" +#include "domain/ScenarioRiskMetrics.h" + +namespace safecrowd::domain { + +enum class AlternativeRecommendationKind { + BlockedConnectionRelief, + BottleneckBypassGuidance, + ExitUsageBalancing, + PressureHotspotRelief, +}; + +struct AlternativeRecommendationEvidence { + std::string label{}; + std::string value{}; + std::string source{}; +}; + +struct AlternativeRecommendationCandidate { + std::string id{}; + AlternativeRecommendationKind kind{AlternativeRecommendationKind::BlockedConnectionRelief}; + int priority{0}; + std::string title{}; + std::string summary{}; + std::string expectedImprovement{}; + std::string artifactSource{}; + std::vector evidence{}; + ScenarioDraft recommendedScenario{}; +}; + +struct AlternativeRecommendationRequest { + FacilityLayout2D layout{}; + ScenarioDraft sourceScenario{}; + std::optional baselineScenario{}; + ScenarioRiskSnapshot risk{}; + ScenarioResultArtifacts artifacts{}; +}; + +struct AlternativeRecommendationResult { + std::vector candidates{}; + std::vector blockingReasons{}; +}; + +class AlternativeRecommendationService { +public: + AlternativeRecommendationResult recommend(const AlternativeRecommendationRequest& request) const; +}; + +const char* alternativeRecommendationKindId(AlternativeRecommendationKind kind) noexcept; + +} // namespace safecrowd::domain diff --git a/src/domain/ScenarioAuthoring.cpp b/src/domain/ScenarioAuthoring.cpp index 4030268..1a0fa0d 100644 --- a/src/domain/ScenarioAuthoring.cpp +++ b/src/domain/ScenarioAuthoring.cpp @@ -173,6 +173,10 @@ bool routeGuidancesEqual(const std::vector& lhs, return true; } +bool isRecommendationSourceTemplateId(const std::string& sourceTemplateId) { + return sourceTemplateId.rfind("recommendation:", 0) == 0; +} + } // namespace ScenarioDraft duplicateScenarioDraft(const ScenarioDraft& source, @@ -182,6 +186,9 @@ ScenarioDraft duplicateScenarioDraft(const ScenarioDraft& source, copy.scenarioId = std::move(newScenarioId); copy.name = std::move(newName); copy.role = ScenarioRole::Alternative; + if (isRecommendationSourceTemplateId(copy.sourceTemplateId)) { + copy.sourceTemplateId.clear(); + } copy.variationDiffKeys.clear(); copy.blockingIssues.clear(); return copy; diff --git a/tests/AlternativeRecommendationServiceTests.cpp b/tests/AlternativeRecommendationServiceTests.cpp new file mode 100644 index 0000000..7bec94a --- /dev/null +++ b/tests/AlternativeRecommendationServiceTests.cpp @@ -0,0 +1,514 @@ +#include "TestSupport.h" +#include "domain/AlternativeRecommendationService.h" + +#include +#include + +using namespace safecrowd::domain; + +namespace { + +FacilityLayout2D makeRecommendationLayout() { + 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.zones.push_back({ + .id = "exit-east", + .floorId = "L1", + .kind = ZoneKind::Exit, + .label = "East Exit", + }); + layout.connections.push_back({ + .id = "door-main", + .floorId = "L1", + .kind = ConnectionKind::Exit, + .fromZoneId = "room-a", + .toZoneId = "exit-main", + }); + layout.connections.push_back({ + .id = "door-east", + .floorId = "L1", + .kind = ConnectionKind::Exit, + .fromZoneId = "room-a", + .toZoneId = "exit-east", + }); + return layout; +} + +ScenarioDraft makeScenario() { + ScenarioDraft scenario; + scenario.scenarioId = "scenario-1"; + scenario.name = "Scenario"; + scenario.role = ScenarioRole::Alternative; + InitialPlacement2D placement; + placement.id = "group-a"; + placement.zoneId = "room-a"; + placement.targetAgentCount = 20; + scenario.population.initialPlacements.push_back(placement); + scenario.execution.timeLimitSeconds = 120.0; + scenario.execution.sampleIntervalSeconds = 1.0; + return scenario; +} + +ScenarioResultArtifacts makeCompletedArtifacts() { + ScenarioResultArtifacts artifacts; + artifacts.timingSummary.finalEvacuationTimeSeconds = 72.0; + return artifacts; +} + +ScenarioResultArtifacts makeExitUsageArtifacts(double mainRatio = 0.85, double eastRatio = 0.15) { + ScenarioResultArtifacts artifacts = makeCompletedArtifacts(); + artifacts.exitUsage.push_back({ + .exitZoneId = "exit-main", + .exitLabel = "Main Exit", + .evacuatedCount = static_cast(mainRatio * 20.0), + .usageRatio = mainRatio, + }); + artifacts.exitUsage.push_back({ + .exitZoneId = "exit-east", + .exitLabel = "East Exit", + .evacuatedCount = static_cast(eastRatio * 20.0), + .usageRatio = eastRatio, + }); + return artifacts; +} + +ScenarioResultArtifacts makeSingleExitUsageArtifacts( + std::string exitZoneId, + std::string exitLabel, + std::size_t evacuatedCount, + double usageRatio) { + ScenarioResultArtifacts artifacts = makeCompletedArtifacts(); + artifacts.exitUsage.push_back({ + .exitZoneId = std::move(exitZoneId), + .exitLabel = std::move(exitLabel), + .evacuatedCount = evacuatedCount, + .usageRatio = usageRatio, + }); + return artifacts; +} + +bool hasCandidateKind( + const AlternativeRecommendationResult& result, + AlternativeRecommendationKind kind) { + return std::any_of(result.candidates.begin(), result.candidates.end(), [&](const auto& candidate) { + return candidate.kind == kind; + }); +} + +bool containsEvidenceLabel( + const AlternativeRecommendationCandidate& candidate, + const std::string& label) { + return std::any_of(candidate.evidence.begin(), candidate.evidence.end(), [&](const auto& evidence) { + return evidence.label == label; + }); +} + +bool containsEvidenceSource( + const AlternativeRecommendationCandidate& candidate, + const std::string& source) { + return std::any_of(candidate.evidence.begin(), candidate.evidence.end(), [&](const auto& evidence) { + return evidence.source == source; + }); +} + +bool containsDiffKey(const ScenarioDraft& scenario, const std::string& key) { + return std::find(scenario.variationDiffKeys.begin(), scenario.variationDiffKeys.end(), key) + != scenario.variationDiffKeys.end(); +} + +} // namespace + +SC_TEST(AlternativeRecommendationService_requiresCompletedResultEvidence) { + auto scenario = makeScenario(); + ConnectionBlockDraft block; + block.id = "block-main"; + block.connectionId = "door-main"; + scenario.control.connectionBlocks.push_back(block); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = scenario, + }); + + SC_EXPECT_TRUE(result.candidates.empty()); + SC_EXPECT_TRUE(!result.blockingReasons.empty()); +} + +SC_TEST(AlternativeRecommendationService_rejectsRiskSnapshotWithoutCompletedArtifacts) { + auto scenario = makeScenario(); + scenario.control.connectionBlocks.push_back({ + .id = "block-main", + .connectionId = "door-main", + }); + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "door-main", + .nearbyAgentCount = 8, + .stalledAgentCount = 5, + }); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = scenario, + .risk = risk, + }); + + SC_EXPECT_TRUE(result.candidates.empty()); + SC_EXPECT_TRUE(!result.blockingReasons.empty()); +} + +SC_TEST(AlternativeRecommendationService_removesBlockedConnectionInRecommendedDraft) { + auto scenario = makeScenario(); + ConnectionBlockDraft block; + block.id = "block-main"; + block.connectionId = "door-main"; + scenario.control.connectionBlocks.push_back(block); + + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "door-main", + .label = "Main Door", + .floorId = "L1", + .nearbyAgentCount = 8, + .stalledAgentCount = 5, + }); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = scenario, + .risk = risk, + .artifacts = makeCompletedArtifacts(), + }); + + SC_EXPECT_TRUE(!result.candidates.empty()); + const auto& candidate = result.candidates.front(); + SC_EXPECT_TRUE(candidate.kind == AlternativeRecommendationKind::BlockedConnectionRelief); + SC_EXPECT_TRUE(candidate.recommendedScenario.role == ScenarioRole::Recommended); + SC_EXPECT_TRUE(candidate.recommendedScenario.control.connectionBlocks.empty()); + SC_EXPECT_EQ( + candidate.recommendedScenario.sourceTemplateId, + std::string{"recommendation:blocked-connection-relief:scenario-1"}); + SC_EXPECT_TRUE(containsDiffKey(candidate.recommendedScenario, "control.connectionBlocks")); +} + +SC_TEST(AlternativeRecommendationService_skipsBlockedConnectionWithoutBottleneckEvidence) { + auto scenario = makeScenario(); + scenario.control.connectionBlocks.push_back({ + .id = "block-main", + .connectionId = "door-main", + }); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = scenario, + .artifacts = makeCompletedArtifacts(), + }); + + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::BlockedConnectionRelief)); +} + +SC_TEST(AlternativeRecommendationService_reopensWorstBlockedBottleneck) { + auto scenario = makeScenario(); + scenario.control.connectionBlocks.push_back({ + .id = "block-main", + .connectionId = "door-main", + }); + scenario.control.connectionBlocks.push_back({ + .id = "block-east", + .connectionId = "door-east", + }); + + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "door-east", + .nearbyAgentCount = 4, + .stalledAgentCount = 1, + }); + risk.bottlenecks.push_back({ + .connectionId = "door-main", + .nearbyAgentCount = 8, + .stalledAgentCount = 5, + }); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = scenario, + .risk = risk, + .artifacts = makeCompletedArtifacts(), + }); + + SC_EXPECT_TRUE(!result.candidates.empty()); + const auto& candidate = result.candidates.front(); + SC_EXPECT_TRUE(candidate.kind == AlternativeRecommendationKind::BlockedConnectionRelief); + SC_EXPECT_EQ(candidate.recommendedScenario.control.connectionBlocks.size(), std::size_t{1}); + SC_EXPECT_EQ(candidate.recommendedScenario.control.connectionBlocks.front().connectionId, std::string{"door-east"}); +} + +SC_TEST(AlternativeRecommendationService_addsRouteGuidanceForExitImbalance) { + auto scenario = makeScenario(); + const auto artifacts = makeExitUsageArtifacts(); + + 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::ExitUsageBalancing; + }); + 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_EXPECT_NEAR(it->recommendedScenario.control.routeGuidances.front().baseComplianceRate, 0.5, 1e-9); + SC_EXPECT_NEAR(it->recommendedScenario.control.routeGuidances.front().guidanceStrength, 0.55, 1e-9); + SC_EXPECT_NEAR(it->recommendedScenario.control.routeGuidances.front().maxDetourMeters, 20.0, 1e-9); + SC_EXPECT_TRUE(containsDiffKey(it->recommendedScenario, "control.routeGuidances")); +} + +SC_TEST(AlternativeRecommendationService_balancesExitUsageTowardUnusedLayoutExit) { + const auto artifacts = makeSingleExitUsageArtifacts("exit-main", "Main Exit", 20, 1.0); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = makeScenario(), + .artifacts = artifacts, + }); + + const auto it = std::find_if(result.candidates.begin(), result.candidates.end(), [](const auto& candidate) { + return candidate.kind == AlternativeRecommendationKind::ExitUsageBalancing; + }); + 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(containsEvidenceSource(*it, "FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage")); +} + +SC_TEST(AlternativeRecommendationService_skipsExitBalancingBelowThreshold) { + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = makeScenario(), + .artifacts = makeExitUsageArtifacts(0.60, 0.40), + }); + + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::ExitUsageBalancing)); +} + +SC_TEST(AlternativeRecommendationService_addsBottleneckGuidanceAtBottleneckConnection) { + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "door-main", + .nearbyAgentCount = 8, + .stalledAgentCount = 5, + }); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .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_EQ(it->recommendedScenario.control.routeGuidances.front().installConnectionId, std::string{"door-main"}); + SC_EXPECT_TRUE(containsDiffKey(it->recommendedScenario, "control.routeGuidances")); +} + +SC_TEST(AlternativeRecommendationService_guidesBottleneckAwayFromAdjacentLeastUsedExit) { + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "door-east", + .nearbyAgentCount = 8, + .stalledAgentCount = 5, + }); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .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-main"}); + SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.front().installConnectionId, std::string{"door-east"}); + SC_EXPECT_TRUE(containsEvidenceLabel(*it, "Excluded adjacent exits")); + SC_EXPECT_TRUE(containsEvidenceSource(*it, "least-used non-adjacent exit from FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage")); +} + +SC_TEST(AlternativeRecommendationService_guidesBottleneckTowardUnusedNonAdjacentLayoutExit) { + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "door-east", + .nearbyAgentCount = 8, + .stalledAgentCount = 5, + }); + + const auto artifacts = makeSingleExitUsageArtifacts("exit-east", "East Exit", 20, 1.0); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = makeScenario(), + .risk = risk, + .artifacts = artifacts, + }); + + 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-main"}); + SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.front().installConnectionId, std::string{"door-east"}); + SC_EXPECT_TRUE(containsEvidenceSource(*it, "least-used non-adjacent exit from FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage")); +} + +SC_TEST(AlternativeRecommendationService_requiresExitUsageForBottleneckGuidance) { + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "door-main", + .nearbyAgentCount = 8, + .stalledAgentCount = 5, + }); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = makeScenario(), + .risk = risk, + .artifacts = makeCompletedArtifacts(), + }); + + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::BottleneckBypassGuidance)); +} + +SC_TEST(AlternativeRecommendationService_addsPressureHotspotReliefWithExitUsage) { + auto artifacts = makeExitUsageArtifacts(0.55, 0.45); + artifacts.pressureSummary.hotspotScoreThreshold = 4.0; + artifacts.pressureSummary.peakPressureScore = 5.5; + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = makeScenario(), + .artifacts = artifacts, + }); + + const auto it = std::find_if(result.candidates.begin(), result.candidates.end(), [](const auto& candidate) { + return candidate.kind == AlternativeRecommendationKind::PressureHotspotRelief; + }); + 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_EXPECT_TRUE(containsDiffKey(it->recommendedScenario, "control.routeGuidances")); +} + +SC_TEST(AlternativeRecommendationService_prefersExitBalancingOverDuplicatePressureRelief) { + auto artifacts = makeExitUsageArtifacts(); + artifacts.pressureSummary.hotspotScoreThreshold = 4.0; + artifacts.pressureSummary.peakPressureScore = 5.5; + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = makeScenario(), + .artifacts = artifacts, + }); + + SC_EXPECT_TRUE(hasCandidateKind(result, AlternativeRecommendationKind::ExitUsageBalancing)); + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::PressureHotspotRelief)); +} + +SC_TEST(AlternativeRecommendationService_requiresExitUsageForPressureHotspotRelief) { + auto artifacts = makeCompletedArtifacts(); + artifacts.pressureSummary.hotspotScoreThreshold = 4.0; + artifacts.pressureSummary.peakPressureScore = 5.5; + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = makeScenario(), + .artifacts = artifacts, + }); + + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::PressureHotspotRelief)); +} + +SC_TEST(AlternativeRecommendationService_usesRiskPressureEvidenceWhenArtifactPeakMissing) { + ScenarioRiskSnapshot risk; + risk.criticalPressureAgentCount = 4; + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = makeScenario(), + .risk = risk, + .artifacts = makeExitUsageArtifacts(0.55, 0.45), + }); + + const auto it = std::find_if(result.candidates.begin(), result.candidates.end(), [](const auto& candidate) { + return candidate.kind == AlternativeRecommendationKind::PressureHotspotRelief; + }); + SC_EXPECT_TRUE(it != result.candidates.end()); + SC_EXPECT_TRUE(containsEvidenceLabel(*it, "Critical pressure agents")); + SC_EXPECT_TRUE(!containsEvidenceLabel(*it, "Peak pressure")); +} + +SC_TEST(AlternativeRecommendationService_sortsBlockedReliefBeforeGuidance) { + auto scenario = makeScenario(); + scenario.control.connectionBlocks.push_back({ + .id = "block-main", + .connectionId = "door-main", + }); + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "door-main", + .nearbyAgentCount = 8, + .stalledAgentCount = 5, + }); + const auto artifacts = makeExitUsageArtifacts(); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = scenario, + .risk = risk, + .artifacts = artifacts, + }); + + SC_EXPECT_TRUE(result.candidates.size() >= 2); + SC_EXPECT_TRUE(result.candidates.front().kind == AlternativeRecommendationKind::BlockedConnectionRelief); +} diff --git a/tests/ProjectPersistenceTests.cpp b/tests/ProjectPersistenceTests.cpp new file mode 100644 index 0000000..d747ce3 --- /dev/null +++ b/tests/ProjectPersistenceTests.cpp @@ -0,0 +1,65 @@ +#include "TestSupport.h" +#include "application/ProjectPersistence.h" + +#include + +using namespace safecrowd::application; +using namespace safecrowd::domain; + +SC_TEST(ProjectPersistence_preservesRecommendedScenarioDraftState) { + QTemporaryDir projectDir; + SC_EXPECT_TRUE(projectDir.isValid()); + + ScenarioDraft draft; + draft.scenarioId = "recommended-1"; + draft.name = "Recommended: balance exits"; + draft.role = ScenarioRole::Recommended; + draft.sourceTemplateId = "recommendation:exit-usage-balancing:scenario-1"; + draft.variationDiffKeys = {"control.routeGuidances"}; + + RouteGuidanceDraft guidance; + guidance.id = "guidance-east"; + guidance.guidedExitZoneId = "exit-east"; + guidance.baseComplianceRate = 0.5; + guidance.guidanceStrength = 0.55; + guidance.maxDetourMeters = 20.0; + draft.control.routeGuidances.push_back(guidance); + + SavedScenarioState scenario; + scenario.draft = draft; + scenario.baseScenarioId = "baseline-1"; + scenario.stagedForRun = false; + + ProjectWorkspaceState workspace; + workspace.activeView = ProjectWorkspaceView::ScenarioAuthoring; + workspace.authoring = SavedScenarioAuthoringState{ + .scenarios = {scenario}, + .currentScenarioIndex = 0, + .rightPanelMode = SavedRightPanelMode::Scenario, + }; + + const ProjectMetadata metadata{ + .name = "Persistence Test", + .folderPath = projectDir.path(), + }; + + QString errorMessage; + SC_EXPECT_TRUE(ProjectPersistence::saveProjectWorkspace(metadata, workspace, &errorMessage)); + + ProjectWorkspaceState loaded; + SC_EXPECT_TRUE(ProjectPersistence::loadProjectWorkspace(metadata, &loaded)); + SC_EXPECT_TRUE(loaded.authoring.has_value()); + SC_EXPECT_EQ(loaded.authoring->scenarios.size(), std::size_t{1}); + + const auto& loadedScenario = loaded.authoring->scenarios.front(); + SC_EXPECT_TRUE(loadedScenario.draft.role == ScenarioRole::Recommended); + SC_EXPECT_EQ(loadedScenario.draft.sourceTemplateId, draft.sourceTemplateId); + SC_EXPECT_EQ(loadedScenario.draft.variationDiffKeys, draft.variationDiffKeys); + SC_EXPECT_EQ(loadedScenario.baseScenarioId, std::string{"baseline-1"}); + SC_EXPECT_TRUE(!loadedScenario.stagedForRun); + SC_EXPECT_EQ(loadedScenario.draft.control.routeGuidances.size(), std::size_t{1}); + SC_EXPECT_EQ(loadedScenario.draft.control.routeGuidances.front().guidedExitZoneId, std::string{"exit-east"}); + SC_EXPECT_NEAR(loadedScenario.draft.control.routeGuidances.front().baseComplianceRate, 0.5, 1e-9); + SC_EXPECT_NEAR(loadedScenario.draft.control.routeGuidances.front().guidanceStrength, 0.55, 1e-9); + SC_EXPECT_NEAR(loadedScenario.draft.control.routeGuidances.front().maxDetourMeters, 20.0, 1e-9); +} diff --git a/tests/ScenarioAuthoringTests.cpp b/tests/ScenarioAuthoringTests.cpp index 0e4abdf..5b650a7 100644 --- a/tests/ScenarioAuthoringTests.cpp +++ b/tests/ScenarioAuthoringTests.cpp @@ -53,13 +53,15 @@ EnvironmentHazardDraft makeSmokeHazard() { } // namespace SC_TEST(duplicateScenarioDraft_setsAlternativeRoleAndIdentity) { - const auto baseline = makeBaselineDraft(); + auto baseline = makeBaselineDraft(); + baseline.sourceTemplateId = "after-sprint-1-baseline"; const auto variant = duplicateScenarioDraft(baseline, "scenario-2", "My Alternative"); SC_EXPECT_TRUE(variant.role == ScenarioRole::Alternative); SC_EXPECT_EQ(variant.scenarioId, std::string("scenario-2")); SC_EXPECT_EQ(variant.name, std::string("My Alternative")); + SC_EXPECT_EQ(variant.sourceTemplateId, std::string("after-sprint-1-baseline")); SC_EXPECT_TRUE(variant.variationDiffKeys.empty()); SC_EXPECT_TRUE(variant.blockingIssues.empty()); SC_EXPECT_EQ(variant.population.initialPlacements.size(), baseline.population.initialPlacements.size()); @@ -67,6 +69,15 @@ SC_TEST(duplicateScenarioDraft_setsAlternativeRoleAndIdentity) { SC_EXPECT_EQ(variant.execution.baseSeed, baseline.execution.baseSeed); } +SC_TEST(duplicateScenarioDraft_clearsRecommendationProvenance) { + auto baseline = makeBaselineDraft(); + baseline.sourceTemplateId = "recommendation:exit-usage-balancing:scenario-1"; + + const auto variant = duplicateScenarioDraft(baseline, "scenario-2", "My Alternative"); + + SC_EXPECT_TRUE(variant.sourceTemplateId.empty()); +} + SC_TEST(duplicateScenarioDraft_doesNotMutateSource) { auto baseline = makeBaselineDraft(); baseline.environment.hazards.push_back(makeSmokeHazard());