diff --git a/src/application/MainWindow.cpp b/src/application/MainWindow.cpp index bc66d20..02443f3 100644 --- a/src/application/MainWindow.cpp +++ b/src/application/MainWindow.cpp @@ -584,9 +584,11 @@ void MainWindow::openProject(const ProjectMetadata& metadata) { auto returnAuthoringState = workspace.authoring.has_value() ? std::make_optional(initialStateFromSaved(*workspace.authoring, *importResult.layout)) : std::optional{}; - const auto initialSelectedRunIndex = selectedRunIndexFor( - workspace.runningScenarios, - workspace.runningScenario); + const auto initialSelectedRunIndex = workspace.runningScenarioIndex >= 0 + ? workspace.runningScenarioIndex + : selectedRunIndexFor( + workspace.runningScenarios, + workspace.runningScenario); showScenarioRun( *importResult.layout, std::move(workspace.runningScenarios), @@ -740,6 +742,7 @@ void MainWindow::saveCurrentProject() { .navigationView = resultWidget->currentSavedNavigationView(), }; } else if (activeWorkflowWidget == runWidget) { + runWidget->commitRunSettings(); if (runWidget->returnAuthoringState().has_value()) { workspace.authoring = savedStateFromInitial(*runWidget->returnAuthoringState()); } @@ -763,6 +766,7 @@ void MainWindow::saveCurrentProject() { workspace.activeView = ProjectWorkspaceView::ScenarioRun; workspace.runningScenario = runWidget->scenario(); workspace.runningScenarios = runWidget->scenarios(); + workspace.runningScenarioIndex = runWidget->selectedRunIndex(); } } diff --git a/src/application/ProjectWorkspaceState.h b/src/application/ProjectWorkspaceState.h index c925fd0..dea70ff 100644 --- a/src/application/ProjectWorkspaceState.h +++ b/src/application/ProjectWorkspaceState.h @@ -71,6 +71,7 @@ struct ProjectWorkspaceState { std::optional authoring{}; std::optional runningScenario{}; std::vector runningScenarios{}; + int runningScenarioIndex{-1}; std::optional result{}; std::optional batchResult{}; }; diff --git a/src/application/ScenarioRunWidget.cpp b/src/application/ScenarioRunWidget.cpp index 1a8cf82..e0d2e11 100644 --- a/src/application/ScenarioRunWidget.cpp +++ b/src/application/ScenarioRunWidget.cpp @@ -2,11 +2,13 @@ #include #include +#include #include #include #include #include +#include #include #include #include @@ -16,7 +18,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -33,6 +37,9 @@ namespace { constexpr double kSimulationDeltaSeconds = 1.0 / 30.0; constexpr int kPlaybackTimerIntervalMs = 33; +constexpr double kDefaultTimeLimitSeconds = 60.0; +constexpr double kDefaultSampleIntervalSeconds = 1.0; +constexpr int kMaxUiSeed = 2147483647; int normalizedRunIndex(int index, std::size_t runCount) { if (runCount == 0) { @@ -140,6 +147,31 @@ int percentValue(double numerator, double denominator) { return static_cast(std::round(percent)); } +double normalizedTimeLimitSeconds(const safecrowd::domain::ExecutionConfig& execution) { + return execution.timeLimitSeconds > 0.0 ? execution.timeLimitSeconds : kDefaultTimeLimitSeconds; +} + +double normalizedSampleIntervalSeconds(const safecrowd::domain::ExecutionConfig& execution) { + return execution.sampleIntervalSeconds > 0.0 ? execution.sampleIntervalSeconds : kDefaultSampleIntervalSeconds; +} + +int normalizedRepeatCount(const safecrowd::domain::ExecutionConfig& execution) { + const auto repeatCount = std::min( + execution.repeatCount, + safecrowd::domain::kScenarioExecutionMaxRepeatCount); + return std::max(1, static_cast(repeatCount)); +} + +int normalizedSeed(const safecrowd::domain::ExecutionConfig& execution) { + if (execution.baseSeed == 0) { + return 1; + } + const auto seed = std::min( + execution.baseSeed, + static_cast(kMaxUiSeed)); + return std::max(1, static_cast(seed)); +} + const safecrowd::domain::Zone2D* firstStartZone(const safecrowd::domain::FacilityLayout2D& layout) { const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { return zone.kind == safecrowd::domain::ZoneKind::Room || zone.kind == safecrowd::domain::ZoneKind::Unknown; @@ -331,9 +363,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_)]; + selectedRunIndex_ = normalizedRunIndex(initialSelectedRunIndex, batchRunner_.size()); + if (!batchRunner_.empty()) { + scenario_ = batchRunner_.run(static_cast(selectedRunIndex_)).scenario; + } else if (!scenarios_.empty()) { + scenario_ = scenarios_.front(); } auto* rootLayout = new QVBoxLayout(this); @@ -395,6 +429,12 @@ int ScenarioRunWidget::selectedRunIndex() const noexcept { return selectedRunIndex_; } +void ScenarioRunWidget::commitRunSettings() { + if (!batchRunner_.complete() && runSettingsChanged()) { + applyRunSettings(); + } +} + std::vector ScenarioRunWidget::resultsForSave() { if (batchRunner_.complete() && !batchRunner_.empty()) { return completedResults(); @@ -422,6 +462,108 @@ std::vector ScenarioRunWidget::completedResults() { return results; } +std::size_t ScenarioRunWidget::selectedSourceScenarioIndex() const { + if (scenarios_.empty()) { + return 0; + } + if (!batchRunner_.empty() + && selectedRunIndex_ >= 0 + && selectedRunIndex_ < static_cast(batchRunner_.size())) { + return std::min( + batchRunner_.run(static_cast(selectedRunIndex_)).sourceScenarioIndex, + scenarios_.size() - 1); + } + return static_cast(normalizedRunIndex(selectedRunIndex_, scenarios_.size())); +} + +int ScenarioRunWidget::firstRunIndexForSource(std::size_t sourceScenarioIndex) const { + for (std::size_t index = 0; index < batchRunner_.size(); ++index) { + if (batchRunner_.run(index).sourceScenarioIndex == sourceScenarioIndex) { + return static_cast(index); + } + } + return normalizedRunIndex(selectedRunIndex_, batchRunner_.size()); +} + +void ScenarioRunWidget::syncRunSettingsControls() { + if (scenarios_.empty() + || timeLimitSpinBox_ == nullptr + || sampleIntervalSpinBox_ == nullptr + || repeatCountSpinBox_ == nullptr + || baseSeedSpinBox_ == nullptr) { + return; + } + + const auto sourceIndex = selectedSourceScenarioIndex(); + const auto& execution = scenarios_[sourceIndex].execution; + + const QSignalBlocker timeBlocker(timeLimitSpinBox_); + const QSignalBlocker sampleBlocker(sampleIntervalSpinBox_); + const QSignalBlocker repeatBlocker(repeatCountSpinBox_); + const QSignalBlocker seedBlocker(baseSeedSpinBox_); + + timeLimitSpinBox_->setValue(normalizedTimeLimitSeconds(execution)); + sampleIntervalSpinBox_->setValue(normalizedSampleIntervalSeconds(execution)); + repeatCountSpinBox_->setValue(normalizedRepeatCount(execution)); + baseSeedSpinBox_->setValue(normalizedSeed(execution)); +} + +void ScenarioRunWidget::rebuildRunCanvas() { + if (shell_ != nullptr) { + shell_->setCanvas(createRunCanvas()); + } +} + +bool ScenarioRunWidget::runSettingsChanged() const { + if (scenarios_.empty() + || timeLimitSpinBox_ == nullptr + || sampleIntervalSpinBox_ == nullptr + || repeatCountSpinBox_ == nullptr + || baseSeedSpinBox_ == nullptr) { + return false; + } + + const auto sourceIndex = selectedSourceScenarioIndex(); + const auto& execution = scenarios_[sourceIndex].execution; + return std::fabs(normalizedTimeLimitSeconds(execution) - timeLimitSpinBox_->value()) > 1e-9 + || std::fabs(normalizedSampleIntervalSeconds(execution) - sampleIntervalSpinBox_->value()) > 1e-9 + || normalizedRepeatCount(execution) != repeatCountSpinBox_->value() + || normalizedSeed(execution) != baseSeedSpinBox_->value(); +} + +void ScenarioRunWidget::applyRunSettings() { + if (scenarios_.empty() + || timeLimitSpinBox_ == nullptr + || sampleIntervalSpinBox_ == nullptr + || repeatCountSpinBox_ == nullptr + || baseSeedSpinBox_ == nullptr) { + return; + } + + const auto sourceIndex = selectedSourceScenarioIndex(); + auto& scenario = scenarios_[sourceIndex]; + scenario.execution.timeLimitSeconds = timeLimitSpinBox_->value(); + scenario.execution.sampleIntervalSeconds = sampleIntervalSpinBox_->value(); + scenario.execution.repeatCount = static_cast(repeatCountSpinBox_->value()); + scenario.execution.baseSeed = static_cast(baseSeedSpinBox_->value()); + + cachedResults_.clear(); + playbackSpeedMultiplier_ = 1; + paused_ = true; + batchRunner_.reset(layout_, scenarios_); + selectedRunIndex_ = firstRunIndexForSource(sourceIndex); + if (!batchRunner_.empty()) { + scenario_ = batchRunner_.run(static_cast(selectedRunIndex_)).scenario; + } else { + scenario_ = {}; + } + rebuildRunCanvas(); + refreshStatus(); + if (timer_ != nullptr && !timer_->isActive()) { + timer_->start(); + } +} + QWidget* ScenarioRunWidget::createRunCanvas() { auto* container = new QWidget(shell_); auto* layout = new QVBoxLayout(container); @@ -496,6 +638,51 @@ QWidget* ScenarioRunWidget::createRunPanel() { layout->setSpacing(12); layout->addWidget(shell_ != nullptr ? shell_->createPanelHeader("Run", panel, false) : createLabel("Run", panel, ui::FontRole::Title)); + layout->addWidget(createLabel("Run Settings", panel, ui::FontRole::SectionTitle)); + + auto* settingsGrid = new QGridLayout(); + settingsGrid->setContentsMargins(0, 0, 0, 0); + settingsGrid->setHorizontalSpacing(8); + settingsGrid->setVerticalSpacing(8); + + timeLimitSpinBox_ = new QDoubleSpinBox(panel); + timeLimitSpinBox_->setRange(1.0, 86400.0); + timeLimitSpinBox_->setDecimals(0); + timeLimitSpinBox_->setSingleStep(30.0); + timeLimitSpinBox_->setSuffix(" sec"); + timeLimitSpinBox_->setToolTip("Time limit"); + + sampleIntervalSpinBox_ = new QDoubleSpinBox(panel); + sampleIntervalSpinBox_->setRange(0.1, 60.0); + sampleIntervalSpinBox_->setDecimals(1); + sampleIntervalSpinBox_->setSingleStep(0.5); + sampleIntervalSpinBox_->setSuffix(" sec"); + sampleIntervalSpinBox_->setToolTip("Sample interval"); + + repeatCountSpinBox_ = new QSpinBox(panel); + repeatCountSpinBox_->setRange(1, static_cast(safecrowd::domain::kScenarioExecutionMaxRepeatCount)); + repeatCountSpinBox_->setSuffix(" runs"); + repeatCountSpinBox_->setToolTip("Repeat count"); + + baseSeedSpinBox_ = new QSpinBox(panel); + baseSeedSpinBox_->setRange(1, kMaxUiSeed); + baseSeedSpinBox_->setToolTip("Base random seed"); + + settingsGrid->addWidget(createLabel("Time limit", panel, ui::FontRole::Caption), 0, 0); + settingsGrid->addWidget(timeLimitSpinBox_, 0, 1); + settingsGrid->addWidget(createLabel("Sample", panel, ui::FontRole::Caption), 1, 0); + settingsGrid->addWidget(sampleIntervalSpinBox_, 1, 1); + settingsGrid->addWidget(createLabel("Repeats", panel, ui::FontRole::Caption), 2, 0); + settingsGrid->addWidget(repeatCountSpinBox_, 2, 1); + settingsGrid->addWidget(createLabel("Seed", panel, ui::FontRole::Caption), 3, 0); + settingsGrid->addWidget(baseSeedSpinBox_, 3, 1); + layout->addLayout(settingsGrid); + + applySettingsButton_ = new QPushButton("Apply Settings", panel); + applySettingsButton_->setFont(ui::font(ui::FontRole::Body)); + applySettingsButton_->setStyleSheet(ui::secondaryButtonStyleSheet()); + layout->addWidget(applySettingsButton_); + scenarioLabel_ = createLabel("", panel); scenarioLabel_->setStyleSheet(ui::mutedTextStyleSheet()); statusLabel_ = createLabel("", panel); @@ -563,6 +750,11 @@ QWidget* ScenarioRunWidget::createRunPanel() { connect(resultButton_, &QPushButton::clicked, this, [this]() { showResults(); }); + connect(applySettingsButton_, &QPushButton::clicked, this, [this]() { + applyRunSettings(); + }); + + syncRunSettingsControls(); return panel; } @@ -572,6 +764,7 @@ void ScenarioRunWidget::returnToAuthoring() { if (timer_ != nullptr) { timer_->stop(); } + const auto sourceScenarioIndex = selectedSourceScenarioIndex(); auto* rootLayout = qobject_cast(layout()); if (rootLayout == nullptr || shell_ == nullptr) { @@ -585,15 +778,15 @@ void ScenarioRunWidget::returnToAuthoring() { } initial.navigationView = ScenarioAuthoringWidget::NavigationView::Layout; } - if (selectedRunIndex_ >= 0 && selectedRunIndex_ < static_cast(scenarios_.size())) { - const auto& selectedScenarioId = scenarios_[static_cast(selectedRunIndex_)].scenarioId; + if (sourceScenarioIndex < scenarios_.size()) { + const auto& selectedScenarioId = scenarios_[sourceScenarioIndex].scenarioId; const auto selectedIt = std::find_if(initial.scenarios.begin(), initial.scenarios.end(), [&](const auto& scenario) { return scenario.draft.scenarioId == selectedScenarioId; }); if (selectedIt != initial.scenarios.end()) { initial.currentScenarioIndex = static_cast(std::distance(initial.scenarios.begin(), selectedIt)); - } else if (selectedRunIndex_ < static_cast(initial.scenarios.size())) { - initial.currentScenarioIndex = selectedRunIndex_; + } else if (sourceScenarioIndex < initial.scenarios.size()) { + initial.currentScenarioIndex = static_cast(sourceScenarioIndex); } } initial.rightPanelMode = ScenarioAuthoringWidget::RightPanelMode::Scenario; @@ -642,7 +835,14 @@ void ScenarioRunWidget::refreshStatus() { : ui::secondaryButtonStyleSheet()); } if (index < previewStatusLabels_.size() && previewStatusLabels_[index] != nullptr) { - previewStatusLabels_[index]->setText(QString("%1 - %2 / %3 evacuated") + const auto repeatText = run.repeatCount > 1 + ? QString("Run %1/%2 - Seed %3\n") + .arg(run.repeatIndex) + .arg(run.repeatCount) + .arg(run.runSeed) + : QString{}; + previewStatusLabels_[index]->setText(QString("%1%2 - %3 / %4 evacuated") + .arg(repeatText) .arg(simulationStatusText(run.complete, paused_, playbackSpeedMultiplier_)) .arg(static_cast(run.frame.evacuatedAgentCount)) .arg(static_cast(run.frame.totalAgentCount))); @@ -656,7 +856,7 @@ void ScenarioRunWidget::refreshStatus() { return run.complete; })); const auto runCount = static_cast(batchRunner_.size()); - scenarioLabel_->setText(QString("Running %1 scenario%2\nSelected: %3\nBatch: %4 / %5 complete") + scenarioLabel_->setText(QString("Running %1 run%2\nSelected: %3\nBatch: %4 / %5 complete") .arg(runCount) .arg(runCount == 1 ? "" : "s") .arg(QString::fromStdString(selectedRun.scenario.name)) @@ -734,6 +934,22 @@ void ScenarioRunWidget::refreshStatus() { resultButton_->setEnabled( batchRunner_.complete() && !batchRunner_.empty()); } + const bool settingsEnabled = paused_ && !batchRunner_.complete(); + if (timeLimitSpinBox_ != nullptr) { + timeLimitSpinBox_->setEnabled(settingsEnabled); + } + if (sampleIntervalSpinBox_ != nullptr) { + sampleIntervalSpinBox_->setEnabled(settingsEnabled); + } + if (repeatCountSpinBox_ != nullptr) { + repeatCountSpinBox_->setEnabled(settingsEnabled); + } + if (baseSeedSpinBox_ != nullptr) { + baseSeedSpinBox_->setEnabled(settingsEnabled); + } + if (applySettingsButton_ != nullptr) { + applySettingsButton_->setEnabled(settingsEnabled); + } } void ScenarioRunWidget::selectRun(int index) { @@ -742,6 +958,7 @@ void ScenarioRunWidget::selectRun(int index) { } selectedRunIndex_ = index; scenario_ = batchRunner_.run(static_cast(selectedRunIndex_)).scenario; + syncRunSettingsControls(); refreshStatus(); } @@ -755,6 +972,9 @@ void ScenarioRunWidget::cycleFastForwardMode() { refreshStatus(); return; } + if (paused_ && runSettingsChanged()) { + applyRunSettings(); + } if (playbackSpeedMultiplier_ == 1) { playbackSpeedMultiplier_ = 2; } else if (playbackSpeedMultiplier_ == 2) { @@ -777,6 +997,10 @@ void ScenarioRunWidget::stopRun() { timer_->stop(); } batchRunner_.reset(layout_, scenarios_); + selectedRunIndex_ = normalizedRunIndex(selectedRunIndex_, batchRunner_.size()); + if (!batchRunner_.empty()) { + scenario_ = batchRunner_.run(static_cast(selectedRunIndex_)).scenario; + } refreshStatus(); if (timer_ != nullptr) { timer_->start(); @@ -855,6 +1079,9 @@ void ScenarioRunWidget::togglePaused() { if (batchRunner_.complete()) { return; } + if (paused_ && runSettingsChanged()) { + applyRunSettings(); + } paused_ = !paused_; if (!paused_ && timer_ != nullptr && !timer_->isActive()) { timer_->start(); diff --git a/src/application/ScenarioRunWidget.h b/src/application/ScenarioRunWidget.h index cc8f609..7168e72 100644 --- a/src/application/ScenarioRunWidget.h +++ b/src/application/ScenarioRunWidget.h @@ -14,8 +14,10 @@ #include "domain/ScenarioBatchRunner.h" class QLabel; +class QDoubleSpinBox; class QProgressBar; class QPushButton; +class QSpinBox; class QTimer; namespace safecrowd::application { @@ -76,19 +78,26 @@ class ScenarioRunWidget : public QWidget { const std::optional& returnAuthoringState() const noexcept; bool hasResultsForSave() const noexcept; int selectedRunIndex() const noexcept; + void commitRunSettings(); std::vector resultsForSave(); private: QWidget* createRunCanvas(); QWidget* createRunPanel(); + void applyRunSettings(); void cycleFastForwardMode(); + int firstRunIndexForSource(std::size_t sourceScenarioIndex) const; bool hasCachedResults() const noexcept; + bool runSettingsChanged() const; std::vector completedResults(); + void rebuildRunCanvas(); void returnToAuthoring(); void refreshStatus(); void selectRun(int index); + std::size_t selectedSourceScenarioIndex() const; void showResults(); void stopRun(); + void syncRunSettingsControls(); void togglePaused(); QString projectName_{}; @@ -116,13 +125,18 @@ class ScenarioRunWidget : public QWidget { QLabel* riskLabel_{nullptr}; QLabel* congestionLabel_{nullptr}; QLabel* bottleneckLabel_{nullptr}; + QDoubleSpinBox* timeLimitSpinBox_{nullptr}; + QDoubleSpinBox* sampleIntervalSpinBox_{nullptr}; + QSpinBox* repeatCountSpinBox_{nullptr}; + QSpinBox* baseSeedSpinBox_{nullptr}; + QPushButton* applySettingsButton_{nullptr}; QPushButton* pauseButton_{nullptr}; QPushButton* stopButton_{nullptr}; QPushButton* fastForwardButton_{nullptr}; QPushButton* resultButton_{nullptr}; int selectedRunIndex_{0}; int playbackSpeedMultiplier_{1}; - bool paused_{false}; + bool paused_{true}; }; } // namespace safecrowd::application diff --git a/src/application/WorkspaceStateCodec.cpp b/src/application/WorkspaceStateCodec.cpp index 9897055..19d7ee3 100644 --- a/src/application/WorkspaceStateCodec.cpp +++ b/src/application/WorkspaceStateCodec.cpp @@ -536,6 +536,9 @@ QJsonObject workspaceStateToJson(const ProjectWorkspaceState& state) { if (!state.runningScenarios.empty()) { object["runningScenarios"] = scenarioDraftsToJson(state.runningScenarios); } + if (state.runningScenarioIndex >= 0) { + object["runningScenarioIndex"] = state.runningScenarioIndex; + } if (state.result.has_value()) { object["result"] = resultStateToJson(*state.result); } @@ -562,6 +565,7 @@ ProjectWorkspaceState workspaceStateFromJson(const QJsonObject& object) { } else if (state.runningScenario.has_value()) { state.runningScenarios.push_back(*state.runningScenario); } + state.runningScenarioIndex = object.value("runningScenarioIndex").toInt(-1); if (object.value("result").isObject()) { state.result = resultStateFromJson(object.value("result").toObject()); } diff --git a/src/domain/ScenarioAuthoring.h b/src/domain/ScenarioAuthoring.h index 7744028..525a93d 100644 --- a/src/domain/ScenarioAuthoring.h +++ b/src/domain/ScenarioAuthoring.h @@ -9,6 +9,8 @@ namespace safecrowd::domain { +constexpr std::uint32_t kScenarioExecutionMaxRepeatCount = 20; + enum class ScenarioRole { Baseline, Alternative, diff --git a/src/domain/ScenarioBatchRunner.cpp b/src/domain/ScenarioBatchRunner.cpp index b81d10b..a059758 100644 --- a/src/domain/ScenarioBatchRunner.cpp +++ b/src/domain/ScenarioBatchRunner.cpp @@ -1,9 +1,47 @@ #include "domain/ScenarioBatchRunner.h" +#include +#include +#include #include +#include #include namespace safecrowd::domain { +namespace { + +std::uint32_t normalizedRepeatCount(std::uint32_t repeatCount) { + return std::clamp(repeatCount, 1, kScenarioExecutionMaxRepeatCount); +} + +std::uint32_t seedForRepeat(std::uint32_t baseSeed, std::uint32_t zeroBasedRepeatIndex) { + constexpr auto kMaxSeed = std::numeric_limits::max(); + const auto base = static_cast(baseSeed == 0 ? 1 : baseSeed); + const auto candidate = base + static_cast(zeroBasedRepeatIndex); + if (candidate <= kMaxSeed) { + return static_cast(candidate); + } + return static_cast(((candidate - 1) % kMaxSeed) + 1); +} + +ScenarioDraft makeRunScenario(ScenarioDraft scenario, std::uint32_t repeatIndex, std::uint32_t repeatCount) { + const auto runSeed = seedForRepeat(scenario.execution.baseSeed, repeatIndex - 1); + if (repeatCount > 1) { + const auto repeatText = std::to_string(repeatIndex); + const auto repeatTotalText = std::to_string(repeatCount); + scenario.scenarioId = scenario.scenarioId.empty() + ? "scenario-repeat-" + repeatText + : scenario.scenarioId + "-repeat-" + repeatText; + scenario.name = scenario.name.empty() + ? "Scenario run " + repeatText + "/" + repeatTotalText + : scenario.name + " (run " + repeatText + "/" + repeatTotalText + ", seed " + std::to_string(runSeed) + ")"; + scenario.execution.baseSeed = runSeed; + } + scenario.execution.repeatCount = 1; + return scenario; +} + +} // namespace ScenarioBatchRunner::ScenarioBatchRunner(FacilityLayout2D layout, std::vector scenarios) { reset(std::move(layout), std::move(scenarios)); @@ -13,14 +51,28 @@ void ScenarioBatchRunner::reset(FacilityLayout2D layout, std::vector +#include #include #include "domain/FacilityLayout2D.h" @@ -19,6 +20,10 @@ struct ScenarioBatchRunState { ScenarioRiskSnapshot resultRisk{}; ScenarioResultArtifacts artifacts{}; double timeLimitSeconds{0.0}; + std::size_t sourceScenarioIndex{0}; + std::uint32_t repeatIndex{1}; + std::uint32_t repeatCount{1}; + std::uint32_t runSeed{0}; bool complete{false}; bool resultSynced{false}; }; diff --git a/tests/ProjectPersistenceTests.cpp b/tests/ProjectPersistenceTests.cpp index dde399b..cc631b4 100644 --- a/tests/ProjectPersistenceTests.cpp +++ b/tests/ProjectPersistenceTests.cpp @@ -65,6 +65,37 @@ SC_TEST(ProjectPersistence_preservesRecommendedScenarioDraftState) { SC_EXPECT_NEAR(loadedScenario.draft.control.routeGuidances.front().maxDetourMeters, 20.0, 1e-9); } +SC_TEST(ProjectPersistence_preservesRunningScenarioIndex) { + QTemporaryDir projectDir; + SC_EXPECT_TRUE(projectDir.isValid()); + + ScenarioDraft baseline; + baseline.scenarioId = "baseline"; + baseline.name = "Baseline"; + baseline.execution.repeatCount = 3; + + ProjectWorkspaceState workspace; + workspace.activeView = ProjectWorkspaceView::ScenarioRun; + workspace.runningScenario = baseline; + workspace.runningScenarios = {baseline}; + workspace.runningScenarioIndex = 2; + + const ProjectMetadata metadata{ + .name = "Running Index 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.activeView == ProjectWorkspaceView::ScenarioRun); + SC_EXPECT_EQ(loaded.runningScenarios.size(), std::size_t{1}); + SC_EXPECT_EQ(loaded.runningScenarioIndex, 2); + SC_EXPECT_EQ(loaded.runningScenarios.front().execution.repeatCount, std::uint32_t{3}); +} + SC_TEST(ProjectPersistence_preservesImportArtifactsBesideLayoutReview) { QTemporaryDir projectDir; SC_EXPECT_TRUE(projectDir.isValid()); diff --git a/tests/ScenarioBatchRunnerTests.cpp b/tests/ScenarioBatchRunnerTests.cpp index 265bbcb..9f52092 100644 --- a/tests/ScenarioBatchRunnerTests.cpp +++ b/tests/ScenarioBatchRunnerTests.cpp @@ -1,5 +1,7 @@ #include "TestSupport.h" +#include + #include "domain/ScenarioBatchRunner.h" namespace { @@ -66,6 +68,59 @@ SC_TEST(ScenarioBatchRunnerAdvancesMultipleScenariosOnSameTick) { SC_EXPECT_EQ(batch.run(1).scenario.scenarioId, std::string{"scenario-2"}); } +SC_TEST(ScenarioBatchRunnerExpandsRepeatCountIntoSeededRuns) { + auto repeated = scenarioDraft("scenario-1", "Repeated", 4.0, 1.0); + repeated.execution.repeatCount = 3; + repeated.execution.baseSeed = 10; + + safecrowd::domain::ScenarioBatchRunner batch(twoRoomExitLayout(), {repeated}); + + SC_EXPECT_EQ(batch.size(), std::size_t{3}); + SC_EXPECT_EQ(batch.run(0).sourceScenarioIndex, std::size_t{0}); + SC_EXPECT_EQ(batch.run(1).sourceScenarioIndex, std::size_t{0}); + SC_EXPECT_EQ(batch.run(2).sourceScenarioIndex, std::size_t{0}); + SC_EXPECT_EQ(batch.run(0).repeatIndex, std::uint32_t{1}); + SC_EXPECT_EQ(batch.run(1).repeatIndex, std::uint32_t{2}); + SC_EXPECT_EQ(batch.run(2).repeatIndex, std::uint32_t{3}); + SC_EXPECT_EQ(batch.run(0).repeatCount, std::uint32_t{3}); + SC_EXPECT_EQ(batch.run(0).runSeed, std::uint32_t{10}); + SC_EXPECT_EQ(batch.run(1).runSeed, std::uint32_t{11}); + SC_EXPECT_EQ(batch.run(2).runSeed, std::uint32_t{12}); + SC_EXPECT_EQ(batch.run(0).scenario.execution.baseSeed, std::uint32_t{10}); + SC_EXPECT_EQ(batch.run(1).scenario.execution.baseSeed, std::uint32_t{11}); + SC_EXPECT_EQ(batch.run(2).scenario.execution.baseSeed, std::uint32_t{12}); + SC_EXPECT_EQ(batch.run(0).scenario.execution.repeatCount, std::uint32_t{1}); + SC_EXPECT_EQ(batch.run(0).scenario.scenarioId, std::string{"scenario-1-repeat-1"}); + SC_EXPECT_TRUE(batch.run(0).scenario.name.find("run 1/3") != std::string::npos); +} + +SC_TEST(ScenarioBatchRunnerKeepsSingleRunIdentityWhenRepeatCountIsOne) { + auto single = scenarioDraft("scenario-1", "Single", 4.0, 1.0); + single.execution.repeatCount = 1; + single.execution.baseSeed = 42; + + safecrowd::domain::ScenarioBatchRunner batch(twoRoomExitLayout(), {single}); + + SC_EXPECT_EQ(batch.size(), std::size_t{1}); + SC_EXPECT_EQ(batch.run(0).scenario.scenarioId, std::string{"scenario-1"}); + SC_EXPECT_EQ(batch.run(0).scenario.name, std::string{"Single"}); + SC_EXPECT_EQ(batch.run(0).runSeed, std::uint32_t{42}); + SC_EXPECT_EQ(batch.run(0).repeatIndex, std::uint32_t{1}); + SC_EXPECT_EQ(batch.run(0).repeatCount, std::uint32_t{1}); +} + +SC_TEST(ScenarioBatchRunnerClampsRepeatCountAtDomainLimit) { + auto repeated = scenarioDraft("scenario-1", "Repeated", 4.0, 1.0); + repeated.execution.repeatCount = safecrowd::domain::kScenarioExecutionMaxRepeatCount + 5; + repeated.execution.baseSeed = 100; + + safecrowd::domain::ScenarioBatchRunner batch(twoRoomExitLayout(), {repeated}); + + SC_EXPECT_EQ(batch.size(), static_cast(safecrowd::domain::kScenarioExecutionMaxRepeatCount)); + SC_EXPECT_EQ(batch.run(0).repeatCount, safecrowd::domain::kScenarioExecutionMaxRepeatCount); + SC_EXPECT_EQ(batch.run(batch.size() - 1).repeatIndex, safecrowd::domain::kScenarioExecutionMaxRepeatCount); +} + SC_TEST(ScenarioBatchRunnerContinuesUnfinishedRunsAfterOneCompletes) { auto shortRun = scenarioDraft("scenario-1", "Short", 0.2, 0.5); auto longRun = scenarioDraft("scenario-2", "Long", 1.0, 0.5);