From 4b749bbbcbf0ecab8d30ce5a308ad7bca4f04451 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Fri, 15 May 2026 01:56:07 +0900 Subject: [PATCH 01/11] [Domain] Add two-floor evacuation demo map --- ...4 \352\260\200\354\235\264\353\223\234.md" | 11 + src/application/MainWindow.cpp | 57 ++- src/application/ProjectMetadata.h | 19 +- src/application/ProjectPersistence.cpp | 1 + src/domain/DemoFixtureService.cpp | 86 ++++ src/domain/DemoFixtureService.h | 8 + src/domain/DemoLayouts.cpp | 382 +++++++++++++++++- src/domain/DemoLayouts.h | 34 +- tests/DemoFixtureServiceTests.cpp | 105 +++++ 9 files changed, 692 insertions(+), 11 deletions(-) diff --git "a/docs/demo/\354\213\234\354\227\260 \355\217\211\353\251\264 \352\260\200\354\235\264\353\223\234.md" "b/docs/demo/\354\213\234\354\227\260 \355\217\211\353\251\264 \352\260\200\354\235\264\353\223\234.md" index d1e71da..9becdbd 100644 --- "a/docs/demo/\354\213\234\354\227\260 \355\217\211\353\251\264 \352\260\200\354\235\264\353\223\234.md" +++ "b/docs/demo/\354\213\234\354\227\260 \355\217\211\353\251\264 \352\260\200\354\235\264\353\223\234.md" @@ -2,6 +2,7 @@ Sprint 1 시연에서 사용할 도면 자산과 각 도면이 강조하는 결과 지표를 정리합니다. 도면 파일은 [`assets/demo-layouts/`](../../assets/demo-layouts/) 에 있습니다. +앱 시작 화면의 built-in 프로젝트에는 코드 fixture 기반의 `Two-floor Evacuation Demo`도 포함됩니다. ## 시연 흐름 요약 @@ -47,6 +48,16 @@ Sprint 1 시연에서 사용할 도면 자산과 각 도면이 강조하는 결 - `DensitySummary.peakField` 의 피크 셀이 복도 중앙 또는 로비 출구 직전에 형성 - **보여줄 화면**: 존별 완료 시간 표, 출구 사용 막대그래프, 복도/로비 영역의 밀도 히트맵, 리플레이로 군중이 복도를 통해 모이는 흐름 +### 4. 앱 내장 `Two-floor Evacuation Demo` — 2층 대피 시나리오 시연 + +- **구조**: 2층의 서측 교육실, 중앙 브리핑룸, 동측 교육실에서 복도로 나온 뒤 양쪽 U자형 계단을 통해 1층 로비와 전실을 거쳐 서측/동측 출구로 대피하는 내장 layout. +- **메시지**: "다층 평면에서 층 선택, 계단 전환, 출구 유도 시나리오가 앱에서 바로 실행되는가." +- **강조 지표**: + - 2층 보행자가 계단 연결을 지나 1층 출구로 이동 + - baseline과 우측 출구 유도 alternative를 같은 run 화면에서 실행 가능 + - 결과 화면의 floor selector로 1층/2층 상태를 나누어 확인 가능 +- **보여줄 화면**: Project Navigator에서 `Two-floor Evacuation Demo` 열기, Run Workspace 실행, Result Summary의 층별 canvas와 출구 사용 지표 + ## 시연 셋업 권장값 (참고) | 항목 | 권장 | diff --git a/src/application/MainWindow.cpp b/src/application/MainWindow.cpp index acdc026..94f5381 100644 --- a/src/application/MainWindow.cpp +++ b/src/application/MainWindow.cpp @@ -35,12 +35,9 @@ void applySavedReviewState(const ProjectMetadata& metadata, safecrowd::domain::I ProjectPersistence::loadProjectReview(metadata, importResult); } -safecrowd::domain::ImportResult makeDemoImportResult() { - safecrowd::domain::DemoFixtureService fixtureService; - const auto fixture = fixtureService.createSprint1DemoFixture(); - +safecrowd::domain::ImportResult makeImportResultForDemoLayout(safecrowd::domain::FacilityLayout2D layout) { safecrowd::domain::ImportResult result; - result.layout = fixture.layout; + result.layout = std::move(layout); safecrowd::domain::ImportValidationService validator; result.issues = validator.validate(*result.layout); @@ -50,6 +47,18 @@ safecrowd::domain::ImportResult makeDemoImportResult() { return result; } +safecrowd::domain::ImportResult makeSprint1DemoImportResult() { + safecrowd::domain::DemoFixtureService fixtureService; + const auto fixture = fixtureService.createSprint1DemoFixture(); + return makeImportResultForDemoLayout(fixture.layout); +} + +safecrowd::domain::ImportResult makeTwoFloorEvacuationDemoImportResult() { + safecrowd::domain::DemoFixtureService fixtureService; + const auto fixture = fixtureService.createTwoFloorEvacuationDemoFixture(); + return makeImportResultForDemoLayout(fixture.layout); +} + ProjectWorkspaceState makeEvacuationScenarioDemoWorkspace() { using namespace safecrowd::domain; @@ -90,6 +99,35 @@ ProjectWorkspaceState makeEvacuationScenarioDemoWorkspace() { return workspace; } +ProjectWorkspaceState makeTwoFloorEvacuationDemoWorkspace() { + using namespace safecrowd::domain; + + safecrowd::domain::DemoFixtureService fixtureService; + auto fixture = fixtureService.createTwoFloorEvacuationDemoFixture(); + + SavedScenarioAuthoringState authoring; + authoring.scenarios.push_back({ + .draft = fixture.baselineScenario, + .baseScenarioId = {}, + .stagedForRun = true, + }); + authoring.scenarios.push_back({ + .draft = fixture.alternativeScenario, + .baseScenarioId = fixture.baselineScenario.scenarioId, + .stagedForRun = true, + }); + authoring.currentScenarioIndex = 1; + authoring.navigationView = SavedNavigationView::Events; + authoring.rightPanelMode = SavedRightPanelMode::Scenario; + + ProjectWorkspaceState workspace; + workspace.activeView = ProjectWorkspaceView::ScenarioRun; + workspace.authoring = std::move(authoring); + workspace.runningScenario = fixture.alternativeScenario; + workspace.runningScenarios = {fixture.baselineScenario, fixture.alternativeScenario}; + return workspace; +} + safecrowd::domain::ImportResult makeBlankImportResult(const QString& projectName) { safecrowd::domain::ImportResult result; result.layout = safecrowd::domain::FacilityLayout2D{ @@ -111,8 +149,11 @@ safecrowd::domain::ImportResult makeBlankImportResult(const QString& projectName } safecrowd::domain::ImportResult importProjectLayout(const ProjectMetadata& metadata) { - if (metadata.isBuiltInDemo()) { - return makeDemoImportResult(); + if (metadata.isBuiltInTwoFloorEvacuationDemo()) { + return makeTwoFloorEvacuationDemoImportResult(); + } + if (metadata.layoutPath == builtInDemoLayoutPath() || metadata.isBuiltInEvacuationScenarioDemo()) { + return makeSprint1DemoImportResult(); } if (metadata.isBlankLayoutProject()) { return makeBlankImportResult(metadata.name); @@ -404,6 +445,8 @@ void MainWindow::openProject(const ProjectMetadata& metadata) { ProjectWorkspaceState workspace; if (metadata.isBuiltInEvacuationScenarioDemo()) { workspace = makeEvacuationScenarioDemoWorkspace(); + } else if (metadata.isBuiltInTwoFloorEvacuationDemo()) { + workspace = makeTwoFloorEvacuationDemoWorkspace(); } else if (!ProjectPersistence::loadProjectWorkspace(metadata, &workspace)) { showLayoutReview(metadata, std::move(importResult)); return; diff --git a/src/application/ProjectMetadata.h b/src/application/ProjectMetadata.h index 59becbc..d2f4bd2 100644 --- a/src/application/ProjectMetadata.h +++ b/src/application/ProjectMetadata.h @@ -12,6 +12,10 @@ inline QString builtInEvacuationScenarioDemoLayoutPath() { return QStringLiteral("safecrowd://demo/evacuation-scenario"); } +inline QString builtInTwoFloorEvacuationDemoLayoutPath() { + return QStringLiteral("safecrowd://demo/two-floor-evacuation"); +} + struct ProjectMetadata { QString name{}; QString folderPath{}; @@ -19,13 +23,19 @@ struct ProjectMetadata { QString savedAt{}; bool isBuiltInDemo() const noexcept { - return layoutPath == builtInDemoLayoutPath() || isBuiltInEvacuationScenarioDemo(); + return layoutPath == builtInDemoLayoutPath() + || isBuiltInEvacuationScenarioDemo() + || isBuiltInTwoFloorEvacuationDemo(); } bool isBuiltInEvacuationScenarioDemo() const noexcept { return layoutPath == builtInEvacuationScenarioDemoLayoutPath(); } + bool isBuiltInTwoFloorEvacuationDemo() const noexcept { + return layoutPath == builtInTwoFloorEvacuationDemoLayoutPath(); + } + bool isBlankLayoutProject() const noexcept { return layoutPath.isEmpty(); } @@ -52,4 +62,11 @@ inline ProjectMetadata makeBuiltInEvacuationScenarioDemoProject() { }; } +inline ProjectMetadata makeBuiltInTwoFloorEvacuationDemoProject() { + return { + .name = QStringLiteral("Two-floor Evacuation Demo"), + .layoutPath = builtInTwoFloorEvacuationDemoLayoutPath(), + }; +} + } // namespace safecrowd::application diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index a631bb5..b329741 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -1621,6 +1621,7 @@ QList ProjectPersistence::loadRecentProjects() { QList projects; projects.push_back(makeBuiltInDemoProject()); projects.push_back(makeBuiltInEvacuationScenarioDemoProject()); + projects.push_back(makeBuiltInTwoFloorEvacuationDemoProject()); const auto document = readJsonDocument(recentProjectsPath()); if (!document.isObject()) { diff --git a/src/domain/DemoFixtureService.cpp b/src/domain/DemoFixtureService.cpp index 2c86ff2..a5189a7 100644 --- a/src/domain/DemoFixtureService.cpp +++ b/src/domain/DemoFixtureService.cpp @@ -22,6 +22,28 @@ ScenarioDraft makeSprint1BlockedDoorAlternative(const ScenarioDraft& baseline) { return alternative; } +ScenarioDraft makeTwoFloorEastExitGuidanceAlternative(const ScenarioDraft& baseline) { + using Ids = DemoLayouts::TwoFloorEvacuationIds; + + auto alternative = duplicateScenarioDraft( + baseline, + "two-floor-guidance", + "East exit guidance alternative"); + alternative.control.routeGuidances.push_back({ + .id = "guidance-east-exit", + .startSeconds = 0.0, + .endSeconds = 180.0, + .periods = {{.startSeconds = 0.0, .endSeconds = 180.0}}, + .guidedExitZoneId = Ids::EastExitZoneId, + .installConnectionId = Ids::UpperCorridorEastStairConnectionId, + .baseComplianceRate = 0.85, + .guidanceStrength = 0.85, + .maxDetourMeters = 30.0, + }); + alternative.variationDiffKeys = computeScenarioDiffKeys(baseline, alternative); + return alternative; +} + DensityCellMetric densityCell( double centerX, double centerY, @@ -779,6 +801,70 @@ DemoFixture DemoFixtureService::createSprint1DemoFixture() const { return fixture; } +DemoAuthoringFixture DemoFixtureService::createTwoFloorEvacuationDemoFixture() const { + using Ids = DemoLayouts::TwoFloorEvacuationIds; + + DemoAuthoringFixture fixture; + fixture.layout = DemoLayouts::twoFloorEvacuationFacility(); + + fixture.population.initialPlacements.push_back({ + .id = "two-floor-west-training-crowd", + .zoneId = Ids::UpperWestTrainingZoneId, + .floorId = Ids::UpperFloorId, + .area = { + .outline = { + {2.0, 2.0}, + {8.5, 2.0}, + {8.5, 5.0}, + {2.0, 5.0}, + }, + }, + .targetAgentCount = 30, + }); + fixture.population.initialPlacements.push_back({ + .id = "two-floor-briefing-crowd", + .zoneId = Ids::UpperBriefingZoneId, + .floorId = Ids::UpperFloorId, + .area = { + .outline = { + {11.0, 2.0}, + {17.0, 2.0}, + {17.0, 5.0}, + {11.0, 5.0}, + }, + }, + .targetAgentCount = 20, + }); + fixture.population.initialPlacements.push_back({ + .id = "two-floor-east-training-crowd", + .zoneId = Ids::UpperEastTrainingZoneId, + .floorId = Ids::UpperFloorId, + .area = { + .outline = { + {19.5, 2.0}, + {26.0, 2.0}, + {26.0, 5.0}, + {19.5, 5.0}, + }, + }, + .targetAgentCount = 30, + }); + + fixture.baselineScenario.scenarioId = "two-floor-baseline"; + fixture.baselineScenario.name = "Two-floor evacuation baseline"; + fixture.baselineScenario.role = ScenarioRole::Baseline; + fixture.baselineScenario.population = fixture.population; + fixture.baselineScenario.execution.timeLimitSeconds = 600.0; + fixture.baselineScenario.execution.sampleIntervalSeconds = 1.0; + fixture.baselineScenario.execution.repeatCount = 1; + fixture.baselineScenario.execution.baseSeed = 42; + fixture.baselineScenario.sourceTemplateId = "two-floor-evacuation-baseline"; + + fixture.alternativeScenario = makeTwoFloorEastExitGuidanceAlternative(fixture.baselineScenario); + + return fixture; +} + DemoScenarioResultFixture DemoFixtureService::createSprint1BlockedDoorResultFixture() const { const auto baselineFixture = createSprint1DemoFixture(); diff --git a/src/domain/DemoFixtureService.h b/src/domain/DemoFixtureService.h index 02a5b8f..320056b 100644 --- a/src/domain/DemoFixtureService.h +++ b/src/domain/DemoFixtureService.h @@ -15,6 +15,13 @@ struct DemoFixture { ScenarioDraft baselineScenario{}; }; +struct DemoAuthoringFixture { + FacilityLayout2D layout{}; + PopulationSpec population{}; + ScenarioDraft baselineScenario{}; + ScenarioDraft alternativeScenario{}; +}; + struct DemoScenarioResultFixture { FacilityLayout2D layout{}; PopulationSpec population{}; @@ -28,6 +35,7 @@ struct DemoScenarioResultFixture { class DemoFixtureService { public: DemoFixture createSprint1DemoFixture() const; + DemoAuthoringFixture createTwoFloorEvacuationDemoFixture() const; DemoScenarioResultFixture createSprint1BlockedDoorResultFixture() const; }; diff --git a/src/domain/DemoLayouts.cpp b/src/domain/DemoLayouts.cpp index ec72e94..2dbf1bd 100644 --- a/src/domain/DemoLayouts.cpp +++ b/src/domain/DemoLayouts.cpp @@ -5,10 +5,10 @@ namespace safecrowd::domain::DemoLayouts { namespace { -Barrier2D makeBarrier(const char* id, std::vector vertices, bool closed = false) { +Barrier2D makeBarrier(const char* id, const char* floorId, std::vector vertices, bool closed = false) { Barrier2D barrier; barrier.id = id; - barrier.floorId = Sprint1FacilityIds::FloorId; + barrier.floorId = floorId; barrier.blocksMovement = true; barrier.geometry = Polyline2D{ .vertices = std::move(vertices), @@ -17,6 +17,60 @@ Barrier2D makeBarrier(const char* id, std::vector vertices, bool closed return barrier; } +Barrier2D makeBarrier(const char* id, std::vector vertices, bool closed = false) { + return makeBarrier(id, Sprint1FacilityIds::FloorId, std::move(vertices), closed); +} + +Zone2D makeRectZone( + const char* id, + const char* floorId, + ZoneKind kind, + const char* label, + double minX, + double minY, + double maxX, + double maxY, + std::size_t capacity, + bool isStair = false) { + Zone2D zone; + zone.id = id; + zone.floorId = floorId; + zone.kind = kind; + zone.label = label; + zone.area = Polygon2D{ + .outline = { + {minX, minY}, + {maxX, minY}, + {maxX, maxY}, + {minX, maxY}, + }, + }; + zone.defaultCapacity = capacity; + zone.isStair = isStair; + return zone; +} + +Connection2D makeConnection( + const char* id, + const char* floorId, + ConnectionKind kind, + const char* fromZoneId, + const char* toZoneId, + double effectiveWidth, + LineSegment2D centerSpan, + bool isStair = false) { + Connection2D connection; + connection.id = id; + connection.floorId = floorId; + connection.kind = kind; + connection.fromZoneId = fromZoneId; + connection.toZoneId = toZoneId; + connection.effectiveWidth = effectiveWidth; + connection.centerSpan = centerSpan; + connection.isStair = isStair; + return connection; +} + } // namespace FacilityLayout2D demoFacility() { @@ -141,4 +195,328 @@ FacilityLayout2D demoFacility() { return layout; } +FacilityLayout2D twoFloorEvacuationFacility() { + using Ids = TwoFloorEvacuationIds; + + FacilityLayout2D layout{}; + layout.id = Ids::LayoutId; + layout.name = "Two-floor Evacuation Demo Layout"; + layout.levelId = Ids::LowerFloorId; + layout.floors.push_back({ + .id = Ids::LowerFloorId, + .label = "Floor 1", + }); + layout.floors.push_back({ + .id = Ids::UpperFloorId, + .label = "Floor 2", + .elevationMeters = 3.5, + }); + + layout.zones.push_back(makeRectZone( + Ids::UpperWestTrainingZoneId, + Ids::UpperFloorId, + ZoneKind::Room, + "West Training Room", + 1.0, + 1.0, + 10.0, + 6.0, + 60)); + layout.zones.push_back(makeRectZone( + Ids::UpperBriefingZoneId, + Ids::UpperFloorId, + ZoneKind::Room, + "Briefing Room", + 10.0, + 1.0, + 18.0, + 6.0, + 48)); + layout.zones.push_back(makeRectZone( + Ids::UpperEastTrainingZoneId, + Ids::UpperFloorId, + ZoneKind::Room, + "East Training Room", + 18.0, + 1.0, + 27.0, + 6.0, + 60)); + layout.zones.push_back(makeRectZone( + Ids::UpperCorridorZoneId, + Ids::UpperFloorId, + ZoneKind::Room, + "Upper Corridor", + 1.0, + 6.0, + 27.0, + 9.0, + 90)); + layout.zones.push_back(makeRectZone( + Ids::UpperWestStairZoneId, + Ids::UpperFloorId, + ZoneKind::Stair, + "Upper West Stair", + 3.0, + 9.0, + 5.0, + 13.0, + 24, + true)); + layout.zones.push_back(makeRectZone( + Ids::UpperEastStairZoneId, + Ids::UpperFloorId, + ZoneKind::Stair, + "Upper East Stair", + 23.0, + 9.0, + 25.0, + 13.0, + 24, + true)); + layout.zones.push_back(makeRectZone( + Ids::LowerWestStairZoneId, + Ids::LowerFloorId, + ZoneKind::Stair, + "Lower West Stair", + 1.0, + 9.0, + 3.0, + 13.0, + 24, + true)); + layout.zones.push_back(makeRectZone( + Ids::LowerEastStairZoneId, + Ids::LowerFloorId, + ZoneKind::Stair, + "Lower East Stair", + 25.0, + 9.0, + 27.0, + 13.0, + 24, + true)); + layout.zones.push_back(makeRectZone( + Ids::LowerWestVestibuleZoneId, + Ids::LowerFloorId, + ZoneKind::Room, + "West Exit Vestibule", + 1.0, + 4.0, + 6.0, + 9.0, + 36)); + layout.zones.push_back(makeRectZone( + Ids::LowerLobbyZoneId, + Ids::LowerFloorId, + ZoneKind::Room, + "Ground Floor Lobby", + 6.0, + 4.0, + 22.0, + 10.0, + 120)); + layout.zones.push_back(makeRectZone( + Ids::LowerEastVestibuleZoneId, + Ids::LowerFloorId, + ZoneKind::Room, + "East Exit Vestibule", + 22.0, + 4.0, + 27.0, + 9.0, + 36)); + layout.zones.push_back(makeRectZone( + Ids::WestExitZoneId, + Ids::LowerFloorId, + ZoneKind::Exit, + "West Exit", + -2.0, + 5.0, + 1.0, + 8.0, + 40)); + layout.zones.push_back(makeRectZone( + Ids::EastExitZoneId, + Ids::LowerFloorId, + ZoneKind::Exit, + "East Exit", + 27.0, + 5.0, + 30.0, + 8.0, + 40)); + + layout.connections.push_back(makeConnection( + Ids::UpperWestTrainingToCorridorConnectionId, + Ids::UpperFloorId, + ConnectionKind::Doorway, + Ids::UpperWestTrainingZoneId, + Ids::UpperCorridorZoneId, + 3.0, + {{4.0, 6.0}, {7.0, 6.0}})); + layout.connections.push_back(makeConnection( + Ids::UpperBriefingToCorridorConnectionId, + Ids::UpperFloorId, + ConnectionKind::Doorway, + Ids::UpperBriefingZoneId, + Ids::UpperCorridorZoneId, + 3.0, + {{12.5, 6.0}, {15.5, 6.0}})); + layout.connections.push_back(makeConnection( + Ids::UpperEastTrainingToCorridorConnectionId, + Ids::UpperFloorId, + ConnectionKind::Doorway, + Ids::UpperEastTrainingZoneId, + Ids::UpperCorridorZoneId, + 3.0, + {{21.0, 6.0}, {24.0, 6.0}})); + layout.connections.push_back(makeConnection( + Ids::UpperCorridorWestStairConnectionId, + Ids::UpperFloorId, + ConnectionKind::Opening, + Ids::UpperCorridorZoneId, + Ids::UpperWestStairZoneId, + 2.0, + {{3.2, 9.0}, {4.8, 9.0}})); + layout.connections.push_back(makeConnection( + Ids::UpperCorridorEastStairConnectionId, + Ids::UpperFloorId, + ConnectionKind::Opening, + Ids::UpperCorridorZoneId, + Ids::UpperEastStairZoneId, + 2.0, + {{23.2, 9.0}, {24.8, 9.0}})); + auto westVerticalStair = makeConnection( + Ids::WestStairVerticalConnectionId, + Ids::LowerFloorId, + ConnectionKind::Stair, + Ids::UpperWestStairZoneId, + Ids::LowerWestStairZoneId, + 2.0, + {{3.0, 11.6}, {3.0, 12.8}}, + true); + westVerticalStair.lowerEntryDirection = StairEntryDirection::South; + westVerticalStair.upperEntryDirection = StairEntryDirection::South; + layout.connections.push_back(westVerticalStair); + auto eastVerticalStair = makeConnection( + Ids::EastStairVerticalConnectionId, + Ids::LowerFloorId, + ConnectionKind::Stair, + Ids::UpperEastStairZoneId, + Ids::LowerEastStairZoneId, + 2.0, + {{25.0, 11.6}, {25.0, 12.8}}, + true); + eastVerticalStair.lowerEntryDirection = StairEntryDirection::South; + eastVerticalStair.upperEntryDirection = StairEntryDirection::South; + layout.connections.push_back(eastVerticalStair); + layout.connections.push_back(makeConnection( + Ids::LowerWestStairVestibuleConnectionId, + Ids::LowerFloorId, + ConnectionKind::Opening, + Ids::LowerWestStairZoneId, + Ids::LowerWestVestibuleZoneId, + 2.0, + {{1.2, 9.0}, {2.8, 9.0}})); + layout.connections.push_back(makeConnection( + Ids::LowerEastStairVestibuleConnectionId, + Ids::LowerFloorId, + ConnectionKind::Opening, + Ids::LowerEastStairZoneId, + Ids::LowerEastVestibuleZoneId, + 2.0, + {{25.2, 9.0}, {26.8, 9.0}})); + layout.connections.push_back(makeConnection( + Ids::LowerWestVestibuleLobbyConnectionId, + Ids::LowerFloorId, + ConnectionKind::Opening, + Ids::LowerWestVestibuleZoneId, + Ids::LowerLobbyZoneId, + 2.0, + {{6.0, 5.5}, {6.0, 7.5}})); + layout.connections.push_back(makeConnection( + Ids::LowerLobbyEastVestibuleConnectionId, + Ids::LowerFloorId, + ConnectionKind::Opening, + Ids::LowerLobbyZoneId, + Ids::LowerEastVestibuleZoneId, + 2.0, + {{22.0, 5.5}, {22.0, 7.5}})); + layout.connections.push_back(makeConnection( + Ids::LowerWestExitConnectionId, + Ids::LowerFloorId, + ConnectionKind::Exit, + Ids::LowerWestVestibuleZoneId, + Ids::WestExitZoneId, + 2.0, + {{1.0, 5.5}, {1.0, 7.5}})); + layout.connections.push_back(makeConnection( + Ids::LowerEastExitConnectionId, + Ids::LowerFloorId, + ConnectionKind::Exit, + Ids::LowerEastVestibuleZoneId, + Ids::EastExitZoneId, + 2.0, + {{27.0, 5.5}, {27.0, 7.5}})); + + layout.barriers.push_back(makeBarrier("two-floor-upper-south-wall", Ids::UpperFloorId, {{1.0, 1.0}, {27.0, 1.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-west-wall", Ids::UpperFloorId, {{1.0, 1.0}, {1.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-east-wall", Ids::UpperFloorId, {{27.0, 1.0}, {27.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-west-room-divider", Ids::UpperFloorId, {{10.0, 1.0}, {10.0, 6.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-east-room-divider", Ids::UpperFloorId, {{18.0, 1.0}, {18.0, 6.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-room-corridor-wall-1", Ids::UpperFloorId, {{1.0, 6.0}, {4.0, 6.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-room-corridor-wall-2", Ids::UpperFloorId, {{7.0, 6.0}, {12.5, 6.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-room-corridor-wall-3", Ids::UpperFloorId, {{15.5, 6.0}, {21.0, 6.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-room-corridor-wall-4", Ids::UpperFloorId, {{24.0, 6.0}, {27.0, 6.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-corridor-north-1", Ids::UpperFloorId, {{1.0, 9.0}, {3.2, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-corridor-north-2", Ids::UpperFloorId, {{4.8, 9.0}, {23.2, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-corridor-north-3", Ids::UpperFloorId, {{24.8, 9.0}, {27.0, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-west-stair-west-1", Ids::UpperFloorId, {{3.0, 9.0}, {3.0, 11.6}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-west-stair-west-2", Ids::UpperFloorId, {{3.0, 12.8}, {3.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-west-stair-east", Ids::UpperFloorId, {{5.0, 9.0}, {5.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-west-stair-north", Ids::UpperFloorId, {{3.0, 13.0}, {5.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-east-stair-west", Ids::UpperFloorId, {{23.0, 9.0}, {23.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-east-stair-east-1", Ids::UpperFloorId, {{25.0, 9.0}, {25.0, 11.6}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-east-stair-east-2", Ids::UpperFloorId, {{25.0, 12.8}, {25.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-upper-east-stair-north", Ids::UpperFloorId, {{23.0, 13.0}, {25.0, 13.0}})); + layout.barriers.push_back(makeBarrier( + "two-floor-upper-corridor-column", + Ids::UpperFloorId, + {{13.0, 7.0}, {15.0, 7.0}, {15.0, 8.0}, {13.0, 8.0}}, + true)); + + layout.barriers.push_back(makeBarrier("two-floor-lower-west-stair-west", Ids::LowerFloorId, {{1.0, 9.0}, {1.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-west-stair-east-1", Ids::LowerFloorId, {{3.0, 9.0}, {3.0, 11.6}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-west-stair-east-2", Ids::LowerFloorId, {{3.0, 12.8}, {3.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-west-stair-north", Ids::LowerFloorId, {{1.0, 13.0}, {3.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-east-stair-west-1", Ids::LowerFloorId, {{25.0, 9.0}, {25.0, 11.6}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-east-stair-west-2", Ids::LowerFloorId, {{25.0, 12.8}, {25.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-east-stair-east", Ids::LowerFloorId, {{27.0, 9.0}, {27.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-east-stair-north", Ids::LowerFloorId, {{25.0, 13.0}, {27.0, 13.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-vestibule-south-west", Ids::LowerFloorId, {{1.0, 4.0}, {6.0, 4.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-vestibule-west-wall-1", Ids::LowerFloorId, {{1.0, 4.0}, {1.0, 5.5}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-vestibule-west-wall-2", Ids::LowerFloorId, {{1.0, 7.5}, {1.0, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-west-vestibule-lobby-wall-1", Ids::LowerFloorId, {{6.0, 4.0}, {6.0, 5.5}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-west-vestibule-lobby-wall-2", Ids::LowerFloorId, {{6.0, 7.5}, {6.0, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-west-stair-south-1", Ids::LowerFloorId, {{1.0, 9.0}, {1.2, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-west-stair-south-2", Ids::LowerFloorId, {{2.8, 9.0}, {6.0, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-lobby-south", Ids::LowerFloorId, {{6.0, 4.0}, {22.0, 4.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-lobby-north", Ids::LowerFloorId, {{6.0, 10.0}, {22.0, 10.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-lobby-east-wall-1", Ids::LowerFloorId, {{22.0, 4.0}, {22.0, 5.5}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-lobby-east-wall-2", Ids::LowerFloorId, {{22.0, 7.5}, {22.0, 10.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-vestibule-south-east", Ids::LowerFloorId, {{22.0, 4.0}, {27.0, 4.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-vestibule-east-wall-1", Ids::LowerFloorId, {{27.0, 4.0}, {27.0, 5.5}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-vestibule-east-wall-2", Ids::LowerFloorId, {{27.0, 7.5}, {27.0, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-east-stair-south-1", Ids::LowerFloorId, {{22.0, 9.0}, {25.2, 9.0}})); + layout.barriers.push_back(makeBarrier("two-floor-lower-east-stair-south-2", Ids::LowerFloorId, {{26.8, 9.0}, {27.0, 9.0}})); + layout.barriers.push_back(makeBarrier( + "two-floor-lower-info-desk", + Ids::LowerFloorId, + {{13.0, 5.8}, {15.0, 5.8}, {15.0, 7.0}, {13.0, 7.0}}, + true)); + + return layout; +} + } // namespace safecrowd::domain::DemoLayouts diff --git a/src/domain/DemoLayouts.h b/src/domain/DemoLayouts.h index 94bf3da..d8b125a 100644 --- a/src/domain/DemoLayouts.h +++ b/src/domain/DemoLayouts.h @@ -32,7 +32,39 @@ struct Sprint1FacilityIds { static constexpr const char* SidePassageWallUpperId = "barrier-wall-side-passage-upper"; }; +struct TwoFloorEvacuationIds { + static constexpr const char* LayoutId = "demo-two-floor-evacuation"; + static constexpr const char* LowerFloorId = "L1"; + static constexpr const char* UpperFloorId = "L2"; + static constexpr const char* UpperWestTrainingZoneId = "two-floor-upper-west-training"; + static constexpr const char* UpperBriefingZoneId = "two-floor-upper-briefing"; + static constexpr const char* UpperEastTrainingZoneId = "two-floor-upper-east-training"; + static constexpr const char* UpperCorridorZoneId = "two-floor-upper-corridor"; + static constexpr const char* UpperWestStairZoneId = "two-floor-upper-west-stair"; + static constexpr const char* UpperEastStairZoneId = "two-floor-upper-east-stair"; + static constexpr const char* LowerWestStairZoneId = "two-floor-lower-west-stair"; + static constexpr const char* LowerEastStairZoneId = "two-floor-lower-east-stair"; + static constexpr const char* LowerWestVestibuleZoneId = "two-floor-lower-west-vestibule"; + static constexpr const char* LowerLobbyZoneId = "two-floor-lower-lobby"; + static constexpr const char* LowerEastVestibuleZoneId = "two-floor-lower-east-vestibule"; + static constexpr const char* WestExitZoneId = "two-floor-west-exit"; + static constexpr const char* EastExitZoneId = "two-floor-east-exit"; + static constexpr const char* UpperWestTrainingToCorridorConnectionId = "two-floor-upper-west-training-corridor"; + static constexpr const char* UpperBriefingToCorridorConnectionId = "two-floor-upper-briefing-corridor"; + static constexpr const char* UpperEastTrainingToCorridorConnectionId = "two-floor-upper-east-training-corridor"; + static constexpr const char* UpperCorridorWestStairConnectionId = "two-floor-upper-corridor-west-stair"; + static constexpr const char* UpperCorridorEastStairConnectionId = "two-floor-upper-corridor-east-stair"; + static constexpr const char* WestStairVerticalConnectionId = "two-floor-west-stair-vertical"; + static constexpr const char* EastStairVerticalConnectionId = "two-floor-east-stair-vertical"; + static constexpr const char* LowerWestStairVestibuleConnectionId = "two-floor-lower-west-stair-vestibule"; + static constexpr const char* LowerEastStairVestibuleConnectionId = "two-floor-lower-east-stair-vestibule"; + static constexpr const char* LowerWestVestibuleLobbyConnectionId = "two-floor-lower-west-vestibule-lobby"; + static constexpr const char* LowerLobbyEastVestibuleConnectionId = "two-floor-lower-lobby-east-vestibule"; + static constexpr const char* LowerWestExitConnectionId = "two-floor-lower-west-exit"; + static constexpr const char* LowerEastExitConnectionId = "two-floor-lower-east-exit"; +}; + FacilityLayout2D demoFacility(); +FacilityLayout2D twoFloorEvacuationFacility(); } // namespace safecrowd::domain::DemoLayouts - diff --git a/tests/DemoFixtureServiceTests.cpp b/tests/DemoFixtureServiceTests.cpp index 9d07e22..02c5841 100644 --- a/tests/DemoFixtureServiceTests.cpp +++ b/tests/DemoFixtureServiceTests.cpp @@ -9,6 +9,7 @@ #include "domain/ImportIssue.h" #include "domain/ImportValidationService.h" #include "domain/ScenarioAuthoring.h" +#include "domain/ScenarioSimulationRunner.h" namespace { @@ -36,6 +37,15 @@ bool containsConnectionId( }); } +const safecrowd::domain::Connection2D* findConnectionId( + const std::vector& connections, + const std::string& id) { + const auto it = std::find_if(connections.begin(), connections.end(), [&](const auto& connection) { + return connection.id == id; + }); + return it == connections.end() ? nullptr : &(*it); +} + bool containsBarrierId( const std::vector& barriers, const std::string& id) { @@ -131,6 +141,101 @@ SC_TEST(DemoFixtureServiceBuildsSprint1Fixture) { SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(issues)); } +SC_TEST(DemoFixtureServiceBuildsTwoFloorEvacuationFixture) { + safecrowd::domain::DemoFixtureService service; + const auto fixture = service.createTwoFloorEvacuationDemoFixture(); + const auto& layout = fixture.layout; + const auto& population = fixture.population; + using Ids = safecrowd::domain::DemoLayouts::TwoFloorEvacuationIds; + + SC_EXPECT_EQ(layout.id, std::string(Ids::LayoutId)); + SC_EXPECT_EQ(layout.name, std::string("Two-floor Evacuation Demo Layout")); + SC_EXPECT_EQ(layout.levelId, std::string(Ids::LowerFloorId)); + SC_EXPECT_EQ(layout.floors.size(), std::size_t{2}); + SC_EXPECT_EQ(layout.floors.at(0).id, std::string(Ids::LowerFloorId)); + SC_EXPECT_EQ(layout.floors.at(1).id, std::string(Ids::UpperFloorId)); + SC_EXPECT_EQ(layout.zones.size(), std::size_t{13}); + SC_EXPECT_EQ(layout.connections.size(), std::size_t{13}); + SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::UpperWestTrainingZoneId)); + SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::UpperCorridorZoneId)); + SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::LowerLobbyZoneId)); + SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::WestExitZoneId)); + SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::EastExitZoneId)); + SC_EXPECT_TRUE(containsConnectionId(layout.connections, Ids::WestStairVerticalConnectionId)); + SC_EXPECT_TRUE(containsConnectionId(layout.connections, Ids::EastStairVerticalConnectionId)); + SC_EXPECT_TRUE(containsConnectionKind(layout.connections, safecrowd::domain::ConnectionKind::Stair)); + const auto* westStair = findConnectionId(layout.connections, Ids::WestStairVerticalConnectionId); + SC_EXPECT_TRUE(westStair != nullptr); + if (westStair != nullptr) { + SC_EXPECT_EQ(westStair->lowerEntryDirection, safecrowd::domain::StairEntryDirection::South); + SC_EXPECT_EQ(westStair->upperEntryDirection, safecrowd::domain::StairEntryDirection::South); + SC_EXPECT_NEAR(westStair->centerSpan.start.x, 3.0, 1e-9); + SC_EXPECT_NEAR(westStair->centerSpan.end.x, 3.0, 1e-9); + } + const auto* eastStair = findConnectionId(layout.connections, Ids::EastStairVerticalConnectionId); + SC_EXPECT_TRUE(eastStair != nullptr); + if (eastStair != nullptr) { + SC_EXPECT_EQ(eastStair->lowerEntryDirection, safecrowd::domain::StairEntryDirection::South); + SC_EXPECT_EQ(eastStair->upperEntryDirection, safecrowd::domain::StairEntryDirection::South); + SC_EXPECT_NEAR(eastStair->centerSpan.start.x, 25.0, 1e-9); + SC_EXPECT_NEAR(eastStair->centerSpan.end.x, 25.0, 1e-9); + } + SC_EXPECT_TRUE(std::all_of(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { + return zone.floorId == "L1" || zone.floorId == "L2"; + })); + + SC_EXPECT_EQ(population.initialPlacements.size(), std::size_t{3}); + SC_EXPECT_EQ(population.initialPlacements.front().zoneId, std::string(Ids::UpperWestTrainingZoneId)); + SC_EXPECT_EQ(population.initialPlacements.front().floorId, std::string(Ids::UpperFloorId)); + SC_EXPECT_EQ(population.initialPlacements.front().targetAgentCount, std::size_t{30}); + SC_EXPECT_EQ(population.initialPlacements.at(1).targetAgentCount, std::size_t{20}); + SC_EXPECT_EQ(population.initialPlacements.at(2).targetAgentCount, std::size_t{30}); + SC_EXPECT_EQ(fixture.baselineScenario.role, safecrowd::domain::ScenarioRole::Baseline); + SC_EXPECT_EQ(fixture.baselineScenario.population.initialPlacements.size(), std::size_t{3}); + SC_EXPECT_EQ(fixture.alternativeScenario.role, safecrowd::domain::ScenarioRole::Alternative); + SC_EXPECT_EQ(fixture.alternativeScenario.control.routeGuidances.size(), std::size_t{1}); + SC_EXPECT_EQ( + fixture.alternativeScenario.control.routeGuidances.front().guidedExitZoneId, + std::string(Ids::EastExitZoneId)); + SC_EXPECT_TRUE(containsDiffKey(fixture.alternativeScenario, "control.routeGuidances")); + + safecrowd::domain::ImportValidationService validator; + const auto issues = validator.validate(layout); + SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(issues)); +} + +SC_TEST(TwoFloorEvacuationDemoFixtureRunsAcrossFloors) { + safecrowd::domain::DemoFixtureService service; + auto fixture = service.createTwoFloorEvacuationDemoFixture(); + auto scenario = fixture.baselineScenario; + + auto placement = scenario.population.initialPlacements.front(); + placement.targetAgentCount = 1; + placement.area.outline = {{4.0, 3.0}}; + scenario.population.initialPlacements = {placement}; + scenario.execution.timeLimitSeconds = 60.0; + + safecrowd::domain::ScenarioSimulationRunner runner(fixture.layout, scenario); + for (int step = 0; step < 600 && !runner.complete(); ++step) { + runner.step(0.1); + } + + SC_EXPECT_EQ(runner.frame().evacuatedAgentCount, std::size_t{1}); +} + +SC_TEST(TwoFloorEvacuationDemoCrowdCompletesAfterStairDescent) { + safecrowd::domain::DemoFixtureService service; + const auto fixture = service.createTwoFloorEvacuationDemoFixture(); + + safecrowd::domain::ScenarioSimulationRunner runner(fixture.layout, fixture.baselineScenario); + for (int step = 0; step < 6000 && !runner.complete(); ++step) { + runner.step(0.1); + } + + SC_EXPECT_EQ(runner.frame().totalAgentCount, std::size_t{80}); + SC_EXPECT_EQ(runner.frame().evacuatedAgentCount, std::size_t{80}); +} + SC_TEST(DemoFixtureBlockedDoorResultFixturePreservesScenarioAndResultData) { safecrowd::domain::DemoFixtureService service; const auto fixture = service.createSprint1BlockedDoorResultFixture(); From 4a4768a5fb3853cdc5f77e5810847f160f1cb864 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Fri, 15 May 2026 02:45:56 +0900 Subject: [PATCH 02/11] [Domain] Stabilize two-floor evacuation demo --- docs/UI.md | 6 +- src/domain/DemoLayouts.cpp | 12 ++-- tests/DemoFixtureServiceTests.cpp | 102 ++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/docs/UI.md b/docs/UI.md index 4b2960c..617506b 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -75,19 +75,19 @@ Sprint 1 시연에서 가장 중요한 기준은 `도면 불러오기 -> 검토/ ### 3.1 Project Navigator -앱을 처음 켰을 때 보이는 시작 화면이다. 예시 프로젝트 목록을 두지 않고, 실제 저장된 프로젝트 목록과 built-in `Demo` 프로젝트를 보여준다. +앱을 처음 켰을 때 보이는 시작 화면이다. 예시 프로젝트 목록을 두지 않고, 실제 저장된 프로젝트 목록과 built-in 데모 프로젝트를 보여준다. 현재 기능: - 최근 프로젝트 목록 표시 -- built-in `Demo` 프로젝트 표시 +- built-in `Demo`, `Evacuation Scenario Demo`, `Two-floor Evacuation Demo` 프로젝트 표시 - 프로젝트 목록이 없을 때 빈 상태 표시 - `+ New Project`로 새 프로젝트 생성 화면 진입 - 최근 프로젝트 row 클릭 시 해당 프로젝트 열기 - 일반 프로젝트 row의 삭제 버튼으로 프로젝트 삭제 - 삭제 전 확인 다이얼로그 표시 - 삭제 확인 시 실제 프로젝트 폴더 삭제 및 최근 목록 갱신 -- built-in `Demo` 프로젝트 삭제 방지 +- built-in 데모 프로젝트 삭제 방지 현재 UI 요소: diff --git a/src/domain/DemoLayouts.cpp b/src/domain/DemoLayouts.cpp index 2dbf1bd..36e83c0 100644 --- a/src/domain/DemoLayouts.cpp +++ b/src/domain/DemoLayouts.cpp @@ -377,7 +377,7 @@ FacilityLayout2D twoFloorEvacuationFacility() { ConnectionKind::Opening, Ids::UpperCorridorZoneId, Ids::UpperWestStairZoneId, - 2.0, + 1.6, {{3.2, 9.0}, {4.8, 9.0}})); layout.connections.push_back(makeConnection( Ids::UpperCorridorEastStairConnectionId, @@ -385,7 +385,7 @@ FacilityLayout2D twoFloorEvacuationFacility() { ConnectionKind::Opening, Ids::UpperCorridorZoneId, Ids::UpperEastStairZoneId, - 2.0, + 1.6, {{23.2, 9.0}, {24.8, 9.0}})); auto westVerticalStair = makeConnection( Ids::WestStairVerticalConnectionId, @@ -393,7 +393,7 @@ FacilityLayout2D twoFloorEvacuationFacility() { ConnectionKind::Stair, Ids::UpperWestStairZoneId, Ids::LowerWestStairZoneId, - 2.0, + 1.2, {{3.0, 11.6}, {3.0, 12.8}}, true); westVerticalStair.lowerEntryDirection = StairEntryDirection::South; @@ -405,7 +405,7 @@ FacilityLayout2D twoFloorEvacuationFacility() { ConnectionKind::Stair, Ids::UpperEastStairZoneId, Ids::LowerEastStairZoneId, - 2.0, + 1.2, {{25.0, 11.6}, {25.0, 12.8}}, true); eastVerticalStair.lowerEntryDirection = StairEntryDirection::South; @@ -417,7 +417,7 @@ FacilityLayout2D twoFloorEvacuationFacility() { ConnectionKind::Opening, Ids::LowerWestStairZoneId, Ids::LowerWestVestibuleZoneId, - 2.0, + 1.6, {{1.2, 9.0}, {2.8, 9.0}})); layout.connections.push_back(makeConnection( Ids::LowerEastStairVestibuleConnectionId, @@ -425,7 +425,7 @@ FacilityLayout2D twoFloorEvacuationFacility() { ConnectionKind::Opening, Ids::LowerEastStairZoneId, Ids::LowerEastVestibuleZoneId, - 2.0, + 1.6, {{25.2, 9.0}, {26.8, 9.0}})); layout.connections.push_back(makeConnection( Ids::LowerWestVestibuleLobbyConnectionId, diff --git a/tests/DemoFixtureServiceTests.cpp b/tests/DemoFixtureServiceTests.cpp index 02c5841..cc2821c 100644 --- a/tests/DemoFixtureServiceTests.cpp +++ b/tests/DemoFixtureServiceTests.cpp @@ -69,6 +69,38 @@ double spanLength(const safecrowd::domain::LineSegment2D& span) { return std::sqrt(dx * dx + dy * dy); } +double distanceToSegment( + const safecrowd::domain::Point2D& point, + const safecrowd::domain::LineSegment2D& segment) { + const auto dx = segment.end.x - segment.start.x; + const auto dy = segment.end.y - segment.start.y; + const auto lengthSquared = dx * dx + dy * dy; + if (lengthSquared <= 1e-12) { + const auto px = point.x - segment.start.x; + const auto py = point.y - segment.start.y; + return std::sqrt(px * px + py * py); + } + + const auto t = std::clamp( + ((point.x - segment.start.x) * dx + (point.y - segment.start.y) * dy) / lengthSquared, + 0.0, + 1.0); + const auto closestX = segment.start.x + dx * t; + const auto closestY = segment.start.y + dy * t; + const auto px = point.x - closestX; + const auto py = point.y - closestY; + return std::sqrt(px * px + py * py); +} + +bool pointInRect( + const safecrowd::domain::Point2D& point, + double minX, + double minY, + double maxX, + double maxY) { + return point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY; +} + void translatePolygon(safecrowd::domain::Polygon2D& polygon, double dx, double dy) { auto translateRing = [&](std::vector& ring) { for (auto& point : ring) { @@ -156,6 +188,9 @@ SC_TEST(DemoFixtureServiceBuildsTwoFloorEvacuationFixture) { SC_EXPECT_EQ(layout.floors.at(1).id, std::string(Ids::UpperFloorId)); SC_EXPECT_EQ(layout.zones.size(), std::size_t{13}); SC_EXPECT_EQ(layout.connections.size(), std::size_t{13}); + for (const auto& connection : layout.connections) { + SC_EXPECT_NEAR(connection.effectiveWidth, spanLength(connection.centerSpan), 1e-9); + } SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::UpperWestTrainingZoneId)); SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::UpperCorridorZoneId)); SC_EXPECT_TRUE(containsZoneId(layout.zones, Ids::LowerLobbyZoneId)); @@ -236,6 +271,73 @@ SC_TEST(TwoFloorEvacuationDemoCrowdCompletesAfterStairDescent) { SC_EXPECT_EQ(runner.frame().evacuatedAgentCount, std::size_t{80}); } +SC_TEST(TwoFloorEvacuationDemoAlternativeCrowdCompletesAfterGuidedStairDescent) { + safecrowd::domain::DemoFixtureService service; + const auto fixture = service.createTwoFloorEvacuationDemoFixture(); + + safecrowd::domain::ScenarioSimulationRunner runner(fixture.layout, fixture.alternativeScenario); + for (int step = 0; step < 6000 && !runner.complete(); ++step) { + runner.step(0.1); + } + + SC_EXPECT_EQ(runner.frame().totalAgentCount, std::size_t{80}); + SC_EXPECT_EQ(runner.frame().evacuatedAgentCount, std::size_t{80}); + SC_EXPECT_TRUE(runner.frame().agents.empty()); +} + +SC_TEST(TwoFloorEvacuationDemoCrowdMovesOffLowerStairPortalAfterLanding) { + safecrowd::domain::DemoFixtureService service; + auto fixture = service.createTwoFloorEvacuationDemoFixture(); + auto scenario = fixture.baselineScenario; + using Ids = safecrowd::domain::DemoLayouts::TwoFloorEvacuationIds; + + safecrowd::domain::InitialPlacement2D placement; + placement.id = "upper-west-stair-crowd"; + placement.floorId = Ids::UpperFloorId; + placement.zoneId = Ids::UpperWestStairZoneId; + placement.initialVelocity = {.x = 1.2, .y = 0.0}; + placement.explicitPositions = { + {.x = 4.45, .y = 12.45}, + {.x = 4.05, .y = 12.45}, + {.x = 4.45, .y = 12.05}, + {.x = 4.05, .y = 12.05}, + {.x = 4.45, .y = 11.65}, + {.x = 4.05, .y = 11.65}, + }; + scenario.population.initialPlacements = {placement}; + scenario.execution.timeLimitSeconds = 30.0; + + safecrowd::domain::ScenarioSimulationRunner runner(fixture.layout, scenario); + const safecrowd::domain::LineSegment2D lowerWestPortal{{3.0, 11.6}, {3.0, 12.8}}; + bool observedLowerFloor = false; + bool observedMovementOffPortal = false; + for (int step = 0; step < 300 && !runner.complete(); ++step) { + runner.step(0.1); + for (const auto& agent : runner.frame().agents) { + if (agent.floorId != Ids::LowerFloorId) { + continue; + } + observedLowerFloor = true; + if (distanceToSegment(agent.position, lowerWestPortal) > 0.45 || agent.position.y < 11.2) { + observedMovementOffPortal = true; + } + } + } + + std::size_t stalledLowerStairAgents = 0; + for (const auto& agent : runner.frame().agents) { + if (agent.floorId == Ids::LowerFloorId + && pointInRect(agent.position, 1.0, 9.0, 3.0, 13.0) + && agent.stalled) { + ++stalledLowerStairAgents; + } + } + + SC_EXPECT_TRUE(observedLowerFloor); + SC_EXPECT_TRUE(observedMovementOffPortal || runner.frame().agents.empty()); + SC_EXPECT_EQ(stalledLowerStairAgents, std::size_t{0}); +} + SC_TEST(DemoFixtureBlockedDoorResultFixturePreservesScenarioAndResultData) { safecrowd::domain::DemoFixtureService service; const auto fixture = service.createSprint1BlockedDoorResultFixture(); From 5da8bdeda1f5b9ac81f71250d00b40891d2bac60 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Fri, 15 May 2026 03:27:43 +0900 Subject: [PATCH 03/11] [Domain] Scope route guidance to installed signs --- src/application/MainWindow.cpp | 41 +++++++-- src/application/MainWindow.h | 3 +- src/application/ScenarioRunWidget.cpp | 32 +++++-- src/application/ScenarioRunWidget.h | 6 +- src/domain/DemoFixtureService.cpp | 8 +- src/domain/ScenarioSimulationMotionSystem.cpp | 83 ++++++++++++++++++- tests/DemoFixtureServiceTests.cpp | 41 +++++++-- tests/ScenarioSimulationSystemsTests.cpp | 69 +++++++++++++++ 8 files changed, 255 insertions(+), 28 deletions(-) diff --git a/src/application/MainWindow.cpp b/src/application/MainWindow.cpp index 94f5381..8a5104a 100644 --- a/src/application/MainWindow.cpp +++ b/src/application/MainWindow.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -311,6 +312,29 @@ SavedScenarioAuthoringState savedStateFromInitial(const ScenarioAuthoringWidget: return saved; } +int selectedRunIndexFor( + const std::vector& scenarios, + const std::optional& selectedScenario) { + if (!selectedScenario.has_value()) { + return 0; + } + + if (!selectedScenario->scenarioId.empty()) { + const auto idIt = std::find_if(scenarios.begin(), scenarios.end(), [&](const auto& scenario) { + return scenario.scenarioId == selectedScenario->scenarioId; + }); + if (idIt != scenarios.end()) { + return static_cast(std::distance(scenarios.begin(), idIt)); + } + } + + const auto nameIt = std::find_if(scenarios.begin(), scenarios.end(), [&](const auto& scenario) { + return scenario.name == selectedScenario->name + && scenario.role == selectedScenario->role; + }); + return nameIt == scenarios.end() ? 0 : static_cast(std::distance(scenarios.begin(), nameIt)); +} + template Widget* visibleChild(QWidget* root) { if (root == nullptr) { @@ -462,12 +486,17 @@ void MainWindow::openProject(const ProjectMetadata& metadata) { break; case ProjectWorkspaceView::ScenarioRun: if (!workspace.runningScenarios.empty()) { + auto returnAuthoringState = workspace.authoring.has_value() + ? std::make_optional(initialStateFromSaved(*workspace.authoring, *importResult.layout)) + : std::optional{}; + const auto initialSelectedRunIndex = selectedRunIndexFor( + workspace.runningScenarios, + workspace.runningScenario); showScenarioRun( *importResult.layout, std::move(workspace.runningScenarios), - workspace.authoring.has_value() - ? std::make_optional(initialStateFromSaved(*workspace.authoring, *importResult.layout)) - : std::nullopt); + std::move(returnAuthoringState), + initialSelectedRunIndex); return; } if (workspace.runningScenario.has_value()) { @@ -758,7 +787,8 @@ void MainWindow::showScenarioRun( void MainWindow::showScenarioRun( const safecrowd::domain::FacilityLayout2D& layout, std::vector scenarios, - std::optional returnAuthoringState) { + std::optional returnAuthoringState, + int initialSelectedRunIndex) { setCentralWidget(new ScenarioRunWidget( currentProject_.name, layout, @@ -779,7 +809,8 @@ void MainWindow::showScenarioRun( } }, std::move(returnAuthoringState), - this)); + this, + initialSelectedRunIndex)); } void MainWindow::showScenarioBatchResult( diff --git a/src/application/MainWindow.h b/src/application/MainWindow.h index 0d6cbbc..b33d4c3 100644 --- a/src/application/MainWindow.h +++ b/src/application/MainWindow.h @@ -46,7 +46,8 @@ class MainWindow : public QMainWindow { void showScenarioRun( const safecrowd::domain::FacilityLayout2D& layout, std::vector scenarios, - std::optional returnAuthoringState = std::nullopt); + std::optional returnAuthoringState = std::nullopt, + int initialSelectedRunIndex = 0); void showScenarioBatchResult( const safecrowd::domain::FacilityLayout2D& layout, std::vector results, diff --git a/src/application/ScenarioRunWidget.cpp b/src/application/ScenarioRunWidget.cpp index 31f130a..0c077c5 100644 --- a/src/application/ScenarioRunWidget.cpp +++ b/src/application/ScenarioRunWidget.cpp @@ -34,6 +34,13 @@ namespace { constexpr double kSimulationDeltaSeconds = 1.0 / 30.0; constexpr int kPlaybackTimerIntervalMs = 33; +int normalizedRunIndex(int index, std::size_t runCount) { + if (runCount == 0) { + return 0; + } + return std::clamp(index, 0, static_cast(runCount) - 1); +} + enum class TransportIconKind { Play, Pause, @@ -232,7 +239,8 @@ ScenarioRunWidget::ScenarioRunWidget( std::move(openProjectHandler), std::move(backToLayoutReviewHandler), std::move(returnAuthoringState), - parent) {} + parent, + 0) {} ScenarioRunWidget::ScenarioRunWidget( const QString& projectName, @@ -260,7 +268,8 @@ ScenarioRunWidget::ScenarioRunWidget( std::move(openProjectHandler), std::move(backToLayoutReviewHandler), std::move(returnAuthoringState), - parent) {} + parent, + 0) {} ScenarioRunWidget::ScenarioRunWidget( const QString& projectName, @@ -270,7 +279,8 @@ ScenarioRunWidget::ScenarioRunWidget( std::function openProjectHandler, std::function backToLayoutReviewHandler, std::optional returnAuthoringState, - QWidget* parent) + QWidget* parent, + int initialSelectedRunIndex) : ScenarioRunWidget( projectName, layout, @@ -280,7 +290,8 @@ ScenarioRunWidget::ScenarioRunWidget( std::move(openProjectHandler), std::move(backToLayoutReviewHandler), std::move(returnAuthoringState), - parent) {} + parent, + initialSelectedRunIndex) {} ScenarioRunWidget::ScenarioRunWidget( const QString& projectName, @@ -291,11 +302,12 @@ ScenarioRunWidget::ScenarioRunWidget( std::function openProjectHandler, std::function backToLayoutReviewHandler, std::optional returnAuthoringState, - QWidget* parent) + QWidget* parent, + int initialSelectedRunIndex) : QWidget(parent), projectName_(projectName), layout_(layout), - scenario_(scenarios.empty() ? safecrowd::domain::ScenarioDraft{} : scenarios.front()), + scenario_({}), scenarios_(std::move(scenarios)), cachedResults_(std::move(cachedResults)), batchRunner_(layout_, scenarios_), @@ -303,6 +315,11 @@ ScenarioRunWidget::ScenarioRunWidget( saveProjectHandler_(std::move(saveProjectHandler)), openProjectHandler_(std::move(openProjectHandler)), backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)) { + selectedRunIndex_ = normalizedRunIndex(initialSelectedRunIndex, scenarios_.size()); + if (!scenarios_.empty()) { + scenario_ = scenarios_[static_cast(selectedRunIndex_)]; + } + auto* rootLayout = new QVBoxLayout(this); rootLayout->setContentsMargins(0, 0, 0, 0); rootLayout->setSpacing(0); @@ -398,7 +415,8 @@ QWidget* ScenarioRunWidget::createRunCanvas() { canvas_ = new SimulationCanvasWidget(layout_, container); canvas_->setMinimumHeight(360); if (!batchRunner_.empty()) { - const auto& run = batchRunner_.run(0); + const auto& run = batchRunner_.run(static_cast( + normalizedRunIndex(selectedRunIndex_, batchRunner_.size()))); canvas_->setConnectionBlocks(run.scenario.control.connectionBlocks); canvas_->setEnvironmentHazards(run.scenario.environment.hazards); canvas_->setRouteGuidances(run.scenario.control.routeGuidances); diff --git a/src/application/ScenarioRunWidget.h b/src/application/ScenarioRunWidget.h index 478bfb5..cc8f609 100644 --- a/src/application/ScenarioRunWidget.h +++ b/src/application/ScenarioRunWidget.h @@ -56,7 +56,8 @@ class ScenarioRunWidget : public QWidget { std::function openProjectHandler, std::function backToLayoutReviewHandler, std::optional returnAuthoringState = std::nullopt, - QWidget* parent = nullptr); + QWidget* parent = nullptr, + int initialSelectedRunIndex = 0); explicit ScenarioRunWidget( const QString& projectName, @@ -67,7 +68,8 @@ class ScenarioRunWidget : public QWidget { std::function openProjectHandler, std::function backToLayoutReviewHandler, std::optional returnAuthoringState = std::nullopt, - QWidget* parent = nullptr); + QWidget* parent = nullptr, + int initialSelectedRunIndex = 0); const safecrowd::domain::ScenarioDraft& scenario() const noexcept; const std::vector& scenarios() const noexcept; diff --git a/src/domain/DemoFixtureService.cpp b/src/domain/DemoFixtureService.cpp index a5189a7..1292090 100644 --- a/src/domain/DemoFixtureService.cpp +++ b/src/domain/DemoFixtureService.cpp @@ -35,10 +35,10 @@ ScenarioDraft makeTwoFloorEastExitGuidanceAlternative(const ScenarioDraft& basel .endSeconds = 180.0, .periods = {{.startSeconds = 0.0, .endSeconds = 180.0}}, .guidedExitZoneId = Ids::EastExitZoneId, - .installConnectionId = Ids::UpperCorridorEastStairConnectionId, - .baseComplianceRate = 0.85, - .guidanceStrength = 0.85, - .maxDetourMeters = 30.0, + .installConnectionId = Ids::UpperWestTrainingToCorridorConnectionId, + .baseComplianceRate = 0.95, + .guidanceStrength = 0.95, + .maxDetourMeters = 60.0, }); alternative.variationDiffKeys = computeScenarioDiffKeys(baseline, alternative); return alternative; diff --git a/src/domain/ScenarioSimulationMotionSystem.cpp b/src/domain/ScenarioSimulationMotionSystem.cpp index 8af041e..26984e3 100644 --- a/src/domain/ScenarioSimulationMotionSystem.cpp +++ b/src/domain/ScenarioSimulationMotionSystem.cpp @@ -451,6 +451,50 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { return it == layoutCache.layout.connections.end() ? nullptr : &(*it); } + bool connectionTouchesFloor( + const ScenarioLayoutCacheResource& layoutCache, + const Connection2D& connection, + const std::string& floorId) const { + if (sameFloor(connection.floorId, floorId)) { + return true; + } + const auto fromFloorId = cachedFloorIdForZone(layoutCache, connection.fromZoneId); + const auto toFloorId = cachedFloorIdForZone(layoutCache, connection.toZoneId); + return sameFloor(fromFloorId, floorId) || sameFloor(toFloorId, floorId); + } + + bool agentCanSeeGuidanceAtInstallConnection( + const ScenarioLayoutCacheResource& layoutCache, + const RouteGuidanceDraft& guidance, + const Position& position, + const Agent& agent, + const EvacuationRoute& route) const { + if (guidance.installConnectionId.empty()) { + return true; + } + + const auto* connection = findConnectionById(layoutCache, guidance.installConnectionId); + if (connection == nullptr || !connectionTouchesFloor(layoutCache, *connection, route.currentFloorId)) { + return false; + } + + const auto currentZoneId = zoneAt(layoutCache, position.value, route.currentFloorId); + if (!currentZoneId.empty() + && currentZoneId != connection->fromZoneId + && currentZoneId != connection->toZoneId) { + return false; + } + + const auto closestOnInstall = closestPointOnSegment( + position.value, + connection->centerSpan.start, + connection->centerSpan.end); + const auto visibilityDistance = std::max( + 2.0, + (connection->effectiveWidth * 0.5) + static_cast(agent.radius) + 0.25); + return distanceBetween(position.value, closestOnInstall) <= visibilityDistance; + } + const Connection2D* nextBlockedConnection( const ScenarioLayoutCacheResource& layoutCache, const EvacuationRoute& route) const { @@ -838,6 +882,7 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { struct ActiveRouteGuidance { const RouteGuidanceDraft* guidance{nullptr}; + std::size_t guidanceIndex{0}; std::size_t periodIndex{0}; double startSeconds{0.0}; double endSeconds{0.0}; @@ -847,7 +892,8 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { std::optional best; double bestStart = -1.0; - for (const auto& guidance : routeGuidances_) { + for (std::size_t guidanceIndex = 0; guidanceIndex < routeGuidances_.size(); ++guidanceIndex) { + const auto& guidance = routeGuidances_[guidanceIndex]; if (guidance.periods.empty()) { // No periods configured => always active (like connection blocks with no intervals). const double start = 0.0; @@ -857,7 +903,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } if (!best.has_value() || start >= bestStart) { bestStart = start; - best = ActiveRouteGuidance{.guidance = &guidance, .periodIndex = 0, .startSeconds = start, .endSeconds = end}; + best = ActiveRouteGuidance{ + .guidance = &guidance, + .guidanceIndex = guidanceIndex, + .periodIndex = 0, + .startSeconds = start, + .endSeconds = end, + }; } continue; } @@ -874,7 +926,13 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } if (!best.has_value() || start >= bestStart) { bestStart = start; - best = ActiveRouteGuidance{.guidance = &guidance, .periodIndex = index, .startSeconds = start, .endSeconds = end}; + best = ActiveRouteGuidance{ + .guidance = &guidance, + .guidanceIndex = guidanceIndex, + .periodIndex = index, + .startSeconds = start, + .endSeconds = end, + }; } } } @@ -929,6 +987,10 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { std::string activeId; if (active.has_value() && active->guidance != nullptr) { activeId = active->guidance->id; + if (activeId.empty()) { + activeId = "route-guidance:"; + activeId.append(std::to_string(active->guidanceIndex)); + } if (!active->guidance->periods.empty()) { activeId.append(":p"); activeId.append(std::to_string(active->periodIndex)); @@ -946,6 +1008,12 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { } guidanceReplanIdHash_ = fnv1a64(activeId); guidanceReplanPending_ = true; + } else if (active.has_value() + && active->guidance != nullptr + && !active->guidance->installConnectionId.empty() + && !guidanceReplanPending_) { + guidanceReplanCursor_ = 0; + guidanceReplanPending_ = true; } if (!guidanceReplanPending_ || guidanceReplanCursor_ >= entities.size()) { @@ -1002,6 +1070,15 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { continue; } + if (!activeGuidance->installConnectionId.empty()) { + if (route.guidanceEventId == activeId) { + continue; + } + if (!agentCanSeeGuidanceAtInstallConnection(layoutCache, *activeGuidance, position, agent, route)) { + continue; + } + } + bool guidedExitValid = false; if (!activeGuidance->guidedExitZoneId.empty()) { if (const auto* exitZone = findCachedZone(layoutCache, activeGuidance->guidedExitZoneId); diff --git a/tests/DemoFixtureServiceTests.cpp b/tests/DemoFixtureServiceTests.cpp index cc2821c..76f05c0 100644 --- a/tests/DemoFixtureServiceTests.cpp +++ b/tests/DemoFixtureServiceTests.cpp @@ -123,6 +123,15 @@ bool containsDiffKey( }); } +std::size_t evacuatedCountForExit( + const safecrowd::domain::ScenarioResultArtifacts& artifacts, + const std::string& exitZoneId) { + const auto it = std::find_if(artifacts.exitUsage.begin(), artifacts.exitUsage.end(), [&](const auto& exitUsage) { + return exitUsage.exitZoneId == exitZoneId; + }); + return it == artifacts.exitUsage.end() ? std::size_t{0} : it->evacuatedCount; +} + } // namespace SC_TEST(DemoFixtureServiceBuildsSprint1Fixture) { @@ -232,6 +241,9 @@ 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(containsDiffKey(fixture.alternativeScenario, "control.routeGuidances")); safecrowd::domain::ImportValidationService validator; @@ -274,15 +286,32 @@ SC_TEST(TwoFloorEvacuationDemoCrowdCompletesAfterStairDescent) { SC_TEST(TwoFloorEvacuationDemoAlternativeCrowdCompletesAfterGuidedStairDescent) { safecrowd::domain::DemoFixtureService service; const auto fixture = service.createTwoFloorEvacuationDemoFixture(); + using Ids = safecrowd::domain::DemoLayouts::TwoFloorEvacuationIds; - safecrowd::domain::ScenarioSimulationRunner runner(fixture.layout, fixture.alternativeScenario); - for (int step = 0; step < 6000 && !runner.complete(); ++step) { - runner.step(0.1); + safecrowd::domain::ScenarioSimulationRunner baselineRunner(fixture.layout, fixture.baselineScenario); + for (int step = 0; step < 6000 && !baselineRunner.complete(); ++step) { + baselineRunner.step(0.1); } - SC_EXPECT_EQ(runner.frame().totalAgentCount, std::size_t{80}); - SC_EXPECT_EQ(runner.frame().evacuatedAgentCount, std::size_t{80}); - SC_EXPECT_TRUE(runner.frame().agents.empty()); + safecrowd::domain::ScenarioSimulationRunner alternativeRunner(fixture.layout, fixture.alternativeScenario); + for (int step = 0; step < 6000 && !alternativeRunner.complete(); ++step) { + alternativeRunner.step(0.1); + } + + SC_EXPECT_EQ(baselineRunner.frame().totalAgentCount, std::size_t{80}); + SC_EXPECT_EQ(baselineRunner.frame().evacuatedAgentCount, std::size_t{80}); + SC_EXPECT_EQ(alternativeRunner.frame().totalAgentCount, std::size_t{80}); + SC_EXPECT_EQ(alternativeRunner.frame().evacuatedAgentCount, std::size_t{80}); + SC_EXPECT_TRUE(alternativeRunner.frame().agents.empty()); + + const auto baselineEastExitCount = evacuatedCountForExit( + baselineRunner.resultArtifacts(), + Ids::EastExitZoneId); + const auto alternativeEastExitCount = evacuatedCountForExit( + alternativeRunner.resultArtifacts(), + Ids::EastExitZoneId); + SC_EXPECT_TRUE(alternativeEastExitCount > baselineEastExitCount); + SC_EXPECT_TRUE(alternativeEastExitCount > std::size_t{0}); } SC_TEST(TwoFloorEvacuationDemoCrowdMovesOffLowerStairPortalAfterLanding) { diff --git a/tests/ScenarioSimulationSystemsTests.cpp b/tests/ScenarioSimulationSystemsTests.cpp index 3a8e814..4dad74b 100644 --- a/tests/ScenarioSimulationSystemsTests.cpp +++ b/tests/ScenarioSimulationSystemsTests.cpp @@ -1659,6 +1659,75 @@ SC_TEST(ScenarioSimulationMotionSystem_TreatsZeroGuidanceDetourAsStrictTolerance SC_EXPECT_TRUE(!route.followsGuidance); } +SC_TEST(ScenarioSimulationMotionSystem_AppliesInstalledGuidanceOnlyNearInstallConnection) { + auto runFromPosition = [](const safecrowd::domain::Point2D& start) { + std::vector seeds; + seeds.push_back({ + .position = {.value = start}, + .agent = {.radius = 0.25f, .maxSpeed = 1.0f, .guidancePropensity = 1.0}, + .velocity = {.value = {}}, + .route = { + .waypoints = {{.x = 2.0, .y = 0.5}}, + .waypointPassages = {{{.x = 2.0, .y = 0.3}, {.x = 2.0, .y = 0.7}}}, + .waypointFromZoneIds = {"room"}, + .waypointZoneIds = {"near-exit"}, + .waypointConnectionIds = {"room-near-exit"}, + .nextWaypointIndex = 0, + .currentSegmentStart = start, + .previousDistanceToWaypoint = 1.5, + .destinationZoneId = "near-exit", + .originalDestinationZoneId = "near-exit", + }, + .status = {}, + }); + + safecrowd::domain::RouteGuidanceDraft guidance; + guidance.id = "installed-guidance"; + guidance.guidedExitZoneId = "far-exit"; + guidance.installConnectionId = "room-near-exit"; + guidance.baseComplianceRate = 1.0; + guidance.guidanceStrength = 1.0; + guidance.maxDetourMeters = 100.0; + + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 1.0 / 30.0, + .maxCatchUpSteps = 1, + .baseSeed = 13, + }); + runtime.addSystem(std::make_unique(std::move(seeds), 10.0)); + runtime.addSystem( + safecrowd::domain::makeScenarioSimulationMotionSystem( + twoExitGuidanceDetourLayout(), + std::vector{guidance}), + {.phase = safecrowd::engine::UpdatePhase::PostSimulation, + .triggerPolicy = safecrowd::engine::TriggerPolicy::EveryFrame}); + + runtime.play(); + runtime.world().resources().set(safecrowd::domain::ScenarioSimulationStepResource{.deltaSeconds = 0.1}); + runtime.stepFrame(0.0); + + const auto entities = runtime.world().query().view< + safecrowd::domain::Position, + safecrowd::domain::Agent, + safecrowd::domain::Velocity, + safecrowd::domain::AvoidanceState, + safecrowd::domain::EvacuationRoute, + safecrowd::domain::EvacuationStatus>(); + SC_EXPECT_EQ(entities.size(), std::size_t{1}); + return runtime.world().query().get(entities.front()); + }; + + const auto unseenRoute = runFromPosition({.x = 0.5, .y = 3.5}); + SC_EXPECT_EQ(unseenRoute.destinationZoneId, std::string{"near-exit"}); + SC_EXPECT_TRUE(!unseenRoute.followsGuidance); + SC_EXPECT_TRUE(unseenRoute.guidanceEventId.empty()); + + const auto visibleRoute = runFromPosition({.x = 1.6, .y = 0.5}); + SC_EXPECT_EQ(visibleRoute.destinationZoneId, std::string{"far-exit"}); + SC_EXPECT_TRUE(visibleRoute.followsGuidance); + SC_EXPECT_EQ(visibleRoute.guidanceEventId, std::string{"installed-guidance"}); +} + SC_TEST(ScenarioSimulationMotionSystem_SkipsIntermediateWaypointWhenCrowdPushesAgentPastApproachArea) { std::vector seeds; seeds.push_back({ From 9a567fcb8377e0869e04541e3581dad0209d127e Mon Sep 17 00:00:00 2001 From: muzygosu Date: Thu, 14 May 2026 21:45:31 +0900 Subject: [PATCH 04/11] =?UTF-8?q?[Docs]=20=EB=8C=80=EC=95=88=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EA=B3=84=ED=9A=8D=20=EB=B0=8F=20=EA=B7=BC=EA=B1=B0?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC=20=EC=B6=94=EA=B0=80=20(#241)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Docs: add alternative recommendation notes * Docs: rename alternative recommendation notes * Docs: move alternative recommendation notes to docs root --- docs/alternative-recommendation-evidence.md | 192 ++++++++++++++++++++ docs/alternative-recommendation-plan.md | 73 ++++++++ 2 files changed, 265 insertions(+) create mode 100644 docs/alternative-recommendation-evidence.md create mode 100644 docs/alternative-recommendation-plan.md diff --git a/docs/alternative-recommendation-evidence.md b/docs/alternative-recommendation-evidence.md new file mode 100644 index 0000000..e9624a5 --- /dev/null +++ b/docs/alternative-recommendation-evidence.md @@ -0,0 +1,192 @@ + +## 0. 이 문서는 근거 정리를 위한 문서다. +포함 범위는 다음 네 가지다. + +| 위험상황 | 시뮬레이터에서 볼 지표 | 추천 대안 | +| ------------- | ---------------------------- | ------------------------------------------- | +| 출구 병목 | 출구 앞 밀도, 대기 지속 시간, 저속/정체 | 출구 추가 개방, 출구 유입 분산, 출구 앞 유입폭 제한, 장애물/가이드 배치 | +| 복도 병목 | 실제 통과 시간, 기준 대비 지연, 평균속도 저하 | 일방통행 전환, 우회 동선 유도, 혼잡 구간 분산 | +| 양방향 흐름 충돌 | 반대 방향 흐름 각도, 양측 흐름 비율, 속도 저하 | 동선 분리, 시간차 진입, 선 하차 후 승차 규칙 | +| 제한시간 초과 / 미대피 | 제한시간 내 미대피 인원 존재, 출구별 부하 불균형 | 출발 구역 분산, 출구 부하 균등화, 단계적 대피 | + + +--- + + + + + +## 1. 위험상황 판단기준을 뒷받침하는 근거 + +### 1.1 출구 병목 + +계획서 기준: + +- 출구 앞 2m 측정 구역의 밀도가 2명/㎡ 이상 +- 대기 또는 저속 상태가 10초 이상 지속 +- 해당 조건을 만족하면 출구 병목으로 분류 + +근거: + +- Daamen & Hoogendoorn(2010)은 출구 폭, 문 개방 상태, 군중 구성 등이 출구 용량에 직접적인 영향을 준다고 정리한다. 이는 출구를 단순 위치가 아니라 “용량을 가진 병목 요소”로 보아야 한다는 근거가 된다. +- Seyfried et al.(2009)은 병목 폭과 보행자 흐름 사이의 관계를 실험적으로 분석하며, 병목에서는 유입 인원과 통과 용량의 차이로 대기열과 정체가 생긴다는 점을 보인다. 따라서 출구 앞 밀도와 대기 지속시간을 함께 보는 방식이 타당하다. +- Cepolina(2009)는 대피 흐름에서 capacity drop 현상을 고려한 단계적 대피 모델을 다룬다. 이는 출구 앞에 인원이 한꺼번에 몰리는 상황을 줄이는 전략, 즉 유입 제한이나 단계적 대피 추천의 근거가 된다. +- Li et al.(2021)의 병목 실험 연구는 병목 전방에서 고밀도 분포, 저속 이동, 대기열이 함께 나타난다는 점을 보여준다. 일시적인 혼잡이 아니라 10초 이상 지속되는 상태를 병목으로 보는 기준을 보강한다. + + +### 1.2 복도 병목 + +계획서 기준: + +- 실제 통과 시간이 정상 예상 통과 시간의 2배 이상 +- 평균속도 0.5m/s 이하가 10초 이상 지속 +- 해당 조건을 만족하면 복도 병목으로 분류 + +근거: + +- Vanumu et al.(2017)은 보행자 흐름의 fundamental diagram 연구를 정리하며, 보행 시설의 성능을 밀도, 속도, 흐름량 관계로 설명한다. 복도 병목 판단에 평균속도와 통과시간을 쓰는 근거가 된다. +- Seyfried et al.(2005)은 보행자의 fundamental diagram을 통해 밀도가 증가하면 평균속도가 저하되는 관계를 다룬다. 복도에서 속도 저하가 지속되면 혼잡 상태로 볼 수 있다. +- Feliciani & Nishinari(2018)는 군중의 혼잡도와 위험도를 속도장 기반으로 측정하는 접근을 제안한다. 이는 특정 구간에서 지속적으로 낮은 속도와 불규칙한 움직임이 관측될 때 병목/핫스팟으로 판단할 수 있음을 뒷받침한다. + + +### 1.3 양방향 흐름 충돌 + +계획서 기준: + +- 120도 이상 반대 방향 흐름 +- 양쪽 흐름의 인원 비중이 각각 30% 이상 +- 평균속도 0.7m/s 이하가 10초 이상 지속 +- 해당 조건을 만족하면 양방향 흐름 충돌로 분류 + +근거: + +- Feliciani & Nishinari(2016)는 양방향 보행 흐름에서 lane formation이 형성되는 과정을 실험적으로 분석한다. 반대 방향 흐름이 같은 공간을 공유할 때 방향 비율과 상호작용이 흐름 안정성에 중요하다는 근거가 된다. +- Liao et al.(2014)은 병목을 통과하는 보행자 흐름에서 밀도, 속도, 병목 폭이 흐름 상태를 설명하는 핵심 변수임을 보인다. 양방향 흐름이 병목과 결합될 때 통과 효율이 악화될 수 있다. +- Aghabayk et al.(2020)은 교차 각도가 큰 보행 흐름일수록 충돌과 지연이 커진다는 점을 실험적으로 다룬다. 계획서의 “120도 이상” 기준은 정면 또는 정면에 가까운 충돌 흐름을 잡기 위한 실용적 기준으로 볼 수 있다. + + +### 1.4 제한시간 초과 / 미대피 + +계획서 기준: + +- 제한시간 동안 모든 인원이 대피하지 못함 +- 남은 인원이 특정 구역 또는 특정 출구에 몰려 있음 +- 해당 조건을 만족하면 제한시간 초과 또는 미대피 위험으로 분류 + +근거: + +- Han et al.(2021)의 time equalization 기반 단계적 실내 대피 알고리즘은 단순 최단거리 배정이 특정 출구를 과부하시킬 수 있음을 지적한다. 출구별 점유시간과 용량을 고려해 부하를 균등화해야 전체 대피시간을 줄일 수 있다는 근거가 된다. +- Cepolina(2009)는 단계적 대피를 통해 보행자 흐름의 capacity drop을 줄이는 접근을 다룬다. 이는 모든 인원이 동시에 출발하는 방식보다 출발 시점과 출구 부하를 조절하는 대안을 추천할 수 있음을 뒷받침한다. + + +--- + + + + + + + + + + + + +## 2. 대안추천별 근거 정리 + +### 2.1 출구 병목 대안 + +| 추천 대안 | 근거 | 구현 시 메시지 방향 | +|---|---|---| +| 출구 추가 개방 | 출구는 용량을 가진 병목 요소이며, 가용 출구 수가 늘면 출구별 부하를 분산할 수 있다. | “현재 출구에 대기 인원이 집중되어 있으므로, 추가 출구를 개방하거나 대체 출구를 안내하는 것이 좋습니다.” | +| 출구 유입 분산 | 병목 전방의 과도한 유입은 대기열과 capacity drop을 만든다. | “가장 가까운 출구만 사용하지 말고, 여유 출구로 일부 인원을 분산하는 대안이 필요합니다.” | +| 출구 전방 유입폭 제한 | 출구 앞 충돌을 줄이면 통과 흐름이 안정될 수 있다. 다만 잘못된 제한은 우회와 지연을 만든다. | “출구 앞 접근 방향을 줄여 충돌을 완화할 수 있으나, 배치 검증이 필요합니다.” | +| 장애물/가이드 배치 | Shi et al.(2019)은 출구 위치와 장애물 크기/거리 조정이 유출률을 높일 수 있지만, 너무 가까운 장애물은 오히려 악화될 수 있음을 보인다. | “출구 앞 장애물은 효과가 조건 의존적이므로, 추천 후보로 제시하되 시뮬레이션 재검증이 필요합니다.” | + + +### 2.2 복도 병목 대안 + +| 추천 대안 | 근거 | 구현 시 메시지 방향 | +|---|---|---| +| 일방통행 전환 | 양방향 마찰을 제거하면 복도 통과 효율이 좋아질 수 있다. | “복도에서 양방향 흐름이 섞이며 속도가 크게 낮아졌으므로, 한 방향 통행 또는 시간대별 방향 분리가 필요합니다.” | +| 우회 동선 유도 | 혼잡 구간을 우회하거나 여유 출구로 분산하면 특정 복도의 부하를 줄일 수 있다. | “현재 복도 통과 시간이 기준보다 크게 증가했으므로, 대체 복도/대체 출구로 분산하는 경로를 추천합니다.” | +| 통로폭 확장 후보 | 구조 변경 가능성이 있는 설계 단계라면 병목 구간 폭 확장이 대안이 될 수 있다. | “설계 대안으로 병목 복도 폭 확장을 검토할 수 있습니다.” | + + + +### 2.3 양방향 흐름 충돌 대안 + +| 추천 대안 | 근거 | 구현 시 메시지 방향 | +| --------- | ---------------------------------------------------------------- | ---------------------------------------------------- | +| 동선 분리 | 양방향 흐름은 자연스럽게 lane formation을 만들 수 있지만, 고밀도/큰 교차각에서는 불안정해질 수 있다. | “반대 방향 흐름이 같은 구간에서 충돌하므로, 중앙 가이드나 방향별 분리 동선이 필요합니다.” | +| 시간차 진입 | 양쪽 흐름이 동시에 진입하면 충돌과 정체가 커진다. | “양쪽 인원이 동시에 진입하지 않도록 시간차 진입을 적용하는 것이 좋습니다.” | +| 선 하차 후 승차 | 승하차/입출입이 맞물린 상황에서는 먼저 빠져나오는 흐름을 확보해야 한다. | “나오는 흐름을 먼저 처리한 뒤 들어가는 흐름을 허용하는 규칙이 필요합니다.” | +| 강성 분리대 | 물리적 분리대는 방향 충돌을 줄일 수 있지만, 폭을 줄이거나 장애물이 되면 악화될 수 있다. | “분리대는 흐름 분리 후보로 추천하되, 설치 후 병목 악화 여부를 재시뮬레이션해야 합니다.” | + + +### 2.4 제한시간 초과 / 미대피 대안 + +| 추천 대안 | 근거 | 구현 시 메시지 방향 | +|---|---|---| +| 출발 구역 분산 | 특정 구역 인원이 동시에 같은 출구로 향하면 출구 부하가 커진다. | “특정 출구에 인원이 집중되어 제한시간 내 대피가 어려우므로 출발 구역별 출구 배정을 조정해야 합니다.” | +| 출구 부하 균등화 | time equalization 연구는 출구별 점유시간을 균등화해 전체 대피시간을 줄이는 방향을 제시한다. | “가장 가까운 출구 기준 배정보다 출구별 예상 처리시간을 균등화하는 배정이 필요합니다.” | +| 단계적 대피 | 한꺼번에 유입되는 인원을 줄이면 병목 전방 정체를 완화할 수 있다. | “일부 구역은 즉시 출발, 일부 구역은 지연 출발하도록 단계적 대피를 적용하는 것이 좋습니다.” | + +--- + + + + + + + +## 3. 구현에 바로 연결되는 추천 규칙 초안 + +| 감지된 위험상황 | 추천 우선순위 | 추천 문구 예시 | +| ------------- | ---------------------------------------- | ----------------------------------- | +| 출구 병목 | 1. 출구 유입 분산 2. 추가 출구 개방 3. 유입폭 제한/가이드 배치 | “출구 앞 밀도와 대기 시간이 높습니다.” | +| 복도 병목 | 1. 우회 동선 유도 2. 일방통행 전환 3. 통로폭 확장 후보 | “복도 통과 시간이 정상 예상보다 크게 증가했습니다.” | +| 양방향 흐름 충돌 | 1. 동선 분리 2. 시간차 진입 3. 선 하차 후 승차 | “반대 방향 흐름이 같은 구간에서 충돌하며 속도가 저하됩니다.” | +| 제한시간 초과 / 미대피 | 1. 출구 부하 균등화 2. 출발 구역 분산 3. 단계적 대피 | “제한시간 내 대피가 완료되지 않았습니다. ” | + +--- + + + + +## 4. 추가 근거로 쓰일만한 논문 + +| 근거 | 이 문서에서 쓰는 이유 | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| Daamen & Hoogendoorn, “Capacity of Doors during Evacuation Conditions”, Procedia Engineering, 2010. https://doi.org/10.1016/j.proeng.2010.07.007 | 출구가 용량을 가진 병목 요소임을 설명한다. | +| Seyfried et al., “New Insights into Pedestrian Flow Through Bottlenecks”, Transportation Science, 2009. https://doi.org/10.1287/trsc.1090.0263 | 병목 폭, 흐름량, 대기열 판단의 근거다. | +| Cepolina, “Phased evacuation: An optimisation model which takes into account the capacity drop phenomenon in pedestrian flows”, Fire Safety Journal, 2009. https://doi.org/10.1016/j.firesaf.2008.11.002 | 단계적 대피와 capacity drop 완화 근거다. | +| Vanumu, Rao & Tiwari, “Fundamental diagrams of pedestrian flow characteristics: A review”, European Transport Research Review, 2017. https://doi.org/10.1007/s12544-017-0264-6 | 복도/보행 시설의 밀도-속도-흐름 관계 근거다. | +| Seyfried et al., “The fundamental diagram of pedestrian movement revisited”, Journal of Statistical Mechanics, 2005. https://doi.org/10.1088/1742-5468/2005/10/P10002 | 속도 저하와 혼잡 판단의 기초 근거다. | +| Feliciani & Nishinari, “Measurement of congestion and intrinsic risk in pedestrian crowds”, Transportation Research Part C, 2018. https://doi.org/10.1016/j.trc.2018.03.027 | 저속/불규칙 흐름 기반 혼잡도 판단 근거다. | +| Li et al., “A comparative study on the bottleneck pedestrian flow under different movement motivations”, Fire Safety Journal, 2021. https://doi.org/10.1016/j.firesaf.2020.103014 | 병목 전방 고밀도, 대기, 저속 상태 근거다. | +| Feliciani & Nishinari, “Empirical analysis of the lane formation process in bidirectional pedestrian flow”, Physical Review E, 2016. https://doi.org/10.1103/PhysRevE.94.032304 | 양방향 흐름과 lane formation 판단 근거다. | +| Liao et al., “Experimental Study on Pedestrian Flow through Wide Bottleneck”, Transportation Research Procedia, 2014. https://doi.org/10.1016/j.trpro.2014.09.006 | 병목 폭, 밀도, 속도, 흐름량 관계 근거다. | +| Aghabayk et al., “Effect of Intersecting Angle on Pedestrian Crowd Flow under Normal and Evacuation Conditions”, Sustainability, 2020. https://doi.org/10.3390/su12041301 | 큰 교차각에서 보행 흐름 충돌과 지연이 커질 수 있음을 보인다. | +| Shi et al., “Examining effect of architectural adjustment on pedestrian crowd flow at bottleneck”, Physica A, 2019. https://doi.org/10.1016/j.physa.2019.01.086 | 출구 위치, 기둥/장애물 배치가 병목 유출률에 영향을 준다는 근거다. | +| Adrian et al., “Crowds in front of bottlenecks at entrances from the perspective of physics and social psychology”, Journal of the Royal Society Interface, 2020. https://doi.org/10.1098/rsif.2019.0871 | 병목 전방 대기열과 군중 상호작용을 설명하는 보조 근거다. | +| Han et al., “A Multi-Zone Staged Indoor Emergency Evacuation Algorithm Based on Time Equalization”, ISPRS International Journal of Geo-Information, 2021. https://doi.org/10.3390/ijgi10080499 | 출구 부하 균등화와 단계적 대피 추천의 핵심 근거다. | + +--- + + +--- + + + + +## 5. 최종 결론 + +현재 대안추천 구현범위에서 가장 안전한 방향은 “정확한 최적 설계 자동 산출”이 아니라 “위험상황을 규칙 기반으로 분류하고, 해당 유형에 맞는 개선 후보를 추천한 뒤, 사용자가 재시뮬레이션으로 효과를 비교하게 하는 것”이다. + +따라서 논문 수치는 UI나 보고서에서 다음처럼 표현하는 것이 안전하다. + +- “문헌상 특정 조건에서 개선 사례가 보고됨” +- “현재 시나리오에서도 효과가 있는지는 재시뮬레이션으로 검증 필요” +- “장애물/분리대는 잘못 배치하면 오히려 악화될 수 있음” + diff --git a/docs/alternative-recommendation-plan.md b/docs/alternative-recommendation-plan.md new file mode 100644 index 0000000..379569b --- /dev/null +++ b/docs/alternative-recommendation-plan.md @@ -0,0 +1,73 @@ + +| 문제상황 | 추천 대안 | +| ------------- | ----------------------------------- | +| 출구 병목 | 출구 추가 개방, 출구 유입 분산, 출구 앞 장애물/가이드 배치 | +| 복도 병목 | 일방통행 전환, 우회 동선 유도 | +| 양방향 충돌 | 동선 분리, 시간차 진입, 선 하차 후 승차 규칙 | +| 제한시간 초과 / 미대피 | 출발 구역 분산, 출구 부하 균등화, 단계적 대피 | + +### 위험 상황 + +- 출구에서 병목 + - 판단기준 + 측정 구역(출구 앞 2M)에 밀도가 2명/㎡ 이상있을때, + 10초 이상 대기 상태가 지속되고, + + +- 복도에서 병목 + - 판단기준 + 복도 실제 통과 시간이 정상 예상 통과 시간의 2배 이상이고, + 평균속도가 0.5이하 상태가 10초 이상 지속되면 복도 병목으로 판단한다. + + +- 제한시간 초과/ 미대피 + - 판단기준 + 제한시간동안 모든인원이 대피하지 못했을때. + + +- 양방향 흐름 충돌 + - 판단기준 + 120도 이상 반대 방향 흐름, 양쪽 인원 각각 30% 이상, 속도 0.7m/s 이하가 10초 이상 + + + +### 대안추천 예상값 +**2. 문제상황별 해결방안과 효과** +논문 수치는 절대 보장값이 아니라 특정 실험/시뮬레이션 조건의 참고값으로 제시해야 한다. + +| 문제상황 | 해결방안 | 효과 | +| ----- | ------------------------- | --------------------------------------- | +| 출구 병목 | 출구 전방 유입폭 제한 | 밀도 감소, 줄서기 행동 증가, 흐름 안정화 | +| 출구 병목 | 출구를 모서리 위치로 변경 | 정상 보행 기준 유출률 +5.3%, 느린 구보 기준 +24.2% | +| 출구 병목 | 출구 앞 최적 위치에 60cm 원형 기둥 배치 | 정상 보행 +6.0%, 느린 구보 +22.3% | +| 출구 병목 | 최적화된 출구/장애물 설계 | 정상 보행 +19.4%, 느린 구보 +32.6% | +| 출구 병목 | 부적절한 기둥 배치 | 오히려 유출률 -10.6% 감소 | +| 출구 병목 | 다중 출구 분산 개방 | 3개 출구 총 6m 조건에서 90% 대피 39초, 100% 대피 55초 | +| 출구 병목 | 단일 대형 출구만 개방 | 같은 총 폭이어도 대피 완료가 85~95초까지 지연 | + + +| 문제상황 | 해결방안 | 효과 | +| ----- | ------------------ | -------------------------------------------- | +| 복도 병목 | 양방향 통행을 일방통행으로 전환 | 시설 수용력 +10~20% | +| 복도 병목 | 일방통행 운영 후 실제 흐름 측정 | 통과 속도 +30.2~50.5%, 대기 지연 -77.9% | +| 복도 병목 | 통로폭 확장 + 양방향 유지 | 대피시간 35.2% 감소 | +| 복도 병목 | 통로폭 확장 + 일방통행 전환 | 대피시간 54.5% 감소 | +| | | | +| | | | + +| 문제상황 | 해결방안 | 효과 | +| --------- | ----------------- | ---------------------------------- | +| 양방향 흐름 충돌 | 방향별 혼합 비율 관리 | 90:10 정도는 흐름 적응으로 효율이 좋아질 수 있음 | +| 양방향 흐름 충돌 | 70:30 비율 피하기 | 이 비율 근처에서 클로깅 위험이 커짐 | +| 양방향 흐름 충돌 | 선 하차 후 승차, 시간차 진입 | 정면충돌과 교착 감소 | +| 양방향 흐름 충돌 | 강성 분리대/물리적 동선 분리 | 평균 유출률 최대 +11.26%, 통과 속도 최대 +6.82% | +| 양방향 흐름 충돌 | 부적절한 장애물/통제구역 배치 | 평균 속도 -30.99%, 유출률 -19.45%까지 악화 가능 | + +|문제상황|해결방안|효과| +|---|---|---| +|제한시간 초과 / 미대피 증가|최단거리 기준 대신 시간 균등화 알고리즘 적용|전체 대피 300.28초 완료| +|제한시간 초과 / 미대피 증가|과부하 출구 인원을 다른 출구로 분산|가장 혼잡한 E1 구역 지연시간 180초 감소| +|제한시간 초과 / 미대피 증가|여유 구역 E2/E3로 부하 이전|E2/E3 시간 증가는 30초 내외로 제한| +|제한시간 초과 / 미대피 증가|단계적 출발 / 지연 대기 전략|병목 전방 동시 압력 감소, 미대피 발생 억제| + + From 3f0d1b2553c548bde1e11360eee7d43114c3e1b7 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Sat, 16 May 2026 01:43:32 +0900 Subject: [PATCH 05/11] Add recommended scenario draft generation --- CMakeLists.txt | 16 + docs/UI.md | 5 +- src/application/ScenarioAuthoringWidget.cpp | 44 +- src/application/ScenarioBatchResultWidget.cpp | 261 ++++++++- src/application/ScenarioBatchResultWidget.h | 5 + .../AlternativeRecommendationService.cpp | 548 ++++++++++++++++++ src/domain/AlternativeRecommendationService.h | 59 ++ src/domain/ScenarioAuthoring.cpp | 1 + .../AlternativeRecommendationServiceTests.cpp | 407 +++++++++++++ tests/ProjectPersistenceTests.cpp | 65 +++ tests/ScenarioAuthoringTests.cpp | 4 +- 11 files changed, 1393 insertions(+), 22 deletions(-) create mode 100644 src/domain/AlternativeRecommendationService.cpp create mode 100644 src/domain/AlternativeRecommendationService.h create mode 100644 tests/AlternativeRecommendationServiceTests.cpp create mode 100644 tests/ProjectPersistenceTests.cpp 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/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index 268e71f..e5e56f3 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -571,6 +571,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); @@ -1176,7 +1193,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; @@ -1309,10 +1326,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( @@ -1330,7 +1347,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_); } } @@ -1350,7 +1367,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) { @@ -1405,8 +1422,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')); @@ -1651,8 +1668,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); @@ -1755,8 +1772,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; } @@ -1942,8 +1958,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 758a2bc..7d6223f 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()) { @@ -584,6 +601,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: @@ -889,6 +962,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); @@ -913,10 +994,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{}); @@ -937,11 +1014,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_, @@ -953,6 +1038,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(); @@ -1215,6 +1361,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; @@ -1296,6 +1536,7 @@ void ScenarioBatchResultWidget::refreshSelectedResult() { static_cast(exitsChart_)->setResults(results_, selectedCompareIndices_, currentResultIndex_); } refreshPressureComparisonTable(); + refreshRecommendationPanel(); refreshResultNavigationPanel(); if (detailLabel_ != nullptr) { const auto selectedFinalSeconds = finalSeconds(result); @@ -1325,12 +1566,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..fa22db2 --- /dev/null +++ b/src/domain/AlternativeRecommendationService.cpp @@ -0,0 +1,548 @@ +#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::optional leastUsedExit( + const AlternativeRecommendationRequest& request, + const std::vector& excludedExitZoneIds = {}) { + if (request.artifacts.exitUsage.empty()) { + return std::nullopt; + } + + std::optional best; + for (const auto& usage : request.artifacts.exitUsage) { + 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 ScenarioResultArtifacts& artifacts) { + const auto it = std::max_element( + artifacts.exitUsage.begin(), + artifacts.exitUsage.end(), + [](const auto& lhs, const auto& rhs) { + if (lhs.usageRatio == rhs.usageRatio) { + return lhs.evacuatedCount < rhs.evacuatedCount; + } + return lhs.usageRatio < rhs.usageRatio; + }); + return it == artifacts.exitUsage.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; + }); +} + +std::optional blockedConnectionToRelieve(const AlternativeRecommendationRequest& request) { + if (request.sourceScenario.control.connectionBlocks.empty()) { + return std::nullopt; + } + for (const auto& bottleneck : request.risk.bottlenecks) { + if (sourceHasConnectionBlock(request.sourceScenario, bottleneck.connectionId)) { + return bottleneck.connectionId; + } + } + for (const auto& block : request.sourceScenario.control.connectionBlocks) { + if (!block.connectionId.empty()) { + return block.connectionId; + } + } + return 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(), [](const auto& lhs, const auto& rhs) { + if (lhs.stalledAgentCount == rhs.stalledAgentCount) { + return lhs.nearbyAgentCount < rhs.nearbyAgentCount; + } + return lhs.stalledAgentCount < rhs.stalledAgentCount; + }); + 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 + 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 ScenarioResultArtifacts.exitUsage" + : "least-used non-adjacent exit from ScenarioResultArtifacts.exitUsage")); + candidate.recommendedScenario = std::move(draft); + return candidate; +} + +std::optional makeExitBalancingCandidate( + const AlternativeRecommendationRequest& request) { + if (request.artifacts.exitUsage.size() < 2) { + return std::nullopt; + } + const auto low = leastUsedExit(request); + const auto high = mostUsedExit(request.artifacts); + 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 = "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), + "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; + } + + 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 + 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 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 7934cfe..52e387c 100644 --- a/src/domain/ScenarioAuthoring.cpp +++ b/src/domain/ScenarioAuthoring.cpp @@ -163,6 +163,7 @@ ScenarioDraft duplicateScenarioDraft(const ScenarioDraft& source, copy.scenarioId = std::move(newScenarioId); copy.name = std::move(newName); copy.role = ScenarioRole::Alternative; + 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..a5411c2 --- /dev/null +++ b/tests/AlternativeRecommendationServiceTests.cpp @@ -0,0 +1,407 @@ +#include "TestSupport.h" +#include "domain/AlternativeRecommendationService.h" + +#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; +} + +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_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_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 ScenarioResultArtifacts.exitUsage")); +} + +SC_TEST(AlternativeRecommendationService_skipsBottleneckGuidanceWithoutNonAdjacentExitUsage) { + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "door-east", + .nearbyAgentCount = 8, + .stalledAgentCount = 5, + }); + + auto artifacts = makeCompletedArtifacts(); + artifacts.exitUsage.push_back({ + .exitZoneId = "exit-east", + .exitLabel = "East Exit", + .evacuatedCount = 20, + .usageRatio = 1.0, + }); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = makeScenario(), + .risk = risk, + .artifacts = artifacts, + }); + + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::BottleneckBypassGuidance)); +} + +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(); + 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_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(), + }); + + 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..3f10397 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 = "recommendation:exit-usage-balancing:scenario-1"; 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_TRUE(variant.sourceTemplateId.empty()); SC_EXPECT_TRUE(variant.variationDiffKeys.empty()); SC_EXPECT_TRUE(variant.blockingIssues.empty()); SC_EXPECT_EQ(variant.population.initialPlacements.size(), baseline.population.initialPlacements.size()); From eb844b5f478ad3ce561f3a3111ac18299d2fdb3f Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Sat, 16 May 2026 01:47:44 +0900 Subject: [PATCH 06/11] Refine scenario duplicate provenance handling --- src/domain/ScenarioAuthoring.cpp | 8 +++++++- tests/ScenarioAuthoringTests.cpp | 13 +++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/domain/ScenarioAuthoring.cpp b/src/domain/ScenarioAuthoring.cpp index 52e387c..4f8a7c5 100644 --- a/src/domain/ScenarioAuthoring.cpp +++ b/src/domain/ScenarioAuthoring.cpp @@ -154,6 +154,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, @@ -163,7 +167,9 @@ ScenarioDraft duplicateScenarioDraft(const ScenarioDraft& source, copy.scenarioId = std::move(newScenarioId); copy.name = std::move(newName); copy.role = ScenarioRole::Alternative; - copy.sourceTemplateId.clear(); + if (isRecommendationSourceTemplateId(copy.sourceTemplateId)) { + copy.sourceTemplateId.clear(); + } copy.variationDiffKeys.clear(); copy.blockingIssues.clear(); return copy; diff --git a/tests/ScenarioAuthoringTests.cpp b/tests/ScenarioAuthoringTests.cpp index 3f10397..5b650a7 100644 --- a/tests/ScenarioAuthoringTests.cpp +++ b/tests/ScenarioAuthoringTests.cpp @@ -54,14 +54,14 @@ EnvironmentHazardDraft makeSmokeHazard() { SC_TEST(duplicateScenarioDraft_setsAlternativeRoleAndIdentity) { auto baseline = makeBaselineDraft(); - baseline.sourceTemplateId = "recommendation:exit-usage-balancing:scenario-1"; + 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_TRUE(variant.sourceTemplateId.empty()); + 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()); @@ -69,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()); From d867fc592baacaf0488237f5f774c0463bd911e7 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Sat, 16 May 2026 01:52:05 +0900 Subject: [PATCH 07/11] Clean recommendation evidence docs whitespace --- docs/alternative-recommendation-evidence.md | 3 +-- docs/alternative-recommendation-plan.md | 16 +++++++--------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/docs/alternative-recommendation-evidence.md b/docs/alternative-recommendation-evidence.md index e9624a5..3dc00fa 100644 --- a/docs/alternative-recommendation-evidence.md +++ b/docs/alternative-recommendation-evidence.md @@ -11,7 +11,7 @@ --- - + @@ -189,4 +189,3 @@ - “문헌상 특정 조건에서 개선 사례가 보고됨” - “현재 시나리오에서도 효과가 있는지는 재시뮬레이션으로 검증 필요” - “장애물/분리대는 잘못 배치하면 오히려 악화될 수 있음” - diff --git a/docs/alternative-recommendation-plan.md b/docs/alternative-recommendation-plan.md index 379569b..d8b0c75 100644 --- a/docs/alternative-recommendation-plan.md +++ b/docs/alternative-recommendation-plan.md @@ -12,23 +12,23 @@ - 판단기준 측정 구역(출구 앞 2M)에 밀도가 2명/㎡ 이상있을때, 10초 이상 대기 상태가 지속되고, - - + + - 복도에서 병목 - 판단기준 복도 실제 통과 시간이 정상 예상 통과 시간의 2배 이상이고, 평균속도가 0.5이하 상태가 10초 이상 지속되면 복도 병목으로 판단한다. - - + + - 제한시간 초과/ 미대피 - 판단기준 제한시간동안 모든인원이 대피하지 못했을때. - - + + - 양방향 흐름 충돌 - 판단기준 120도 이상 반대 방향 흐름, 양쪽 인원 각각 30% 이상, 속도 0.7m/s 이하가 10초 이상 - + ### 대안추천 예상값 @@ -69,5 +69,3 @@ |제한시간 초과 / 미대피 증가|과부하 출구 인원을 다른 출구로 분산|가장 혼잡한 E1 구역 지연시간 180초 감소| |제한시간 초과 / 미대피 증가|여유 구역 E2/E3로 부하 이전|E2/E3 시간 증가는 30초 내외로 제한| |제한시간 초과 / 미대피 증가|단계적 출발 / 지연 대기 전략|병목 전방 동시 압력 감소, 미대피 발생 억제| - - From 4cdc996b2d3c7e6f41e54ad1ab58394c678ab6f9 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Sat, 16 May 2026 02:19:53 +0900 Subject: [PATCH 08/11] Require bottleneck evidence for blocked connection recommendations --- src/domain/AlternativeRecommendationService.cpp | 8 ++------ tests/AlternativeRecommendationServiceTests.cpp | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/domain/AlternativeRecommendationService.cpp b/src/domain/AlternativeRecommendationService.cpp index fa22db2..bd8248c 100644 --- a/src/domain/AlternativeRecommendationService.cpp +++ b/src/domain/AlternativeRecommendationService.cpp @@ -230,15 +230,11 @@ std::optional blockedConnectionToRelieve(const AlternativeRecommend return std::nullopt; } for (const auto& bottleneck : request.risk.bottlenecks) { - if (sourceHasConnectionBlock(request.sourceScenario, bottleneck.connectionId)) { + if (!bottleneck.connectionId.empty() + && sourceHasConnectionBlock(request.sourceScenario, bottleneck.connectionId)) { return bottleneck.connectionId; } } - for (const auto& block : request.sourceScenario.control.connectionBlocks) { - if (!block.connectionId.empty()) { - return block.connectionId; - } - } return std::nullopt; } diff --git a/tests/AlternativeRecommendationServiceTests.cpp b/tests/AlternativeRecommendationServiceTests.cpp index a5411c2..752d011 100644 --- a/tests/AlternativeRecommendationServiceTests.cpp +++ b/tests/AlternativeRecommendationServiceTests.cpp @@ -189,6 +189,23 @@ SC_TEST(AlternativeRecommendationService_removesBlockedConnectionInRecommendedDr 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_addsRouteGuidanceForExitImbalance) { auto scenario = makeScenario(); const auto artifacts = makeExitUsageArtifacts(); From dced0f3fa196affbde1214a63e41787422fef3d9 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Sat, 16 May 2026 13:25:33 +0900 Subject: [PATCH 09/11] Improve blocked recommendation ranking --- .../AlternativeRecommendationService.cpp | 22 +++++++---- .../AlternativeRecommendationServiceTests.cpp | 38 +++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/domain/AlternativeRecommendationService.cpp b/src/domain/AlternativeRecommendationService.cpp index bd8248c..534381d 100644 --- a/src/domain/AlternativeRecommendationService.cpp +++ b/src/domain/AlternativeRecommendationService.cpp @@ -225,29 +225,35 @@ bool sourceHasConnectionBlock(const ScenarioDraft& scenario, const std::string& }); } +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)) { - return bottleneck.connectionId; + if (!best.has_value() || bottleneckLessSevere(*best, bottleneck)) { + best = bottleneck; + } } } - return std::nullopt; + 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(), [](const auto& lhs, const auto& rhs) { - if (lhs.stalledAgentCount == rhs.stalledAgentCount) { - return lhs.nearbyAgentCount < rhs.nearbyAgentCount; - } - return lhs.stalledAgentCount < rhs.stalledAgentCount; - }); + const auto it = std::max_element(risk.bottlenecks.begin(), risk.bottlenecks.end(), bottleneckLessSevere); return it == risk.bottlenecks.end() ? std::nullopt : std::optional{*it}; } diff --git a/tests/AlternativeRecommendationServiceTests.cpp b/tests/AlternativeRecommendationServiceTests.cpp index 752d011..6aaa2b8 100644 --- a/tests/AlternativeRecommendationServiceTests.cpp +++ b/tests/AlternativeRecommendationServiceTests.cpp @@ -206,6 +206,44 @@ SC_TEST(AlternativeRecommendationService_skipsBlockedConnectionWithoutBottleneck 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(); From d320e35027254d599fce80c1b8d867a50287fe66 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Sat, 16 May 2026 13:30:06 +0900 Subject: [PATCH 10/11] Avoid duplicate recommendation actions --- .../AlternativeRecommendationService.cpp | 6 ++++++ .../AlternativeRecommendationServiceTests.cpp | 20 +++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/domain/AlternativeRecommendationService.cpp b/src/domain/AlternativeRecommendationService.cpp index 534381d..c0da354 100644 --- a/src/domain/AlternativeRecommendationService.cpp +++ b/src/domain/AlternativeRecommendationService.cpp @@ -428,6 +428,12 @@ std::optional makePressureHotspotCandidate( || hasRouteGuidance(request.sourceScenario, targetExit->exitZoneId, {})) { return std::nullopt; } + if (const auto high = mostUsedExit(request.artifacts); + high.has_value() + && high->exitZoneId != targetExit->exitZoneId + && high->usageRatio - targetExit->usageRatio >= kExitImbalanceThreshold) { + return std::nullopt; + } auto draft = makeRecommendedDraft( request, diff --git a/tests/AlternativeRecommendationServiceTests.cpp b/tests/AlternativeRecommendationServiceTests.cpp index 6aaa2b8..bbfa752 100644 --- a/tests/AlternativeRecommendationServiceTests.cpp +++ b/tests/AlternativeRecommendationServiceTests.cpp @@ -379,7 +379,7 @@ SC_TEST(AlternativeRecommendationService_requiresExitUsageForBottleneckGuidance) } SC_TEST(AlternativeRecommendationService_addsPressureHotspotReliefWithExitUsage) { - auto artifacts = makeExitUsageArtifacts(); + auto artifacts = makeExitUsageArtifacts(0.55, 0.45); artifacts.pressureSummary.hotspotScoreThreshold = 4.0; artifacts.pressureSummary.peakPressureScore = 5.5; @@ -400,6 +400,22 @@ SC_TEST(AlternativeRecommendationService_addsPressureHotspotReliefWithExitUsage) 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; @@ -424,7 +440,7 @@ SC_TEST(AlternativeRecommendationService_usesRiskPressureEvidenceWhenArtifactPea .layout = makeRecommendationLayout(), .sourceScenario = makeScenario(), .risk = risk, - .artifacts = makeExitUsageArtifacts(), + .artifacts = makeExitUsageArtifacts(0.55, 0.45), }); const auto it = std::find_if(result.candidates.begin(), result.candidates.end(), [](const auto& candidate) { From 574319ba243ea3e2fd41ddf26fe26342854ab48f Mon Sep 17 00:00:00 2001 From: learncold Date: Sun, 17 May 2026 00:26:17 +0900 Subject: [PATCH 11/11] Fix recommendations for unused exits --- .../AlternativeRecommendationService.cpp | 55 ++++++++++++------ src/domain/ScenarioSimulationMotionSystem.cpp | 44 --------------- .../AlternativeRecommendationServiceTests.cpp | 56 +++++++++++++++---- 3 files changed, 85 insertions(+), 70 deletions(-) diff --git a/src/domain/AlternativeRecommendationService.cpp b/src/domain/AlternativeRecommendationService.cpp index c0da354..ace51f8 100644 --- a/src/domain/AlternativeRecommendationService.cpp +++ b/src/domain/AlternativeRecommendationService.cpp @@ -113,15 +113,37 @@ bool exitUsageContainsZone(const ScenarioResultArtifacts& artifacts, const std:: }); } +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 = {}) { - if (request.artifacts.exitUsage.empty()) { + const auto candidates = exitUsageCandidates(request); + if (candidates.empty()) { return std::nullopt; } std::optional best; - for (const auto& usage : request.artifacts.exitUsage) { + for (const auto& usage : candidates) { if (containsString(excludedExitZoneIds, usage.exitZoneId)) { continue; } @@ -134,17 +156,18 @@ std::optional leastUsedExit( return best; } -std::optional mostUsedExit(const ScenarioResultArtifacts& artifacts) { +std::optional mostUsedExit(const AlternativeRecommendationRequest& request) { + const auto candidates = exitUsageCandidates(request); const auto it = std::max_element( - artifacts.exitUsage.begin(), - artifacts.exitUsage.end(), + 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 == artifacts.exitUsage.end() ? std::nullopt : std::optional{*it}; + return it == candidates.end() ? std::nullopt : std::optional{*it}; } bool hasRouteGuidance(const ScenarioDraft& scenario, @@ -340,7 +363,7 @@ std::optional makeBottleneckGuidanceCandidat + 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 + ScenarioResultArtifacts.exitUsage"; + candidate.artifactSource = "ScenarioRiskSnapshot.bottlenecks + FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage"; candidate.evidence.push_back(evidence( "Bottleneck signal", std::to_string(bottleneck->stalledAgentCount) + " stalled / " @@ -356,19 +379,19 @@ std::optional makeBottleneckGuidanceCandidat "Guided exit", zoneName(request.layout, targetExit->exitZoneId), adjacentExitZoneIds.empty() - ? "least-used exit from ScenarioResultArtifacts.exitUsage" - : "least-used non-adjacent exit from ScenarioResultArtifacts.exitUsage")); + ? "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 (request.artifacts.exitUsage.size() < 2) { + if (exitUsageCandidates(request).size() < 2) { return std::nullopt; } const auto low = leastUsedExit(request); - const auto high = mostUsedExit(request.artifacts); + const auto high = mostUsedExit(request); if (!low.has_value() || !high.has_value() || low->exitZoneId.empty() || high->exitZoneId.empty()) { return std::nullopt; } @@ -396,7 +419,7 @@ std::optional makeExitBalancingCandidate( + zoneName(request.layout, low->exitZoneId) + "."; candidate.expectedImprovement = "Reduces dependence on " + zoneName(request.layout, high->exitZoneId) + " and may lower final evacuation time."; - candidate.artifactSource = "ScenarioResultArtifacts.exitUsage"; + candidate.artifactSource = "FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage"; candidate.evidence.push_back(evidence( "Most used exit", zoneName(request.layout, high->exitZoneId) + " at " + percent(high->usageRatio), @@ -404,7 +427,7 @@ std::optional makeExitBalancingCandidate( candidate.evidence.push_back(evidence( "Underused exit", zoneName(request.layout, low->exitZoneId) + " at " + percent(low->usageRatio), - "ScenarioResultArtifacts.exitUsage")); + "FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage")); candidate.recommendedScenario = std::move(draft); return candidate; } @@ -428,7 +451,7 @@ std::optional makePressureHotspotCandidate( || hasRouteGuidance(request.sourceScenario, targetExit->exitZoneId, {})) { return std::nullopt; } - if (const auto high = mostUsedExit(request.artifacts); + if (const auto high = mostUsedExit(request); high.has_value() && high->exitZoneId != targetExit->exitZoneId && high->usageRatio - targetExit->usageRatio >= kExitImbalanceThreshold) { @@ -452,7 +475,7 @@ std::optional makePressureHotspotCandidate( 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 + ScenarioResultArtifacts.exitUsage"; + 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", @@ -498,7 +521,7 @@ std::optional makePressureHotspotCandidate( candidate.evidence.push_back(evidence( "Guided exit", zoneName(request.layout, targetExit->exitZoneId), - "least-used exit from ScenarioResultArtifacts.exitUsage")); + "least-used exit from FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage")); candidate.recommendedScenario = std::move(draft); return candidate; } diff --git a/src/domain/ScenarioSimulationMotionSystem.cpp b/src/domain/ScenarioSimulationMotionSystem.cpp index 5ad9492..e8ae4b6 100644 --- a/src/domain/ScenarioSimulationMotionSystem.cpp +++ b/src/domain/ScenarioSimulationMotionSystem.cpp @@ -592,50 +592,6 @@ class ScenarioSimulationMotionSystem final : public engine::EngineSystem { return distanceBetween(position.value, closestOnInstall) <= visibilityDistance; } - bool connectionTouchesFloor( - const ScenarioLayoutCacheResource& layoutCache, - const Connection2D& connection, - const std::string& floorId) const { - if (sameFloor(connection.floorId, floorId)) { - return true; - } - const auto fromFloorId = cachedFloorIdForZone(layoutCache, connection.fromZoneId); - const auto toFloorId = cachedFloorIdForZone(layoutCache, connection.toZoneId); - return sameFloor(fromFloorId, floorId) || sameFloor(toFloorId, floorId); - } - - bool agentCanSeeGuidanceAtInstallConnection( - const ScenarioLayoutCacheResource& layoutCache, - const RouteGuidanceDraft& guidance, - const Position& position, - const Agent& agent, - const EvacuationRoute& route) const { - if (guidance.installConnectionId.empty()) { - return true; - } - - const auto* connection = findConnectionById(layoutCache, guidance.installConnectionId); - if (connection == nullptr || !connectionTouchesFloor(layoutCache, *connection, route.currentFloorId)) { - return false; - } - - const auto currentZoneId = zoneAt(layoutCache, position.value, route.currentFloorId); - if (!currentZoneId.empty() - && currentZoneId != connection->fromZoneId - && currentZoneId != connection->toZoneId) { - return false; - } - - const auto closestOnInstall = closestPointOnSegment( - position.value, - connection->centerSpan.start, - connection->centerSpan.end); - const auto visibilityDistance = std::max( - 2.0, - (connection->effectiveWidth * 0.5) + static_cast(agent.radius) + 0.25); - return distanceBetween(position.value, closestOnInstall) <= visibilityDistance; - } - const Connection2D* nextBlockedConnection( const ScenarioLayoutCacheResource& layoutCache, const EvacuationRoute& route) const { diff --git a/tests/AlternativeRecommendationServiceTests.cpp b/tests/AlternativeRecommendationServiceTests.cpp index bbfa752..7bec94a 100644 --- a/tests/AlternativeRecommendationServiceTests.cpp +++ b/tests/AlternativeRecommendationServiceTests.cpp @@ -2,6 +2,7 @@ #include "domain/AlternativeRecommendationService.h" #include +#include using namespace safecrowd::domain; @@ -82,6 +83,21 @@ ScenarioResultArtifacts makeExitUsageArtifacts(double mainRatio = 0.85, double e 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) { @@ -268,6 +284,25 @@ SC_TEST(AlternativeRecommendationService_addsRouteGuidanceForExitImbalance) { 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({ @@ -329,10 +364,10 @@ SC_TEST(AlternativeRecommendationService_guidesBottleneckAwayFromAdjacentLeastUs 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 ScenarioResultArtifacts.exitUsage")); + SC_EXPECT_TRUE(containsEvidenceSource(*it, "least-used non-adjacent exit from FacilityLayout2D.zones + ScenarioResultArtifacts.exitUsage")); } -SC_TEST(AlternativeRecommendationService_skipsBottleneckGuidanceWithoutNonAdjacentExitUsage) { +SC_TEST(AlternativeRecommendationService_guidesBottleneckTowardUnusedNonAdjacentLayoutExit) { ScenarioRiskSnapshot risk; risk.bottlenecks.push_back({ .connectionId = "door-east", @@ -340,13 +375,7 @@ SC_TEST(AlternativeRecommendationService_skipsBottleneckGuidanceWithoutNonAdjace .stalledAgentCount = 5, }); - auto artifacts = makeCompletedArtifacts(); - artifacts.exitUsage.push_back({ - .exitZoneId = "exit-east", - .exitLabel = "East Exit", - .evacuatedCount = 20, - .usageRatio = 1.0, - }); + const auto artifacts = makeSingleExitUsageArtifacts("exit-east", "East Exit", 20, 1.0); const AlternativeRecommendationService service; const auto result = service.recommend({ @@ -356,7 +385,14 @@ SC_TEST(AlternativeRecommendationService_skipsBottleneckGuidanceWithoutNonAdjace .artifacts = artifacts, }); - SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::BottleneckBypassGuidance)); + 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) {