From d8a647208e4bd69400c5518f1ba7c02a299f2ecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Choi=20=28=EC=B5=9C=EB=85=B8=EC=95=84=29?= <73541496+Noah8218@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:35:44 +0900 Subject: [PATCH 01/12] chore(release): prepare v0.2.0-dev.8 public promotion --- Directory.Build.props | 2 +- README.md | 10 +- docs/ARCHITECTURE.md | 1142 +++++++++- docs/NET_VERSION_POLICY.md | 2 +- .../Resources/LocalizationCatalog.tsv | 7 + .../SemiconductorStationSkeletonPlanner.cs | 888 ++++++++ .../SemiconductorStationSkeletonTemplate.cs | 876 +------ .../SemiconductorProcessBlockComposer.cs | 390 +--- .../SemiconductorProcessBlockPlanBuilder.cs | 404 ++++ .../Commands/ConfigureRuntimeCommand.cs | 6 +- .../Commands/SimulationCommand.cs | 7 + .../Commands/SimulationCommandResult.cs | 3 +- .../Commands/TriggerVirtualCameraCommand.cs | 16 +- ...icMultiAxisCommissioningEvidenceHistory.cs | 15 +- ...sticMultiAxisCommissioningResultPackage.cs | 15 +- .../Engine/FixedStepSimulationEngine.cs | 248 +- .../Engine/SimulationEngineLifecycle.cs | 1 + .../SimulationManualCameraCommandHandler.cs | 13 + .../SimulationManualControlCommandHandler.cs | 4 +- .../Engine/SimulationRuntimeState.cs | 294 +++ .../Engine/SimulationSettings.cs | 6 + .../Events/SimulationEventJournal.cs | 179 ++ .../SimulationEventJournalExportPackage.cs | 327 +++ .../Events/SimulationEventPublisher.cs | 39 +- .../Scenarios/AtomicEvidenceFile.cs | 26 + ...terministicSimulationBatchResultPackage.cs | 14 +- .../DeterministicSimulationCommandTrace.cs | 21 +- ...nisticSimulationEvidenceExchangePackage.cs | 14 +- ...DeterministicSimulationRunResultPackage.cs | 14 +- ...sticUnifiedCommissioningEvidencePackage.cs | 14 +- ...rministicVisionExecutionEvidencePackage.cs | 14 +- .../SimulationEvidenceRetentionPolicy.cs | 428 ++++ .../Snapshots/SimulationSnapshot.cs | 11 +- .../Snapshots/SimulationSnapshotFactory.cs | 8 +- .../SimulationOperationalDiagnostic.cs | 3 +- .../Smoke/DirectExeSmokeHost.cs | 6 +- .../SmokeAxisCommissioningStateVerifier.cs | 269 +++ .../Smoke/SmokeAxisCommissioningVerifier.cs | 252 --- .../Smoke/SmokeCameraCommissioningVerifier.cs | 180 +- ...mokeMultiAxisCommissioningStateVerifier.cs | 288 +++ .../SmokeMultiAxisCommissioningVerifier.cs | 276 --- .../View/Inspector/RightToolRegionView.xaml | 35 +- .../View/Project/LeftToolRegionView.xaml | 2 +- .../Project/RecipeConnectionQuickAddView.xaml | 8 +- .../RecipeConnectionWorkbenchView.xaml | 10 +- .../View/Scene/MachineSceneViewport.cs | 88 +- .../View/Scene/SceneDocumentView.xaml | 10 +- .../View/Scene/SceneViewportProjection.cs | 74 + .../View/Shell/MainWpfInteractionHost.cs | 9 + .../View/Shell/ShellWindow.xaml.cs | 14 +- .../View/Shell/StudioTitleBar.xaml | 4 +- .../CameraCommissioningPresentation.cs | 31 +- .../Camera/CameraCommissioningViewModel.cs | 371 +++ .../CameraImageSourceApplicationWorkflow.cs | 64 + .../CameraImageSourceEditorViewModel.cs | 33 +- .../{ => Camera}/CameraSelectionWorkflow.cs | 2 +- .../ManualCameraAcquisitionParticipant.cs | 120 + .../Camera/ManualCameraPreparationLifetime.cs | 95 + .../ManualCameraTriggerRequestFactory.cs | 9 +- .../ManualCameraTriggerWorkflow.cs | 52 +- .../VirtualCameraInspectionWorkflow.cs | 8 +- .../VisionExecutionEvidenceViewModel.cs | 0 .../AnalogIoAuthoringViewModel.cs | 0 .../AxisCommissioningViewModel.cs | 0 .../AxisDriveTuningEditorViewModel.cs | 0 .../DigitalIoCommissioningViewModel.cs | 0 .../EquipmentCommandDispatcher.cs | 26 +- .../EquipmentStatusPresentation.cs | 0 .../ManualControlCommandWorkflow.cs | 3 +- .../ManualEquipmentCommissioningViewModel.cs | 224 ++ .../ManualEquipmentPresentation.cs | 39 +- .../MultiAxisCommissioningArtifactStore.cs | 0 ...MultiAxisCommissioningExecutionWorkflow.cs | 135 ++ .../MultiAxisCommissioningParticipant.cs | 46 + ...iAxisCommissioningRecipeEditorViewModel.cs | 0 .../MultiAxisCommissioningViewModel.cs | 23 +- .../UnifiedCommissioningEvidenceViewModel.cs | 0 .../{ => Common}/AsyncOperationLifetime.cs | 44 + .../Common/AsyncOperationParticipant.cs | 111 + .../{ => Common}/AsyncRelayCommand.cs | 0 .../ViewModel/{ => Common}/RelayCommand.cs | 0 .../ViewModel/{ => Common}/ViewModelBase.cs | 0 ...MachineIntegrationHandoffRequestFactory.cs | 0 .../MachineIntegrationParticipant.cs | 219 ++ .../MachineIntegrationPathReadinessPolicy.cs | 49 + .../MachineIntegrationRequestWorkflow.cs | 42 +- .../MachineIntegrationResultFileWatcher.cs | 10 +- ...ineIntegrationResultObservationWorkflow.cs | 16 +- .../MachineIntegrationSetupStore.cs | 0 .../MachineIntegrationSharedKeyStore.cs | 0 .../MachineIntegrationTcpControlViewModel.cs | 135 +- .../MachineIntegrationTcpOperationOwner.cs | 278 +++ .../MachineIntegrationTcpWorkflow.cs | 0 .../MachineIntegrationViewModel.cs | 270 ++- .../LayoutAuthoringHistoryViewModel.cs | 0 .../LayoutAuthoringMutationWorkflow.cs | 0 .../Layout/LayoutAuthoringWorkspace.cs | 154 ++ .../{ => Layout}/LayoutComponentClipboard.cs | 0 .../LayoutComponentEditorViewModel.cs | 0 .../{ => Layout}/LayoutEditHistory.cs | 0 .../LayoutSelectionCommandWorkflow.cs | 0 .../LayoutSelectionEditingWorkflow.cs | 0 .../{ => Layout}/MachineLayoutViewModel.cs | 0 .../{ => Layout}/SceneSnapshotStore.cs | 0 .../SceneViewportInteractionWorkflow.cs | 97 + .../{ => Layout}/SceneViewportRequests.cs | 0 .../ViewModel/MainViewModel.cs | 2005 ++++++----------- ...MultiAxisCommissioningExecutionWorkflow.cs | 85 - .../{ => Project}/ProjectDirtyState.cs | 0 .../Project/ProjectDocumentOperationGate.cs | 37 + .../Project/ProjectDocumentSaveReceipt.cs | 7 + .../{ => Project}/ProjectDocumentSession.cs | 34 +- .../Project/ProjectLifecycleCoordinator.cs | 309 +++ .../{ => Project}/ProjectOpenWorkflow.cs | 0 .../ProjectRuntimeApplicationWorkflow.cs | 0 .../Project/ProjectSaveParticipant.cs | 160 ++ .../{ => Project}/ProjectSaveWorkflow.cs | 13 +- ...ProjectSelectionSynchronizationWorkflow.cs | 0 .../{ => Project}/ProjectTreeViewModel.cs | 0 .../Project/ProjectUnsavedChangesWorkflow.cs | 73 + .../{ => Project}/PropertiesViewModel.cs | 0 .../{ => Project}/TreeNodeViewModel.cs | 0 .../{ => Recipes}/LoadLockSetupViewModel.cs | 0 .../ProcessPlanReviewViewModel.cs | 0 .../Recipes/RecipeAuthoringWorkspace.cs | 304 +++ .../RecipeCheckpointTemplateViewModel.cs | 0 .../RecipeConnectionProjectApplier.cs | 0 .../RecipeConnectionRowCatalog.cs | 0 .../RecipeConnectionSetupWorkflow.cs | 2 +- .../RecipeConnectionSimulationWorkflow.cs | 0 .../RecipeConnectionWorkbenchViewModel.cs | 0 .../RecipeDryRunPlaybackViewModel.cs | 2 +- .../{ => Recipes}/RecipeDryRunViewModel.cs | 0 ...ackCompatibilityComparisonItemViewModel.cs | 76 + .../RecipeProcessBlockViewModel.cs | 0 .../RecipeSequenceStepPreviewViewModel.cs | 0 .../SemanticEquipmentSetupViewModel.cs | 0 .../SemiconductorRecipeCopyWorkflow.cs | 0 .../SemiconductorRecipeGalleryCatalog.cs | 0 ...SemiconductorRecipeGalleryItemViewModel.cs | 143 ++ ...conductorRecipeGalleryValidationSession.cs | 0 ...onductorRecipeGalleryValidationWorkflow.cs | 0 .../SemiconductorRecipeGalleryViewModel.cs | 210 -- .../StationSkeletonSetupViewModel.cs | 0 .../SequenceAuthoringTargetCatalog.cs | 0 .../{ => Sequence}/SequenceEditorViewModel.cs | 0 .../SequenceStepEditorCollection.cs | 0 .../{ => Sequence}/SequenceStepEditorItem.cs | 0 .../Shell/ShellNavigationViewModel.cs | 172 ++ .../BoundedShutdownCoordinator.cs | 6 +- .../{ => Simulation}/FaultManagerViewModel.cs | 0 .../RuntimeAlarmCollectionViewModel.cs | 0 .../RuntimeDebuggerViewModel.cs | 0 .../RuntimeDebuggerWatchTargetCatalog.cs | 0 .../RuntimeDefinitionApplicationWorkflow.cs | 2 +- .../RuntimeObservabilityJournal.cs | 17 +- .../RuntimeObservabilityPresenter.cs | 70 +- .../RuntimeTimelineViewModel.cs | 0 .../ScheduledFaultScenarioWorkflow.cs | 0 ...SimulationCommandPresentationDispatcher.cs | 0 .../SimulationCommandTraceViewModel.cs | 0 .../SimulationRunControlWorkflow.cs | 0 .../{ => Simulation}/SimulationRuntimeLoop.cs | 116 +- .../SimulationRuntimeProjectionCoordinator.cs | 0 .../SimulationRuntimeResourceOwner.cs | 2 +- .../SimulationRuntimeShutdownWorkflow.cs | 99 +- .../SimulationRuntimeSnapshotProjection.cs | 0 .../SimulationScenarioBatchArtifactStore.cs | 61 + .../SimulationScenarioBatchParticipant.cs | 46 + .../SimulationScenarioBatchPresentation.cs | 0 ...SimulationScenarioBatchRepetitionRunner.cs | 0 .../SimulationScenarioBatchViewModel.cs | 27 +- .../SimulationScenarioExecutionCoordinator.cs | 0 .../SimulationScenarioWorkflow.cs | 0 .../SimulationSessionCloseWorkflow.cs | 263 +++ .../SimulationSessionCoordinator.cs | 194 ++ .../AtomicEvidenceFileTests.cs | 120 + ...eterministicSimulationCommandTraceTests.cs | 61 + .../FixedStepSimulationEngineTests.cs | 25 + .../RuntimeGenerationTests.cs | 284 +++ .../SimulationEvidenceRetentionPolicyTests.cs | 258 +++ .../SimulationOutputStoreTests.cs | 149 ++ .../SimulationRuntimeStateTests.cs | 108 + .../SimulationSnapshotFactoryTests.cs | 11 +- .../CameraCommissioningPresentationTests.cs | 27 +- .../CameraCommissioningViewModelTests.cs | 212 ++ ...meraImageSourceApplicationWorkflowTests.cs | 74 + .../CameraImageSourceEditorViewModelTests.cs | 34 + .../CameraSelectionWorkflowTests.cs | 0 ...ManualCameraAcquisitionParticipantTests.cs | 137 ++ .../ManualCameraPreparationLifetimeTests.cs | 47 + .../ManualCameraTriggerRequestFactoryTests.cs | 14 +- .../ManualCameraTriggerWorkflowTests.cs | 100 +- .../VirtualCameraFirstUseViewModelTests.cs | 0 .../VirtualCameraInspectionWorkflowTests.cs | 0 .../VisionExecutionEvidenceViewModelTests.cs | 0 .../AnalogIoAuthoringViewModelTests.cs | 0 .../AxisCommissioningViewModelTests.cs | 0 .../DigitalIoCommissioningViewModelTests.cs | 0 .../EquipmentCommandDispatcherTests.cs | 0 .../ManualControlCommandWorkflowTests.cs | 0 ...ualEquipmentCommissioningViewModelTests.cs | 185 ++ .../ManualEquipmentPresentationTests.cs | 0 ...ultiAxisCommissioningArtifactStoreTests.cs | 0 ...AxisCommissioningExecutionWorkflowTests.cs | 458 ++++ .../MultiAxisCommissioningParticipantTests.cs | 132 ++ .../MultiAxisCommissioningViewModelTests.cs | 0 ...fiedCommissioningEvidenceViewModelTests.cs | 0 ...issioningEvidenceWorkflowViewModelTests.cs | 0 .../AsyncOperationLifetimeTests.cs | 0 .../{ => Common}/AsyncRelayCommandTests.cs | 0 .../{ => Hosting}/BuildIdentityTests.cs | 0 .../DirectExeFaultScenarioHostTests.cs | 0 .../DirectExeSmokeArgumentParserTests.cs | 0 .../DirectExeSmokeFailurePolicyTests.cs | 0 .../DirectExeSmokeProjectLoaderTests.cs | 0 .../SmokeLayoutAlignmentVerifierTests.cs | 0 .../SmokeLayoutHistoryVerifierTests.cs | 0 ...neIntegrationHandoffRequestFactoryTests.cs | 0 .../MachineIntegrationParticipantTests.cs | 131 ++ ...hineIntegrationPathReadinessPolicyTests.cs | 82 + .../MachineIntegrationRequestWorkflowTests.cs | 79 +- ...tegrationResultObservationWorkflowTests.cs | 0 .../MachineIntegrationSetupStoreTests.cs | 0 .../MachineIntegrationSharedKeyStoreTests.cs | 0 ...hineIntegrationTcpControlViewModelTests.cs | 42 + ...achineIntegrationTcpOperationOwnerTests.cs | 152 ++ .../MachineIntegrationTcpWorkflowTests.cs | 0 .../MachineIntegrationViewModelTests.cs | 209 ++ .../LayoutAuthoringMutationWorkflowTests.cs | 0 .../Layout/LayoutAuthoringWorkspaceTests.cs | 300 +++ .../LayoutSelectionEditingWorkflowTests.cs | 0 .../LayoutStartupViewModelTests.cs | 0 .../MachineLayoutViewModelTests.cs | 0 .../SceneViewportInteractionWorkflowTests.cs | 155 ++ .../Layout/SceneViewportProjectionTests.cs | 66 + ...AxisCommissioningExecutionWorkflowTests.cs | 190 -- .../{ => Project}/ProjectDirtyStateTests.cs | 0 .../ProjectDocumentOperationGateTests.cs | 75 + .../Project/ProjectDocumentSessionTests.cs | 95 + .../ProjectLifecycleCoordinatorTests.cs | 255 +++ .../{ => Project}/ProjectOpenWorkflowTests.cs | 0 .../ProjectRuntimeApplicationWorkflowTests.cs | 0 .../Project/ProjectSaveParticipantTests.cs | 121 + .../{ => Project}/ProjectSaveWorkflowTests.cs | 31 + .../ProjectUnsavedChangesWorkflowTests.cs | 124 + .../ProjectDocumentSessionTests.cs | 49 - .../ProcessPlanReviewViewModelTests.cs | 0 .../Recipes/RecipeAuthoringWorkspaceTests.cs | 430 ++++ .../RecipeCheckpointTemplateViewModelTests.cs | 0 .../RecipeConnectionProjectApplierTests.cs | 0 .../RecipeConnectionRowCatalogTests.cs | 0 .../RecipeConnectionSetupWorkflowTests.cs | 0 ...RecipeConnectionSimulationWorkflowTests.cs | 0 ...RecipeConnectionWorkbenchViewModelTests.cs | 0 .../RecipeDryRunPlaybackViewModelTests.cs | 0 .../RecipeDryRunViewModelTests.cs | 0 ...RecipeSequenceStepPreviewViewModelTests.cs | 0 .../SemiconductorRecipeCopyWorkflowTests.cs | 0 .../SemiconductorRecipeGalleryCatalogTests.cs | 0 ...ctorRecipeGalleryValidationSessionTests.cs | 0 ...torRecipeGalleryValidationWorkflowTests.cs | 0 ...emiconductorRecipeGalleryViewModelTests.cs | 0 .../SequenceAuthoringTargetCatalogTests.cs | 0 .../SequenceEditorViewModelTests.cs | 0 .../Shell/ShellNavigationViewModelTests.cs | 143 ++ .../CommandTraceWorkflowViewModelTests.cs | 0 .../FaultManagerViewModelTests.cs | 0 .../RuntimeAlarmCollectionViewModelTests.cs | 0 .../RuntimeDebuggerViewModelTests.cs | 0 .../RuntimeDebuggerWatchTargetCatalogTests.cs | 0 ...ntimeDefinitionApplicationWorkflowTests.cs | 8 +- .../RuntimeObservabilityJournalTests.cs | 19 + .../RuntimeObservabilityPresenterTests.cs | 57 +- .../RuntimeObservabilityRetentionTests.cs | 0 .../{ => Simulation}/RuntimeShutdownTests.cs | 5 +- .../ScheduledFaultScenarioWorkflowTests.cs | 0 ...ationCommandPresentationDispatcherTests.cs | 0 .../SimulationCommandTraceViewModelTests.cs | 0 .../SimulationRunControlWorkflowTests.cs | 0 .../SimulationRuntimeLoopTests.cs | 59 +- .../SimulationRuntimeResourceOwnerTests.cs | 0 .../SimulationRuntimeShutdownWorkflowTests.cs | 209 ++ ...imulationRuntimeSnapshotProjectionTests.cs | 0 ...mulationScenarioBatchArtifactStoreTests.cs | 43 + ...SimulationScenarioBatchParticipantTests.cs | 132 ++ ...imulationScenarioBatchPresentationTests.cs | 0 ...ationScenarioBatchRepetitionRunnerTests.cs | 0 .../SimulationScenarioBatchViewModelTests.cs | 0 ...lationScenarioExecutionCoordinatorTests.cs | 0 .../SimulationScenarioProfileTests.cs | 0 .../SimulationScenarioProjectMapperTests.cs | 0 .../SimulationScenarioWorkflowTests.cs | 0 .../SimulationSessionCloseWorkflowTests.cs | 552 +++++ .../SimulationSessionCoordinatorTests.cs | 128 ++ .../SimulationRuntimeShutdownWorkflowTests.cs | 93 - 296 files changed, 16265 insertions(+), 4432 deletions(-) create mode 100644 src/OpenVisionLab.Machine.Core/Projects/SemiconductorStationSkeletonPlanner.cs create mode 100644 src/OpenVisionLab.Machine.Sequence/Authoring/SemiconductorProcessBlockPlanBuilder.cs create mode 100644 src/OpenVisionLab.Machine.Simulation/Engine/SimulationRuntimeState.cs create mode 100644 src/OpenVisionLab.Machine.Simulation/Events/SimulationEventJournal.cs create mode 100644 src/OpenVisionLab.Machine.Simulation/Events/SimulationEventJournalExportPackage.cs create mode 100644 src/OpenVisionLab.Machine.Simulation/Scenarios/AtomicEvidenceFile.cs create mode 100644 src/OpenVisionLab.Machine.Simulation/Scenarios/SimulationEvidenceRetentionPolicy.cs create mode 100644 src/OpenVisionLab.MachineStudio/Smoke/SmokeAxisCommissioningStateVerifier.cs create mode 100644 src/OpenVisionLab.MachineStudio/Smoke/SmokeMultiAxisCommissioningStateVerifier.cs create mode 100644 src/OpenVisionLab.MachineStudio/View/Scene/SceneViewportProjection.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Camera}/CameraCommissioningPresentation.cs (83%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Camera/CameraCommissioningViewModel.cs create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Camera/CameraImageSourceApplicationWorkflow.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Camera}/CameraImageSourceEditorViewModel.cs (88%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Camera}/CameraSelectionWorkflow.cs (99%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Camera/ManualCameraAcquisitionParticipant.cs create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Camera/ManualCameraPreparationLifetime.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Camera}/ManualCameraTriggerRequestFactory.cs (89%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Camera}/ManualCameraTriggerWorkflow.cs (72%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Camera}/VirtualCameraInspectionWorkflow.cs (95%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Camera}/VisionExecutionEvidenceViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Commissioning}/AnalogIoAuthoringViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Commissioning}/AxisCommissioningViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Commissioning}/AxisDriveTuningEditorViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Commissioning}/DigitalIoCommissioningViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Commissioning}/EquipmentCommandDispatcher.cs (82%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Commissioning}/EquipmentStatusPresentation.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Commissioning}/ManualControlCommandWorkflow.cs (96%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Commissioning/ManualEquipmentCommissioningViewModel.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Commissioning}/ManualEquipmentPresentation.cs (84%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Commissioning}/MultiAxisCommissioningArtifactStore.cs (100%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Commissioning/MultiAxisCommissioningExecutionWorkflow.cs create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Commissioning/MultiAxisCommissioningParticipant.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Commissioning}/MultiAxisCommissioningRecipeEditorViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Commissioning}/MultiAxisCommissioningViewModel.cs (94%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Commissioning}/UnifiedCommissioningEvidenceViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Common}/AsyncOperationLifetime.cs (62%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Common/AsyncOperationParticipant.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Common}/AsyncRelayCommand.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Common}/RelayCommand.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Common}/ViewModelBase.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Integration}/MachineIntegrationHandoffRequestFactory.cs (100%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Integration/MachineIntegrationParticipant.cs create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Integration/MachineIntegrationPathReadinessPolicy.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Integration}/MachineIntegrationRequestWorkflow.cs (64%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Integration}/MachineIntegrationResultFileWatcher.cs (91%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Integration}/MachineIntegrationResultObservationWorkflow.cs (94%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Integration}/MachineIntegrationSetupStore.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Integration}/MachineIntegrationSharedKeyStore.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Integration}/MachineIntegrationTcpControlViewModel.cs (85%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Integration/MachineIntegrationTcpOperationOwner.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Integration}/MachineIntegrationTcpWorkflow.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Integration}/MachineIntegrationViewModel.cs (77%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Layout}/LayoutAuthoringHistoryViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Layout}/LayoutAuthoringMutationWorkflow.cs (100%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Layout/LayoutAuthoringWorkspace.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Layout}/LayoutComponentClipboard.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Layout}/LayoutComponentEditorViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Layout}/LayoutEditHistory.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Layout}/LayoutSelectionCommandWorkflow.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Layout}/LayoutSelectionEditingWorkflow.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Layout}/MachineLayoutViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Layout}/SceneSnapshotStore.cs (100%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Layout/SceneViewportInteractionWorkflow.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Layout}/SceneViewportRequests.cs (100%) delete mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/MultiAxisCommissioningExecutionWorkflow.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Project}/ProjectDirtyState.cs (100%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Project/ProjectDocumentOperationGate.cs create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Project/ProjectDocumentSaveReceipt.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Project}/ProjectDocumentSession.cs (63%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Project/ProjectLifecycleCoordinator.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Project}/ProjectOpenWorkflow.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Project}/ProjectRuntimeApplicationWorkflow.cs (100%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Project/ProjectSaveParticipant.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Project}/ProjectSaveWorkflow.cs (83%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Project}/ProjectSelectionSynchronizationWorkflow.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Project}/ProjectTreeViewModel.cs (100%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Project/ProjectUnsavedChangesWorkflow.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Project}/PropertiesViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Project}/TreeNodeViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/LoadLockSetupViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/ProcessPlanReviewViewModel.cs (100%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Recipes/RecipeAuthoringWorkspace.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/RecipeCheckpointTemplateViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/RecipeConnectionProjectApplier.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/RecipeConnectionRowCatalog.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/RecipeConnectionSetupWorkflow.cs (98%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/RecipeConnectionSimulationWorkflow.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/RecipeConnectionWorkbenchViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/RecipeDryRunPlaybackViewModel.cs (98%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/RecipeDryRunViewModel.cs (100%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Recipes/RecipePackCompatibilityComparisonItemViewModel.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/RecipeProcessBlockViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/RecipeSequenceStepPreviewViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/SemanticEquipmentSetupViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/SemiconductorRecipeCopyWorkflow.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/SemiconductorRecipeGalleryCatalog.cs (100%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Recipes/SemiconductorRecipeGalleryItemViewModel.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/SemiconductorRecipeGalleryValidationSession.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/SemiconductorRecipeGalleryValidationWorkflow.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/SemiconductorRecipeGalleryViewModel.cs (73%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Recipes}/StationSkeletonSetupViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Sequence}/SequenceAuthoringTargetCatalog.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Sequence}/SequenceEditorViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Sequence}/SequenceStepEditorCollection.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Sequence}/SequenceStepEditorItem.cs (100%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Shell/ShellNavigationViewModel.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/BoundedShutdownCoordinator.cs (94%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/FaultManagerViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/RuntimeAlarmCollectionViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/RuntimeDebuggerViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/RuntimeDebuggerWatchTargetCatalog.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/RuntimeDefinitionApplicationWorkflow.cs (99%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/RuntimeObservabilityJournal.cs (81%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/RuntimeObservabilityPresenter.cs (66%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/RuntimeTimelineViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/ScheduledFaultScenarioWorkflow.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/SimulationCommandPresentationDispatcher.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/SimulationCommandTraceViewModel.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/SimulationRunControlWorkflow.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/SimulationRuntimeLoop.cs (60%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/SimulationRuntimeProjectionCoordinator.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/SimulationRuntimeResourceOwner.cs (97%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/SimulationRuntimeShutdownWorkflow.cs (67%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/SimulationRuntimeSnapshotProjection.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/SimulationScenarioBatchArtifactStore.cs (85%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Simulation/SimulationScenarioBatchParticipant.cs rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/SimulationScenarioBatchPresentation.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/SimulationScenarioBatchRepetitionRunner.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/SimulationScenarioBatchViewModel.cs (94%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/SimulationScenarioExecutionCoordinator.cs (100%) rename src/OpenVisionLab.MachineStudio/ViewModel/{ => Simulation}/SimulationScenarioWorkflow.cs (100%) create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Simulation/SimulationSessionCloseWorkflow.cs create mode 100644 src/OpenVisionLab.MachineStudio/ViewModel/Simulation/SimulationSessionCoordinator.cs create mode 100644 tests/OpenVisionLab.Machine.Simulation.Tests/AtomicEvidenceFileTests.cs create mode 100644 tests/OpenVisionLab.Machine.Simulation.Tests/RuntimeGenerationTests.cs create mode 100644 tests/OpenVisionLab.Machine.Simulation.Tests/SimulationEvidenceRetentionPolicyTests.cs create mode 100644 tests/OpenVisionLab.Machine.Simulation.Tests/SimulationRuntimeStateTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Camera}/CameraCommissioningPresentationTests.cs (80%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Camera/CameraCommissioningViewModelTests.cs create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Camera/CameraImageSourceApplicationWorkflowTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Camera}/CameraImageSourceEditorViewModelTests.cs (69%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Camera}/CameraSelectionWorkflowTests.cs (100%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Camera/ManualCameraAcquisitionParticipantTests.cs create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Camera/ManualCameraPreparationLifetimeTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Camera}/ManualCameraTriggerRequestFactoryTests.cs (86%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Camera}/ManualCameraTriggerWorkflowTests.cs (66%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Camera}/VirtualCameraFirstUseViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Camera}/VirtualCameraInspectionWorkflowTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Camera}/VisionExecutionEvidenceViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Commissioning}/AnalogIoAuthoringViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Commissioning}/AxisCommissioningViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Commissioning}/DigitalIoCommissioningViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Commissioning}/EquipmentCommandDispatcherTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Commissioning}/ManualControlCommandWorkflowTests.cs (100%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Commissioning/ManualEquipmentCommissioningViewModelTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Commissioning}/ManualEquipmentPresentationTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Commissioning}/MultiAxisCommissioningArtifactStoreTests.cs (100%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Commissioning/MultiAxisCommissioningExecutionWorkflowTests.cs create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Commissioning/MultiAxisCommissioningParticipantTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Commissioning}/MultiAxisCommissioningViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Commissioning}/UnifiedCommissioningEvidenceViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Commissioning}/UnifiedCommissioningEvidenceWorkflowViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Common}/AsyncOperationLifetimeTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Common}/AsyncRelayCommandTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Hosting}/BuildIdentityTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Hosting}/DirectExeFaultScenarioHostTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Hosting}/DirectExeSmokeArgumentParserTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Hosting}/DirectExeSmokeFailurePolicyTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Hosting}/DirectExeSmokeProjectLoaderTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Hosting}/SmokeLayoutAlignmentVerifierTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Hosting}/SmokeLayoutHistoryVerifierTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Integration}/MachineIntegrationHandoffRequestFactoryTests.cs (100%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Integration/MachineIntegrationParticipantTests.cs create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Integration/MachineIntegrationPathReadinessPolicyTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Integration}/MachineIntegrationRequestWorkflowTests.cs (54%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Integration}/MachineIntegrationResultObservationWorkflowTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Integration}/MachineIntegrationSetupStoreTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Integration}/MachineIntegrationSharedKeyStoreTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Integration}/MachineIntegrationTcpControlViewModelTests.cs (77%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Integration/MachineIntegrationTcpOperationOwnerTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Integration}/MachineIntegrationTcpWorkflowTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Integration}/MachineIntegrationViewModelTests.cs (73%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Layout}/LayoutAuthoringMutationWorkflowTests.cs (100%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Layout/LayoutAuthoringWorkspaceTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Layout}/LayoutSelectionEditingWorkflowTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Layout}/LayoutStartupViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Layout}/MachineLayoutViewModelTests.cs (100%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Layout/SceneViewportInteractionWorkflowTests.cs create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Layout/SceneViewportProjectionTests.cs delete mode 100644 tests/OpenVisionLab.MachineStudio.Tests/MultiAxisCommissioningExecutionWorkflowTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Project}/ProjectDirtyStateTests.cs (100%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Project/ProjectDocumentOperationGateTests.cs create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Project/ProjectDocumentSessionTests.cs create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Project/ProjectLifecycleCoordinatorTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Project}/ProjectOpenWorkflowTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Project}/ProjectRuntimeApplicationWorkflowTests.cs (100%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Project/ProjectSaveParticipantTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Project}/ProjectSaveWorkflowTests.cs (76%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Project/ProjectUnsavedChangesWorkflowTests.cs delete mode 100644 tests/OpenVisionLab.MachineStudio.Tests/ProjectDocumentSessionTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/ProcessPlanReviewViewModelTests.cs (100%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Recipes/RecipeAuthoringWorkspaceTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/RecipeCheckpointTemplateViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/RecipeConnectionProjectApplierTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/RecipeConnectionRowCatalogTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/RecipeConnectionSetupWorkflowTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/RecipeConnectionSimulationWorkflowTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/RecipeConnectionWorkbenchViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/RecipeDryRunPlaybackViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/RecipeDryRunViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/RecipeSequenceStepPreviewViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/SemiconductorRecipeCopyWorkflowTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/SemiconductorRecipeGalleryCatalogTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/SemiconductorRecipeGalleryValidationSessionTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/SemiconductorRecipeGalleryValidationWorkflowTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Recipes}/SemiconductorRecipeGalleryViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Sequence}/SequenceAuthoringTargetCatalogTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Sequence}/SequenceEditorViewModelTests.cs (100%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Shell/ShellNavigationViewModelTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/CommandTraceWorkflowViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/FaultManagerViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/RuntimeAlarmCollectionViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/RuntimeDebuggerViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/RuntimeDebuggerWatchTargetCatalogTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/RuntimeDefinitionApplicationWorkflowTests.cs (96%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/RuntimeObservabilityJournalTests.cs (77%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/RuntimeObservabilityPresenterTests.cs (70%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/RuntimeObservabilityRetentionTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/RuntimeShutdownTests.cs (96%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/ScheduledFaultScenarioWorkflowTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/SimulationCommandPresentationDispatcherTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/SimulationCommandTraceViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/SimulationRunControlWorkflowTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/SimulationRuntimeLoopTests.cs (52%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/SimulationRuntimeResourceOwnerTests.cs (100%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Simulation/SimulationRuntimeShutdownWorkflowTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/SimulationRuntimeSnapshotProjectionTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/SimulationScenarioBatchArtifactStoreTests.cs (57%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Simulation/SimulationScenarioBatchParticipantTests.cs rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/SimulationScenarioBatchPresentationTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/SimulationScenarioBatchRepetitionRunnerTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/SimulationScenarioBatchViewModelTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/SimulationScenarioExecutionCoordinatorTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/SimulationScenarioProfileTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/SimulationScenarioProjectMapperTests.cs (100%) rename tests/OpenVisionLab.MachineStudio.Tests/{ => Simulation}/SimulationScenarioWorkflowTests.cs (100%) create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Simulation/SimulationSessionCloseWorkflowTests.cs create mode 100644 tests/OpenVisionLab.MachineStudio.Tests/Simulation/SimulationSessionCoordinatorTests.cs delete mode 100644 tests/OpenVisionLab.MachineStudio.Tests/SimulationRuntimeShutdownWorkflowTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 16ff7c4..0e6d15a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@  - 0.2.0-dev.7 + 0.2.0-dev.8 false 12.0 enable diff --git a/README.md b/README.md index 8bddd29..a394e52 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ single-step, reset, dry-run, and layout playback workflows. ## Version -Current version: `v0.2.0-dev.7` +Current version: `v0.2.0-dev.8` This project is maintained using explicit version numbers. The current branch is a Release 2 development candidate; it is not a release tag or downloadable @@ -21,6 +21,14 @@ package. ### Recent version history +#### `v0.2.0-dev.8` (2026-09-08) + +- Extracted Process Block plan construction into the WPF-neutral + `SemiconductorProcessBlockPlanBuilder` while preserving Composer mutation and + public contracts. +- Recorded structural proof, focused and full Simulation tests, and the + junior-developer navigation assessment for the Dev checkpoint. + #### `v0.2.0-dev.7` (2026-09-07) - Consolidated concrete ownership boundaries across simulation, integration, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fd20655..9fddd26 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,6 +13,12 @@ Camera acquisition and Vision evidence remain supported secondary capabilities. They join a machine cycle when an inspection task requires them; they do not own the core equipment-simulation workflow. +For current source navigation, read [DEVELOPER_ONBOARDING.md](DEVELOPER_ONBOARDING.md) +and [MVVM_ARCHITECTURE.md](MVVM_ARCHITECTURE.md). MachineStudio feature owners +live in shallow `ViewModel` domain folders; `MainViewModel.cs` remains the root +composition entrypoint. Folder movement preserves existing namespaces and does +not itself change the module dependency graph below. + ## Layered architecture ```text @@ -40,11 +46,80 @@ acquisition/frame/runner contracts. `Infrastructure` may perform asynchronous project-asset I/O, but file I/O is never executed inside a fixed simulation tick. -The wall-clock accumulator, catch-up cap, pause/reset alignment, and real-time -delay calculation are owned by `SimulationRunLoopTiming`. The engine retains -run-mode policy and fixed-tick execution; it resets or aligns the timing owner -at the same lifecycle boundaries so simulation-time behavior remains -deterministic and independently testable. +The fixed-step engine remains the authoritative owner of simulation-thread +state transitions and tick ordering, while concrete owners hold cohesive +runtime state and policy boundaries. `DeterministicSimulationCommandTraceStore` owns synchronized in-memory +command-boundary entries, clearing, snapshots, and trace-package creation; +`FixedStepSimulationEngine` only decides when an applied command boundary is +captured. `DeterministicSequenceRuntimeContext` adapts the Sequence runtime +contract to the current signal hub, axes, and virtual cameras through explicit +dependencies and an event-emitter callback. It has no engine or WPF reference; +the engine retains tick state, event sequencing, and component ownership. + +Simulation output resource lifetime has two concrete owners. `SimulationEventPublisher` +owns the bounded event channel, monotonic event index, event construction, and +completion. `LatestSnapshotStore` owns the current snapshot value, bounded +snapshot publication, reader, and completion. `FixedStepSimulationEngine` +retains snapshot construction and all simulation-thread ordering, and delegates +only publication to these owners. `ISimulationEngine` readers and the existing +event/snapshot contracts remain unchanged. + +Sequence authoring keeps Process Block planning, mutation, and managed timeout +policy in separate concrete owners. `SemiconductorProcessBlockPlanBuilder` owns +the WPF-neutral read-only preview/recognition plan, step derivation, status and +authored-role checks, and plan cloning. `SemiconductorProcessBlockComposer` +remains the public compatibility facade and owns project mutation: insertion, +removal, transition rebuilding, and explicit copy/commit. Its public Preview and +recognition methods delegate to the plan builder, while Apply keeps the existing +mutation order. `SemiconductorProcessBlockTimeoutAdjuster` owns managed-step +timeout request normalization, validation, stale-preview comparison, project +snapshotting, and timeout application. The composer preserves its public timeout +methods as compatibility forwards; these owners have no WPF dependency. Do not +split these owners again without a newly measured defect, requirement, +responsibility conflict, or constraint. + +Semiconductor station setup policy, plan construction, and mutation commit are +separate. `SemiconductorStationSetupResolver` owns persisted setup restoration, +default derivation from an authored project, and setup validation without +mutating the project. WPF-neutral `SemiconductorStationSkeletonPlanner` owns the +clone/build/role-resolution plan and returns +`SemiconductorStationSkeletonPlan` with the prepared clone and read-only preview. +`SemiconductorStationSkeletonTemplate` remains the public compatibility facade +and owns schema/setup/serialization comparison plus the explicit assignment +commit into the caller's project. Its public `ResolveSetup`/`IsValidSetup` +forwards preserve existing callers. Do not split these owners again without a +newly measured defect, requirement, responsibility conflict, or constraint. + +Deterministic simulation run comparison has its own concrete owner, +`DeterministicSimulationRunResultComparer`. It owns package identity, +scenario, ordered tick, per-field evidence, and aggregate result mismatch +policy. Deterministic run evidence hashing and per-tick evidence construction +have a separate concrete owner, `DeterministicSimulationRunEvidenceHasher`. +`DeterministicSimulationRunResultPackage` retains result creation, integrity +validation, JSON persistence, and public `CompareTo`/`IsEquivalentTo` +compatibility forwards while routing its hash policy through the hasher. + +Multi-axis commissioning execution has a separate concrete owner, +`DeterministicMultiAxisCommissioningRunner`. It owns repetition orchestration, +fixed-step engine lifetime, command sequencing, snapshot waiting, target +verification, progress, and cancellation. `DeterministicMultiAxisCommissioningResultPackage` +retains immutable result data, result construction, evidence hashing, context +validation, and JSON persistence; the existing public runner API is unchanged. + +Deterministic batch execution has a separate concrete owner, +`DeterministicSimulationBatchRunner`. It owns input normalization, +accepted-baseline selection, sequential repetition execution, cancellation, +first-mismatch capture, and batch result construction. +`DeterministicSimulationBatchResultPackage` retains immutable batch data, +mismatch translation, evidence hashing, integrity/context validation, and JSON +persistence. + +Batch comparison policy has a separate concrete owner, +`DeterministicSimulationBatchResultComparer`. It owns batch identity and +completion comparison, per-run comparison, mismatch translation, and failed-run +normalization. The package retains public `CompareTo`/`IsEquivalentTo` +compatibility forwards and uses the comparer for persisted mismatch integrity +checks. ## Authoring-to-runtime flow @@ -56,8 +131,29 @@ MachineProjectDocument | v MachineProjectRuntimeCompiler - validates identities, bindings, channel kinds, delays, and fixed-step units - selects the active layout + validates project contract, fixed-step compatibility, signals, sequences, + automatic-run, and pick/place policy + delegates authored runtime conversion: + | + +--> MachineProjectRuntimeAxisCompiler + | converts authored axes and validates motion parameters + +--> MachineProjectRuntimeCameraCompiler + | converts virtual cameras and legacy camera properties + +--> MachineProjectRuntimeLayoutCompiler + validates/selects the active layout and assembles base components + delegates specialized layout-device configuration: + | + +------> MachineLayoutDeviceRuntimeCompiler + validates LoadLock, WaferHandler, Inspection, OHT, and Prealigner bindings + returns the immutable layout-device runtime configuration + +--> MachineProjectRuntimeSequenceCompiler + | compiles authored sequences and maps typed compilation errors + +--> MachineProjectRuntimeAutomaticRunCompiler + | validates optional automatic-run policy and timing + +--> MachineProjectRuntimePickPlaceCompiler + validates optional pick/place workpiece policy + | + v emits immutable runtime Axis, Layout, IO, Sequence, and AutomaticRun config | v @@ -67,8 +163,8 @@ FixedStepSimulationEngine (single runtime owner) 3. evaluate load-lock pressure commands and door interlocks 4. read the allowed cylinder command DO, advance deterministic travel, and publish authored delayed Extended/Retracted DI - 5. read conveyor Run/Reverse DO and transport bound workpieces along the - conveyor's authored local axis + 5. read conveyor Run/Reverse DO and transport Source/Destination workpieces + along their current authored-carrier local axis 6. evaluate sensor/target geometry and authored on/off delay ticks 7. write geometry-sensor Digital Input state and emit transitions 8. advance automatic-repeat state and the embedded Sequence @@ -79,6 +175,256 @@ WaitSignal observes the current tick's sensor DI -> immutable SimulationSnapshot + non-dropping ordered events ``` +`DeterministicSignalHub` owns both digital and the approved first-slice analog +channel state. Digital values retain their existing `Signals` contract; analog +values are exposed through `AnalogSignals` beside them under the same signal +revision. Analog channels store finite scalar `double` values only: no units, +range, scaling, calibration, or hardware mapping is implied. Analog Input +writes are owned by `Manual` and `SimulationComponent`; Analog Output writes +are owned by `Manual` and `EmbeddedSequence`. The fixed-step engine projects +both lists into the immutable `SimulationSnapshot`. Analog Sequence semantics, +WPF authoring, and external adapters remain separate follow-up boundaries. + +During Design-mode authoring, `LayoutAuthoringWorkspace` under +`ViewModel/Layout` owns command creation and the composition of history, +mutation, selection-command, and Scene-interaction workflows. It also owns their +Reset, command-invalidation, and history-disposal routing. `MainViewModel` +retains public command/API facades and explicit project/session and cross-feature +refresh callbacks; it no longer constructs those collaborators individually. +`LayoutAuthoringHistoryViewModel` owns bounded Undo/Redo/clipboard transactions +and selection restoration. `LayoutAuthoringMutationWorkflow` captures/commits +history around add/remove and maps typed results to dirty/runtime/definition +refresh and status/log callbacks. + +The Core authoring rules remain in their existing concrete owners. +`LayoutComponentAuthoringFactory` creates a component and its bound +axis/device/channel artifacts with stable identifiers. +`LayoutComponentPlacementService` owns grid snapping, collision-free default +placement, and synchronization with a moved component's bound axis/device. +`LayoutComponentAuthoringService` orchestrates active-layout resolution, +rollback, full-project validation, deletion policy, and dependency guards. +`MachineLayoutViewModel` retains layout selection, definition synchronization, +editor lifetime, and binding state. `LayoutSelectionEditingWorkflow` owns +WPF-neutral drag/transform, snapping, alignment, nudging, and layer-order policy. +`SceneViewportInteractionWorkflow` handles neutral View requests through the +workspace. `SceneViewportProjection` owns the pure fit, screen/world, zoom-anchor, +and translation calculations shared by rendering and hit-testing; it has no WPF +control, resource, snapshot, or pointer state. `MachineSceneViewport` retains the +actual WPF resource lifetime, collection/snapshot observation, pointer capture, and +gesture state. Neither a partial Main type nor a second runtime is introduced. + +`ProjectLifecycleCoordinator` under `ViewModel/Project` owns the active +`ProjectDocumentSession`, project-operation gate, save participant, and concrete +open/save/unsaved/copy workflows. It orders document transitions and delegates +runtime application and shell refresh through explicit callbacks. +`ProjectOpenWorkflow` owns file validation before the unsaved-change gate and +reloading after acceptance. `ProjectUnsavedChangesWorkflow` owns focused-editor +commit, save preparation, dirty-state refresh, and Save/Discard/Cancel resolution. +The operation gate coordinates entry to the project transaction, while the save +participant exposes its in-flight result to session close. + +`ProjectSaveWorkflow` prepares the captured project, records its content hash, +awaits `ProjectDocumentStore.SaveAsync`, and persists linked scenario-batch, +multi-axis, and Vision artifacts in their existing order. It returns a +`ProjectDocumentSaveReceipt`. `ProjectDocumentSession` uses session identity, +revision, and content evidence to decide whether the completed save can update +the active path and dirty baseline. Each artifact owner retains its own path +relinking and persistence. `MainViewModel` retains public commands, title/binding +notifications, child updates, and the runtime-application callback. + +`MainMessageDialogHost` under `View/Dialogs` owns localized unsaved/open-failure/ +save-failure options, WPF owner resolution, modal invocation, and conversion to +neutral decisions. The project coordinator/workflows own the decision sequence +and expected save/open failure routing. Main supplies the host, status, and log +callbacks. Native dialog mechanics and file persistence remain separate. + +Shell-only WPF interaction has a separate concrete `MainWpfInteractionHost` +boundary under `View/Shell`. It owns application shutdown, keyboard focus +clearing with the existing `DataBind` wait, and the guarded dispatcher +forwarding used by runtime and batch progress. `MainViewModel` retains command +and workflow timing while delegating those framework calls; `ShellWindow` +continues to own visual lifecycle, keyboard shortcut, resize, close, and +disposal events. + +Project and recipe path selection has a separate concrete +`ProjectFileDialogHost` boundary under `View/Dialogs`. It owns the three +native Open/Save dialog configurations for opening a project, saving a project +as, and choosing a semiconductor recipe-copy destination. It returns a path or +cancelled selection and performs no project I/O, unsaved-change policy, or +workflow action. `MainViewModel` keeps the existing command and callback +routing, while the project/recipe workflows keep persistence and activation. + +`SemiconductorRecipeCopyWorkflow` under `ViewModel/Recipes` owns the file-copy +transaction: path normalization, same-path overwrite rejection, source loading, +new identity/timestamp assignment, and destination persistence. +`ProjectLifecycleCoordinator` owns the surrounding unsaved-change decision, +copy/open ordering, and document-operation admission. Main supplies the copied +project's runtime-application and presentation callbacks. +`ProjectFileDialogHost` owns destination selection; neither the copy workflow +nor the project coordinator depends on WPF controls. + +Simulation evidence and command-trace file selection has a separate concrete +`SimulationEvidenceFileDialogHost` boundary under `View/Dialogs`. It owns the +six native Open/Save dialog configurations for simulation evidence, command +trace, and unified commissioning evidence, and converts cancel/acceptance to a +selected path. It performs no application file I/O, business operation, or +runtime action. `MainViewModel` retains the existing callback and action +routing, while project workflow message dialogs use +`MainMessageDialogHost` and other view-local dialogs remain in their existing +owners. + +Camera image-source selection has a separate concrete +`CameraImageSourceFileDialogHost` boundary under `View/Dialogs`. It owns the +native image-file dialog title, filter, project-root initial directory, and +cancel-to-null result. `CameraImageSourceEditorViewModel` retains the selected +path's project-relative conversion, validation, draft state, and camera-source +application. The selector is passed as a delegate so the editor workflow can +be exercised without opening an OS dialog; no interface or image-buffer owner +is introduced. + +Machine integration path selection has a separate concrete +`MachineIntegrationFileDialogHost` boundary under `View/Dialogs`. It owns the +exchange-folder and 2D inspection-recipe native dialog configuration and +returns only a selected path or cancellation. `MachineIntegrationViewModel` +retains setting updates, status presentation, persistence, TCP, and handoff +workflow policy; the host performs no integration I/O or runtime action. + +Semiconductor recipe compatibility-report path selection has a separate +concrete `SemiconductorRecipeCompatibilityDialogHost` boundary under +`View/Dialogs`. It owns the report Save and baseline/current Open dialog +configuration and returns only a selected path or cancellation. +`SemiconductorRecipeGalleryViewModel` retains validation, report creation and +persistence, report loading, comparison, and command state. Both hosts are +concrete/non-`partial` and use delegate seams so their caller workflows can be +tested without opening an OS dialog. + +The semiconductor recipe gallery catalog and validation paths have separate +concrete workflow boundaries. `SemiconductorRecipeGalleryCatalog` owns bundled +recipe enumeration, project loading, and topology/count metadata extraction, +returning descriptors without WPF or localized presentation. The +`SemiconductorRecipeGalleryValidationWorkflow` owns one recipe's asynchronous +project load, automatic-sequence handling, deterministic dry-run, and failure +stage selection. `SemiconductorRecipeGalleryValidationSession` owns the +ordered multi-item validation loop and translates one-recipe results into +item outcomes through explicit progress/result callbacks. The +`SemiconductorRecipeGalleryViewModel` owns observable validation +progress/summary, selection, report composition, commands, localization, and the +existing smoke-facing façade. Its item validation state/projection is owned by +the concrete `SemiconductorRecipeGalleryItemViewModel` in +`ViewModel/Recipes/SemiconductorRecipeGalleryItemViewModel.cs`, while +compatibility comparison rows are owned by +`RecipePackCompatibilityComparisonItemViewModel` in +`ViewModel/Recipes/RecipePackCompatibilityComparisonItemViewModel.cs`. Both +projection owners are WPF/parent-ViewModel neutral; the project schema and +dry-run semantics are unchanged. Existing catalog/session/workflow/copy/dialog +owners are not re-split without new evidence. + +Simulation scenario project mapping has a separate concrete +`SimulationScenarioProjectMapper` boundary. It owns the neutral scenario +snapshot, `SimulationDefinition` load/save mapping, legacy axis-fault +compatibility, assertion definition construction, scheduled-fault validation, +and deterministic engine-profile assembly. `SimulationWorkspaceViewModel` +retains observable authoring state, profile selection, localization, target +correction, commands, and event lifetime; its existing public mapping methods +remain compatibility facades. The mapper has no WPF or ViewModel dependency, +and the project schema and engine contract remain unchanged. + +`RuntimeDefinitionApplicationWorkflow` under `ViewModel/Simulation` owns live +compilation through `MachineProjectRuntimeCompiler` and one +`ConfigureRuntimeCommand` application, with distinct compilation-rejected and +engine-rejected outcomes. `ProjectRuntimeApplicationWorkflow` owns the reentrant +runtime-application gate and accepted/rejected routing. Main retains the +applying-state facade, child restoration, runtime-dirty presentation, and the +callback that applies a project through `ProjectLifecycleCoordinator`'s session. +Initial configuration is performed by `SimulationRuntimeLoop` composed by +`SimulationSessionCoordinator`. Main's `BuildRuntimeConfiguration` remains the +adapter used by initial, child-validation, and readiness callbacks. + +`ProjectDirtyState` owns the saved-content baseline and comparison transitions. +`ProjectDocumentSession`, created by `ProjectLifecycleCoordinator`, combines it +with active document/path/session/revision state and evidence serialization. +The coordinator owns save and unsaved-resolution sequencing. Main retains +`HasUnsavedChanges`/title notifications and host callbacks; +`MainWpfInteractionHost` owns focus/dispatcher mechanics. These owners preserve +project replacement, persistence, and runtime command contracts. + +Scheduled-fault scenario startup has a separate +`ScheduledFaultScenarioWorkflow` boundary. It owns the ordered Reset, +condition-scenario start, automatic-run or recovery-sequence branch, and Play +transaction, including stop-condition compensation after a post-start +rejection. `SimulationScenarioExecutionCoordinator` owns the shell-facing +runtime preparation, Test Scenario Start/Stop/Replay ownership state, and +localized success/failure presentation while reusing the workspace and this +transaction workflow. `MainViewModel` retains the public Start/Stop/Replay +facades and supplies explicit mode, run-state, status, and log callbacks. Both +owners are concrete, independently testable, and WPF/parent ViewModel neutral. + +Ordinary Test Scenario command orchestration has a separate +`SimulationScenarioWorkflow` boundary. It owns ordinary start, Reset-then- +start replay, and Stop-then-optional-Pause ordering, delegates scheduled-fault +start/replay to `ScheduledFaultScenarioWorkflow`, and returns neutral results. +The coordinator owns the caller-facing target/profile preparation and result +mapping; `MainViewModel` retains only the public facades and shell callbacks. +The workflow and coordinator are concrete, independently testable, and +WPF/parent ViewModel neutral. + +Non-equipment simulation command presentation has a separate +`SimulationCommandPresentationDispatcher` boundary. It owns engine enqueue and +localized accepted/rejected status/log mapping for Runtime Debugger, digital +I/O, and fault commands. `MainViewModel` retains public child ViewModel +composition and applies the Runtime Debugger snapshot through an explicit +callback after enqueue. Equipment command presentation remains owned by +`EquipmentCommandDispatcher`; no generic command registry or simulation +schema change is introduced. + +`CameraCommissioningViewModel` under `ViewModel/Camera` owns camera selection, +source editing/application, presentation, manual-trigger commands, acquisition +observation, and Vision evidence composition/lifetime. It creates the concrete +selection, source-application, trigger, request, participant, and evidence owners. +Main retains public binding facades and supplies immutable runtime/project/session +context, engine dispatch, monitor refresh, and Integration-context callbacks. + +`CameraSelectionWorkflow` retains selected camera/recipe state, fallback rules, +recipe derivation, and ordered selection callbacks. `ManualCameraTriggerRequestFactory` +captures one request's camera, recipe, project, runtime, and evidence context. +`ManualCameraTriggerWorkflow` performs acquire/inspect/revalidate/evidence/dispatch, +using `VirtualCameraInspectionWorkflow` for source and inspection work. The +camera workspace owns availability and localized outcome presentation. Its +acquisition/preparation participants preserve cancellation and stale-session/ +runtime-generation rejection before results can become current. No additional +image-buffer owner or WPF visual-tree dependency is introduced. + +The Process Block to Sequence review route is owned by the +`ProcessPlanReviewViewModel`. It keeps the review step snapshot, boundary +navigation, and Return command state behind explicit callbacks. +`RecipeAuthoringWorkspace` composes this route with Sequence selection and closes +review context when the Process Block preview closes. `MainViewModel` retains +the public binding facade and cross-feature presentation callbacks, while +`SequenceEditorViewModel` and `RecipeProcessBlockViewModel` remain the data and +selection owners. + +Recipe Connection project application has a separate domain boundary. +`RecipeConnectionProjectApplier` owns typed setup equality, deterministic +device lookup/creation, channel and definition copying, and process-block/ +managed-timeout project mutation. It returns an explicit applied/no-change/ +multiple-device result. `RecipeAuthoringWorkspace` owns composition, dry-run +exit, recipe mutation completion, Sequence refresh/navigation, managed timeout +adjustment and checkpoint completion. Main supplies current-project, shared dirty/ +availability/definition/history callbacks and camera/runtime presentation integration. +Status and log sinks remain explicit. The applier is concrete, non-`partial`, and has no +WPF or parent ViewModel dependency. + +Recipe Connection simulation preflight and isolated previews have a separate +`RecipeConnectionSimulationWorkflow` boundary. It owns readiness-result +presentation and the deterministic sequence-step preview and recipe dry-run +runner invocations, including their existing localized status and category log +mapping. `RecipeAuthoringWorkspace` composes this workflow with the workbench +and isolated playback. Main supplies the current project and shared +`BuildRuntimeConfiguration` adapter, retaining public facades and cross-feature +presentation callbacks. The workflow is concrete, +independently testable, and WPF/parent-ViewModel neutral; project schema, +callback contracts, and simulation semantics remain unchanged. + The axis-to-layout mapping is explicit. A `LinearStage` names an authored `Linear` axis; its world X is its base X plus the axis delta from the authored home. A `RotaryStage` names an authored `Rotary` axis; its world X/Y remain at @@ -90,6 +436,9 @@ Retracted `DigitalInput` channels, exact extend/retract durations, end-feedback delays, and stroke. A `Conveyor` names distinct Run and Reverse outputs and a positive speed. A `Workpiece` names one conveyor in the same layout, retains its type and inspection state, and starts fully inside and aligned with its carrier. +When linked to a wafer handler, the workpiece also owns its current transfer +state and derives current-carrier exposure and conveyor movement from that same +state. A `DigitalSensor` names its target layout component and one configured `DigitalInput`. The runtime evaluates deterministic inclusive geometry and converts authored milliseconds to exact fixed-step tick counts. It @@ -109,18 +458,21 @@ control-state model and does not model vacuum conductance or physical pressure. A schema `1.7` `WaferHandler` device is a non-visual transfer contract. It references two distinct linear axes, one active-layout workpiece, source-present and gate-open inputs, Pick/Place outputs, Holding/Placed feedback inputs, and -pick/place coordinates inside both axis limits. Independent -`WaferHandlerRuntimeState` owns Source, Handler, Destination, and latched -InterlockFault state. Pick requires both pick coordinates, source presence, and -a closed gate; place requires both place coordinates, handler ownership, and an -open gate. Unsafe, wrong-order, or simultaneous requests publish false feedback -until reset. One workpiece may be referenced by at most one wafer handler. After -each handler evaluation, the layout projects the owner id and ownership state -onto that workpiece's immutable snapshot; this projection does not transition -state or reinterpret axis coordinates as a robot path. WPF renders the projected -Source/Handler/Destination/Fault evidence and does not reconstruct transfer -policy. The workpiece remains governed by its authored conveyor geometry, so -this contract does not claim physical robot motion or destination placement. +pick/place coordinates inside both axis limits. `WaferHandlerRuntimeState` +evaluates the pick/place command edges and interlock policy, while the linked +`WorkpieceRuntimeState` is the single mutable owner of Source, Handler, +Destination, and latched InterlockFault. Pick requires both pick coordinates, +source presence, and a closed gate; place requires both place coordinates, +handler ownership, and an open gate. Unsafe, wrong-order, or simultaneous +requests publish false feedback until reset. One workpiece may be referenced by +at most one wafer handler. Source and Destination expose the authored conveyor +as the current carrier and can move with it. Handler and InterlockFault expose +no conveyor carrier and retain the last valid physical pose. Destination +reattaches at that retained pose so the existing downstream unload flow can +continue without fabricating a robot path or destination transform. Handler +feedback, handler snapshots, workpiece snapshots, and WPF presentation all read +that Workpiece-owned state; the layout no longer projects a duplicate state. +This contract does not claim physical robot motion or destination placement. A schema `1.8` `Sorter` device with `inspectionSortRouter` is a non-visual inspection-disposition contract. It references one configured virtual camera, @@ -175,15 +527,17 @@ Runtime equipment is owned by independent referenced objects rather than `WorkpieceRuntimeState`, `DigitalSensorRuntimeState`, `LoadLockRuntimeState`, `WaferHandlerRuntimeState`, `InspectionSortRouterRuntimeState`, `InspectionHandoffRuntimeState`, -`OhtHandoffRuntimeState`, and `PrealignerRuntimeState` each own their -state transition. `DeterministicMachineLayout` only creates, orders, and coordinates -those objects. New equipment kinds should follow the same boundary: add one +`OhtHandoffRuntimeState`, and `PrealignerRuntimeState` own their equipment +state transitions. For a linked transfer, `WorkpieceRuntimeState` owns Material +state and `WaferHandlerRuntimeState` owns only command/interlock policy. +`DeterministicMachineLayout` only creates, orders, and coordinates those +objects. New equipment kinds should follow the same boundary: add one owned runtime object and reference it through the layout orchestrator instead of extending the orchestrator through partial files. The fixed-step order is intentional: axis integration, load-lock interlock evaluation, cylinder state/feedback, OHT handoff permission, -conveyor/workpiece transport, +conveyor/workpiece transport from the Workpiece-owned carrier state, geometry-sensor DI publication, wafer-handler evaluation, inspection-sorter and inspection-handoff evaluation from the prior camera snapshot, pre-aligner evaluation from the current rotary-axis/clamp/sensor state, and then the camera tick occur before @@ -191,7 +545,8 @@ the Sequence tick. Therefore `WaitVisionResult` sees the current camera result, while the sorter safely latches it on the following layout tick before either branch can issue its route command; `WaitSignal` reads current-tick equipment feedback. -Reset restores axes, cylinders, conveyors, and workpieces to authored home, +Reset restores axes, cylinders, conveyors, and workpieces to authored home and +restores linked workpieces to Source on their authored conveyor, clears sensor delay history, restores load locks to Atmosphere, returns inspection sorters to AwaitingDecision with both route feedback inputs false, writes inspection handoffs to AwaitingMaterial with Ready/Complete false, @@ -218,7 +573,151 @@ execution. The UI then displays immutable snapshots and ordered events. `App` owns only normal interactive startup and the top-level decision to enter direct-EXE automation. `DirectExeSmokeHost` owns automation arguments, smoke -window placement, scripted interactions, captures, reports, and exit codes. +window/process lifecycle, shared WPF interaction adapters, common report +routing, and shutdown. `DirectExeFaultScenarioHost` owns the headless +fault-scenario CLI workflow. `DirectExeSmokeFailurePolicy` owns the ordered +report-failure exit-code selection. `SmokeNativeInput` owns the user32 calls, +WPF input synchronization, pointer ownership diagnostics, and the per-run +held-pointer `IDisposable` lifetime. `SmokeWindowCapture` owns Popup visual state, +Window/Popup bitmap composition, and PNG persistence. Scenario-specific report +contracts and assertions stay with their concrete smoke verifiers. +`SmokeTestScenarioRuntimeVerifier` owns deterministic condition-scenario and +scheduled-axis-fault runtime checks; the Host retains their top-level route +selection and the separate settings visual-state workflow. +`SmokeRuntimeEvidenceVerifier` is the +concrete owner for command-trace capture/export/replay, portable scenario +evidence exchange, and unified commissioning-evidence exchange. It receives +explicit visual and native-input callbacks and has no dependency on the Host; +the production runtime and evidence state remain owned by the session/workspace and +the simulation/evidence modules. +`SmokeScenarioBatchVerifier` owns the non-persistence scenario-batch smoke +sequence: deterministic batch setup, cancellation, baseline/mismatch checks, +and routing to the evidence verifiers. Project-linked batch persistence and +View-specific result-panel scrolling remain Host-owned boundaries. + `SmokeBatchPersistenceVerifier` owns the smoke-only save/run/sidecar/reopen/ + restore verification for an explicitly supplied project path; restore-only + flag checks and visual capture preparation remain in the Host. These smoke + owners do not replace the production project save/open workflows or alter the + project format. + `DirectExeSmokeArgumentParser` owns Direct-EXE argument lookup, primitive + parsing, camera-first-use request derivation, and cross-argument validation. + `DirectExeSmokeOptions` owns the typed non-WPF snapshot of smoke values, + defaults, derived flags, and window settings. `DirectExeSmokeHost` retains + top-level smoke/fault route selection, WPF interaction, scenario ordering, + report routing, and shutdown ownership. `DirectExeFaultScenarioHost` owns + the headless fault-scenario argument/result workflow. + `DirectExeSmokeFailurePolicy` owns the ordered report-failure + exit-code selection. `SmokeNativeInput` owns user32 input and pointer + cleanup. + `SmokeWindowCapture` owns screenshot/Popup composition + and output persistence. + `DirectExeSmokeProjectLoader` owns initial blank/startup-choice/explicit-path/ + bundled-sample project materialization. `DirectExeConnectionWorkbenchWorkflow` + owns Recipe Connection workbench preparation, named-control lookup, state + dispatch to concrete smoke verifiers, and workbench-specific report + persistence. `DirectExeSmokeHost` retains WPF shell creation, shared visual + interaction setup, top-level scenario selection, common report routing, and + shutdown selection. `SmokeNativeInput` owns native input and pointer state. + `SmokeWindowCapture` owns Window/Popup + screenshot composition. + `SmokePerformanceVerifier` owns startup-to-idle, navigation, and steady + interaction measurements plus the `SmokePerformanceReport` contract. + `SmokeProjectRoundTripVerifier` owns the SaveReload/Reopen restored-state + assertions and `SmokeProjectRoundTripReport` contract. `SmokeVisualTreeQuery` + and `SmokeProjectTreeQuery` own the shared WPF visual-tree and project-tree + queries used by those smoke scenarios. These owners do not change production + ViewModel, project-format, or runtime behavior. + `SmokeUiInteraction` owns the concrete visual/native-input callback contract + shared by runtime-evidence and visual-state smoke verifiers. + `SmokeAxisTuningStateVerifier`, `SmokeLayoutPropertyStateVerifier`, + `SmokeEditMenuStateVerifier`, and `SmokeEvidenceDrawerStateVerifier` own the + corresponding Direct EXE visual-state preparation workflows. The host retains + their routing and shutdown selection; `SmokeNativeInput` owns pointer + cleanup; `SmokeWindowCapture` owns screenshot + composition. The overlapping + direct-scene gesture state path documented by PL-0060. + `SmokePickAndPlaceStateVerifier` now owns fixed-tick Pick-and-Place workpiece, + gripper, render, and language-refresh assertions. `SmokeGlobalCommandStateVerifier` + owns Simulation ON plus Abort/Retry command-state and lifecycle assertions; + `SmokeUiInteraction` supplies its cursor diagnostics. The host retains the + global-command start/fault preconditions, state routing, and shutdown. + `SmokeNativeInput` owns pointer cleanup. `SmokeWindowCapture` owns screenshot + composition. + `SmokeSequenceStateVerifier` owns the + sequence-editor focus, hover, popup, subsequence, validation, and expected- + state checkpoint smoke preparation. The host retains the sequence route and + shutdown; `SmokeNativeInput` owns pointer cleanup. `SmokeWindowCapture` owns + Popup screenshot storage. Direct-scene gesture + preparation remains host-owned under the separate PL-0060 boundary decision. + The existing commissioning smoke verifiers also own their matching Direct + EXE state preparation and scroll targets: `SmokeFaultManagerVerifier`, + `SmokeCameraCommissioningVerifier`, `SmokeDigitalIoCommissioningVerifier`, + `SmokeCylinderCommissioningVerifier`, `SmokeConveyorCommissioningVerifier`, + and `SmokeSensorCommissioningVerifier`. Axis state preparation and scrolling + are owned by the concrete `SmokeAxisCommissioningStateVerifier`, while + `SmokeAxisCommissioningVerifier` owns the 64-check report verification and + serialization path. + `SmokeMultiAxisCommissioningStateVerifier` owns the 17 multi-axis recipe + state-preparation paths, while `SmokeMultiAxisCommissioningVerifier` owns the + multi-axis report verification and serialization path. `DirectExeSmokeHost` + calls those two owners separately and retains commissioning + argument routing and shutdown. `SmokeNativeInput` owns shared pointer + cleanup. `SmokeWindowCapture` owns Popup screenshot composition. + `SmokeRecipeDryRunStateVerifier` owns the Recipe Connection dry-run state + family: deterministic dry-run execution, fault setup, isolated playback, + timeline navigation, and dry-run control focus/hover/pressed/disabled + preparation. It uses `SmokeUiInteraction` for native input. The host retains + dry-run route selection, shared lifecycle, and shutdown. `SmokeNativeInput` + owns pointer cleanup. + `SmokeWindowCapture` owns Popup/screenshot composition. + `SmokeProcessBlockPreparation` owns the process smoke setup snapshot, while + `SmokeProcessBlockSequenceStateVerifier` validates the actual authored + `process-block.*` ID set rather than a fixed bundled-sample count. + `SmokeProcessBlockApplicationVerifier` derives the expected post-Apply and + dry-run step count from that setup snapshot, preserving the existing PB-C2 + report keys while avoiding a stale fixture-specific literal. + `SmokeRecipeCheckpointStateVerifier` owns its deterministic in-memory + checkpoint and strict-linear edit fixtures; those clones are smoke-only and + never mutate the tracked sample. `SmokeButtonPointerState` owns the shared + focus/hover/pressed WPF protocol, including native-input retry and release + cleanup coordination; the process-block application verifier uses the same + helper for Apply focus/pressed states. These owners keep fixture policy and + pointer state out of the generic host and do not change production runtime + behavior. + Direct EXE and integration/simulation tests share the linked + `tests/TestSupport/TestStorage.cs` policy: D: is preferred when it has the + required free space, while `OPENVISIONLAB_TEST_DATA_ROOT` or the TEMP path + provides an explicit fallback when D: is full. This is test-only storage + routing; the production exchange and evidence writers retain their own + free-space and atomic-save guards. + `MachineLayoutDeviceRuntimeCompiler` owns the specialized layout-device runtime +composition boundary. It receives the already selected layout's runtime +components, authored device definitions, axis definitions, channel kinds, and +fixed-step timing policy, then validates and assembles LoadLock, WaferHandler, +OHT handoff, and Prealigner runtime configuration. Its concrete +`MachineLayoutInspectionRuntimeCompiler` companion owns InspectionSortRouter + and InspectionHandoff configuration, including inspection camera/channel + validation and immutable result construction. `MachineLayoutRuntimeConfiguration` + remains the public immutable runtime configuration and property owner, while + its concrete `MachineLayoutRuntimeConfigurationValidator` companion owns + cross-component reference checks, channel/semantic ownership invariants, and + deterministic ordinal ordering. `MachineLayoutSignalBindingValidator` + owns construction-time digital signal-kind checks for the runtime device + states, while `DeterministicMachineLayout` retains single-thread tick/reset + orchestration. `MachineLayoutRuntimeResetter` owns runtime-state reset and + workpiece carrier initialization; the layout retains the signal-restoration + callback that bridges reset state to the signal hub. `MachineProjectRuntimeLayoutCompiler` owns project layout + validation, active-layout selection, and base component assembly. The concrete + `MachineProjectRuntimeAxisCompiler` and `MachineProjectRuntimeCameraCompiler` + own authored axis/camera conversion, including the existing legacy camera + property path. `FixedStepDelayConverter` is the shared owner of deterministic + milliseconds-to-ticks conversion for these compilation owners. The public + `MachineProjectRuntimeCompiler` retains project contracts, orchestration, + signal contract construction, dependency gating, and the public compilation + result contract. `MachineProjectRuntimeSequenceCompiler`, +`MachineProjectRuntimeAutomaticRunCompiler`, and +`MachineProjectRuntimePickPlaceCompiler` own their respective typed project +policy conversions and have no WPF/ViewModel dependency. The active application shell is `ShellWindow`; the retired `MainWindow`, `WorkspaceView`, and `SimulationWorkspaceView` resources are not part of the compiled UI path. @@ -244,20 +743,10 @@ The UI does not own or calculate: Views never mutate simulation state directly. ViewModels never reconstruct runtime truth by comparing rendered frames. Presentation refresh may discard a -stale visual snapshot, while the separate Event Journal path remains -non-dropping. - -Long-running application workflows use concrete lifetime owners rather than -sharing disposal state through the shell. ViewModels and workflow owners reject -new work after disposal and re-check the lifetime gate after awaited operations -before publishing status, results, notifications, or row updates. The -`MainViewModel` remains the WPF composition owner and disposes its child -workflows; `RecipeConnectionWorkbenchViewModel` likewise disposes its -sequence-step preview and dry-run children in a defined order. This keeps -Window/control-specific behavior in the WPF boundary while preventing late -asynchronous callbacks from mutating a closed shell. These guards preserve the -existing public command, recipe, project, and runtime contracts while making -shutdown and repeated-command behavior independently testable. +stale visual snapshot. The Event Journal is a bounded latest-value projection: +the engine retains the latest 4,096 ordered events and its monotonically +increasing `EventIndex` exposes any discarded prefix, while MachineStudio shows +the latest 1,000 localized log lines. The Sequence editor mutates only authored `SequenceDefinition` values while the application is in Design mode. Field edits are continuously checked by the @@ -269,6 +758,475 @@ remain visible and field-editable, but structural commands fail closed rather than silently rewriting control flow. **Simulation ON** remains the only path that validates and atomically replaces the runtime configuration. +Each authored Sequence carries `watchdogTimeoutMs`. Zero explicitly permits an +unlimited whole-Sequence execution for backward compatibility; a positive value +is compiled into the UI-neutral `CompiledSequence`. The +`DeterministicSequenceExecutor` compares that limit only with deterministic +simulation time, faults a still-running execution with +`SequenceWatchdogTimedOut` at the inclusive boundary, and clears the budget on +its existing Reset/repeat path. Immutable Sequence snapshots expose the +configured limit and typed error; the engine publishes the fault through its +existing ordered `SequenceFaulted` event. WPF does not run a duplicate timer. + +Sequence debugging uses the same ownership boundary. `StepSequenceCommand` +asks the engine to run until exactly one transition, completion, or fault; +`SetSequenceBreakpointCommand` changes a session-only engine breakpoint set. +The engine pauses before an entered breakpoint step executes and publishes one +immutable `SequenceDebugSnapshot` plus ordered debug events. Reset retains valid +session breakpoints while clearing pending step state; atomic runtime +replacement clears them. `RuntimeDebuggerViewModel` retains the public debugger +facade and selected snapshot watches. Its concrete `RuntimeTimelineViewModel` +child owns the session-only bounded 200-event history, localized timeline-row +materialization, clear lifecycle, and timeline summary while the parent keeps +the existing `Timeline`/summary binding facade. Its concrete +`RuntimeAlarmCollectionViewModel` child owns fault/error alarm +projection, active-occurrence tracking, clear/reappear lifecycle, bounded +history, acknowledgement, and localized recovery guidance. The child receives +only explicit enabled-state, sequence-name, and status callbacks; neither owner +creates a timer or transition detector, and debugger choices are not silently +written to the project. + +`RuntimeDebuggerWatchTargetCatalog` owns the WPF-neutral combined projection +and deterministic kind/name/ID ordering of Sequence, axis, signal, and +equipment watch targets from one immutable snapshot. The debugger ViewModel +retains the observable collection, identity comparison, selected-target +restoration, value formatting, and commands. + +MachineStudio async commands share one small concurrency boundary. +`AsyncRelayCommand` atomically claims its own execution slot before invoking +the delegate and releases it in a `finally` path. This prevents concurrent +callers from starting the same command twice while preserving each command's +existing `CanExecute` predicate, exception routing, cancellation handling, and +cross-command policy. It is not a global lock for unrelated commands. + +Deterministic command tracing uses the same boundary. The +`FixedStepSimulationEngine` identifies applied command boundaries and delegates +their synchronized in-memory storage to `DeterministicSimulationCommandTraceStore`, while +`DeterministicSimulationCommandTraceCommandCodec` owns the WPF- and +engine-neutral command argument serialization, replayability policy, and +command reconstruction. `DeterministicSimulationCommandTracePackage` owns +schema validation, semantic hashing, and JSON persistence, and +`DeterministicSimulationCommandTraceReplayRunner` owns paused replay through +the existing engine queue. MachineStudio's Run Inspector exposes only explicit +Start capture, Export, and Replay actions. `SimulationCommandTraceViewModel` +owns their capture/replay state, CanExecute predicates, status, and transitions; +`MainViewModel` retains the public binding facade, file-dialog routing, engine snapshot +projection, and unified-evidence callbacks. Neither UI owner serializes command +arguments or reconstructs runtime commands. Capture clears only the in-memory +trace, and runtime snapshot projection is guarded so derived reset/replay +corrections do not become authored project changes. + +`DeterministicSignalHub` owns synchronized multi-channel lookup, revision +accounting, write ownership, and cross-channel interlock policy. +`DeterministicSignalState` owns the independent mutable state of one channel, +including nominal/effective values, digital overrides, reset, and immutable +snapshot conversion. The single-channel owner does not reference the hub or +its lock. + +`MachineProjectLayoutValidator` owns layout identity, geometry, component +identity, and structural shape validation. Its concrete +`MachineProjectLayoutBehaviorBindingValidator` companion owns the explicit +axis, device, channel, and cross-component binding rules, including reference +lookup and component-kind dispatch. The two owners return the same existing +validation result contract; neither mutates the authored project, and the +structural owner does not share its duplicate-ID state or error collector with +binding policy. + +Embedded Sequence steps use `DeterministicSequenceRuntimeContext` as the +simulation-to-Sequence adapter. Signal reads/writes, axis move requests and +motion-state reads, virtual-camera triggering, and vision-result reads remain +deterministic and are translated into the existing typed Sequence results. +Accepted state-changing operations publish through the engine's existing event +sequencer; the adapter does not own the engine loop or snapshot state. + +Sequence debugging has a separate narrow state owner. `DeterministicSequenceDebugState` +owns enabled sequence breakpoints, pending semantic-step matching, pause +metadata, reset, and `SequenceDebugSnapshot` projection. The +`FixedStepSimulationEngine` retains command validation, pause-mode transitions, +sequence execution, event emission, and deterministic tick orchestration. The +owner is Simulation-only, non-partial, and does not reference WPF or the +engine, so debug state can be tested without constructing the runtime shell. + +The configured Sequence catalog, executor catalog, and Sequence-debug lifecycle +now have one concrete `SimulationSequenceRuntime` owner. It owns configuration, +debug clearing, executor reset, and current-step projection. The +`FixedStepSimulationEngine` retains `ActiveSequenceId`, shared RunMode/ +ControlOwner/PendingSteps state, cross-feature recovery policy, tick ordering, +event/snapshot publication, and the public engine contract. The owner is +Simulation-only, non-partial, and receives no WPF or hardware dependency; the +existing handler context contracts continue to receive the same read-only +catalog/debug projections. + +Runtime configuration uses the same candidate/application split. +`SimulationRuntimeConfigurationBuilder` validates and materializes axes, +cameras, signals, compiled sequences/executors, automatic-run timing, layout, +and pick/place into an explicit candidate result without touching installed +engine state. `FixedStepSimulationEngine` remains the transaction owner: it +installs the candidate only after the complete build succeeds, resets related +runtime state, and emits the existing command result/event. Axis-only +configuration reuses the same axis candidate owner. + +Manual commissioning command policy has a concrete Simulation owner as well. +`SimulationManualControlCommandHandler` remains the compatibility façade over an +explicit context containing the installed axes, cameras, layout, signal hub, +active faults, sequences, and command-boundary state. It routes axis, +camera, output-equipment, and input-force commands, keeps manual-session entry +and the shared result envelope, and returns the existing command result plus a +small state delta and operation-event list. The concrete +`SimulationManualAxisCommandHandler`, `SimulationManualCameraCommandHandler`, +`SimulationManualEquipmentCommandHandler`, and +`SimulationManualInputCommandHandler` own the corresponding validation and +runtime mutation policies. `FixedStepSimulationEngine` remains the owner of +authoritative installed state, command-boundary timing, event publication, +final command trace, lifecycle, and deterministic tick policy. + +Run-control command policy follows the same narrow boundary. The concrete +`SimulationRunControlCommandHandler` owns Play, Pause, fixed-step, semantic +Sequence-step, and Sequence-breakpoint command transitions using explicit +run/debug/sequence context. It returns the existing command result and typed +run/control state delta plus ordered operation events. The engine remains the +owner of installed state, command dispatch and final trace/event publication, +and keeps tick-time Sequence debug pause behavior with deterministic tick +ordering. + +Fault command policy follows the same explicit boundary. The concrete +SimulationFaultCommandHandler owns Inject/Clear fault validation, target +mutation, active-fault registration, recovery mutation, and ordered operation +events through a runtime context. `SimulationFaultRuntime` owns the mutable +active-fault records and exposes the existing read-only dictionary projection +to manual-control, condition, and Sequence policies. FixedStepSimulationEngine +uses the same concrete runtime for external fault commands and condition- +scenario scheduled faults; it retains condition timing/restart policy, +external final command event/trace, and snapshot/lifecycle/tick ownership. + +External sequence command policy now has its own concrete boundary as well. +SimulationSequenceCommandHandler owns Start, Abort, and Retry validation, +DeterministicSequenceExecutor mutation, sequence-debug mutation, automatic-run +state deltas, and ordered Sequence/AutomaticRun operation events through an +explicit command context. FixedStepSimulationEngine applies the returned +state delta and publishes those events at the existing command boundary; it +retains authoritative state storage, automatic cycle/recovery policy, tick +execution, lifecycle, snapshot publication, and final command event/trace. + +Condition-scenario start policy follows the same concrete boundary. +SimulationConditionScenarioCommandHandler owns StartConditionScenario +normalization, profile validation, runtime-target compatibility checks, +condition state-machine initialization, and ordered start/zero-duration +completion operation events through an explicit snapshot-based context. +FixedStepSimulationEngine applies the returned condition state and publishes +those events at the existing command boundary. It intentionally retains +StopConditionScenario, scheduled-fault clearing, automatic recovery, condition +tick progression, authoritative state, lifecycle, snapshots, and final +command event/trace ownership because those operations still share engine-owned +recovery state. + +Automatic-run start policy is also isolated from the engine shell. +`SimulationAutomaticRunCommandHandler` owns StartAutomaticRun validation, +optional start-input mutation, sequence-executor start, automatic/run state +delta construction, and ordered I/O/Sequence/AutomaticRun operation events +through an explicit context. `FixedStepSimulationEngine` applies the returned +state and publishes those events at the established command boundary; it +retains automatic repeat, cycle completion, fault/recovery, condition +interaction, tick progression, lifecycle, snapshots, and final command +event/trace ownership. + +Automatic-run cycle policy has a concrete tick-time boundary. +`SimulationAutomaticRunCycleHandler` owns repeat-delay decrement, successful +sequence restart, cycle-count transitions, and ordered cycle/restart/completion +operation events through explicit state and configuration. On a restart failure +it returns the existing fault detail; `FixedStepSimulationEngine` applies the +returned state through `SimulationAutomaticRunRuntime` and invokes its existing +`FaultAutomaticRun` policy so condition-scenario recovery context remains +engine-owned. `SimulationAutomaticRunRuntime` owns AutomaticRun configuration, +active/repeat state, cycle count, delay state, handler-state projection, and +cycle-context projection; the engine retains shared sequence/control state, +tick ordering, fault/recovery, lifecycle, snapshots, and final command +event/trace publication. + +Condition scheduled-fault injection is also isolated behind a concrete +boundary. \`SimulationConditionScheduledFaultInjectionHandler\` owns injection +command construction, existing fault-handler delegation, injection/rejection +event translation, and condition-stop state outcome through explicit context. +The engine retains scenario tick calculation, command-boundary time assignment, +condition state-machine order, hold-boundary detection, and the scheduled-fault +recovery wrapper. + +Multi-axis commissioning validation follows the same explicit ownership +boundary. `AsyncOperationLifetime` owns one cancellable operation's Task and +cancellation-source lifetime. `AsyncOperationParticipant` builds on +that primitive for the repeated participant mechanics: one current typed task, +duplicate-start sharing, bounded observation, typed cancellation/timeout/failure +mapping, and disposal. Batch and multi-axis keep their domain-specific result +records and concrete participant facades; Camera preparation, Project Save's +multiple in-flight saves, and Integration's admission/state contract remain +separate owners. +`MultiAxisCommissioningViewModel` owns repeat-validation state, progress/status/result presentation, bounded history +projection, baseline comparison, stale-context presentation, and mismatch +navigation. The concrete WPF-neutral `MultiAxisCommissioningArtifactStore` +owns result/history/baseline artifact state, project-context validation, sidecar +persistence and restore, project-path relinking, and baseline deletion. The +existing Simulation package types remain the validation, JSON/hash and file-save +API authority. `Scenarios/AtomicEvidenceFile` owns their shared temporary-file +lifetime: invoke the synchronous writer, replace the destination, and clean up +that call's temporary file. Run/batch/exchange/trace/Vision/unified/multi-axis +evidence and canonical journal export use the same owner. Packages still prepare +paths/directories and retain serializer timing, encoding and journal space/size +checks. Baseline/history keep their existing internal forwarding entry point. +Core project save/backup and retention deletion have separate contracts and are +unchanged. See [PL-0265](../.proofline/evidence-file-persistence-refactor-report.md). +`MainViewModel` retains the public binding facade and supplies project/runtime, +status/logging, UI-progress, layout-selection, and presentation callbacks. The +recipe editor remains the authoring owner, while manual coordinated Run/Stop, +project mutation, and runtime compilation remain outside the child. The child +and store have no WPF visual-tree dependency and do not mutate project or +runtime state directly. + +Manual multi-axis commissioning execution has a separate +`MultiAxisCommissioningExecutionWorkflow` boundary. It owns the ordered +Pause-if-needed, `StartManualControlCommand`, and `MoveAxesAbsoluteCommand` +transaction, snapshots the move targets before asynchronous dispatch, and +returns typed pause/manual-control/move rejection outcomes. `MainViewModel` +retains recipe selection and validation, command availability, `IsRunning` +state, pause-rejection logging, and the public Run/Stop command facades. The +workflow reuses the existing `EquipmentCommandDispatcher`, is concrete and +independently testable, and has no WPF or parent-ViewModel dependency. + +The workflow captures one `SimulationRuntimeIdentity` from the initial snapshot +and binds all three preparation commands through `SimulationCommand.ExpectedRuntime`. +At application time, the existing engine `ApplyCommand` compares the expectation +with its owned project ID and runtime generation before any handler runs. A stale +command returns `RuntimeIdentityMismatch` through the normal completion, command +event and trace path. It must not consult the published snapshot here: an earlier +command in the same drain may already have changed the owned runtime. Null +`ExpectedRuntime` preserves unbound callers; a bound identity may contain a null +project ID. Project comparison is ordinal. Previously applied commands are not +rolled back, and PL-0263's host lifetime checks remain necessary and unchanged. + +Portable command traces preserve bound commands' arguments and actual outcomes, +but mark them non-replayable. Replay must not drop the admission condition or +invent a mapping to a new session's generation. Existing unbound trace schema, +argument codec and hash rules are unchanged. See the +[PL-0264 report](../.proofline/runtime-command-admission-refactor-report.md). + +Deterministic Test Scenario batch execution follows the same boundary. +`SimulationScenarioBatchViewModel` owns the sequential batch command/state +facade, progress, accepted baseline, mismatch navigation, and artifact-store +coordination; its cancellable operation lifetime is owned by the typed +`SimulationScenarioBatchParticipant`, which reuses +`AsyncOperationParticipant` for the +common lifecycle mechanics. `SimulationScenarioBatchPresentation` is the +concrete WPF-neutral owner for localized read-only status, result, baseline, +artifact, and assertion display mapping. `SimulationScenarioBatchRepetitionRunner` +is the concrete WPF-neutral owner for one temporary engine's configure, +automatic/recovery start, deterministic replay, result-package creation, and +stop lifecycle. The concrete WPF-neutral `SimulationScenarioBatchArtifactStore` +owns the batch/baseline packages, project-linked sidecars, context validation, +stale rejection, path relinking, and portable evidence import/export. +`MainViewModel` remains the compatibility facade and forwards live Scenario +commands to their coordinator while supplying project/runtime context, primary engine pause/compile callbacks, +unified-evidence aggregation, and callback routing. The concrete +`SimulationEvidenceFileDialogHost` owns the six simulation evidence/trace file +dialogs; project/recipe path selection uses its dedicated dialog host. Restore +and import validate context without starting or replaying a runtime. + +Vision execution evidence is composed by `CameraCommissioningViewModel`. +`VisionExecutionEvidenceViewModel` owns recorder lifetime, correlated event +completion, repeat comparison, linked sidecar persistence/restore, stale-context +state, and localized evidence presentation. The camera workspace supplies its +context and owns selection, trigger guards, and acquisition lifetime. Main +retains explicit project/runtime and dispatch callbacks, dialog-host routing, +and unified-evidence aggregation. `VirtualCameraInspectionWorkflow` owns +project-relative image acquisition, deterministic inspection, and evidence +command construction. Cancel/reset/reopen releases pending evidence without +implicitly acquiring a frame or starting a run. + +The 2D Integration handoff callback has two narrow boundaries. +`MachineIntegrationRequestWorkflow` owns the explicit-context eligibility +policy, current frame extraction, and Trigger step lookup before delegating to +the existing `MachineIntegrationHandoffRequestFactory`. The factory owns +project-relative source resolution, source length/hash/frame identity +validation, and `MachineInspectionHandoffRequest` plus coordinate-projection +construction. `MainViewModel` supplies the current source context and a lazy +qualified-build identity provider. `MachineIntegrationViewModel` constructs the +request workflow and owns source-context refresh deduplication. Refresh does not +load build manifests; request admission and preparation read current context and +identity. The existing public request-callback constructor and unconditional +explicit `RefreshContext()` remain compatible. The ViewModel also retains + dialog-result callbacks, setup state and + persistence, Handoff/exchange publication, result-observation composition, + and the public TCP command/property forwarding facade. Its lifetime gate + rejects disposed Publish/Refresh command admission and suppresses late + status, projection, and busy-state publication. +`MachineIntegrationPathReadinessPolicy` is the shared concrete owner for +trimmed path normalization, current exchange-root/recipe availability, and +full-path conversion. The ViewModel uses its snapshot for binding/status and +command admission; `MachineIntegrationRequestWorkflow` uses it for the recipe +precondition; `MachineIntegrationResultObservationWorkflow` uses it for refresh and projection path canonicalization; `MachineIntegrationResultFileWatcher` uses it before watcher +creation. Setup persistence, transaction reads, and watcher lifetime remain in +their existing owners. +`MachineIntegrationTcpControlViewModel` owns the TCP commands, listener/client +operations, setup/result presentation, transient shared-key diagnostics and +zeroization, and the `MachineIntegrationTcpWorkflow` transport resource. +`MachineIntegrationTcpOperationOwner` (PL-0267) is its WPF-neutral concrete child for +one-operation admission, current-task observation, cancellation, status/busy +callback ordering, late-publication suppression and disposal. The owner keeps +the close admission and typed `Tcp` observation contract without owning a View, +control, or transport. Disposal cancels the operation and lets the existing +completion path release its task resources. + The parent supplies explicit settings, latest-transaction, result-refresh, + and status callbacks; the child has no `MainViewModel`, WPF visual-tree, or + dialog dependency. `MachineIntegrationSetupStore` is the concrete, + independently testable owner +of the file-backed setup format, validation, atomic persistence, reset defaults, +and saved-versus-current TCP snapshot comparison. `MachineIntegrationTcpWorkflow` +owns TCP listener/client transport lifetime and transfer calls. +`MachineIntegrationSharedKeyStore` owns transient session-key parsing, +environment fallback, caller-copy creation, and deterministic zeroization; the +EXE smoke harness uses the same owner for environment-key validation. +`MachineIntegrationResultObservationWorkflow` owns file-backed transaction +discovery, acknowledgement/result/projection reads, and project filtering. Its +lifetime gate suppresses acknowledgement, result, projection, and read-error +publication after disposal, including between staged asynchronous reads. +`MachineIntegrationResultFileWatcher` owns the `FileSystemWatcher`, result-file +event subscriptions, debounced refresh scheduling, UI-dispatch handoff, and +watcher/cancellation disposal. Its lifetime gate also prevents a UI-dispatched +refresh that is still queued from starting after disposal. The observation +workflow remains its small compatibility façade for configuring that owner. +These concrete owners are concrete and independently testable, and have no +WPF, visual-tree, or parent ViewModel dependency. + +The infrastructure exchange keeps its public static compatibility facade in +`MachineIntegrationExchange`. `MachineIntegrationTransactionMaintenance` owns +transaction discovery, diagnostics, stale staging cleanup, quarantine purge, +and quarantine-manifest recovery policy. The shared +`MachineIntegrationTransactionFileSystem` owner centralizes transaction paths, +message-file writes, timestamps, free-space lookup, and reparse-point safety so +normal publish/read and maintenance paths cannot drift into separate path rules. +Neither owner changes the Integration contract or starts a runtime action. + +`SimulationSessionCoordinator` under `ViewModel/Simulation` creates and owns +one live engine plus `SimulationRunControlWorkflow`, `SimulationRuntimeLoop`, +`SimulationRuntimeResourceOwner`, `SimulationRuntimeShutdownWorkflow`, and +`SimulationSessionCloseWorkflow`. Main supplies project/runtime context and +presentation callbacks, retains public APIs, and delegates start/close/disposal +to the session. The session does not own authored project persistence. + +`SimulationRuntimeLoop` owns Snapshot/Event readers, initial configuration, +monitor throttling, dispatch, termination observation, and cancellation/task +lifetime. `SimulationSessionCloseWorkflow` coordinates unsaved resolution and +save/camera/batch/commissioning/integration participant observation with the +runtime shutdown transaction. `SimulationRuntimeShutdownWorkflow` and +`BoundedShutdownCoordinator` retain ordered engine-stop, reader/canonical-event +drain, cancellation, completion barriers, typed diagnostics, and the one-shot +deadline. `SimulationRuntimeResourceOwner` owns safe disposal of the concrete +runtime-resource set. Main retains View/event unsubscription and maps typed +results to shell presentation. A failed deadline retains its named stage; +close-time cancellation and failure remain explicit typed results. + +Runtime observability presentation has a concrete boundary. The +`RuntimeObservabilityPresenter` owns the existing bounded journal and maps +runtime events to Vision execution evidence, the Runtime Debugger timeline, and +structured diagnostics. It also maps engine termination, generic runtime logs, +and shutdown diagnostics while preserving the existing field and coordinate +contracts. `MainViewModel` retains current snapshot projection, the public +projection facade, public `LogMessages` and `OperationalDiagnostics` +compatibility, and broad WPF event/notification fan-out. Runtime shutdown +sequencing belongs to `SimulationRuntimeShutdownWorkflow`. The presenter is +non-partial, WPF-neutral, and independently tested; it is not a generic +callback registry. + +Runtime snapshot calculation has a separate narrow projection owner. +`SimulationRuntimeSnapshotProjection` converts one immutable +`SimulationSnapshot` plus a neutral selection context into the selected axis, +camera, active sequence, cycle signals, run state, and runtime target-id lists. +`SimulationRuntimeProjectionCoordinator` owns the mutable application of that +projection to the runtime-facing child ViewModels, workspace option lists, +camera/manual projections, debugger, and Vision evidence completion. +`MainViewModel` retains selection-source access, public compatibility facades, +and WPF notification fan-out. Both projection owners are concrete, +independently testable, and have no WPF visual-tree dependency. + +The remaining MainViewModel runtime command policy has a concrete owner. +`SimulationRunControlWorkflow` owns Run/Pause/Abort/Retry/Step/Reset/Cycle +Start availability, command construction, active-sequence preconditions, and +cross-command serialization. Main keeps the public `ICommand` names and mode, +runtime-definition, status, log, and snapshot callbacks. The workflow +rechecks state after waiting on its gate, so a queued command cannot repeat an +already completed transition, and rechecks lifecycle admission after the gate +so queued work does not enter an engine command core after disposal is +requested. + +`ProjectSelectionSynchronizationWorkflow` under `ViewModel/Project` owns +ProjectTree/Layout selection subscriptions, editor instances, property-panel +synchronization, camera/sequence/recipe selection, and selected-axis projection +updates. Main supplies product-specific callbacks and facade notifications. +`LayoutAuthoringWorkspace` composes `LayoutSelectionCommandWorkflow` and +`SceneViewportInteractionWorkflow` for command parameters and neutral Scene +requests. WPF event/coordinate acquisition remains in View/Behavior. + +`ManualEquipmentCommissioningViewModel` under `ViewModel/Commissioning` owns +selected manual-equipment projection, command creation, availability, +localization, close admission, and disposal invalidation. It composes +`ManualEquipmentPresentation` and `ManualControlCommandWorkflow`. +The presentation derives selected sensor/cylinder/conveyor identifiers, +force/interlock hints, and equipment gates from immutable projection facts; +axis facts for shared manual-start come from the current runtime projection. +The command workflow maps selected targets to typed equipment/camera commands. +`EquipmentCommandDispatcher` owns enqueue and accepted/rejected status/log +mapping. Main supplies selection/project/runtime facts and the accepted manual +run-state callback while retaining public binding facades. Engine interlock +and runtime truth remain authoritative; UI command availability is guidance. + +The common Recipe Connection setup callback workflow is also separated from the +shell. `RecipeConnectionSetupWorkflow` dispatches the eight typed station/device/ +process-block setup callbacks through `RecipeConnectionProjectApplier`, maps +applied/no-change/multiple-device outcomes to localized status and category logs, +and selects the existing setup or process-block completion callback. +`RecipeAuthoringWorkspace` supplies playback exit and completion callbacks and owns +workbench composition and the distinct managed-timeout path. Main supplies current +project and cross-feature presentation callbacks. The workflow is concrete and independently testable; project +mutation remains owned by the applier and no WPF or parent ViewModel is referenced. + +Recipe Connection row projection has a separate narrow owner. +`RecipeConnectionRowCatalog` resolves the active layout, preserves component +layer ordering, and projects behavior links, related target identifiers, +validation errors, and Sequence-use metadata into the existing +`RecipeConnectionRowViewModel` rows. It is concrete, stateless, WPF-neutral, +and has no file-I/O or parent ViewModel dependency. `RecipeConnectionWorkbenchViewModel` +retains the `Rows` collection, selection synchronization, child setup +composition, preview/dry-run commands, mutation callbacks, and public +compatibility façade; it only applies the catalog's rows to the observable +collection. The existing layout fallback and localized row values remain +unchanged. + +Unified commissioning evidence has its own MachineStudio lifecycle owner. +`UnifiedCommissioningEvidenceViewModel` owns aggregate artifact state, explicit +export/import transitions, context validation, JSON persistence, localized +status, and rejection/failure presentation. `MainViewModel` retains dialog-host routing, current project/runtime context, +package-input composition, imported +child-artifact application, and compatibility facade. The child is concrete +and non-`partial`; it receives neutral callbacks, has no WPF or visual-tree +dependency, and does not execute or replay a runtime while saving or restoring +an artifact. + +The commissioning handoff keeps aggregation in the same neutral Simulation +boundary. `DeterministicUnifiedCommissioningEvidencePackage` composes the +portable Test Scenario exchange, command trace, and optional Vision execution +evidence, validates each nested hash plus their common project/build/fixed-step +context, and owns only the parent hash and source-path-safe import rebinding. +The command trace remains the only replayable artifact; Vision evidence is an +inspection/acquisition record. No bundle operation runs, replays, acquires, or +mutates authored project state, and the aggregate has no WPF or session-state +dependency. + +MachineStudio exposes this contract through one explicit Run Inspector +file/status adapter. `UnifiedCommissioningEvidenceViewModel` owns the artifact +state, persistence, localized status, and context/rejection decisions; +`MainViewModel` supplies command availability, file-dialog routing, package inputs, +and memory-only import rebinding. `RightToolRegionView` binds the existing +semantic card and command-button styles. Export and import do not run, replay, +acquire, save, dirty, or acknowledge anything implicitly. + `SequenceStepTemplateCatalog` owns source-neutral action-to-target-kind mapping and deterministic draft construction. MachineStudio adapts project axes, digital channels, and camera devices into typed authoring targets and presents @@ -276,6 +1234,34 @@ only compatible choices. The catalog does not inspect WPF controls and does not compile or execute the resulting step. A missing compatible target rejects the draft instead of creating a placeholder identity. +`SequenceAuthoringTargetCatalog` owns the remaining project-backed target +projection boundary. It creates authoring targets from channels, axes, cameras, +and subsequences; creates expected-state targets from the active layout; filters +the selected subsequence; and builds compiler lookup targets. The concrete +catalog is WPF-neutral and returns read-only projection results. +`SequenceStepEditorCollection` owns materialized step-editor creation, +replacement, child-step event subscriptions, validation projection, and +disposal. `SequenceEditorViewModel` retains observable sequence/template state, +selection, structural editing, validation summary, localization, and the +existing public facade. Existing template-catalog and compiler contracts remain +the reusable downstream boundaries. + +`SequenceDefinitionEditor.NormalizeStep` owns the source-neutral normalization +of authored step fields after an action change: compatible target fallback, +parameter-choice defaults, action-specific timeout/parameter rules, and +failure-branch cleanup. `SequenceStepEditorItem` keeps the WPF-facing property +notifications and `DefinitionChanged` event flow, then delegates the policy to +that editor. This keeps action normalization directly testable without +constructing the ViewModel or a WPF view. + +`VirtualCameraInspectionTemplate` owns the smallest complete first-use camera +authoring policy: one explicit camera, recipe identifier `default`, and a +compiler-valid four-step Trigger/Wait pass/fail graph. It generates collision- +safe device and sequence identifiers and refuses to duplicate a project that +already has a camera. MachineStudio owns only the explicit command, dirty-state +refresh, selection, and navigation. The template creates no image source or +automatic run and never configures or advances the runtime. + ## Dependency rules - `Simulation` depends on `Core`, `IO`, and `Sequence`. @@ -310,10 +1296,14 @@ Forbidden: copied into one runtime configuration. A missing layout binding, axis, channel, sequence, or invalid fixed-step delay rejects the replacement without partially changing the current runtime. -4. **Command queue**: All runtime state changes go through a thread-safe command - queue. Commands include identity and timing evidence for traceability. +4. **Command queue**: All runtime state changes go through a thread-safe bounded + command queue (1,024 by default). Full queues apply asynchronous backpressure; + commands are not dropped. Commands include identity and timing evidence for + traceability. 5. **Immutable snapshots and ordered events**: UI reads snapshots and events; - it never reads mutable runtime objects. + it never reads mutable runtime objects. Snapshot transport keeps the latest + state, and ordered event transport keeps a finite recent window rather than + accumulating an unbounded history. 6. **Deterministic execution**: The same project, fixed step, seed, and commands produce the same ordered results. 7. **MVVM strictness**: ViewModel owns UI state and commands only. Domain and @@ -323,3 +1313,67 @@ Forbidden: project-relative paths, SHA-256 content identity, simulation time, and exact acquisition correlation. They do not use wall-clock timestamps, mutable pixel arrays, or random IDs as runtime identity. + +Condition scheduled-fault recovery is isolated behind a concrete boundary. +`SimulationConditionScheduledFaultRecoveryHandler` owns fault clearing, +recovery-sequence restart, automatic-run resume state, and ordered fault/ +recovery event translation through explicit runtime/state context. The engine +assigns command-boundary time, applies the returned state, and emits events; it +retains scheduled injection, `FaultAutomaticRun` interruption calculation, +condition timing, tick ordering, lifecycle, snapshots, and final command +event/trace publication. +Condition-scenario tick progression is now isolated behind a second concrete +boundary. SimulationConditionScenarioProgressHandler owns deterministic +state-machine advancement, executed-tick progression, transition event +description, and duration-completion state through explicit context and +outcome records. `SimulationConditionScenarioRuntime` now owns the mutable +condition profile, state machine, active state, executed ticks, last +transition, scheduled-fault active/interruption state, snapshot projection, and +Reset/Clear state. `FixedStepSimulationEngine` applies command and handler +outcomes and invokes the runtime after scheduled-fault processing; it retains +scheduled-fault timing and handler invocation, Sequence/AutomaticRun recovery +state, snapshot/lifecycle ownership, and engine tick/time identity. + +Condition-scenario Stop policy is now isolated behind a concrete command +boundary. SimulationConditionScenarioStopHandler owns active-state validation, +composition of the existing scheduled-fault recovery outcome, condition stop +state, ordered recovery/stop events, and the accepted/rejected command result. +The engine applies the returned state and publishes events at the existing +command boundary; it retains authoritative state storage, final command trace, +snapshot, lifecycle, and tick ownership. + +The FixedStep run-loop wall-clock policy is now isolated behind the concrete +SimulationRunLoopTiming owner. It owns only the wall-clock anchor, real-time +accumulator, catch-up budget, fixed-step remainder, and bounded delay +calculation. FixedStepSimulationEngine retains run-mode transitions, +pending-step behavior, tick execution, command completion, snapshots, events, +and lifecycle. SimulationClock continues to own deterministic simulation time; +the two timing domains remain explicit and separate. + +Simulation engine lifecycle and command completion now have a separate +concrete owner, `SimulationEngineLifecycle`. It owns lifecycle transitions, +start/stop/cancellation admission, typed command rejection, pending/applied +command completion, terminal translation, output completion, and disposal. +`FixedStepSimulationEngine` retains mutable simulation state, command-reader +consumption, command policy application, deterministic tick ordering, event +content, and snapshot construction. The lifecycle owner receives only concrete +channel/store dependencies and narrow engine-context callbacks; the shared +command channel remains explicit between lifecycle admission and engine +application. + +The fixed-tick physical runtime phases are now isolated behind the concrete +`SimulationPhysicalRuntimeTick` owner. It advances servo axes, the optional +machine layout, and virtual cameras in the existing axis -> layout -> camera +order and translates their transition messages through one narrow engine event +callback. `FixedStepSimulationEngine` retains condition, automatic-run, +sequence, workpiece, clock, snapshot, command, and lifecycle orchestration. +Physical component instances remain the state owners; the new owner receives an +explicit tick context and does not maintain a duplicate runtime state cache. + +Immutable simulation snapshot composition is now isolated behind the concrete +`SimulationSnapshotFactory` owner. It captures the signal hub and component +snapshots, applies the existing deterministic ordering, and constructs the +public `SimulationSnapshot` through an explicit context. `FixedStepSimulationEngine` +retains mutable runtime state and derives engine-specific automatic, +condition-scenario, and sequence-debug snapshot values before delegating; no +new snapshot cache or public contract was introduced. diff --git a/docs/NET_VERSION_POLICY.md b/docs/NET_VERSION_POLICY.md index 318997b..22f2db9 100644 --- a/docs/NET_VERSION_POLICY.md +++ b/docs/NET_VERSION_POLICY.md @@ -6,7 +6,7 @@ - Language version: C# 12 - Nullable: enable - ImplicitUsings: enable -- Product version: `0.2.0-dev.7` +- Product version: `0.2.0-dev.8` The public repository is currently on the Release 2 development line. This is not a release tag or a downloadable release; the release-candidate gates below diff --git a/src/OpenVisionLab.Localization/Resources/LocalizationCatalog.tsv b/src/OpenVisionLab.Localization/Resources/LocalizationCatalog.tsv index c4ba0c9..df4ace1 100644 --- a/src/OpenVisionLab.Localization/Resources/LocalizationCatalog.tsv +++ b/src/OpenVisionLab.Localization/Resources/LocalizationCatalog.tsv @@ -736,6 +736,13 @@ Camera.Result 결과 Result Camera.Frame 프레임 Frame Camera.Source 이미지 소스 Image source Camera.SourceSetup 이미지 소스 설정 Image source setup +Camera.SourceMode 실행 경로 Execution path +Camera.SourceModeAutomatic 자동 Sequence · placeholder Automatic Sequence · placeholder +Camera.SourceModeManual 수동 · 프로젝트 이미지 소스 Manual · project image source +Camera.SourceModeManualReady 수동 이미지 소스 준비됨 Manual image source ready +Camera.SourceModeManualUnavailable 수동 이미지 소스 미설정 Manual image source unavailable +Camera.SourceModeAutomaticOnly 자동 Sequence · placeholder만 사용 Automatic Sequence · placeholder only +Camera.SourceModeUnavailable 카메라 실행 경로 없음 No camera execution path Camera.SourceRelativePath 프로젝트 상대 경로 Project-relative path Camera.Browse 찾아보기 Browse Camera.BrowseTooltip 프로젝트 폴더 안의 단일 이미지 파일을 선택합니다. Select a single-image file inside the project folder. diff --git a/src/OpenVisionLab.Machine.Core/Projects/SemiconductorStationSkeletonPlanner.cs b/src/OpenVisionLab.Machine.Core/Projects/SemiconductorStationSkeletonPlanner.cs new file mode 100644 index 0000000..1849bbc --- /dev/null +++ b/src/OpenVisionLab.Machine.Core/Projects/SemiconductorStationSkeletonPlanner.cs @@ -0,0 +1,888 @@ +using OpenVisionLab.Machine.Core.Axes; +using OpenVisionLab.Machine.Core.Channels; +using OpenVisionLab.Machine.Core.Devices; +using OpenVisionLab.Machine.Core.Layouts; +using OpenVisionLab.Machine.Core.Models; +using OpenVisionLab.Machine.Core.Sequences; + +namespace OpenVisionLab.Machine.Core.Projects; + +/// +/// Builds a deterministic station plan on a cloned project. The compatibility +/// template owns the separate step that commits that prepared clone to a caller. +/// +internal sealed class SemiconductorStationSkeletonPlanner +{ + private readonly ProjectDocumentStore _store = new(); + private readonly SemiconductorStationSetupResolver _setupResolver = new(); + + internal SemiconductorStationSkeletonPreview Preview( + MachineProjectDocument project, + SemiconductorStationSetupDefinition setup) => CreatePlan(project, setup).Preview; + + internal SemiconductorStationSkeletonPlan CreatePlan( + MachineProjectDocument project, + SemiconductorStationSetupDefinition setup) + { + ArgumentNullException.ThrowIfNull(project); + ArgumentNullException.ThrowIfNull(setup); + if (!IsValidSetup(setup)) + { + throw new ArgumentException("The semiconductor station setup is invalid.", nameof(setup)); + } + + var preparedProject = Clone(project); + var preview = Build(preparedProject, setup); + return new SemiconductorStationSkeletonPlan(preparedProject, preview); + } + + internal SemiconductorStationSetupDefinition ResolveSetup(MachineProjectDocument project) => + _setupResolver.Resolve(project); + + internal static bool IsValidSetup(SemiconductorStationSetupDefinition setup) => + SemiconductorStationSetupResolver.IsValid(setup); + private MachineProjectDocument Clone(MachineProjectDocument project) => + _store.Load(_store.Serialize(project)); + + private static SemiconductorStationSkeletonPreview Build( + MachineProjectDocument project, + SemiconductorStationSetupDefinition setup) + { + var entries = new List(10); + var layout = ResolveLayout(project, setup, entries); + if (layout is null) + { + AddUnavailableDependencies(entries); + return CreatePreview(entries); + } + + ResolveFrame(project, layout, setup, entries); + var transport = ResolveTransport(project, layout, setup, entries); + var workpiece = transport is null + ? Unavailable(entries, SemiconductorStationSkeletonRole.Workpiece) + : ResolveWorkpiece(project, layout, transport.Value.Component, setup, entries); + var axisStage = ResolveAxisStage(project, layout, setup, entries); + var sensors = workpiece is null + ? Unavailable(entries, + SemiconductorStationSkeletonRole.EntrySensor, + SemiconductorStationSkeletonRole.ProcessSensor) + : ResolveSensors(project, layout, workpiece.Value.Component, setup, entries); + var cylinder = ResolveCylinder(project, layout, setup, entries); + + var initialChannelCount = entries + .Where(entry => entry.Role is SemiconductorStationSkeletonRole.Transport + or SemiconductorStationSkeletonRole.Workpiece + or SemiconductorStationSkeletonRole.EntrySensor + or SemiconductorStationSkeletonRole.ProcessSensor + or SemiconductorStationSkeletonRole.ProcessCylinder) + .Sum(entry => entry.AddedCount); + + var cycleActive = project.Simulation.AutomaticRun is null + ? ResolveOutput(project, "do.station-cycle-active", "Station Cycle Active") + : new OutputRole(new ChannelDefinition(), 0); + var cycleDone = project.Simulation.AutomaticRun is null + ? ResolveOutput(project, "do.station-cycle-done", "Station Cycle Done") + : new OutputRole(new ChannelDefinition(), 0); + var addedChannelCount = initialChannelCount + cycleActive.AddedCount + cycleDone.AddedCount; + entries.Add(new SemiconductorStationSkeletonEntry( + SemiconductorStationSkeletonRole.RequiredIo, + addedChannelCount > 0 + ? SemiconductorStationSkeletonStatus.Proposed + : SemiconductorStationSkeletonStatus.Existing, + null, + project.Channels.Count - addedChannelCount, + addedChannelCount)); + + ResolveAutomaticSequence( + project, + entries, + transport, + workpiece, + axisStage, + sensors, + cylinder, + cycleActive.Channel, + cycleDone.Channel); + return CreatePreview(entries); + } + + private static MachineLayoutDefinition? ResolveLayout( + MachineProjectDocument project, + SemiconductorStationSetupDefinition setup, + ICollection entries) + { + if (!string.IsNullOrWhiteSpace(project.Simulation.ActiveLayoutId)) + { + var active = project.Layouts.FirstOrDefault(layout => string.Equals( + layout.Id, + project.Simulation.ActiveLayoutId, + StringComparison.Ordinal)); + entries.Add(active is null + ? UnavailableEntry( + SemiconductorStationSkeletonRole.StationLayout, + project.Simulation.ActiveLayoutId, + SemiconductorStationSkeletonUnavailableReason.ActiveLayoutConflict) + : ExistingEntry(SemiconductorStationSkeletonRole.StationLayout, active.Id)); + if (active is not null) + { + active.Name = setup.StationName; + } + return active; + } + + if (project.Layouts.Count > 1) + { + entries.Add(UnavailableEntry( + SemiconductorStationSkeletonRole.StationLayout, + null, + SemiconductorStationSkeletonUnavailableReason.ActiveLayoutConflict)); + return null; + } + + if (project.Layouts.Count == 1) + { + var existing = project.Layouts[0]; + existing.Name = setup.StationName; + project.Simulation.ActiveLayoutId = existing.Id; + entries.Add(ProposedEntry(SemiconductorStationSkeletonRole.StationLayout, existing.Id)); + return existing; + } + + var layoutId = UniqueId("station-layout", project.Layouts.Select(item => item.Id)); + var created = new MachineLayoutDefinition + { + Id = layoutId, + Name = setup.StationName, + GridSize = 10, + SnapToGrid = true + }; + project.Layouts.Add(created); + project.Simulation.ActiveLayoutId = created.Id; + entries.Add(ProposedEntry(SemiconductorStationSkeletonRole.StationLayout, created.Id)); + return created; + } + + private static void ResolveFrame( + MachineProjectDocument project, + MachineLayoutDefinition layout, + SemiconductorStationSetupDefinition setup, + ICollection entries) + { + var existing = layout.Components.FirstOrDefault(component => + component.Kind == LayoutComponentKind.MachineFrame); + if (existing is not null) + { + existing.Name = $"{setup.StationName} Frame"; + entries.Add(ExistingEntry(SemiconductorStationSkeletonRole.MachineFrame, existing.Id)); + return; + } + + var component = new LayoutComponentDefinition + { + Id = UniqueComponentId(project, "frame"), + Name = $"{setup.StationName} Frame", + Kind = LayoutComponentKind.MachineFrame, + Transform = new Transform2D { X = 360, Y = 250 }, + Size = new Size2D { Width = 680, Height = 360 }, + ZIndex = -100 + }; + layout.Components.Add(component); + entries.Add(ProposedEntry(SemiconductorStationSkeletonRole.MachineFrame, component.Id)); + } + + private static ConveyorRole? ResolveTransport( + MachineProjectDocument project, + MachineLayoutDefinition layout, + SemiconductorStationSetupDefinition setup, + ICollection entries) + { + var candidates = layout.Components + .Where(component => component.Kind == LayoutComponentKind.Conveyor) + .ToArray(); + foreach (var candidateComponent in candidates) + { + if (TryResolveConveyor(project, candidateComponent, out var role)) + { + role.Device.Conveyor!.SpeedUnitsPerSecond = setup.TransportSpeed; + entries.Add(ExistingEntry(SemiconductorStationSkeletonRole.Transport, candidateComponent.Id)); + return role; + } + } + + if (candidates.Length > 0) + { + entries.Add(UnavailableEntry( + SemiconductorStationSkeletonRole.Transport, + candidates[0].Id, + SemiconductorStationSkeletonUnavailableReason.ExistingRoleInvalid)); + return null; + } + + var componentId = UniqueComponentId(project, "transport"); + var deviceId = UniqueId("device.transport", project.Devices.Select(device => device.Id)); + var run = AddChannel(project, "do.transport.run", "Transport Run", ChannelKind.DigitalOutput, 0); + var reverse = AddChannel(project, "do.transport.reverse", "Transport Reverse", ChannelKind.DigitalOutput, 0); + var device = new DeviceDefinition + { + Id = deviceId, + Name = "Wafer Transport", + Kind = DeviceKind.Conveyor, + MountPosition = new Coordinate3D(340, 380, 0), + ChannelIds = { run.Id, reverse.Id }, + Conveyor = new ConveyorDefinition + { + RunCommandChannelId = run.Id, + ReverseCommandChannelId = reverse.Id, + SpeedUnitsPerSecond = setup.TransportSpeed + }, + Properties = { ["connectionRole"] = "material transport" } + }; + var component = new LayoutComponentDefinition + { + Id = componentId, + Name = "Wafer Transport", + Kind = LayoutComponentKind.Conveyor, + Transform = new Transform2D { X = 340, Y = 380 }, + Size = new Size2D { Width = 560, Height = 70 }, + ZIndex = 10, + BehaviorBindingId = device.Id + }; + project.Devices.Add(device); + layout.Components.Add(component); + entries.Add(ProposedEntry( + SemiconductorStationSkeletonRole.Transport, + component.Id, + addedCount: 2)); + return new ConveyorRole(component, device, run, reverse); + } + + private static WorkpieceRole? ResolveWorkpiece( + MachineProjectDocument project, + MachineLayoutDefinition layout, + LayoutComponentDefinition transport, + SemiconductorStationSetupDefinition setup, + ICollection entries) + { + var candidates = layout.Components + .Where(component => component.Kind == LayoutComponentKind.Workpiece) + .ToArray(); + foreach (var candidateComponent in candidates) + { + var candidateDevice = ResolveDevice(project, candidateComponent, DeviceKind.Workpiece); + if (candidateDevice?.Workpiece is { } definition && string.Equals( + definition.ConveyorComponentId, + transport.Id, + StringComparison.Ordinal)) + { + candidateDevice.Name = setup.WaferType; + definition.Type = setup.WaferType; + candidateComponent.Name = setup.WaferType; + entries.Add(ExistingEntry(SemiconductorStationSkeletonRole.Workpiece, candidateComponent.Id)); + return new WorkpieceRole(candidateComponent, candidateDevice); + } + } + + if (candidates.Any(component => ResolveDevice(project, component, DeviceKind.Workpiece) is null)) + { + entries.Add(UnavailableEntry( + SemiconductorStationSkeletonRole.Workpiece, + candidates[0].Id, + SemiconductorStationSkeletonUnavailableReason.ExistingRoleInvalid)); + return null; + } + + var componentId = UniqueComponentId(project, "wafer"); + var deviceId = UniqueId("device.wafer", project.Devices.Select(device => device.Id)); + var x = transport.Transform.X - ((transport.Size.Width - 34) / 2d) + 20; + var y = transport.Transform.Y; + var device = new DeviceDefinition + { + Id = deviceId, + Name = setup.WaferType, + Kind = DeviceKind.Workpiece, + MountPosition = new Coordinate3D(x, y, 0), + Workpiece = new WorkpieceDefinition + { + Type = setup.WaferType, + ConveyorComponentId = transport.Id, + InspectionState = WorkpieceInspectionState.Pending + }, + Properties = { ["connectionRole"] = "process material" } + }; + var component = new LayoutComponentDefinition + { + Id = componentId, + Name = setup.WaferType, + Kind = LayoutComponentKind.Workpiece, + Transform = new Transform2D { X = x, Y = y }, + Size = new Size2D { Width = 34, Height = 34 }, + ZIndex = 25, + BehaviorBindingId = device.Id + }; + project.Devices.Add(device); + layout.Components.Add(component); + entries.Add(ProposedEntry(SemiconductorStationSkeletonRole.Workpiece, component.Id)); + return new WorkpieceRole(component, device); + } + + private static AxisStageRole? ResolveAxisStage( + MachineProjectDocument project, + MachineLayoutDefinition layout, + SemiconductorStationSetupDefinition setup, + ICollection entries) + { + var candidates = layout.Components + .Where(component => component.Kind == LayoutComponentKind.LinearStage) + .ToArray(); + foreach (var candidateComponent in candidates) + { + var candidateAxis = project.Axes.FirstOrDefault(candidate => + candidate.Kind == AxisKind.Linear + && string.Equals(candidate.Id, candidateComponent.BehaviorBindingId, StringComparison.Ordinal)); + if (candidateAxis is not null) + { + candidateAxis.SoftLimitMin = 0; + candidateAxis.SoftLimitMax = setup.AxisTravel; + entries.Add(ExistingEntry( + SemiconductorStationSkeletonRole.ProcessAxisStage, + $"{candidateAxis.Id} / {candidateComponent.Id}")); + return new AxisStageRole(candidateAxis, candidateComponent); + } + } + + if (candidates.Length > 0) + { + entries.Add(UnavailableEntry( + SemiconductorStationSkeletonRole.ProcessAxisStage, + candidates[0].Id, + SemiconductorStationSkeletonUnavailableReason.ExistingRoleInvalid)); + return null; + } + + var axis = new VirtualAxisDefinition + { + Id = UniqueId("axis.process", project.Axes.Select(item => item.Id)), + Name = "Wafer Transfer Axis", + Kind = AxisKind.Linear, + Unit = "mm", + HomePosition = 0, + SoftLimitMin = 0, + SoftLimitMax = setup.AxisTravel, + MaxVelocity = 180, + MaxAcceleration = 700, + MaxDeceleration = 650, + FollowingErrorLimit = VirtualAxisDefinition.DefaultFollowingErrorLimit, + Position = new Coordinate3D(100, 230, 0) + }; + var component = new LayoutComponentDefinition + { + Id = UniqueComponentId(project, "process-stage"), + Name = "Wafer Transfer Stage", + Kind = LayoutComponentKind.LinearStage, + Transform = new Transform2D { X = 100, Y = 230 }, + Size = new Size2D { Width = 90, Height = 50 }, + ZIndex = 25, + BehaviorBindingId = axis.Id + }; + project.Axes.Add(axis); + layout.Components.Add(component); + entries.Add(ProposedEntry( + SemiconductorStationSkeletonRole.ProcessAxisStage, + $"{axis.Id} / {component.Id}")); + return new AxisStageRole(axis, component); + } + + private static SensorRoles? ResolveSensors( + MachineProjectDocument project, + MachineLayoutDefinition layout, + LayoutComponentDefinition workpiece, + SemiconductorStationSetupDefinition setup, + ICollection entries) + { + var components = layout.Components + .Where(component => component.Kind == LayoutComponentKind.DigitalSensor) + .ToArray(); + var malformed = components.Any(component => !TryResolveSensor(project, component, out _)); + var existing = components + .Select(component => TryResolveSensor(project, component, out var role) ? role : null) + .Where(role => role is not null && string.Equals( + role.Device.Sensor!.TargetComponentId, + workpiece.Id, + StringComparison.Ordinal)) + .Cast() + .OrderBy(role => role.Component.Transform.X) + .ToList(); + + if (malformed && existing.Count < 2) + { + if (existing.Count == 1) + { + entries.Add(ExistingEntry(SemiconductorStationSkeletonRole.EntrySensor, existing[0].Component.Id)); + entries.Add(UnavailableEntry( + SemiconductorStationSkeletonRole.ProcessSensor, + null, + SemiconductorStationSkeletonUnavailableReason.ExistingRoleInvalid)); + } + else + { + Unavailable(entries, + SemiconductorStationSkeletonRole.EntrySensor, + SemiconductorStationSkeletonRole.ProcessSensor); + } + return null; + } + + while (existing.Count < 2) + { + var role = existing.Count == 0 + ? SemiconductorStationSkeletonRole.EntrySensor + : SemiconductorStationSkeletonRole.ProcessSensor; + var created = CreateSensor(project, layout, workpiece, setup, role); + existing.Add(created); + entries.Add(ProposedEntry(role, created.Component.Id, addedCount: 1)); + } + + if (!entries.Any(entry => entry.Role == SemiconductorStationSkeletonRole.EntrySensor)) + { + entries.Add(ExistingEntry( + SemiconductorStationSkeletonRole.EntrySensor, + existing[0].Component.Id)); + } + if (!entries.Any(entry => entry.Role == SemiconductorStationSkeletonRole.ProcessSensor)) + { + entries.Add(ExistingEntry( + SemiconductorStationSkeletonRole.ProcessSensor, + existing[1].Component.Id)); + } + + ApplySensorPosition(existing[0], setup.EntrySensorPosition); + ApplySensorPosition(existing[1], setup.ProcessSensorPosition); + + return new SensorRoles(existing[0], existing[1]); + } + + private static SensorRole CreateSensor( + MachineProjectDocument project, + MachineLayoutDefinition layout, + LayoutComponentDefinition workpiece, + SemiconductorStationSetupDefinition setup, + SemiconductorStationSkeletonRole role) + { + var isEntry = role == SemiconductorStationSkeletonRole.EntrySensor; + var baseId = isEntry ? "sensor-entry" : "sensor-process"; + var componentId = UniqueComponentId(project, baseId); + var deviceId = UniqueId($"device.{baseId}", project.Devices.Select(device => device.Id)); + var channel = AddChannel( + project, + $"di.{baseId}", + isEntry ? "Wafer Entry Sensor" : "Process Position Sensor", + ChannelKind.DigitalInput, + 0); + var x = isEntry ? setup.EntrySensorPosition : setup.ProcessSensorPosition; + var device = new DeviceDefinition + { + Id = deviceId, + Name = channel.Name, + Kind = DeviceKind.Sensor, + MountPosition = new Coordinate3D(x, workpiece.Transform.Y, 0), + ChannelIds = { channel.Id }, + Sensor = new DigitalSensorDefinition + { + OutputChannelId = channel.Id, + TargetComponentId = workpiece.Id, + OnDelayMilliseconds = 10, + OffDelayMilliseconds = 10 + }, + Properties = { ["connectionRole"] = isEntry ? "entry detection" : "process-position detection" } + }; + var component = new LayoutComponentDefinition + { + Id = componentId, + Name = device.Name, + Kind = LayoutComponentKind.DigitalSensor, + Transform = new Transform2D { X = x, Y = workpiece.Transform.Y }, + Size = new Size2D { Width = 20, Height = 76 }, + ZIndex = 20, + BehaviorBindingId = device.Id + }; + project.Devices.Add(device); + layout.Components.Add(component); + return new SensorRole(component, device, channel); + } + + private static void ApplySensorPosition(SensorRole sensor, double x) + { + sensor.Component.Transform.X = x; + sensor.Device.MountPosition = sensor.Device.MountPosition with { X = x }; + } + + private static CylinderRole? ResolveCylinder( + MachineProjectDocument project, + MachineLayoutDefinition layout, + SemiconductorStationSetupDefinition setup, + ICollection entries) + { + var candidates = layout.Components + .Where(component => component.Kind == LayoutComponentKind.PneumaticCylinder) + .ToArray(); + foreach (var candidateComponent in candidates) + { + if (TryResolveCylinder(project, candidateComponent, out var role)) + { + role.Device.Cylinder!.ExtendDurationMilliseconds = setup.CylinderTravelTimeMilliseconds; + role.Device.Cylinder.RetractDurationMilliseconds = setup.CylinderTravelTimeMilliseconds; + entries.Add(ExistingEntry(SemiconductorStationSkeletonRole.ProcessCylinder, candidateComponent.Id)); + return role; + } + } + + if (candidates.Length > 0) + { + entries.Add(UnavailableEntry( + SemiconductorStationSkeletonRole.ProcessCylinder, + candidates[0].Id, + SemiconductorStationSkeletonUnavailableReason.ExistingRoleInvalid)); + return null; + } + + var componentId = UniqueComponentId(project, "process-cylinder"); + var deviceId = UniqueId("device.process-cylinder", project.Devices.Select(device => device.Id)); + var extend = AddChannel(project, "do.cylinder.extend", "Process Cylinder Extend", ChannelKind.DigitalOutput, 0); + var extended = AddChannel(project, "di.cylinder.extended", "Process Cylinder Extended", ChannelKind.DigitalInput, 0); + var retracted = AddChannel(project, "di.cylinder.retracted", "Process Cylinder Retracted", ChannelKind.DigitalInput, 1); + var device = new DeviceDefinition + { + Id = deviceId, + Name = "Process Cylinder", + Kind = DeviceKind.Cylinder, + MountPosition = new Coordinate3D(430, 230, 0), + ChannelIds = { extend.Id, extended.Id, retracted.Id }, + Cylinder = new PneumaticCylinderDefinition + { + ExtendCommandChannelId = extend.Id, + ExtendedSensorChannelId = extended.Id, + RetractedSensorChannelId = retracted.Id, + ExtendDurationMilliseconds = setup.CylinderTravelTimeMilliseconds, + RetractDurationMilliseconds = setup.CylinderTravelTimeMilliseconds, + ExtendedSensorDelayMilliseconds = 10, + RetractedSensorDelayMilliseconds = 10, + Stroke = 60 + }, + Properties = { ["connectionRole"] = "station interlock" } + }; + var component = new LayoutComponentDefinition + { + Id = componentId, + Name = "Process Cylinder", + Kind = LayoutComponentKind.PneumaticCylinder, + Transform = new Transform2D { X = 430, Y = 230 }, + Size = new Size2D { Width = 100, Height = 38 }, + ZIndex = 30, + BehaviorBindingId = device.Id + }; + project.Devices.Add(device); + layout.Components.Add(component); + entries.Add(ProposedEntry( + SemiconductorStationSkeletonRole.ProcessCylinder, + component.Id, + addedCount: 3)); + return new CylinderRole(component, device, extend, extended, retracted); + } + + private static void ResolveAutomaticSequence( + MachineProjectDocument project, + ICollection entries, + ConveyorRole? transport, + WorkpieceRole? workpiece, + AxisStageRole? axisStage, + SensorRoles? sensors, + CylinderRole? cylinder, + ChannelDefinition cycleActive, + ChannelDefinition cycleDone) + { + if (project.Simulation.AutomaticRun is { } automatic) + { + var existingSequence = project.Sequences.FirstOrDefault(candidate => string.Equals( + candidate.Id, + automatic.SequenceId, + StringComparison.Ordinal)); + entries.Add(existingSequence is null + ? UnavailableEntry( + SemiconductorStationSkeletonRole.AutomaticSequence, + automatic.SequenceId, + SemiconductorStationSkeletonUnavailableReason.AutomaticSequenceConflict) + : ExistingEntry(SemiconductorStationSkeletonRole.AutomaticSequence, existingSequence.Id)); + return; + } + + if (transport is null || workpiece is null || axisStage is null || sensors is null || cylinder is null) + { + entries.Add(UnavailableEntry( + SemiconductorStationSkeletonRole.AutomaticSequence, + null, + SemiconductorStationSkeletonUnavailableReason.DependencyUnavailable)); + return; + } + + var sequenceId = UniqueId("automatic-cycle", project.Sequences.Select(sequence => sequence.Id)); + var moveTarget = ResolveMoveTarget(axisStage.Value.Axis); + var sequence = new SequenceDefinition + { + Id = sequenceId, + Name = "Semiconductor station automatic cycle", + Steps = + { + Step("cycle-active", "Cycle Active", SequenceStepAction.SetSignal, cycleActive.Id, "true", "extend-cylinder"), + Step("extend-cylinder", "Extend Process Cylinder", SequenceStepAction.SetSignal, cylinder.Value.Extend.Id, "true", "wait-cylinder-extended"), + Step("wait-cylinder-extended", "Wait Process Cylinder Extended", SequenceStepAction.WaitSignal, cylinder.Value.Extended.Id, "true", "start-transport", 3000), + Step("start-transport", "Start Wafer Transport", SequenceStepAction.SetSignal, transport.Value.Run.Id, "true", "wait-process-position"), + Step("wait-process-position", "Wait Process Position Sensor", SequenceStepAction.WaitSignal, sensors.Value.Process.Channel.Id, "true", "stop-transport", 5000), + Step("stop-transport", "Stop Wafer Transport", SequenceStepAction.SetSignal, transport.Value.Run.Id, "false", "move-process-axis"), + Step("move-process-axis", "Move Wafer Transfer Axis", SequenceStepAction.MoveAxis, axisStage.Value.Axis.Id, moveTarget, "wait-process-axis"), + Step("wait-process-axis", "Wait Wafer Transfer Axis", SequenceStepAction.WaitAxisDone, axisStage.Value.Axis.Id, string.Empty, "retract-cylinder", 5000), + Step("retract-cylinder", "Retract Process Cylinder", SequenceStepAction.SetSignal, cylinder.Value.Extend.Id, "false", "wait-cylinder-retracted"), + Step("wait-cylinder-retracted", "Wait Process Cylinder Retracted", SequenceStepAction.WaitSignal, cylinder.Value.Retracted.Id, "true", "cycle-done", 3000), + Step("cycle-done", "Cycle Done", SequenceStepAction.SetSignal, cycleDone.Id, "true", "complete"), + Step("complete", "Complete", SequenceStepAction.Complete, string.Empty, string.Empty, null) + } + }; + project.Sequences.Add(sequence); + project.Simulation.AutomaticRun = new AutomaticRunDefinition + { + SequenceId = sequence.Id, + Repeat = false, + RepeatDelayMilliseconds = 0 + }; + entries.Add(ProposedEntry(SemiconductorStationSkeletonRole.AutomaticSequence, sequence.Id)); + } + + private static SequenceStepDefinition Step( + string id, + string name, + SequenceStepAction action, + string targetId, + string parameter, + string? nextStepId, + int timeoutMs = 0) => new() + { + Id = id, + Name = name, + Action = action, + TargetId = targetId, + Parameter = parameter, + NextStepId = nextStepId, + TimeoutMs = timeoutMs + }; + + private static string ResolveMoveTarget(VirtualAxisDefinition axis) + { + var home = axis.HomePosition; + var maximum = axis.SoftLimitMax; + if (maximum is { } max && double.IsFinite(max) && max > home) + { + return (home + ((max - home) / 2d)).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); + } + var minimum = axis.SoftLimitMin; + if (minimum is { } min && double.IsFinite(min) && min < home) + { + return (home - ((home - min) / 2d)).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); + } + return (home + 100).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); + } + + private static OutputRole ResolveOutput(MachineProjectDocument project, string baseId, string name) + { + var existing = project.Channels.FirstOrDefault(channel => + channel.Kind == ChannelKind.DigitalOutput + && string.Equals(channel.Id, baseId, StringComparison.Ordinal)); + return existing is not null + ? new OutputRole(existing, 0) + : new OutputRole(AddChannel(project, baseId, name, ChannelKind.DigitalOutput, 0), 1); + } + + private static bool TryResolveConveyor( + MachineProjectDocument project, + LayoutComponentDefinition component, + out ConveyorRole role) + { + var device = ResolveDevice(project, component, DeviceKind.Conveyor); + var definition = device?.Conveyor; + var run = definition is null ? null : ResolveChannel(project, definition.RunCommandChannelId, ChannelKind.DigitalOutput); + var reverse = definition is null ? null : ResolveChannel(project, definition.ReverseCommandChannelId, ChannelKind.DigitalOutput); + if (device is null || definition is null || run is null || reverse is null || run.Id == reverse.Id) + { + role = default; + return false; + } + role = new ConveyorRole(component, device, run, reverse); + return true; + } + + private static bool TryResolveSensor( + MachineProjectDocument project, + LayoutComponentDefinition component, + out SensorRole? role) + { + var device = ResolveDevice(project, component, DeviceKind.Sensor); + var channel = device?.Sensor is { } sensor + ? ResolveChannel(project, sensor.OutputChannelId, ChannelKind.DigitalInput) + : null; + if (device?.Sensor is null || channel is null) + { + role = null; + return false; + } + role = new SensorRole(component, device, channel); + return true; + } + + private static bool TryResolveCylinder( + MachineProjectDocument project, + LayoutComponentDefinition component, + out CylinderRole role) + { + var device = ResolveDevice(project, component, DeviceKind.Cylinder); + var definition = device?.Cylinder; + var extend = definition is null ? null : ResolveChannel(project, definition.ExtendCommandChannelId, ChannelKind.DigitalOutput); + var extended = definition is null ? null : ResolveChannel(project, definition.ExtendedSensorChannelId, ChannelKind.DigitalInput); + var retracted = definition is null ? null : ResolveChannel(project, definition.RetractedSensorChannelId, ChannelKind.DigitalInput); + if (device is null || definition is null || extend is null || extended is null || retracted is null) + { + role = default; + return false; + } + role = new CylinderRole(component, device, extend, extended, retracted); + return true; + } + + private static DeviceDefinition? ResolveDevice( + MachineProjectDocument project, + LayoutComponentDefinition component, + DeviceKind kind) => project.Devices.FirstOrDefault(device => + device.Kind == kind + && string.Equals(device.Id, component.BehaviorBindingId, StringComparison.Ordinal)); + + private static ChannelDefinition? ResolveChannel( + MachineProjectDocument project, + string? id, + ChannelKind kind) => project.Channels.FirstOrDefault(channel => + channel.Kind == kind && string.Equals(channel.Id, id, StringComparison.Ordinal)); + + private static ChannelDefinition AddChannel( + MachineProjectDocument project, + string baseId, + string name, + ChannelKind kind, + double initialValue) + { + var channel = new ChannelDefinition + { + Id = UniqueId(baseId, project.Channels.Select(item => item.Id)), + Name = name, + Kind = kind, + InitialValue = initialValue + }; + project.Channels.Add(channel); + return channel; + } + + private static string UniqueComponentId(MachineProjectDocument project, string baseId) => UniqueId( + baseId, + project.Layouts.SelectMany(layout => layout.Components).Select(component => component.Id)); + + private static SemiconductorStationSkeletonPreview CreatePreview( + IEnumerable entries) => new( + entries.OrderBy(entry => entry.Role).ToArray()); + + private static string UniqueId(string baseId, IEnumerable existingIds) + { + var ids = existingIds.ToHashSet(StringComparer.Ordinal); + if (!ids.Contains(baseId)) + { + return baseId; + } + var suffix = 2; + while (ids.Contains($"{baseId}-{suffix}")) + { + suffix++; + } + return $"{baseId}-{suffix}"; + } + + private static SemiconductorStationSkeletonEntry ProposedEntry( + SemiconductorStationSkeletonRole role, + string? targetId, + int addedCount = 0) => new( + role, + SemiconductorStationSkeletonStatus.Proposed, + targetId, + AddedCount: addedCount); + + private static SemiconductorStationSkeletonEntry ExistingEntry( + SemiconductorStationSkeletonRole role, + string? targetId) => new( + role, + SemiconductorStationSkeletonStatus.Existing, + targetId, + ExistingCount: 1); + + private static SemiconductorStationSkeletonEntry UnavailableEntry( + SemiconductorStationSkeletonRole role, + string? targetId, + SemiconductorStationSkeletonUnavailableReason reason) => new( + role, + SemiconductorStationSkeletonStatus.Unavailable, + targetId, + UnavailableReason: reason); + + private static T? Unavailable( + ICollection entries, + params SemiconductorStationSkeletonRole[] roles) + where T : struct + { + foreach (var role in roles) + { + entries.Add(UnavailableEntry( + role, + null, + SemiconductorStationSkeletonUnavailableReason.DependencyUnavailable)); + } + return null; + } + + private static void AddUnavailableDependencies(ICollection entries) + { + Unavailable(entries, + SemiconductorStationSkeletonRole.MachineFrame, + SemiconductorStationSkeletonRole.Transport, + SemiconductorStationSkeletonRole.Workpiece, + SemiconductorStationSkeletonRole.ProcessAxisStage, + SemiconductorStationSkeletonRole.EntrySensor, + SemiconductorStationSkeletonRole.ProcessSensor, + SemiconductorStationSkeletonRole.ProcessCylinder, + SemiconductorStationSkeletonRole.RequiredIo, + SemiconductorStationSkeletonRole.AutomaticSequence); + } + + private readonly record struct ConveyorRole( + LayoutComponentDefinition Component, + DeviceDefinition Device, + ChannelDefinition Run, + ChannelDefinition Reverse); + private readonly record struct WorkpieceRole(LayoutComponentDefinition Component, DeviceDefinition Device); + private readonly record struct AxisStageRole(VirtualAxisDefinition Axis, LayoutComponentDefinition Component); + private sealed record SensorRole(LayoutComponentDefinition Component, DeviceDefinition Device, ChannelDefinition Channel); + private readonly record struct SensorRoles(SensorRole Entry, SensorRole Process); + private readonly record struct CylinderRole( + LayoutComponentDefinition Component, + DeviceDefinition Device, + ChannelDefinition Extend, + ChannelDefinition Extended, + ChannelDefinition Retracted); + private readonly record struct OutputRole(ChannelDefinition Channel, int AddedCount); +} + +/// Pairs the read-only station preview with its prepared project clone. +internal sealed record SemiconductorStationSkeletonPlan( + MachineProjectDocument PreparedProject, + SemiconductorStationSkeletonPreview Preview); diff --git a/src/OpenVisionLab.Machine.Core/Projects/SemiconductorStationSkeletonTemplate.cs b/src/OpenVisionLab.Machine.Core/Projects/SemiconductorStationSkeletonTemplate.cs index 391bf0d..35cb25f 100644 --- a/src/OpenVisionLab.Machine.Core/Projects/SemiconductorStationSkeletonTemplate.cs +++ b/src/OpenVisionLab.Machine.Core/Projects/SemiconductorStationSkeletonTemplate.cs @@ -70,23 +70,14 @@ public sealed record SemiconductorStationSkeletonApplyResult( public sealed class SemiconductorStationSkeletonTemplate { private readonly ProjectDocumentStore _store = new(); - private readonly SemiconductorStationSetupResolver _setupResolver = new(); + private readonly SemiconductorStationSkeletonPlanner _planner = new(); public SemiconductorStationSkeletonPreview Preview(MachineProjectDocument project) => Preview(project, ResolveSetup(project)); public SemiconductorStationSkeletonPreview Preview( MachineProjectDocument project, - SemiconductorStationSetupDefinition setup) - { - ArgumentNullException.ThrowIfNull(project); - ArgumentNullException.ThrowIfNull(setup); - if (!IsValidSetup(setup)) - { - throw new ArgumentException("The semiconductor station setup is invalid.", nameof(setup)); - } - return Build(Clone(project), setup); - } + SemiconductorStationSetupDefinition setup) => _planner.Preview(project, setup); public SemiconductorStationSkeletonApplyResult Apply(MachineProjectDocument project) => Apply(project, ResolveSetup(project)); @@ -95,20 +86,13 @@ public SemiconductorStationSkeletonApplyResult Apply( MachineProjectDocument project, SemiconductorStationSetupDefinition setup) { - ArgumentNullException.ThrowIfNull(project); - ArgumentNullException.ThrowIfNull(setup); - if (!IsValidSetup(setup)) - { - throw new ArgumentException("The semiconductor station setup is invalid.", nameof(setup)); - } - - var updated = Clone(project); - var preview = Build(updated, setup); - if (preview.UnavailableCount > 0) + var plan = _planner.CreatePlan(project, setup); + if (plan.Preview.UnavailableCount > 0) { - return new SemiconductorStationSkeletonApplyResult(preview, 0); + return new SemiconductorStationSkeletonApplyResult(plan.Preview, 0); } + var updated = plan.PreparedProject; updated.Schema = MachineProjectDocument.CurrentSchema; updated.SemiconductorStationSetup = setup with { }; var changed = !string.Equals( @@ -117,7 +101,7 @@ public SemiconductorStationSkeletonApplyResult Apply( StringComparison.Ordinal); if (!changed) { - return new SemiconductorStationSkeletonApplyResult(preview, 0); + return new SemiconductorStationSkeletonApplyResult(plan.Preview, 0); } project.Schema = updated.Schema; @@ -129,852 +113,12 @@ public SemiconductorStationSkeletonApplyResult Apply( project.Devices = updated.Devices; project.Channels = updated.Channels; project.Sequences = updated.Sequences; - return new SemiconductorStationSkeletonApplyResult(preview, preview.ProposedCount, true); + return new SemiconductorStationSkeletonApplyResult(plan.Preview, plan.Preview.ProposedCount, true); } public SemiconductorStationSetupDefinition ResolveSetup(MachineProjectDocument project) - => _setupResolver.Resolve(project); + => _planner.ResolveSetup(project); public static bool IsValidSetup(SemiconductorStationSetupDefinition setup) => - SemiconductorStationSetupResolver.IsValid(setup); - - private MachineProjectDocument Clone(MachineProjectDocument project) => - _store.Load(_store.Serialize(project)); - - private static SemiconductorStationSkeletonPreview Build( - MachineProjectDocument project, - SemiconductorStationSetupDefinition setup) - { - var entries = new List(10); - var layout = ResolveLayout(project, setup, entries); - if (layout is null) - { - AddUnavailableDependencies(entries); - return CreatePreview(entries); - } - - ResolveFrame(project, layout, setup, entries); - var transport = ResolveTransport(project, layout, setup, entries); - var workpiece = transport is null - ? Unavailable(entries, SemiconductorStationSkeletonRole.Workpiece) - : ResolveWorkpiece(project, layout, transport.Value.Component, setup, entries); - var axisStage = ResolveAxisStage(project, layout, setup, entries); - var sensors = workpiece is null - ? Unavailable(entries, - SemiconductorStationSkeletonRole.EntrySensor, - SemiconductorStationSkeletonRole.ProcessSensor) - : ResolveSensors(project, layout, workpiece.Value.Component, setup, entries); - var cylinder = ResolveCylinder(project, layout, setup, entries); - - var initialChannelCount = entries - .Where(entry => entry.Role is SemiconductorStationSkeletonRole.Transport - or SemiconductorStationSkeletonRole.Workpiece - or SemiconductorStationSkeletonRole.EntrySensor - or SemiconductorStationSkeletonRole.ProcessSensor - or SemiconductorStationSkeletonRole.ProcessCylinder) - .Sum(entry => entry.AddedCount); - - var cycleActive = project.Simulation.AutomaticRun is null - ? ResolveOutput(project, "do.station-cycle-active", "Station Cycle Active") - : new OutputRole(new ChannelDefinition(), 0); - var cycleDone = project.Simulation.AutomaticRun is null - ? ResolveOutput(project, "do.station-cycle-done", "Station Cycle Done") - : new OutputRole(new ChannelDefinition(), 0); - var addedChannelCount = initialChannelCount + cycleActive.AddedCount + cycleDone.AddedCount; - entries.Add(new SemiconductorStationSkeletonEntry( - SemiconductorStationSkeletonRole.RequiredIo, - addedChannelCount > 0 - ? SemiconductorStationSkeletonStatus.Proposed - : SemiconductorStationSkeletonStatus.Existing, - null, - project.Channels.Count - addedChannelCount, - addedChannelCount)); - - ResolveAutomaticSequence( - project, - entries, - transport, - workpiece, - axisStage, - sensors, - cylinder, - cycleActive.Channel, - cycleDone.Channel); - return CreatePreview(entries); - } - - private static MachineLayoutDefinition? ResolveLayout( - MachineProjectDocument project, - SemiconductorStationSetupDefinition setup, - ICollection entries) - { - if (!string.IsNullOrWhiteSpace(project.Simulation.ActiveLayoutId)) - { - var active = project.Layouts.FirstOrDefault(layout => string.Equals( - layout.Id, - project.Simulation.ActiveLayoutId, - StringComparison.Ordinal)); - entries.Add(active is null - ? UnavailableEntry( - SemiconductorStationSkeletonRole.StationLayout, - project.Simulation.ActiveLayoutId, - SemiconductorStationSkeletonUnavailableReason.ActiveLayoutConflict) - : ExistingEntry(SemiconductorStationSkeletonRole.StationLayout, active.Id)); - if (active is not null) - { - active.Name = setup.StationName; - } - return active; - } - - if (project.Layouts.Count > 1) - { - entries.Add(UnavailableEntry( - SemiconductorStationSkeletonRole.StationLayout, - null, - SemiconductorStationSkeletonUnavailableReason.ActiveLayoutConflict)); - return null; - } - - if (project.Layouts.Count == 1) - { - var existing = project.Layouts[0]; - existing.Name = setup.StationName; - project.Simulation.ActiveLayoutId = existing.Id; - entries.Add(ProposedEntry(SemiconductorStationSkeletonRole.StationLayout, existing.Id)); - return existing; - } - - var layoutId = UniqueId("station-layout", project.Layouts.Select(item => item.Id)); - var created = new MachineLayoutDefinition - { - Id = layoutId, - Name = setup.StationName, - GridSize = 10, - SnapToGrid = true - }; - project.Layouts.Add(created); - project.Simulation.ActiveLayoutId = created.Id; - entries.Add(ProposedEntry(SemiconductorStationSkeletonRole.StationLayout, created.Id)); - return created; - } - - private static void ResolveFrame( - MachineProjectDocument project, - MachineLayoutDefinition layout, - SemiconductorStationSetupDefinition setup, - ICollection entries) - { - var existing = layout.Components.FirstOrDefault(component => - component.Kind == LayoutComponentKind.MachineFrame); - if (existing is not null) - { - existing.Name = $"{setup.StationName} Frame"; - entries.Add(ExistingEntry(SemiconductorStationSkeletonRole.MachineFrame, existing.Id)); - return; - } - - var component = new LayoutComponentDefinition - { - Id = UniqueComponentId(project, "frame"), - Name = $"{setup.StationName} Frame", - Kind = LayoutComponentKind.MachineFrame, - Transform = new Transform2D { X = 360, Y = 250 }, - Size = new Size2D { Width = 680, Height = 360 }, - ZIndex = -100 - }; - layout.Components.Add(component); - entries.Add(ProposedEntry(SemiconductorStationSkeletonRole.MachineFrame, component.Id)); - } - - private static ConveyorRole? ResolveTransport( - MachineProjectDocument project, - MachineLayoutDefinition layout, - SemiconductorStationSetupDefinition setup, - ICollection entries) - { - var candidates = layout.Components - .Where(component => component.Kind == LayoutComponentKind.Conveyor) - .ToArray(); - foreach (var candidateComponent in candidates) - { - if (TryResolveConveyor(project, candidateComponent, out var role)) - { - role.Device.Conveyor!.SpeedUnitsPerSecond = setup.TransportSpeed; - entries.Add(ExistingEntry(SemiconductorStationSkeletonRole.Transport, candidateComponent.Id)); - return role; - } - } - - if (candidates.Length > 0) - { - entries.Add(UnavailableEntry( - SemiconductorStationSkeletonRole.Transport, - candidates[0].Id, - SemiconductorStationSkeletonUnavailableReason.ExistingRoleInvalid)); - return null; - } - - var componentId = UniqueComponentId(project, "transport"); - var deviceId = UniqueId("device.transport", project.Devices.Select(device => device.Id)); - var run = AddChannel(project, "do.transport.run", "Transport Run", ChannelKind.DigitalOutput, 0); - var reverse = AddChannel(project, "do.transport.reverse", "Transport Reverse", ChannelKind.DigitalOutput, 0); - var device = new DeviceDefinition - { - Id = deviceId, - Name = "Wafer Transport", - Kind = DeviceKind.Conveyor, - MountPosition = new Coordinate3D(340, 380, 0), - ChannelIds = { run.Id, reverse.Id }, - Conveyor = new ConveyorDefinition - { - RunCommandChannelId = run.Id, - ReverseCommandChannelId = reverse.Id, - SpeedUnitsPerSecond = setup.TransportSpeed - }, - Properties = { ["connectionRole"] = "material transport" } - }; - var component = new LayoutComponentDefinition - { - Id = componentId, - Name = "Wafer Transport", - Kind = LayoutComponentKind.Conveyor, - Transform = new Transform2D { X = 340, Y = 380 }, - Size = new Size2D { Width = 560, Height = 70 }, - ZIndex = 10, - BehaviorBindingId = device.Id - }; - project.Devices.Add(device); - layout.Components.Add(component); - entries.Add(ProposedEntry( - SemiconductorStationSkeletonRole.Transport, - component.Id, - addedCount: 2)); - return new ConveyorRole(component, device, run, reverse); - } - - private static WorkpieceRole? ResolveWorkpiece( - MachineProjectDocument project, - MachineLayoutDefinition layout, - LayoutComponentDefinition transport, - SemiconductorStationSetupDefinition setup, - ICollection entries) - { - var candidates = layout.Components - .Where(component => component.Kind == LayoutComponentKind.Workpiece) - .ToArray(); - foreach (var candidateComponent in candidates) - { - var candidateDevice = ResolveDevice(project, candidateComponent, DeviceKind.Workpiece); - if (candidateDevice?.Workpiece is { } definition && string.Equals( - definition.ConveyorComponentId, - transport.Id, - StringComparison.Ordinal)) - { - candidateDevice.Name = setup.WaferType; - definition.Type = setup.WaferType; - candidateComponent.Name = setup.WaferType; - entries.Add(ExistingEntry(SemiconductorStationSkeletonRole.Workpiece, candidateComponent.Id)); - return new WorkpieceRole(candidateComponent, candidateDevice); - } - } - - if (candidates.Any(component => ResolveDevice(project, component, DeviceKind.Workpiece) is null)) - { - entries.Add(UnavailableEntry( - SemiconductorStationSkeletonRole.Workpiece, - candidates[0].Id, - SemiconductorStationSkeletonUnavailableReason.ExistingRoleInvalid)); - return null; - } - - var componentId = UniqueComponentId(project, "wafer"); - var deviceId = UniqueId("device.wafer", project.Devices.Select(device => device.Id)); - var x = transport.Transform.X - ((transport.Size.Width - 34) / 2d) + 20; - var y = transport.Transform.Y; - var device = new DeviceDefinition - { - Id = deviceId, - Name = setup.WaferType, - Kind = DeviceKind.Workpiece, - MountPosition = new Coordinate3D(x, y, 0), - Workpiece = new WorkpieceDefinition - { - Type = setup.WaferType, - ConveyorComponentId = transport.Id, - InspectionState = WorkpieceInspectionState.Pending - }, - Properties = { ["connectionRole"] = "process material" } - }; - var component = new LayoutComponentDefinition - { - Id = componentId, - Name = setup.WaferType, - Kind = LayoutComponentKind.Workpiece, - Transform = new Transform2D { X = x, Y = y }, - Size = new Size2D { Width = 34, Height = 34 }, - ZIndex = 25, - BehaviorBindingId = device.Id - }; - project.Devices.Add(device); - layout.Components.Add(component); - entries.Add(ProposedEntry(SemiconductorStationSkeletonRole.Workpiece, component.Id)); - return new WorkpieceRole(component, device); - } - - private static AxisStageRole? ResolveAxisStage( - MachineProjectDocument project, - MachineLayoutDefinition layout, - SemiconductorStationSetupDefinition setup, - ICollection entries) - { - var candidates = layout.Components - .Where(component => component.Kind == LayoutComponentKind.LinearStage) - .ToArray(); - foreach (var candidateComponent in candidates) - { - var candidateAxis = project.Axes.FirstOrDefault(candidate => - candidate.Kind == AxisKind.Linear - && string.Equals(candidate.Id, candidateComponent.BehaviorBindingId, StringComparison.Ordinal)); - if (candidateAxis is not null) - { - candidateAxis.SoftLimitMin = 0; - candidateAxis.SoftLimitMax = setup.AxisTravel; - entries.Add(ExistingEntry( - SemiconductorStationSkeletonRole.ProcessAxisStage, - $"{candidateAxis.Id} / {candidateComponent.Id}")); - return new AxisStageRole(candidateAxis, candidateComponent); - } - } - - if (candidates.Length > 0) - { - entries.Add(UnavailableEntry( - SemiconductorStationSkeletonRole.ProcessAxisStage, - candidates[0].Id, - SemiconductorStationSkeletonUnavailableReason.ExistingRoleInvalid)); - return null; - } - - var axis = new VirtualAxisDefinition - { - Id = UniqueId("axis.process", project.Axes.Select(item => item.Id)), - Name = "Wafer Transfer Axis", - Kind = AxisKind.Linear, - Unit = "mm", - HomePosition = 0, - SoftLimitMin = 0, - SoftLimitMax = setup.AxisTravel, - MaxVelocity = 180, - MaxAcceleration = 700, - MaxDeceleration = 650, - FollowingErrorLimit = VirtualAxisDefinition.DefaultFollowingErrorLimit, - Position = new Coordinate3D(100, 230, 0) - }; - var component = new LayoutComponentDefinition - { - Id = UniqueComponentId(project, "process-stage"), - Name = "Wafer Transfer Stage", - Kind = LayoutComponentKind.LinearStage, - Transform = new Transform2D { X = 100, Y = 230 }, - Size = new Size2D { Width = 90, Height = 50 }, - ZIndex = 25, - BehaviorBindingId = axis.Id - }; - project.Axes.Add(axis); - layout.Components.Add(component); - entries.Add(ProposedEntry( - SemiconductorStationSkeletonRole.ProcessAxisStage, - $"{axis.Id} / {component.Id}")); - return new AxisStageRole(axis, component); - } - - private static SensorRoles? ResolveSensors( - MachineProjectDocument project, - MachineLayoutDefinition layout, - LayoutComponentDefinition workpiece, - SemiconductorStationSetupDefinition setup, - ICollection entries) - { - var components = layout.Components - .Where(component => component.Kind == LayoutComponentKind.DigitalSensor) - .ToArray(); - var malformed = components.Any(component => !TryResolveSensor(project, component, out _)); - var existing = components - .Select(component => TryResolveSensor(project, component, out var role) ? role : null) - .Where(role => role is not null && string.Equals( - role.Device.Sensor!.TargetComponentId, - workpiece.Id, - StringComparison.Ordinal)) - .Cast() - .OrderBy(role => role.Component.Transform.X) - .ToList(); - - if (malformed && existing.Count < 2) - { - if (existing.Count == 1) - { - entries.Add(ExistingEntry(SemiconductorStationSkeletonRole.EntrySensor, existing[0].Component.Id)); - entries.Add(UnavailableEntry( - SemiconductorStationSkeletonRole.ProcessSensor, - null, - SemiconductorStationSkeletonUnavailableReason.ExistingRoleInvalid)); - } - else - { - Unavailable(entries, - SemiconductorStationSkeletonRole.EntrySensor, - SemiconductorStationSkeletonRole.ProcessSensor); - } - return null; - } - - while (existing.Count < 2) - { - var role = existing.Count == 0 - ? SemiconductorStationSkeletonRole.EntrySensor - : SemiconductorStationSkeletonRole.ProcessSensor; - var created = CreateSensor(project, layout, workpiece, setup, role); - existing.Add(created); - entries.Add(ProposedEntry(role, created.Component.Id, addedCount: 1)); - } - - if (!entries.Any(entry => entry.Role == SemiconductorStationSkeletonRole.EntrySensor)) - { - entries.Add(ExistingEntry( - SemiconductorStationSkeletonRole.EntrySensor, - existing[0].Component.Id)); - } - if (!entries.Any(entry => entry.Role == SemiconductorStationSkeletonRole.ProcessSensor)) - { - entries.Add(ExistingEntry( - SemiconductorStationSkeletonRole.ProcessSensor, - existing[1].Component.Id)); - } - - ApplySensorPosition(existing[0], setup.EntrySensorPosition); - ApplySensorPosition(existing[1], setup.ProcessSensorPosition); - - return new SensorRoles(existing[0], existing[1]); - } - - private static SensorRole CreateSensor( - MachineProjectDocument project, - MachineLayoutDefinition layout, - LayoutComponentDefinition workpiece, - SemiconductorStationSetupDefinition setup, - SemiconductorStationSkeletonRole role) - { - var isEntry = role == SemiconductorStationSkeletonRole.EntrySensor; - var baseId = isEntry ? "sensor-entry" : "sensor-process"; - var componentId = UniqueComponentId(project, baseId); - var deviceId = UniqueId($"device.{baseId}", project.Devices.Select(device => device.Id)); - var channel = AddChannel( - project, - $"di.{baseId}", - isEntry ? "Wafer Entry Sensor" : "Process Position Sensor", - ChannelKind.DigitalInput, - 0); - var x = isEntry ? setup.EntrySensorPosition : setup.ProcessSensorPosition; - var device = new DeviceDefinition - { - Id = deviceId, - Name = channel.Name, - Kind = DeviceKind.Sensor, - MountPosition = new Coordinate3D(x, workpiece.Transform.Y, 0), - ChannelIds = { channel.Id }, - Sensor = new DigitalSensorDefinition - { - OutputChannelId = channel.Id, - TargetComponentId = workpiece.Id, - OnDelayMilliseconds = 10, - OffDelayMilliseconds = 10 - }, - Properties = { ["connectionRole"] = isEntry ? "entry detection" : "process-position detection" } - }; - var component = new LayoutComponentDefinition - { - Id = componentId, - Name = device.Name, - Kind = LayoutComponentKind.DigitalSensor, - Transform = new Transform2D { X = x, Y = workpiece.Transform.Y }, - Size = new Size2D { Width = 20, Height = 76 }, - ZIndex = 20, - BehaviorBindingId = device.Id - }; - project.Devices.Add(device); - layout.Components.Add(component); - return new SensorRole(component, device, channel); - } - - private static void ApplySensorPosition(SensorRole sensor, double x) - { - sensor.Component.Transform.X = x; - sensor.Device.MountPosition = sensor.Device.MountPosition with { X = x }; - } - - private static CylinderRole? ResolveCylinder( - MachineProjectDocument project, - MachineLayoutDefinition layout, - SemiconductorStationSetupDefinition setup, - ICollection entries) - { - var candidates = layout.Components - .Where(component => component.Kind == LayoutComponentKind.PneumaticCylinder) - .ToArray(); - foreach (var candidateComponent in candidates) - { - if (TryResolveCylinder(project, candidateComponent, out var role)) - { - role.Device.Cylinder!.ExtendDurationMilliseconds = setup.CylinderTravelTimeMilliseconds; - role.Device.Cylinder.RetractDurationMilliseconds = setup.CylinderTravelTimeMilliseconds; - entries.Add(ExistingEntry(SemiconductorStationSkeletonRole.ProcessCylinder, candidateComponent.Id)); - return role; - } - } - - if (candidates.Length > 0) - { - entries.Add(UnavailableEntry( - SemiconductorStationSkeletonRole.ProcessCylinder, - candidates[0].Id, - SemiconductorStationSkeletonUnavailableReason.ExistingRoleInvalid)); - return null; - } - - var componentId = UniqueComponentId(project, "process-cylinder"); - var deviceId = UniqueId("device.process-cylinder", project.Devices.Select(device => device.Id)); - var extend = AddChannel(project, "do.cylinder.extend", "Process Cylinder Extend", ChannelKind.DigitalOutput, 0); - var extended = AddChannel(project, "di.cylinder.extended", "Process Cylinder Extended", ChannelKind.DigitalInput, 0); - var retracted = AddChannel(project, "di.cylinder.retracted", "Process Cylinder Retracted", ChannelKind.DigitalInput, 1); - var device = new DeviceDefinition - { - Id = deviceId, - Name = "Process Cylinder", - Kind = DeviceKind.Cylinder, - MountPosition = new Coordinate3D(430, 230, 0), - ChannelIds = { extend.Id, extended.Id, retracted.Id }, - Cylinder = new PneumaticCylinderDefinition - { - ExtendCommandChannelId = extend.Id, - ExtendedSensorChannelId = extended.Id, - RetractedSensorChannelId = retracted.Id, - ExtendDurationMilliseconds = setup.CylinderTravelTimeMilliseconds, - RetractDurationMilliseconds = setup.CylinderTravelTimeMilliseconds, - ExtendedSensorDelayMilliseconds = 10, - RetractedSensorDelayMilliseconds = 10, - Stroke = 60 - }, - Properties = { ["connectionRole"] = "station interlock" } - }; - var component = new LayoutComponentDefinition - { - Id = componentId, - Name = "Process Cylinder", - Kind = LayoutComponentKind.PneumaticCylinder, - Transform = new Transform2D { X = 430, Y = 230 }, - Size = new Size2D { Width = 100, Height = 38 }, - ZIndex = 30, - BehaviorBindingId = device.Id - }; - project.Devices.Add(device); - layout.Components.Add(component); - entries.Add(ProposedEntry( - SemiconductorStationSkeletonRole.ProcessCylinder, - component.Id, - addedCount: 3)); - return new CylinderRole(component, device, extend, extended, retracted); - } - - private static void ResolveAutomaticSequence( - MachineProjectDocument project, - ICollection entries, - ConveyorRole? transport, - WorkpieceRole? workpiece, - AxisStageRole? axisStage, - SensorRoles? sensors, - CylinderRole? cylinder, - ChannelDefinition cycleActive, - ChannelDefinition cycleDone) - { - if (project.Simulation.AutomaticRun is { } automatic) - { - var existingSequence = project.Sequences.FirstOrDefault(candidate => string.Equals( - candidate.Id, - automatic.SequenceId, - StringComparison.Ordinal)); - entries.Add(existingSequence is null - ? UnavailableEntry( - SemiconductorStationSkeletonRole.AutomaticSequence, - automatic.SequenceId, - SemiconductorStationSkeletonUnavailableReason.AutomaticSequenceConflict) - : ExistingEntry(SemiconductorStationSkeletonRole.AutomaticSequence, existingSequence.Id)); - return; - } - - if (transport is null || workpiece is null || axisStage is null || sensors is null || cylinder is null) - { - entries.Add(UnavailableEntry( - SemiconductorStationSkeletonRole.AutomaticSequence, - null, - SemiconductorStationSkeletonUnavailableReason.DependencyUnavailable)); - return; - } - - var sequenceId = UniqueId("automatic-cycle", project.Sequences.Select(sequence => sequence.Id)); - var moveTarget = ResolveMoveTarget(axisStage.Value.Axis); - var sequence = new SequenceDefinition - { - Id = sequenceId, - Name = "Semiconductor station automatic cycle", - Steps = - { - Step("cycle-active", "Cycle Active", SequenceStepAction.SetSignal, cycleActive.Id, "true", "extend-cylinder"), - Step("extend-cylinder", "Extend Process Cylinder", SequenceStepAction.SetSignal, cylinder.Value.Extend.Id, "true", "wait-cylinder-extended"), - Step("wait-cylinder-extended", "Wait Process Cylinder Extended", SequenceStepAction.WaitSignal, cylinder.Value.Extended.Id, "true", "start-transport", 3000), - Step("start-transport", "Start Wafer Transport", SequenceStepAction.SetSignal, transport.Value.Run.Id, "true", "wait-process-position"), - Step("wait-process-position", "Wait Process Position Sensor", SequenceStepAction.WaitSignal, sensors.Value.Process.Channel.Id, "true", "stop-transport", 5000), - Step("stop-transport", "Stop Wafer Transport", SequenceStepAction.SetSignal, transport.Value.Run.Id, "false", "move-process-axis"), - Step("move-process-axis", "Move Wafer Transfer Axis", SequenceStepAction.MoveAxis, axisStage.Value.Axis.Id, moveTarget, "wait-process-axis"), - Step("wait-process-axis", "Wait Wafer Transfer Axis", SequenceStepAction.WaitAxisDone, axisStage.Value.Axis.Id, string.Empty, "retract-cylinder", 5000), - Step("retract-cylinder", "Retract Process Cylinder", SequenceStepAction.SetSignal, cylinder.Value.Extend.Id, "false", "wait-cylinder-retracted"), - Step("wait-cylinder-retracted", "Wait Process Cylinder Retracted", SequenceStepAction.WaitSignal, cylinder.Value.Retracted.Id, "true", "cycle-done", 3000), - Step("cycle-done", "Cycle Done", SequenceStepAction.SetSignal, cycleDone.Id, "true", "complete"), - Step("complete", "Complete", SequenceStepAction.Complete, string.Empty, string.Empty, null) - } - }; - project.Sequences.Add(sequence); - project.Simulation.AutomaticRun = new AutomaticRunDefinition - { - SequenceId = sequence.Id, - Repeat = false, - RepeatDelayMilliseconds = 0 - }; - entries.Add(ProposedEntry(SemiconductorStationSkeletonRole.AutomaticSequence, sequence.Id)); - } - - private static SequenceStepDefinition Step( - string id, - string name, - SequenceStepAction action, - string targetId, - string parameter, - string? nextStepId, - int timeoutMs = 0) => new() - { - Id = id, - Name = name, - Action = action, - TargetId = targetId, - Parameter = parameter, - NextStepId = nextStepId, - TimeoutMs = timeoutMs - }; - - private static string ResolveMoveTarget(VirtualAxisDefinition axis) - { - var home = axis.HomePosition; - var maximum = axis.SoftLimitMax; - if (maximum is { } max && double.IsFinite(max) && max > home) - { - return (home + ((max - home) / 2d)).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); - } - var minimum = axis.SoftLimitMin; - if (minimum is { } min && double.IsFinite(min) && min < home) - { - return (home - ((home - min) / 2d)).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); - } - return (home + 100).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); - } - - private static OutputRole ResolveOutput(MachineProjectDocument project, string baseId, string name) - { - var existing = project.Channels.FirstOrDefault(channel => - channel.Kind == ChannelKind.DigitalOutput - && string.Equals(channel.Id, baseId, StringComparison.Ordinal)); - return existing is not null - ? new OutputRole(existing, 0) - : new OutputRole(AddChannel(project, baseId, name, ChannelKind.DigitalOutput, 0), 1); - } - - private static bool TryResolveConveyor( - MachineProjectDocument project, - LayoutComponentDefinition component, - out ConveyorRole role) - { - var device = ResolveDevice(project, component, DeviceKind.Conveyor); - var definition = device?.Conveyor; - var run = definition is null ? null : ResolveChannel(project, definition.RunCommandChannelId, ChannelKind.DigitalOutput); - var reverse = definition is null ? null : ResolveChannel(project, definition.ReverseCommandChannelId, ChannelKind.DigitalOutput); - if (device is null || definition is null || run is null || reverse is null || run.Id == reverse.Id) - { - role = default; - return false; - } - role = new ConveyorRole(component, device, run, reverse); - return true; - } - - private static bool TryResolveSensor( - MachineProjectDocument project, - LayoutComponentDefinition component, - out SensorRole? role) - { - var device = ResolveDevice(project, component, DeviceKind.Sensor); - var channel = device?.Sensor is { } sensor - ? ResolveChannel(project, sensor.OutputChannelId, ChannelKind.DigitalInput) - : null; - if (device?.Sensor is null || channel is null) - { - role = null; - return false; - } - role = new SensorRole(component, device, channel); - return true; - } - - private static bool TryResolveCylinder( - MachineProjectDocument project, - LayoutComponentDefinition component, - out CylinderRole role) - { - var device = ResolveDevice(project, component, DeviceKind.Cylinder); - var definition = device?.Cylinder; - var extend = definition is null ? null : ResolveChannel(project, definition.ExtendCommandChannelId, ChannelKind.DigitalOutput); - var extended = definition is null ? null : ResolveChannel(project, definition.ExtendedSensorChannelId, ChannelKind.DigitalInput); - var retracted = definition is null ? null : ResolveChannel(project, definition.RetractedSensorChannelId, ChannelKind.DigitalInput); - if (device is null || definition is null || extend is null || extended is null || retracted is null) - { - role = default; - return false; - } - role = new CylinderRole(component, device, extend, extended, retracted); - return true; - } - - private static DeviceDefinition? ResolveDevice( - MachineProjectDocument project, - LayoutComponentDefinition component, - DeviceKind kind) => project.Devices.FirstOrDefault(device => - device.Kind == kind - && string.Equals(device.Id, component.BehaviorBindingId, StringComparison.Ordinal)); - - private static ChannelDefinition? ResolveChannel( - MachineProjectDocument project, - string? id, - ChannelKind kind) => project.Channels.FirstOrDefault(channel => - channel.Kind == kind && string.Equals(channel.Id, id, StringComparison.Ordinal)); - - private static ChannelDefinition AddChannel( - MachineProjectDocument project, - string baseId, - string name, - ChannelKind kind, - double initialValue) - { - var channel = new ChannelDefinition - { - Id = UniqueId(baseId, project.Channels.Select(item => item.Id)), - Name = name, - Kind = kind, - InitialValue = initialValue - }; - project.Channels.Add(channel); - return channel; - } - - private static string UniqueComponentId(MachineProjectDocument project, string baseId) => UniqueId( - baseId, - project.Layouts.SelectMany(layout => layout.Components).Select(component => component.Id)); - - private static SemiconductorStationSkeletonPreview CreatePreview( - IEnumerable entries) => new( - entries.OrderBy(entry => entry.Role).ToArray()); - - private static string UniqueId(string baseId, IEnumerable existingIds) - { - var ids = existingIds.ToHashSet(StringComparer.Ordinal); - if (!ids.Contains(baseId)) - { - return baseId; - } - var suffix = 2; - while (ids.Contains($"{baseId}-{suffix}")) - { - suffix++; - } - return $"{baseId}-{suffix}"; - } - - private static SemiconductorStationSkeletonEntry ProposedEntry( - SemiconductorStationSkeletonRole role, - string? targetId, - int addedCount = 0) => new( - role, - SemiconductorStationSkeletonStatus.Proposed, - targetId, - AddedCount: addedCount); - - private static SemiconductorStationSkeletonEntry ExistingEntry( - SemiconductorStationSkeletonRole role, - string? targetId) => new( - role, - SemiconductorStationSkeletonStatus.Existing, - targetId, - ExistingCount: 1); - - private static SemiconductorStationSkeletonEntry UnavailableEntry( - SemiconductorStationSkeletonRole role, - string? targetId, - SemiconductorStationSkeletonUnavailableReason reason) => new( - role, - SemiconductorStationSkeletonStatus.Unavailable, - targetId, - UnavailableReason: reason); - - private static T? Unavailable( - ICollection entries, - params SemiconductorStationSkeletonRole[] roles) - where T : struct - { - foreach (var role in roles) - { - entries.Add(UnavailableEntry( - role, - null, - SemiconductorStationSkeletonUnavailableReason.DependencyUnavailable)); - } - return null; - } - - private static void AddUnavailableDependencies(ICollection entries) - { - Unavailable(entries, - SemiconductorStationSkeletonRole.MachineFrame, - SemiconductorStationSkeletonRole.Transport, - SemiconductorStationSkeletonRole.Workpiece, - SemiconductorStationSkeletonRole.ProcessAxisStage, - SemiconductorStationSkeletonRole.EntrySensor, - SemiconductorStationSkeletonRole.ProcessSensor, - SemiconductorStationSkeletonRole.ProcessCylinder, - SemiconductorStationSkeletonRole.RequiredIo, - SemiconductorStationSkeletonRole.AutomaticSequence); - } - - private readonly record struct ConveyorRole( - LayoutComponentDefinition Component, - DeviceDefinition Device, - ChannelDefinition Run, - ChannelDefinition Reverse); - private readonly record struct WorkpieceRole(LayoutComponentDefinition Component, DeviceDefinition Device); - private readonly record struct AxisStageRole(VirtualAxisDefinition Axis, LayoutComponentDefinition Component); - private sealed record SensorRole(LayoutComponentDefinition Component, DeviceDefinition Device, ChannelDefinition Channel); - private readonly record struct SensorRoles(SensorRole Entry, SensorRole Process); - private readonly record struct CylinderRole( - LayoutComponentDefinition Component, - DeviceDefinition Device, - ChannelDefinition Extend, - ChannelDefinition Extended, - ChannelDefinition Retracted); - private readonly record struct OutputRole(ChannelDefinition Channel, int AddedCount); + SemiconductorStationSkeletonPlanner.IsValidSetup(setup); } diff --git a/src/OpenVisionLab.Machine.Sequence/Authoring/SemiconductorProcessBlockComposer.cs b/src/OpenVisionLab.Machine.Sequence/Authoring/SemiconductorProcessBlockComposer.cs index 7431fd9..4059a63 100644 --- a/src/OpenVisionLab.Machine.Sequence/Authoring/SemiconductorProcessBlockComposer.cs +++ b/src/OpenVisionLab.Machine.Sequence/Authoring/SemiconductorProcessBlockComposer.cs @@ -1,6 +1,3 @@ -using OpenVisionLab.Machine.Core.Axes; -using OpenVisionLab.Machine.Core.Devices; -using OpenVisionLab.Machine.Core.Layouts; using OpenVisionLab.Machine.Core.Projects; using OpenVisionLab.Machine.Core.Sequences; @@ -94,112 +91,27 @@ public sealed class SemiconductorProcessBlockComposer private readonly ProjectDocumentStore _store = new(); private readonly SemiconductorStationSkeletonTemplate _station = new(); + private readonly SemiconductorProcessBlockPlanBuilder _planBuilder; private readonly SemiconductorProcessBlockTimeoutAdjuster _timeoutAdjuster = new(); + public SemiconductorProcessBlockComposer() + { + _planBuilder = new SemiconductorProcessBlockPlanBuilder(_store, _station); + } + public SemiconductorProcessBlockPreview Preview( MachineProjectDocument project, SemiconductorProcessBlockKind kind) - { - ArgumentNullException.ThrowIfNull(project); - var stationPreview = _station.Preview(project); - if (stationPreview.UnavailableCount > 0) - { - return new SemiconductorProcessBlockPreview(kind, stationPreview, []); - } - - var resolved = Clone(project); - _station.Apply(resolved); - var expected = BuildSteps(resolved, kind); - var sourceSequence = ResolveAutomaticSequence(project); - var sourceSupportsComposition = sourceSequence is null || SupportsManagedSuffix(sourceSequence); - var entries = expected.Select(step => - { - var existing = sourceSequence?.Steps.FirstOrDefault(candidate => - string.Equals(candidate.Id, step.Id, StringComparison.Ordinal)); - var status = ResolveStatus(existing, step, sourceSupportsComposition, isSelected: true); - return new SemiconductorProcessBlockStepEntry( - step.Id, - step.Name, - step.Action, - step.TargetId, - step.Parameter ?? string.Empty, - step.TimeoutMs, - status); - }).ToArray(); - return new SemiconductorProcessBlockPreview(kind, stationPreview, entries); - } + => _planBuilder.Preview(project, kind); public SemiconductorProcessBlockPlanPreview Preview( MachineProjectDocument project, IEnumerable kinds) - { - ArgumentNullException.ThrowIfNull(project); - ArgumentNullException.ThrowIfNull(kinds); - var selected = Normalize(kinds); - var selectedSet = selected.ToHashSet(); - var existingKinds = RecognizeExistingKinds(project); - var stationPreview = _station.Preview(project); - if (stationPreview.UnavailableCount > 0) - { - return new SemiconductorProcessBlockPlanPreview( - selected, - existingKinds, - stationPreview, - []); - } - - var resolved = Clone(project); - _station.Apply(resolved); - var sourceSequence = ResolveAutomaticSequence(project); - var sourceSupportsComposition = sourceSequence is null || SupportsManagedSuffix(sourceSequence); - var entries = new List(); - foreach (var kind in Enum.GetValues()) - { - foreach (var step in BuildSteps(resolved, kind)) - { - var existing = sourceSequence?.Steps.FirstOrDefault(candidate => - string.Equals(candidate.Id, step.Id, StringComparison.Ordinal)); - if (!selectedSet.Contains(kind) && existing is null) - { - continue; - } - - var status = ResolveStatus(existing, step, sourceSupportsComposition, selectedSet.Contains(kind)); - entries.Add(new SemiconductorProcessBlockStepEntry( - step.Id, - step.Name, - step.Action, - step.TargetId, - step.Parameter ?? string.Empty, - step.TimeoutMs, - status)); - } - } - - return new SemiconductorProcessBlockPlanPreview( - selected, - existingKinds, - stationPreview, - entries); - } + => _planBuilder.Preview(project, kinds); public IReadOnlyList RecognizeExistingKinds( MachineProjectDocument project) - { - ArgumentNullException.ThrowIfNull(project); - var sequence = ResolveAutomaticSequence(project); - return sequence is null - ? [] - : Enum.GetValues() - .Where(kind => - (kind == SemiconductorProcessBlockKind.Load && HasAuthoredOhtLoad(project, sequence)) - || (kind == SemiconductorProcessBlockKind.Align && HasAuthoredPrealignerAlignment(project, sequence)) - || (kind == SemiconductorProcessBlockKind.Inspect && HasAuthoredInspectionHandoff(project, sequence)) - || sequence.Steps.Any(step => step.Id.StartsWith( - ProcessBlockPrefix(kind), - StringComparison.Ordinal))) - .ToArray(); - } + => _planBuilder.RecognizeExistingKinds(project); public SemiconductorProcessBlockApplyResult Apply( MachineProjectDocument project, @@ -219,9 +131,9 @@ public SemiconductorProcessBlockApplyResult Apply( return new SemiconductorProcessBlockApplyResult(preview, 0, 0, false); } - var sequence = ResolveAutomaticSequence(updated) + var sequence = _planBuilder.ResolveAutomaticSequence(updated) ?? throw new InvalidOperationException("The station template did not provide an automatic Sequence."); - var expected = BuildSteps(updated, kind); + var expected = _planBuilder.BuildSteps(updated, kind); var addedSteps = InsertMissingSteps(sequence, kind, expected); var changed = stationResult.Changed || addedSteps > 0; if (changed) @@ -251,7 +163,7 @@ public SemiconductorProcessBlockPlanApplyResult Apply( var addedConnections = 0; var addedSteps = 0; var removedSteps = 0; - var sequence = ResolveAutomaticSequence(updated) + var sequence = _planBuilder.ResolveAutomaticSequence(updated) ?? throw new InvalidOperationException("The managed process plan has no automatic Sequence."); bool sourceIsLinear = SequenceDefinitionEditor.IsStrictLinear(sequence); foreach (var entry in preview.Steps.Where(step => @@ -301,127 +213,13 @@ public SemiconductorManagedTimeoutAdjustmentApplyResult ApplyTimeoutAdjustment( public static bool CanAdjustTimeout(SequenceStepAction action) => SemiconductorProcessBlockTimeoutAdjuster.CanAdjustTimeout(action); - private static SemiconductorProcessBlockKind[] Normalize( - IEnumerable kinds) - { - var selected = kinds.ToHashSet(); - return Enum.GetValues() - .Where(selected.Contains) - .ToArray(); - } - - private static string ProcessBlockPrefix(SemiconductorProcessBlockKind kind) => - $"process-block.{kind.ToString().ToLowerInvariant()}."; - - private static IReadOnlyList BuildSteps( - MachineProjectDocument project, - SemiconductorProcessBlockKind kind) - { - if (kind == SemiconductorProcessBlockKind.Load - && ResolveAutomaticSequence(project) is { } sequence - && HasAuthoredOhtLoad(project, sequence)) - { - return []; - } - - if (kind == SemiconductorProcessBlockKind.Inspect - && ResolveAutomaticSequence(project) is { } inspectionSequence - && HasAuthoredInspectionHandoff(project, inspectionSequence)) - { - return []; - } - - if (kind == SemiconductorProcessBlockKind.Align - && ResolveAutomaticSequence(project) is { } alignmentSequence - && HasAuthoredPrealignerAlignment(project, alignmentSequence)) - { - return []; - } - - var layout = project.Layouts.First(layout => string.Equals( - layout.Id, - project.Simulation.ActiveLayoutId, - StringComparison.Ordinal)); - var conveyor = project.Devices.First(device => device is { Kind: DeviceKind.Conveyor, Conveyor: not null }); - var loadLock = project.Devices.FirstOrDefault(device => - device is { Kind: DeviceKind.LoadLock, LoadLock: not null }); - var loadLockOuterDoor = loadLock is null - ? null - : layout.Components.FirstOrDefault(component => string.Equals( - component.Id, - loadLock.LoadLock!.OuterDoorComponentId, - StringComparison.Ordinal)); - var cylinder = loadLockOuterDoor is null - ? project.Devices.First(device => device is { Kind: DeviceKind.Cylinder, Cylinder: not null }) - : project.Devices.Single(device => - device is { Kind: DeviceKind.Cylinder, Cylinder: not null } - && string.Equals(device.Id, loadLockOuterDoor.BehaviorBindingId, StringComparison.Ordinal)); - var axis = project.Axes.First(axis => axis.Kind == AxisKind.Linear); - var sensors = layout.Components - .Where(component => component.Kind == LayoutComponentKind.DigitalSensor) - .OrderBy(component => component.Transform.X) - .Select(component => project.Devices.First(device => - string.Equals(device.Id, component.BehaviorBindingId, StringComparison.Ordinal))) - .ToArray(); - var entrySensor = sensors.FirstOrDefault(sensor => string.Equals( - sensor.Id, - "device.sensor-entry", - StringComparison.Ordinal)) - ?? sensors[0]; - var processSensor = sensors.FirstOrDefault(sensor => string.Equals( - sensor.Id, - "device.sensor-process", - StringComparison.Ordinal)) - ?? sensors[1]; - var run = conveyor.Conveyor!.RunCommandChannelId; - var entry = entrySensor.Sensor!.OutputChannelId; - var process = processSensor.Sensor!.OutputChannelId; - var extend = cylinder.Cylinder!.ExtendCommandChannelId; - var extended = cylinder.Cylinder.ExtendedSensorChannelId; - var retracted = cylinder.Cylinder.RetractedSensorChannelId; - var moveTarget = ResolveMoveTarget(axis); - - return kind switch - { - SemiconductorProcessBlockKind.Load => - [ - Step("process-block.load.start", "Load · Start transport", SequenceStepAction.SetSignal, run, "true"), - Step("process-block.load.wait-entry", "Load · Wait entry sensor", SequenceStepAction.WaitSignal, entry, "true", 5000), - Step("process-block.load.stop", "Load · Stop transport", SequenceStepAction.SetSignal, run, "false") - ], - SemiconductorProcessBlockKind.Align => - [ - Step("process-block.align.move", "Align · Move process axis", SequenceStepAction.MoveAxis, axis.Id, moveTarget), - Step("process-block.align.wait", "Align · Wait process axis", SequenceStepAction.WaitAxisDone, axis.Id, string.Empty, 5000) - ], - SemiconductorProcessBlockKind.Process => - [ - Step("process-block.process.extend", "Process · Extend cylinder", SequenceStepAction.SetSignal, extend, "true"), - Step("process-block.process.wait-extended", "Process · Wait cylinder extended", SequenceStepAction.WaitSignal, extended, "true", 3000), - Step("process-block.process.retract", "Process · Retract cylinder", SequenceStepAction.SetSignal, extend, "false"), - Step("process-block.process.wait-retracted", "Process · Wait cylinder retracted", SequenceStepAction.WaitSignal, retracted, "true", 3000) - ], - SemiconductorProcessBlockKind.Inspect => - [ - Step("process-block.inspect.confirm-position", "Inspect · Confirm process position", SequenceStepAction.WaitSignal, process, "true", 3000) - ], - SemiconductorProcessBlockKind.Unload => - [ - Step("process-block.unload.start", "Unload · Start transport", SequenceStepAction.SetSignal, run, "true"), - Step("process-block.unload.wait-clear", "Unload · Wait process position clear", SequenceStepAction.WaitSignal, process, "false", 5000), - Step("process-block.unload.stop", "Unload · Stop transport", SequenceStepAction.SetSignal, run, "false") - ], - _ => throw new ArgumentOutOfRangeException(nameof(kind)) - }; - } - private static int InsertMissingSteps( SequenceDefinition sequence, SemiconductorProcessBlockKind kind, IReadOnlyList expected) { bool sourceIsLinear = SequenceDefinitionEditor.IsStrictLinear(sequence); - if (!SupportsManagedSuffix(sequence)) + if (!SemiconductorProcessBlockPlanBuilder.SupportsManagedSuffix(sequence)) { return 0; } @@ -471,38 +269,18 @@ private static int InsertMissingSteps( return added; } - private static bool SupportsManagedSuffix(SequenceDefinition sequence) - { - if (SequenceDefinitionEditor.IsStrictLinear(sequence)) - { - return true; - } - - if (sequence.Steps.Count == 0 - || sequence.Steps[^1].Action is not (SequenceStepAction.Complete or SequenceStepAction.None) - || sequence.Steps.Take(sequence.Steps.Count - 1).Any(step => - step.Action is SequenceStepAction.Complete or SequenceStepAction.None)) - { - return false; - } - - string terminalId = sequence.Steps[^1].Id; - return sequence.Steps.Take(sequence.Steps.Count - 1).Any(step => - string.Equals(step.NextStepId, terminalId, StringComparison.Ordinal)); - } - private static void RebuildManagedTransitions(SequenceDefinition sequence) { SequenceStepDefinition terminal = sequence.Steps[^1]; SequenceStepDefinition[] load = sequence.Steps .Where(step => step.Id.StartsWith( - ProcessBlockPrefix(SemiconductorProcessBlockKind.Load), + SemiconductorProcessBlockPlanBuilder.ProcessBlockPrefix(SemiconductorProcessBlockKind.Load), StringComparison.Ordinal)) .ToArray(); SequenceStepDefinition[] suffix = sequence.Steps .Where(step => step.Id.StartsWith("process-block.", StringComparison.Ordinal) && !step.Id.StartsWith( - ProcessBlockPrefix(SemiconductorProcessBlockKind.Load), + SemiconductorProcessBlockPlanBuilder.ProcessBlockPrefix(SemiconductorProcessBlockKind.Load), StringComparison.Ordinal)) .ToArray(); @@ -572,7 +350,7 @@ private static int FindSuffixInsertionIndex( var currentOrder = Array.IndexOf(SuffixOrder, kind); foreach (var laterKind in SuffixOrder.Skip(currentOrder + 1)) { - var prefix = ProcessBlockPrefix(laterKind); + var prefix = SemiconductorProcessBlockPlanBuilder.ProcessBlockPrefix(laterKind); var index = sequence.Steps.FindIndex(step => step.Id.StartsWith(prefix, StringComparison.Ordinal)); if (index >= 0) { @@ -590,142 +368,6 @@ private static void RebuildLinearTransitions(IReadOnlyList - string.Equals(existing.TargetId, expected.TargetId, StringComparison.Ordinal) - && string.Equals(existing.Parameter ?? string.Empty, expected.Parameter ?? string.Empty, StringComparison.Ordinal) - && existing.TimeoutMs == expected.TimeoutMs; - - private static bool HasCompatibleManagedRole( - SequenceStepDefinition existing, - SequenceStepDefinition expected) => - existing.Action == expected.Action - && string.Equals(existing.TargetId, expected.TargetId, StringComparison.Ordinal) - && (existing.Action == SequenceStepAction.MoveAxis - || string.Equals(existing.Parameter ?? string.Empty, expected.Parameter ?? string.Empty, StringComparison.Ordinal)); - - private static SequenceDefinition? ResolveAutomaticSequence(MachineProjectDocument project) => - project.Simulation.AutomaticRun is not { } automatic - ? null - : project.Sequences.FirstOrDefault(sequence => string.Equals( - sequence.Id, - automatic.SequenceId, - StringComparison.Ordinal)); - - private static bool HasAuthoredOhtLoad( - MachineProjectDocument project, - SequenceDefinition sequence) - { - OhtHandoffDefinition? handoff = project.Devices.FirstOrDefault(device => - device is { Kind: DeviceKind.Oht, OhtHandoff: not null })?.OhtHandoff; - return handoff is not null - && sequence.Steps.Any(step => - step.Action == SequenceStepAction.WaitSignal - && string.Equals(step.TargetId, handoff.HandoffReadyFeedbackChannelId, StringComparison.Ordinal) - && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)) - && sequence.Steps.Any(step => - step.Action == SequenceStepAction.WaitSignal - && string.Equals(step.TargetId, handoff.CarrierTransferredFeedbackChannelId, StringComparison.Ordinal) - && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)); - } - - private static bool HasAuthoredInspectionHandoff( - MachineProjectDocument project, - SequenceDefinition sequence) - { - InspectionHandoffDefinition? handoff = project.Devices.FirstOrDefault(device => - device is { Kind: DeviceKind.Inspection, InspectionHandoff: not null })?.InspectionHandoff; - return handoff is not null - && sequence.Steps.Any(step => - step.Action == SequenceStepAction.WaitSignal - && string.Equals(step.TargetId, handoff.InspectionReadyFeedbackChannelId, StringComparison.Ordinal) - && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)) - && sequence.Steps.Any(step => - step.Action == SequenceStepAction.TriggerCamera - && string.Equals(step.TargetId, handoff.CameraId, StringComparison.Ordinal)) - && sequence.Steps.Any(step => - step.Action == SequenceStepAction.WaitVisionResult - && string.Equals(step.TargetId, handoff.CameraId, StringComparison.Ordinal)) - && sequence.Steps.Any(step => - step.Action == SequenceStepAction.SetSignal - && string.Equals(step.TargetId, handoff.ResultAcceptedCommandChannelId, StringComparison.Ordinal) - && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)) - && sequence.Steps.Any(step => - step.Action == SequenceStepAction.WaitSignal - && string.Equals(step.TargetId, handoff.InspectionCompleteFeedbackChannelId, StringComparison.Ordinal) - && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)); - } - - private static bool HasAuthoredPrealignerAlignment( - MachineProjectDocument project, - SequenceDefinition sequence) - { - PrealignerDefinition? prealigner = project.Devices.FirstOrDefault(device => - device is { Kind: DeviceKind.Prealigner, Prealigner: not null })?.Prealigner; - return prealigner is not null - && sequence.Steps.Any(step => - step.Action == SequenceStepAction.WaitSignal - && string.Equals(step.TargetId, prealigner.AlignmentReadyFeedbackChannelId, StringComparison.Ordinal) - && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)) - && sequence.Steps.Any(step => - step.Action == SequenceStepAction.SetSignal - && string.Equals(step.TargetId, prealigner.AlignmentAcceptedCommandChannelId, StringComparison.Ordinal) - && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)) - && sequence.Steps.Any(step => - step.Action == SequenceStepAction.WaitSignal - && string.Equals(step.TargetId, prealigner.AlignmentCompleteFeedbackChannelId, StringComparison.Ordinal) - && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)); - } - - private static SequenceStepDefinition Step( - string id, - string name, - SequenceStepAction action, - string targetId, - string parameter, - int timeoutMs = 0) => new() - { - Id = id, - Name = name, - Action = action, - TargetId = targetId, - Parameter = parameter, - TimeoutMs = timeoutMs - }; - - private static string ResolveMoveTarget(VirtualAxisDefinition axis) - { - var target = axis.SoftLimitMax is { } maximum && maximum > axis.HomePosition - ? axis.HomePosition + ((maximum - axis.HomePosition) / 2d) - : axis.HomePosition; - return target.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); - } - private MachineProjectDocument Clone(MachineProjectDocument project) => _store.Load(_store.Serialize(project)); diff --git a/src/OpenVisionLab.Machine.Sequence/Authoring/SemiconductorProcessBlockPlanBuilder.cs b/src/OpenVisionLab.Machine.Sequence/Authoring/SemiconductorProcessBlockPlanBuilder.cs new file mode 100644 index 0000000..55dbff0 --- /dev/null +++ b/src/OpenVisionLab.Machine.Sequence/Authoring/SemiconductorProcessBlockPlanBuilder.cs @@ -0,0 +1,404 @@ +using OpenVisionLab.Machine.Core.Axes; +using OpenVisionLab.Machine.Core.Devices; +using OpenVisionLab.Machine.Core.Layouts; +using OpenVisionLab.Machine.Core.Projects; +using OpenVisionLab.Machine.Core.Sequences; + +namespace OpenVisionLab.Machine.Sequence.Authoring; + +/// +/// Builds read-only Process Block plans and recognizes authored managed blocks. +/// It never mutates the caller project; mutation and commit stay in +/// . +/// +internal sealed class SemiconductorProcessBlockPlanBuilder +{ + private readonly ProjectDocumentStore _store; + private readonly SemiconductorStationSkeletonTemplate _station; + + internal SemiconductorProcessBlockPlanBuilder( + ProjectDocumentStore store, + SemiconductorStationSkeletonTemplate station) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _station = station ?? throw new ArgumentNullException(nameof(station)); + } + + public SemiconductorProcessBlockPreview Preview( + MachineProjectDocument project, + SemiconductorProcessBlockKind kind) + { + ArgumentNullException.ThrowIfNull(project); + var stationPreview = _station.Preview(project); + if (stationPreview.UnavailableCount > 0) + { + return new SemiconductorProcessBlockPreview(kind, stationPreview, []); + } + + var resolved = Clone(project); + _station.Apply(resolved); + var expected = BuildSteps(resolved, kind); + var sourceSequence = ResolveAutomaticSequence(project); + var sourceSupportsComposition = sourceSequence is null || SupportsManagedSuffix(sourceSequence); + var entries = expected.Select(step => + { + var existing = sourceSequence?.Steps.FirstOrDefault(candidate => + string.Equals(candidate.Id, step.Id, StringComparison.Ordinal)); + var status = ResolveStatus(existing, step, sourceSupportsComposition, isSelected: true); + return new SemiconductorProcessBlockStepEntry( + step.Id, + step.Name, + step.Action, + step.TargetId, + step.Parameter ?? string.Empty, + step.TimeoutMs, + status); + }).ToArray(); + return new SemiconductorProcessBlockPreview(kind, stationPreview, entries); + } + + public SemiconductorProcessBlockPlanPreview Preview( + MachineProjectDocument project, + IEnumerable kinds) + { + ArgumentNullException.ThrowIfNull(project); + ArgumentNullException.ThrowIfNull(kinds); + var selected = Normalize(kinds); + var selectedSet = selected.ToHashSet(); + var existingKinds = RecognizeExistingKinds(project); + var stationPreview = _station.Preview(project); + if (stationPreview.UnavailableCount > 0) + { + return new SemiconductorProcessBlockPlanPreview( + selected, + existingKinds, + stationPreview, + []); + } + + var resolved = Clone(project); + _station.Apply(resolved); + var sourceSequence = ResolveAutomaticSequence(project); + var sourceSupportsComposition = sourceSequence is null || SupportsManagedSuffix(sourceSequence); + var entries = new List(); + foreach (var kind in Enum.GetValues()) + { + foreach (var step in BuildSteps(resolved, kind)) + { + var existing = sourceSequence?.Steps.FirstOrDefault(candidate => + string.Equals(candidate.Id, step.Id, StringComparison.Ordinal)); + if (!selectedSet.Contains(kind) && existing is null) + { + continue; + } + + var status = ResolveStatus(existing, step, sourceSupportsComposition, selectedSet.Contains(kind)); + entries.Add(new SemiconductorProcessBlockStepEntry( + step.Id, + step.Name, + step.Action, + step.TargetId, + step.Parameter ?? string.Empty, + step.TimeoutMs, + status)); + } + } + + return new SemiconductorProcessBlockPlanPreview( + selected, + existingKinds, + stationPreview, + entries); + } + + public IReadOnlyList RecognizeExistingKinds( + MachineProjectDocument project) + { + ArgumentNullException.ThrowIfNull(project); + var sequence = ResolveAutomaticSequence(project); + return sequence is null + ? [] + : Enum.GetValues() + .Where(kind => + (kind == SemiconductorProcessBlockKind.Load && HasAuthoredOhtLoad(project, sequence)) + || (kind == SemiconductorProcessBlockKind.Align && HasAuthoredPrealignerAlignment(project, sequence)) + || (kind == SemiconductorProcessBlockKind.Inspect && HasAuthoredInspectionHandoff(project, sequence)) + || sequence.Steps.Any(step => step.Id.StartsWith( + ProcessBlockPrefix(kind), + StringComparison.Ordinal))) + .ToArray(); + } + + private static SemiconductorProcessBlockKind[] Normalize( + IEnumerable kinds) + { + var selected = kinds.ToHashSet(); + return Enum.GetValues() + .Where(selected.Contains) + .ToArray(); + } + + internal static string ProcessBlockPrefix(SemiconductorProcessBlockKind kind) => + $"process-block.{kind.ToString().ToLowerInvariant()}."; + + internal IReadOnlyList BuildSteps( + MachineProjectDocument project, + SemiconductorProcessBlockKind kind) + { + if (kind == SemiconductorProcessBlockKind.Load + && ResolveAutomaticSequence(project) is { } sequence + && HasAuthoredOhtLoad(project, sequence)) + { + return []; + } + + if (kind == SemiconductorProcessBlockKind.Inspect + && ResolveAutomaticSequence(project) is { } inspectionSequence + && HasAuthoredInspectionHandoff(project, inspectionSequence)) + { + return []; + } + + if (kind == SemiconductorProcessBlockKind.Align + && ResolveAutomaticSequence(project) is { } alignmentSequence + && HasAuthoredPrealignerAlignment(project, alignmentSequence)) + { + return []; + } + + var layout = project.Layouts.First(layout => string.Equals( + layout.Id, + project.Simulation.ActiveLayoutId, + StringComparison.Ordinal)); + var conveyor = project.Devices.First(device => device is { Kind: DeviceKind.Conveyor, Conveyor: not null }); + var loadLock = project.Devices.FirstOrDefault(device => + device is { Kind: DeviceKind.LoadLock, LoadLock: not null }); + var loadLockOuterDoor = loadLock is null + ? null + : layout.Components.FirstOrDefault(component => string.Equals( + component.Id, + loadLock.LoadLock!.OuterDoorComponentId, + StringComparison.Ordinal)); + var cylinder = loadLockOuterDoor is null + ? project.Devices.First(device => device is { Kind: DeviceKind.Cylinder, Cylinder: not null }) + : project.Devices.Single(device => + device is { Kind: DeviceKind.Cylinder, Cylinder: not null } + && string.Equals(device.Id, loadLockOuterDoor.BehaviorBindingId, StringComparison.Ordinal)); + var axis = project.Axes.First(axis => axis.Kind == AxisKind.Linear); + var sensors = layout.Components + .Where(component => component.Kind == LayoutComponentKind.DigitalSensor) + .OrderBy(component => component.Transform.X) + .Select(component => project.Devices.First(device => + string.Equals(device.Id, component.BehaviorBindingId, StringComparison.Ordinal))) + .ToArray(); + var entrySensor = sensors.FirstOrDefault(sensor => string.Equals( + sensor.Id, + "device.sensor-entry", + StringComparison.Ordinal)) + ?? sensors[0]; + var processSensor = sensors.FirstOrDefault(sensor => string.Equals( + sensor.Id, + "device.sensor-process", + StringComparison.Ordinal)) + ?? sensors[1]; + var run = conveyor.Conveyor!.RunCommandChannelId; + var entry = entrySensor.Sensor!.OutputChannelId; + var process = processSensor.Sensor!.OutputChannelId; + var extend = cylinder.Cylinder!.ExtendCommandChannelId; + var extended = cylinder.Cylinder.ExtendedSensorChannelId; + var retracted = cylinder.Cylinder.RetractedSensorChannelId; + var moveTarget = ResolveMoveTarget(axis); + + return kind switch + { + SemiconductorProcessBlockKind.Load => + [ + Step("process-block.load.start", "Load · Start transport", SequenceStepAction.SetSignal, run, "true"), + Step("process-block.load.wait-entry", "Load · Wait entry sensor", SequenceStepAction.WaitSignal, entry, "true", 5000), + Step("process-block.load.stop", "Load · Stop transport", SequenceStepAction.SetSignal, run, "false") + ], + SemiconductorProcessBlockKind.Align => + [ + Step("process-block.align.move", "Align · Move process axis", SequenceStepAction.MoveAxis, axis.Id, moveTarget), + Step("process-block.align.wait", "Align · Wait process axis", SequenceStepAction.WaitAxisDone, axis.Id, string.Empty, 5000) + ], + SemiconductorProcessBlockKind.Process => + [ + Step("process-block.process.extend", "Process · Extend cylinder", SequenceStepAction.SetSignal, extend, "true"), + Step("process-block.process.wait-extended", "Process · Wait cylinder extended", SequenceStepAction.WaitSignal, extended, "true", 3000), + Step("process-block.process.retract", "Process · Retract cylinder", SequenceStepAction.SetSignal, extend, "false"), + Step("process-block.process.wait-retracted", "Process · Wait cylinder retracted", SequenceStepAction.WaitSignal, retracted, "true", 3000) + ], + SemiconductorProcessBlockKind.Inspect => + [ + Step("process-block.inspect.confirm-position", "Inspect · Confirm process position", SequenceStepAction.WaitSignal, process, "true", 3000) + ], + SemiconductorProcessBlockKind.Unload => + [ + Step("process-block.unload.start", "Unload · Start transport", SequenceStepAction.SetSignal, run, "true"), + Step("process-block.unload.wait-clear", "Unload · Wait process position clear", SequenceStepAction.WaitSignal, process, "false", 5000), + Step("process-block.unload.stop", "Unload · Stop transport", SequenceStepAction.SetSignal, run, "false") + ], + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + } + + internal static bool SupportsManagedSuffix(SequenceDefinition sequence) + { + if (SequenceDefinitionEditor.IsStrictLinear(sequence)) + { + return true; + } + + if (sequence.Steps.Count == 0 + || sequence.Steps[^1].Action is not (SequenceStepAction.Complete or SequenceStepAction.None) + || sequence.Steps.Take(sequence.Steps.Count - 1).Any(step => + step.Action is SequenceStepAction.Complete or SequenceStepAction.None)) + { + return false; + } + + string terminalId = sequence.Steps[^1].Id; + return sequence.Steps.Take(sequence.Steps.Count - 1).Any(step => + string.Equals(step.NextStepId, terminalId, StringComparison.Ordinal)); + } + + private static SemiconductorProcessBlockStepStatus ResolveStatus( + SequenceStepDefinition? existing, + SequenceStepDefinition expected, + bool sourceSupportsComposition, + bool isSelected) + { + if (!sourceSupportsComposition || (existing is not null && !HasCompatibleManagedRole(existing, expected))) + { + return SemiconductorProcessBlockStepStatus.Unavailable; + } + + if (!isSelected) + { + return SemiconductorProcessBlockStepStatus.ProposedRemoval; + } + + if (existing is null) + { + return SemiconductorProcessBlockStepStatus.Proposed; + } + + return HasTemplateSettings(existing, expected) + ? SemiconductorProcessBlockStepStatus.Existing + : SemiconductorProcessBlockStepStatus.Customized; + } + + private static bool HasTemplateSettings(SequenceStepDefinition existing, SequenceStepDefinition expected) => + string.Equals(existing.TargetId, expected.TargetId, StringComparison.Ordinal) + && string.Equals(existing.Parameter ?? string.Empty, expected.Parameter ?? string.Empty, StringComparison.Ordinal) + && existing.TimeoutMs == expected.TimeoutMs; + + private static bool HasCompatibleManagedRole( + SequenceStepDefinition existing, + SequenceStepDefinition expected) => + existing.Action == expected.Action + && string.Equals(existing.TargetId, expected.TargetId, StringComparison.Ordinal) + && (existing.Action == SequenceStepAction.MoveAxis + || string.Equals(existing.Parameter ?? string.Empty, expected.Parameter ?? string.Empty, StringComparison.Ordinal)); + + internal SequenceDefinition? ResolveAutomaticSequence(MachineProjectDocument project) => + project.Simulation.AutomaticRun is not { } automatic + ? null + : project.Sequences.FirstOrDefault(sequence => string.Equals( + sequence.Id, + automatic.SequenceId, + StringComparison.Ordinal)); + + private static bool HasAuthoredOhtLoad( + MachineProjectDocument project, + SequenceDefinition sequence) + { + OhtHandoffDefinition? handoff = project.Devices.FirstOrDefault(device => + device is { Kind: DeviceKind.Oht, OhtHandoff: not null })?.OhtHandoff; + return handoff is not null + && sequence.Steps.Any(step => + step.Action == SequenceStepAction.WaitSignal + && string.Equals(step.TargetId, handoff.HandoffReadyFeedbackChannelId, StringComparison.Ordinal) + && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)) + && sequence.Steps.Any(step => + step.Action == SequenceStepAction.WaitSignal + && string.Equals(step.TargetId, handoff.CarrierTransferredFeedbackChannelId, StringComparison.Ordinal) + && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)); + } + + private static bool HasAuthoredInspectionHandoff( + MachineProjectDocument project, + SequenceDefinition sequence) + { + InspectionHandoffDefinition? handoff = project.Devices.FirstOrDefault(device => + device is { Kind: DeviceKind.Inspection, InspectionHandoff: not null })?.InspectionHandoff; + return handoff is not null + && sequence.Steps.Any(step => + step.Action == SequenceStepAction.WaitSignal + && string.Equals(step.TargetId, handoff.InspectionReadyFeedbackChannelId, StringComparison.Ordinal) + && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)) + && sequence.Steps.Any(step => + step.Action == SequenceStepAction.TriggerCamera + && string.Equals(step.TargetId, handoff.CameraId, StringComparison.Ordinal)) + && sequence.Steps.Any(step => + step.Action == SequenceStepAction.WaitVisionResult + && string.Equals(step.TargetId, handoff.CameraId, StringComparison.Ordinal)) + && sequence.Steps.Any(step => + step.Action == SequenceStepAction.SetSignal + && string.Equals(step.TargetId, handoff.ResultAcceptedCommandChannelId, StringComparison.Ordinal) + && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)) + && sequence.Steps.Any(step => + step.Action == SequenceStepAction.WaitSignal + && string.Equals(step.TargetId, handoff.InspectionCompleteFeedbackChannelId, StringComparison.Ordinal) + && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)); + } + + private static bool HasAuthoredPrealignerAlignment( + MachineProjectDocument project, + SequenceDefinition sequence) + { + PrealignerDefinition? prealigner = project.Devices.FirstOrDefault(device => + device is { Kind: DeviceKind.Prealigner, Prealigner: not null })?.Prealigner; + return prealigner is not null + && sequence.Steps.Any(step => + step.Action == SequenceStepAction.WaitSignal + && string.Equals(step.TargetId, prealigner.AlignmentReadyFeedbackChannelId, StringComparison.Ordinal) + && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)) + && sequence.Steps.Any(step => + step.Action == SequenceStepAction.SetSignal + && string.Equals(step.TargetId, prealigner.AlignmentAcceptedCommandChannelId, StringComparison.Ordinal) + && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)) + && sequence.Steps.Any(step => + step.Action == SequenceStepAction.WaitSignal + && string.Equals(step.TargetId, prealigner.AlignmentCompleteFeedbackChannelId, StringComparison.Ordinal) + && string.Equals(step.Parameter, "true", StringComparison.OrdinalIgnoreCase)); + } + + private static SequenceStepDefinition Step( + string id, + string name, + SequenceStepAction action, + string targetId, + string parameter, + int timeoutMs = 0) => new() + { + Id = id, + Name = name, + Action = action, + TargetId = targetId, + Parameter = parameter, + TimeoutMs = timeoutMs + }; + + private static string ResolveMoveTarget(VirtualAxisDefinition axis) + { + var target = axis.SoftLimitMax is { } maximum && maximum > axis.HomePosition + ? axis.HomePosition + ((maximum - axis.HomePosition) / 2d) + : axis.HomePosition; + return target.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); + } + + private MachineProjectDocument Clone(MachineProjectDocument project) => + _store.Load(_store.Serialize(project)); +} diff --git a/src/OpenVisionLab.Machine.Simulation/Commands/ConfigureRuntimeCommand.cs b/src/OpenVisionLab.Machine.Simulation/Commands/ConfigureRuntimeCommand.cs index b4bdaab..fcc043c 100644 --- a/src/OpenVisionLab.Machine.Simulation/Commands/ConfigureRuntimeCommand.cs +++ b/src/OpenVisionLab.Machine.Simulation/Commands/ConfigureRuntimeCommand.cs @@ -4,10 +4,14 @@ namespace OpenVisionLab.Machine.Simulation.Commands; public sealed class ConfigureRuntimeCommand : SimulationCommand { - public ConfigureRuntimeCommand(SimulationRuntimeConfiguration configuration) + public ConfigureRuntimeCommand( + SimulationRuntimeConfiguration configuration, + string? projectId = null) { Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + ProjectId = string.IsNullOrWhiteSpace(projectId) ? null : projectId; } public SimulationRuntimeConfiguration Configuration { get; } + public string? ProjectId { get; } } diff --git a/src/OpenVisionLab.Machine.Simulation/Commands/SimulationCommand.cs b/src/OpenVisionLab.Machine.Simulation/Commands/SimulationCommand.cs index ef36011..0e44317 100644 --- a/src/OpenVisionLab.Machine.Simulation/Commands/SimulationCommand.cs +++ b/src/OpenVisionLab.Machine.Simulation/Commands/SimulationCommand.cs @@ -1,5 +1,8 @@ namespace OpenVisionLab.Machine.Simulation.Commands; +/// Identifies the runtime that an explicitly bound command may affect. +public readonly record struct SimulationRuntimeIdentity(string? ProjectId, long RuntimeGeneration); + public abstract class SimulationCommand { private readonly TaskCompletionSource _completion = @@ -8,6 +11,10 @@ public abstract class SimulationCommand public string CommandId { get; } = Guid.NewGuid().ToString("n"); public DateTimeOffset IssuedAt { get; } = DateTimeOffset.UtcNow; + // Null preserves existing unbound callers. Once created, a queued command's + // expectation cannot be changed by its caller. + public SimulationRuntimeIdentity? ExpectedRuntime { get; init; } + internal Task Completion => _completion.Task; internal bool TryComplete(SimulationCommandResult result) => diff --git a/src/OpenVisionLab.Machine.Simulation/Commands/SimulationCommandResult.cs b/src/OpenVisionLab.Machine.Simulation/Commands/SimulationCommandResult.cs index 1c2d129..3b9afc9 100644 --- a/src/OpenVisionLab.Machine.Simulation/Commands/SimulationCommandResult.cs +++ b/src/OpenVisionLab.Machine.Simulation/Commands/SimulationCommandResult.cs @@ -44,7 +44,8 @@ public enum SimulationCommandErrorCode UnsupportedCommand, EngineNotStarted, EngineFaulted, - EngineStopped + EngineStopped, + RuntimeIdentityMismatch } public sealed record SimulationCommandResult( diff --git a/src/OpenVisionLab.Machine.Simulation/Commands/TriggerVirtualCameraCommand.cs b/src/OpenVisionLab.Machine.Simulation/Commands/TriggerVirtualCameraCommand.cs index 87f44d9..c736a89 100644 --- a/src/OpenVisionLab.Machine.Simulation/Commands/TriggerVirtualCameraCommand.cs +++ b/src/OpenVisionLab.Machine.Simulation/Commands/TriggerVirtualCameraCommand.cs @@ -8,16 +8,30 @@ public TriggerVirtualCameraCommand( string cameraId, string recipeId, VirtualCameraFrameEvidence frameEvidence, - VirtualCameraInspectionEvidence? inspectionEvidence = null) + VirtualCameraInspectionEvidence? inspectionEvidence = null, + string? projectId = null, + long? runtimeGeneration = null) { + if ((string.IsNullOrWhiteSpace(projectId)) != !runtimeGeneration.HasValue) + { + throw new ArgumentException( + "Project id and runtime generation must be supplied together.", + nameof(projectId)); + } + CameraId = cameraId; RecipeId = recipeId; FrameEvidence = frameEvidence ?? throw new ArgumentNullException(nameof(frameEvidence)); InspectionEvidence = inspectionEvidence; + ProjectId = string.IsNullOrWhiteSpace(projectId) ? null : projectId; + RuntimeGeneration = runtimeGeneration; } public string CameraId { get; } public string RecipeId { get; } public VirtualCameraFrameEvidence FrameEvidence { get; } public VirtualCameraInspectionEvidence? InspectionEvidence { get; } + public string? ProjectId { get; } + public long? RuntimeGeneration { get; } + public bool HasRuntimeIdentity => ProjectId is not null && RuntimeGeneration.HasValue; } diff --git a/src/OpenVisionLab.Machine.Simulation/Commissioning/DeterministicMultiAxisCommissioningEvidenceHistory.cs b/src/OpenVisionLab.Machine.Simulation/Commissioning/DeterministicMultiAxisCommissioningEvidenceHistory.cs index cf5e0bc..bb6ea6d 100644 --- a/src/OpenVisionLab.Machine.Simulation/Commissioning/DeterministicMultiAxisCommissioningEvidenceHistory.cs +++ b/src/OpenVisionLab.Machine.Simulation/Commissioning/DeterministicMultiAxisCommissioningEvidenceHistory.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using System.Text.Json; using System.Text.Json.Serialization; +using OpenVisionLab.Machine.Simulation.Scenarios; namespace OpenVisionLab.Machine.Simulation.Commissioning; @@ -138,19 +139,7 @@ internal static void SaveAtomic(string json, string path) { var fullPath = Path.GetFullPath(path); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); - var temporaryPath = $"{fullPath}.{Guid.NewGuid():N}.tmp"; - try - { - File.WriteAllText(temporaryPath, json); - File.Move(temporaryPath, fullPath, overwrite: true); - } - finally - { - if (File.Exists(temporaryPath)) - { - File.Delete(temporaryPath); - } - } + AtomicEvidenceFile.Write(fullPath, temporaryPath => File.WriteAllText(temporaryPath, json)); } internal static T? LoadFromJson(string path) diff --git a/src/OpenVisionLab.Machine.Simulation/Commissioning/DeterministicMultiAxisCommissioningResultPackage.cs b/src/OpenVisionLab.Machine.Simulation/Commissioning/DeterministicMultiAxisCommissioningResultPackage.cs index 861437c..1e43cc6 100644 --- a/src/OpenVisionLab.Machine.Simulation/Commissioning/DeterministicMultiAxisCommissioningResultPackage.cs +++ b/src/OpenVisionLab.Machine.Simulation/Commissioning/DeterministicMultiAxisCommissioningResultPackage.cs @@ -6,6 +6,7 @@ using OpenVisionLab.Machine.Core.Projects; using OpenVisionLab.Machine.Simulation.Events; using OpenVisionLab.Machine.Simulation.Snapshots; +using OpenVisionLab.Machine.Simulation.Scenarios; namespace OpenVisionLab.Machine.Simulation.Commissioning; @@ -166,19 +167,7 @@ public static void SaveToJson( var fullPath = Path.GetFullPath(path); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); - var temporaryPath = $"{fullPath}.{Guid.NewGuid():N}.tmp"; - try - { - File.WriteAllText(temporaryPath, SaveToJson(package)); - File.Move(temporaryPath, fullPath, overwrite: true); - } - finally - { - if (File.Exists(temporaryPath)) - { - File.Delete(temporaryPath); - } - } + AtomicEvidenceFile.Write(fullPath, temporaryPath => File.WriteAllText(temporaryPath, SaveToJson(package))); } public static DeterministicMultiAxisCommissioningResultPackage? LoadFromJson(string path) diff --git a/src/OpenVisionLab.Machine.Simulation/Engine/FixedStepSimulationEngine.cs b/src/OpenVisionLab.Machine.Simulation/Engine/FixedStepSimulationEngine.cs index f6a2f2e..01b34ed 100644 --- a/src/OpenVisionLab.Machine.Simulation/Engine/FixedStepSimulationEngine.cs +++ b/src/OpenVisionLab.Machine.Simulation/Engine/FixedStepSimulationEngine.cs @@ -1,9 +1,6 @@ using System.Collections.Immutable; using System.Threading.Channels; -using OpenVisionLab.Machine.Core.Channels; -using OpenVisionLab.Machine.Core.Devices; using OpenVisionLab.Machine.IO.Channels; -using OpenVisionLab.Machine.Sequence.Compilation; using OpenVisionLab.Machine.Sequence.Runtime; using OpenVisionLab.Machine.Simulation.Axis; using OpenVisionLab.Machine.Simulation.Camera; @@ -18,22 +15,16 @@ namespace OpenVisionLab.Machine.Simulation.Engine; -public sealed class FixedStepSimulationEngine : ISimulationEngine +public sealed class FixedStepSimulationEngine : ISimulationEngine, ISimulationEventJournalSource { private readonly SimulationSettings _settings; - private readonly SimulationClock _clock; private readonly Channel _commandChannel; private readonly SimulationEventPublisher _eventPublisher; private readonly LatestSnapshotStore _snapshotStore; private readonly SimulationEngineLifecycle _lifecycle; private readonly SimulationPhysicalRuntimeTick _physicalRuntimeTick; - private readonly List _axes = new(); + private readonly SimulationRuntimeState _runtimeState; private readonly DeterministicSimulationCommandTraceStore _commandTraceStore = new(); - private readonly List _cameras = new(); - private readonly SimulationSequenceRuntime _sequenceRuntime = new(); - private readonly SimulationRuntimeConfigurationBuilder _runtimeConfigurationBuilder; - private readonly SimulationConditionScenarioRuntime _conditionScenarioRuntime = new(); - private readonly SimulationAutomaticRunRuntime _automaticRunRuntime = new(); private readonly SimulationManualControlCommandHandler _manualControlCommandHandler = new(); private readonly SimulationFaultCommandHandler _faultCommandHandler = new(); private readonly SimulationConditionScheduledFaultRecoveryHandler _conditionScheduledFaultRecoveryHandler = new(); @@ -44,23 +35,52 @@ public sealed class FixedStepSimulationEngine : ISimulationEngine private readonly SimulationAutomaticRunCycleHandler _automaticRunCycleHandler = new(); private readonly SimulationSequenceCommandHandler _sequenceCommandHandler = new(); private readonly SimulationRunControlCommandHandler _runControlCommandHandler = new(); - private readonly SimulationFaultRuntime _faultRuntime = new(); private readonly Action? _faultInjector; - private double _timeScale; - private DeterministicSignalHub _signalHub; - private DeterministicMachineLayout? _machineLayout; - private DeterministicPickPlaceWorkpiece? _pickPlaceWorkpiece; - private SimulationRunMode _runMode = SimulationRunMode.Paused; - private SimulationControlOwner _controlOwner = SimulationControlOwner.Definition; - private string? _activeSequenceId; - private int _pendingSteps; - private long _tickIndex; - private long _commandBoundaryTick; - private TimeSpan _commandBoundaryTime; private SimulationCommand? _currentCommand; private string? _operationContext; private bool _disposed; + private SimulationClock _clock => _runtimeState.Clock; + private List _axes => _runtimeState.Axes; + private List _cameras => _runtimeState.Cameras; + private SimulationSequenceRuntime _sequenceRuntime => _runtimeState.SequenceRuntime; + private SimulationConditionScenarioRuntime _conditionScenarioRuntime => _runtimeState.ConditionScenarioRuntime; + private SimulationAutomaticRunRuntime _automaticRunRuntime => _runtimeState.AutomaticRunRuntime; + private SimulationFaultRuntime _faultRuntime => _runtimeState.FaultRuntime; + private DeterministicSignalHub _signalHub => _runtimeState.SignalHub; + private DeterministicMachineLayout? _machineLayout => _runtimeState.MachineLayout; + private DeterministicPickPlaceWorkpiece? _pickPlaceWorkpiece => _runtimeState.PickPlaceWorkpiece; + private double _timeScale + { + get => _runtimeState.TimeScale; + set => _runtimeState.TimeScale = value; + } + private SimulationRunMode _runMode + { + get => _runtimeState.RunMode; + set => _runtimeState.RunMode = value; + } + private SimulationControlOwner _controlOwner + { + get => _runtimeState.ControlOwner; + set => _runtimeState.ControlOwner = value; + } + private string? _activeSequenceId + { + get => _runtimeState.ActiveSequenceId; + set => _runtimeState.ActiveSequenceId = value; + } + private int _pendingSteps + { + get => _runtimeState.PendingSteps; + set => _runtimeState.PendingSteps = value; + } + private long _tickIndex => _runtimeState.TickIndex; + private long _commandBoundaryTick => _runtimeState.CommandBoundaryTick; + private TimeSpan _commandBoundaryTime => _runtimeState.CommandBoundaryTime; + private string? _projectId => _runtimeState.ProjectId; + private long _runtimeGeneration => _runtimeState.RuntimeGeneration; + public FixedStepSimulationEngine(SimulationSettings settings) : this(settings, null) { @@ -88,10 +108,14 @@ internal FixedStepSimulationEngine( { throw new ArgumentOutOfRangeException(nameof(settings), "EventBufferCapacity must be positive."); } + if (settings.CanonicalEventJournalCapacity is <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(settings), + "CanonicalEventJournalCapacity must be positive when configured."); + } - _timeScale = settings.TimeScale; - _clock = new SimulationClock(settings.FixedStep); - _runtimeConfigurationBuilder = new SimulationRuntimeConfigurationBuilder(settings.FixedStep); + _runtimeState = new SimulationRuntimeState(settings.FixedStep, settings.TimeScale); _commandChannel = Channel.CreateBounded( new BoundedChannelOptions(settings.CommandQueueCapacity) { @@ -99,8 +123,9 @@ internal FixedStepSimulationEngine( SingleReader = true, SingleWriter = false }); - _eventPublisher = new SimulationEventPublisher(settings.EventBufferCapacity); - _signalHub = DeterministicSignalHub.Create(Array.Empty()).Hub!; + _eventPublisher = new SimulationEventPublisher( + settings.EventBufferCapacity, + settings.CanonicalEventJournalCapacity ?? settings.EventBufferCapacity); _snapshotStore = new LatestSnapshotStore(CreateSnapshot()); _physicalRuntimeTick = new SimulationPhysicalRuntimeTick(EmitPhysicalRuntimeEvent); _lifecycle = new SimulationEngineLifecycle( @@ -119,8 +144,13 @@ internal FixedStepSimulationEngine( public TimeSpan FixedStep => _settings.FixedStep; public ChannelReader SnapshotReader => _snapshotStore.Reader; public ChannelReader EventReader => _eventPublisher.Reader; + public SimulationEventJournalSnapshot EventJournal => _eventPublisher.JournalSnapshot; public Task Termination => _lifecycle.Termination; + public IAsyncEnumerable ReadCanonicalEventsAsync( + CancellationToken cancellationToken = default) => + _eventPublisher.ReadJournalAsync(cancellationToken); + public ImmutableArray CommandTrace => _commandTraceStore.Snapshot(); public DeterministicSimulationCommandTracePackage CreateCommandTracePackage() => @@ -141,12 +171,7 @@ public void AddAxis(ServoAxisComponent axis) throw new InvalidOperationException("Axes cannot be added directly after the engine starts."); } - if (_axes.Any(existing => string.Equals(existing.Id, axis.Id, StringComparison.Ordinal))) - { - throw new ArgumentException($"Axis id '{axis.Id}' is duplicated.", nameof(axis)); - } - - _axes.Add(axis); + _runtimeState.AddAxis(axis); _snapshotStore.SetCurrent(CreateSnapshot()); } @@ -316,11 +341,20 @@ private async Task RunLoop(CancellationToken cancellationToken) private SimulationCommandResult ApplyCommand(SimulationCommand command) { - _commandBoundaryTick = _tickIndex; - _commandBoundaryTime = _clock.Time; + _runtimeState.SetCommandBoundary(); SimulationCommandResult result; switch (command) { + // Check owned state at application time, before any handler mutates it. + // CurrentSnapshot may still describe the previous configuration here. + case { ExpectedRuntime: { } expected } when !_runtimeState.Matches(expected): + result = Reject( + command, + SimulationCommandErrorCode.RuntimeIdentityMismatch, + $"Command expects project '{expected.ProjectId ?? ""}' generation {expected.RuntimeGeneration}, " + + $"but current runtime is project '{_projectId ?? ""}' generation {_runtimeGeneration}."); + break; + case PlayCommand: case PauseCommand: case StepCommand: @@ -349,7 +383,7 @@ private SimulationCommandResult ApplyCommand(SimulationCommand command) break; case ConfigureRuntimeCommand configureRuntime: - result = ApplyRuntimeConfiguration(command, configureRuntime.Configuration); + result = ApplyRuntimeConfiguration(command, configureRuntime); break; case ConfigureAxesCommand configureAxes: @@ -574,42 +608,17 @@ private SimulationCommandResult ApplyStopConditionScenario(SimulationCommand com private SimulationCommandResult ApplyRuntimeConfiguration( SimulationCommand command, - SimulationRuntimeConfiguration configuration) + ConfigureRuntimeCommand configureRuntime) { - if (!_runtimeConfigurationBuilder.TryBuild( + var configuration = configureRuntime.Configuration; + if (!_runtimeState.TryApplyRuntimeConfiguration( configuration, - out var candidate, + configureRuntime.ProjectId, out var configurationError)) { return Reject(command, SimulationCommandErrorCode.RuntimeConfigurationInvalid, configurationError); } - SimulationRuntimeConfigurationBuildResult runtime = candidate!; - _runMode = SimulationRunMode.Paused; - _pendingSteps = 0; - ClearSequenceDebugConfiguration(); - _clock.Reset(); - _tickIndex = 0; - _axes.Clear(); - _axes.AddRange(runtime.Axes); - _cameras.Clear(); - _cameras.AddRange(runtime.Cameras); - _signalHub = runtime.SignalHub; - _machineLayout = runtime.MachineLayout; - _pickPlaceWorkpiece = runtime.PickPlaceWorkpiece; - if (configuration.TimeScale.HasValue) - { - _timeScale = configuration.TimeScale.Value; - } - _faultRuntime.Clear(); - _conditionScenarioRuntime.Clear(); - _sequenceRuntime.Configure(runtime.CompiledSequences, runtime.SequenceExecutors); - _automaticRunRuntime.Configure( - configuration.AutomaticRun, - runtime.AutomaticRunRepeatDelayTicks); - _activeSequenceId = null; - _controlOwner = SimulationControlOwner.Definition; - var configurationSummary = $"Configured {_axes.Count} axis/axes, {configuration.Channels.Count} signal(s), " + $"{_cameras.Count} camera(s), {_sequenceRuntime.SequenceExecutors.Count} sequence(s), and " + @@ -631,30 +640,11 @@ private SimulationCommandResult ApplyAxisConfiguration( SimulationCommand command, IReadOnlyList configurations) { - if (!_runtimeConfigurationBuilder.TryCreateAxes(configurations, out var axes, out var error)) + if (!_runtimeState.TryApplyAxisConfiguration(configurations, out var error)) { return Reject(command, SimulationCommandErrorCode.RuntimeConfigurationInvalid, error); } - var emptySignalHub = DeterministicSignalHub.Create(Array.Empty()).Hub!; - - _runMode = SimulationRunMode.Paused; - _pendingSteps = 0; - ClearSequenceDebugConfiguration(); - _clock.Reset(); - _tickIndex = 0; - _axes.Clear(); - _axes.AddRange(axes); - _cameras.Clear(); - _signalHub = emptySignalHub; - _machineLayout = null; - _pickPlaceWorkpiece = null; - _faultRuntime.Clear(); - _conditionScenarioRuntime.Clear(); - _sequenceRuntime.ClearConfiguration(); - _automaticRunRuntime.Configure(null, repeatDelayTicks: 0); - _activeSequenceId = null; - _controlOwner = SimulationControlOwner.Definition; EmitAtCommandBoundary( "Runtime", "AxesConfigured", @@ -679,7 +669,9 @@ private SimulationCommandResult ApplyManualControlCommand(SimulationCommand comm _faultRuntime, _commandBoundaryTick, _commandBoundaryTime, - FormatSignal)); + FormatSignal, + _projectId, + _runtimeGeneration)); if (outcome.RunMode.HasValue) { _runMode = outcome.RunMode.Value; @@ -835,8 +827,8 @@ private SimulationCommandResult ApplyAutomaticRunCommand(SimulationCommand comma private void Tick() { - var eventTick = _tickIndex + 1; - var eventTime = _clock.Time + _settings.FixedStep; + var eventTick = _runtimeState.NextTickIndex; + var eventTime = _runtimeState.NextSimulationTime; AdvanceConditionScenario(eventTick, eventTime); @@ -924,8 +916,7 @@ private void Tick() AdvancePickPlaceWorkpiece(eventTick, eventTime); - _clock.Advance(); - _tickIndex = eventTick; + _runtimeState.AdvanceTick(); PublishSnapshot(); } @@ -962,8 +953,7 @@ private void AdvanceConditionScheduledFault( if (scenarioTick == schedule.InjectTick) { - _commandBoundaryTick = eventTick; - _commandBoundaryTime = eventTime; + _runtimeState.SetCommandBoundary(eventTick, eventTime); var outcome = _conditionScheduledFaultInjectionHandler.Apply( new SimulationConditionScheduledFaultInjectionContext( schedule, @@ -1009,8 +999,7 @@ private void ClearConditionScheduledFault( return; } - _commandBoundaryTick = eventTick; - _commandBoundaryTime = eventTime; + _runtimeState.SetCommandBoundary(eventTick, eventTime); var outcome = _conditionScheduledFaultRecoveryHandler.Apply( CreateConditionScheduledFaultRecoveryContext(restartSequence, commandId)); if (outcome.State is { } state) @@ -1123,77 +1112,20 @@ private void FaultAutomaticRun( private void ResetRuntime() { - _runMode = SimulationRunMode.Paused; - _pendingSteps = 0; - _sequenceRuntime.DebugState.ClearPendingSemanticStep(); - _sequenceRuntime.DebugState.SetPause(SequenceDebugPauseReason.None, null); - _clock.Reset(); - _tickIndex = 0; - foreach (var axis in _axes) - { - axis.Reset(); - } - foreach (var camera in _cameras) - { - camera.Reset(); - } - _faultRuntime.Clear(); - _signalHub.Reset(); - _machineLayout?.Reset(); - _pickPlaceWorkpiece?.Reset(); - _sequenceRuntime.ResetExecutors(); - _automaticRunRuntime.Reset(); - _conditionScenarioRuntime.Reset(); - _activeSequenceId = null; - _controlOwner = SimulationControlOwner.Definition; + _runtimeState.Reset(); } - private SimulationSnapshot CreateSnapshot() => - SimulationSnapshotFactory.Create( - new SimulationSnapshotFactoryContext( - _clock.Time, - _tickIndex, - _runMode, - _controlOwner, - _timeScale, - _axes, - _signalHub, - _sequenceRuntime.SequenceExecutors, - _cameras, - new AutomaticRunSnapshot( - _automaticRunRuntime.Configuration is not null, - _automaticRunRuntime.IsActive, - _automaticRunRuntime.WaitingForRepeat, - _automaticRunRuntime.CompletedCycleCount, - _automaticRunRuntime.RemainingDelayTicks), - _machineLayout, - _faultRuntime.Values, - _conditionScenarioRuntime.CreateSnapshot(), - _pickPlaceWorkpiece, - _sequenceRuntime.DebugState.CreateSnapshot())); + private SimulationSnapshot CreateSnapshot() => _runtimeState.CreateSnapshot(); private void AdvancePickPlaceWorkpiece(long eventTick, TimeSpan eventTime) { - if (_pickPlaceWorkpiece is null) - { - return; - } - - var x = _axes.Single(axis => string.Equals( - axis.Id, - _pickPlaceWorkpiece.XAxisId, - StringComparison.Ordinal)).Position; - var y = _axes.Single(axis => string.Equals( - axis.Id, - _pickPlaceWorkpiece.YAxisId, - StringComparison.Ordinal)).Position; - var gripper = _signalHub.ReadDigitalSignal(_pickPlaceWorkpiece.GripperSignalId); - PickPlaceWorkpieceTransition? transition = _pickPlaceWorkpiece.Tick(x, y, gripper.Value == true); - if (transition is null) + var result = _runtimeState.AdvancePickPlaceWorkpiece(); + if (result is null) { return; } + var (transition, workpieceId) = result.Value; var code = transition.CurrentState == PickPlaceWorkpieceState.Attached ? "WorkpieceAttached" : "WorkpiecePlaced"; @@ -1201,7 +1133,7 @@ private void AdvancePickPlaceWorkpiece(long eventTick, TimeSpan eventTime) "Workpiece", code, FormattableString.Invariant( - $"{_pickPlaceWorkpiece.CaptureSnapshot().Id}: {transition.PreviousState} -> {transition.CurrentState} at X {transition.X:F3}, Y {transition.Y:F3}."), + $"{workpieceId}: {transition.PreviousState} -> {transition.CurrentState} at X {transition.X:F3}, Y {transition.Y:F3}."), tickIndex: eventTick, simulationTime: eventTime); } diff --git a/src/OpenVisionLab.Machine.Simulation/Engine/SimulationEngineLifecycle.cs b/src/OpenVisionLab.Machine.Simulation/Engine/SimulationEngineLifecycle.cs index e22fda0..a71fae2 100644 --- a/src/OpenVisionLab.Machine.Simulation/Engine/SimulationEngineLifecycle.cs +++ b/src/OpenVisionLab.Machine.Simulation/Engine/SimulationEngineLifecycle.cs @@ -306,6 +306,7 @@ public void Dispose() _startCancellationRegistration.Dispose(); _startCancellationRegistration = default; _stopCts.Dispose(); + _eventPublisher.Dispose(); } private enum EngineLifecycleState diff --git a/src/OpenVisionLab.Machine.Simulation/Engine/SimulationManualCameraCommandHandler.cs b/src/OpenVisionLab.Machine.Simulation/Engine/SimulationManualCameraCommandHandler.cs index 0c08c42..4d67be8 100644 --- a/src/OpenVisionLab.Machine.Simulation/Engine/SimulationManualCameraCommandHandler.cs +++ b/src/OpenVisionLab.Machine.Simulation/Engine/SimulationManualCameraCommandHandler.cs @@ -28,6 +28,19 @@ private static SimulationManualControlOutcome ApplyManualCameraTrigger( TriggerVirtualCameraCommand triggerCamera, SimulationManualControlContext context) { + if (triggerCamera.HasRuntimeIdentity + && (!string.Equals(triggerCamera.ProjectId, context.ProjectId, StringComparison.Ordinal) + || triggerCamera.RuntimeGeneration != context.RuntimeGeneration)) + { + return SimulationManualControlCommandHandler.Reject( + command, + context, + SimulationCommandErrorCode.CameraTriggerRejected, + $"Manual camera request belongs to project '{triggerCamera.ProjectId}' runtime generation " + + $"{triggerCamera.RuntimeGeneration}, but current runtime is project " + + $"'{context.ProjectId ?? ""}' generation {context.RuntimeGeneration}."); + } + if (context.ControlOwner != SimulationControlOwner.Manual) { return SimulationManualControlCommandHandler.Reject( diff --git a/src/OpenVisionLab.Machine.Simulation/Engine/SimulationManualControlCommandHandler.cs b/src/OpenVisionLab.Machine.Simulation/Engine/SimulationManualControlCommandHandler.cs index e764be2..767498f 100644 --- a/src/OpenVisionLab.Machine.Simulation/Engine/SimulationManualControlCommandHandler.cs +++ b/src/OpenVisionLab.Machine.Simulation/Engine/SimulationManualControlCommandHandler.cs @@ -20,7 +20,9 @@ internal sealed record SimulationManualControlContext( IReadOnlyDictionary ActiveFaults, long CommandBoundaryTick, TimeSpan CommandBoundaryTime, - Func FormatSignal); + Func FormatSignal, + string? ProjectId = null, + long RuntimeGeneration = 0); internal sealed record SimulationManualControlEvent( string Category, diff --git a/src/OpenVisionLab.Machine.Simulation/Engine/SimulationRuntimeState.cs b/src/OpenVisionLab.Machine.Simulation/Engine/SimulationRuntimeState.cs new file mode 100644 index 0000000..c405ce8 --- /dev/null +++ b/src/OpenVisionLab.Machine.Simulation/Engine/SimulationRuntimeState.cs @@ -0,0 +1,294 @@ +using OpenVisionLab.Machine.Core.Channels; +using OpenVisionLab.Machine.IO.Channels; +using OpenVisionLab.Machine.Sequence.Runtime; +using OpenVisionLab.Machine.Simulation.Axis; +using OpenVisionLab.Machine.Simulation.Camera; +using OpenVisionLab.Machine.Simulation.Commands; +using OpenVisionLab.Machine.Simulation.Faults; +using OpenVisionLab.Machine.Simulation.Layout; +using OpenVisionLab.Machine.Simulation.Scenarios; +using OpenVisionLab.Machine.Simulation.Snapshots; +using OpenVisionLab.Machine.Simulation.Workpieces; + +namespace OpenVisionLab.Machine.Simulation.Engine; + +/// +/// Owns the mutable deterministic simulation state used by the engine. +/// +/// The engine still decides command and tick order. This owner keeps the +/// runtime aggregate together so configuration, reset, snapshot, identity, +/// and clock transitions cannot accidentally update only part of that state. +/// +internal sealed class SimulationRuntimeState +{ + private readonly SimulationClock _clock; + private readonly SimulationRuntimeConfigurationBuilder _configurationBuilder; + private readonly List _axes = new(); + private readonly List _cameras = new(); + private readonly SimulationSequenceRuntime _sequenceRuntime = new(); + private readonly SimulationConditionScenarioRuntime _conditionScenarioRuntime = new(); + private readonly SimulationAutomaticRunRuntime _automaticRunRuntime = new(); + private readonly SimulationFaultRuntime _faultRuntime = new(); + private double _timeScale; + private DeterministicSignalHub _signalHub; + private DeterministicMachineLayout? _machineLayout; + private DeterministicPickPlaceWorkpiece? _pickPlaceWorkpiece; + private SimulationRunMode _runMode = SimulationRunMode.Paused; + private SimulationControlOwner _controlOwner = SimulationControlOwner.Definition; + private string? _activeSequenceId; + private int _pendingSteps; + private long _tickIndex; + private long _commandBoundaryTick; + private TimeSpan _commandBoundaryTime; + private string? _projectId; + private long _runtimeGeneration; + + internal SimulationRuntimeState(TimeSpan fixedStep, double timeScale) + { + _clock = new SimulationClock(fixedStep); + _configurationBuilder = new SimulationRuntimeConfigurationBuilder(fixedStep); + _timeScale = timeScale; + _signalHub = DeterministicSignalHub.Create(Array.Empty()).Hub!; + } + + internal SimulationClock Clock => _clock; + internal TimeSpan FixedStep => _clock.FixedStep; + internal TimeSpan SimulationTime => _clock.Time; + internal double TimeScale + { + get => _timeScale; + set => _timeScale = value; + } + + internal List Axes => _axes; + internal List Cameras => _cameras; + internal SimulationSequenceRuntime SequenceRuntime => _sequenceRuntime; + internal SimulationConditionScenarioRuntime ConditionScenarioRuntime => _conditionScenarioRuntime; + internal SimulationAutomaticRunRuntime AutomaticRunRuntime => _automaticRunRuntime; + internal SimulationFaultRuntime FaultRuntime => _faultRuntime; + internal DeterministicSignalHub SignalHub => _signalHub; + internal DeterministicMachineLayout? MachineLayout => _machineLayout; + internal DeterministicPickPlaceWorkpiece? PickPlaceWorkpiece => _pickPlaceWorkpiece; + + internal SimulationRunMode RunMode + { + get => _runMode; + set => _runMode = value; + } + + internal SimulationControlOwner ControlOwner + { + get => _controlOwner; + set => _controlOwner = value; + } + + internal string? ActiveSequenceId + { + get => _activeSequenceId; + set => _activeSequenceId = value; + } + + internal int PendingSteps + { + get => _pendingSteps; + set => _pendingSteps = value; + } + + internal long TickIndex => _tickIndex; + internal long CommandBoundaryTick => _commandBoundaryTick; + internal TimeSpan CommandBoundaryTime => _commandBoundaryTime; + internal string? ProjectId => _projectId; + internal long RuntimeGeneration => _runtimeGeneration; + + internal long NextTickIndex => _tickIndex + 1; + internal TimeSpan NextSimulationTime => _clock.Time + _clock.FixedStep; + + internal void AddAxis(ServoAxisComponent axis) + { + ArgumentNullException.ThrowIfNull(axis); + if (_axes.Any(existing => string.Equals(existing.Id, axis.Id, StringComparison.Ordinal))) + { + throw new ArgumentException($"Axis id '{axis.Id}' is duplicated.", nameof(axis)); + } + + _axes.Add(axis); + } + + internal bool TryApplyRuntimeConfiguration( + SimulationRuntimeConfiguration configuration, + string? projectId, + out string error) + { + ArgumentNullException.ThrowIfNull(configuration); + if (!_configurationBuilder.TryBuild( + configuration, + out var candidate, + out error)) + { + return false; + } + + SimulationRuntimeConfigurationBuildResult runtime = candidate!; + _runMode = SimulationRunMode.Paused; + _pendingSteps = 0; + _sequenceRuntime.DebugState.ClearPendingSemanticStep(); + _sequenceRuntime.DebugState.SetPause(SequenceDebugPauseReason.None, null); + _clock.Reset(); + _tickIndex = 0; + _axes.Clear(); + _axes.AddRange(runtime.Axes); + _cameras.Clear(); + _cameras.AddRange(runtime.Cameras); + _signalHub = runtime.SignalHub; + _machineLayout = runtime.MachineLayout; + _pickPlaceWorkpiece = runtime.PickPlaceWorkpiece; + if (configuration.TimeScale.HasValue) + { + _timeScale = configuration.TimeScale.Value; + } + _faultRuntime.Clear(); + _conditionScenarioRuntime.Clear(); + _sequenceRuntime.Configure(runtime.CompiledSequences, runtime.SequenceExecutors); + _automaticRunRuntime.Configure( + configuration.AutomaticRun, + runtime.AutomaticRunRepeatDelayTicks); + _activeSequenceId = null; + _controlOwner = SimulationControlOwner.Definition; + _projectId = projectId; + _runtimeGeneration++; + error = string.Empty; + return true; + } + + internal bool TryApplyAxisConfiguration( + IReadOnlyList configurations, + out string error) + { + ArgumentNullException.ThrowIfNull(configurations); + if (!_configurationBuilder.TryCreateAxes(configurations, out var axes, out error)) + { + return false; + } + + _runMode = SimulationRunMode.Paused; + _pendingSteps = 0; + _sequenceRuntime.DebugState.ClearPendingSemanticStep(); + _sequenceRuntime.DebugState.SetPause(SequenceDebugPauseReason.None, null); + _clock.Reset(); + _tickIndex = 0; + _axes.Clear(); + _axes.AddRange(axes); + _cameras.Clear(); + _signalHub = DeterministicSignalHub.Create(Array.Empty()).Hub!; + _machineLayout = null; + _pickPlaceWorkpiece = null; + _faultRuntime.Clear(); + _conditionScenarioRuntime.Clear(); + _sequenceRuntime.ClearConfiguration(); + _automaticRunRuntime.Configure(null, repeatDelayTicks: 0); + _activeSequenceId = null; + _controlOwner = SimulationControlOwner.Definition; + _projectId = null; + _runtimeGeneration++; + return true; + } + + internal void Reset() + { + _runMode = SimulationRunMode.Paused; + _pendingSteps = 0; + _sequenceRuntime.DebugState.ClearPendingSemanticStep(); + _sequenceRuntime.DebugState.SetPause(SequenceDebugPauseReason.None, null); + _clock.Reset(); + _tickIndex = 0; + foreach (var axis in _axes) + { + axis.Reset(); + } + foreach (var camera in _cameras) + { + camera.Reset(); + } + _faultRuntime.Clear(); + _signalHub.Reset(); + _machineLayout?.Reset(); + _pickPlaceWorkpiece?.Reset(); + _sequenceRuntime.ResetExecutors(); + _automaticRunRuntime.Reset(); + _conditionScenarioRuntime.Reset(); + _activeSequenceId = null; + _controlOwner = SimulationControlOwner.Definition; + _runtimeGeneration++; + } + + internal void SetCommandBoundary() + { + _commandBoundaryTick = _tickIndex; + _commandBoundaryTime = _clock.Time; + } + + internal void SetCommandBoundary(long tickIndex, TimeSpan simulationTime) + { + _commandBoundaryTick = tickIndex; + _commandBoundaryTime = simulationTime; + } + + internal bool Matches(SimulationRuntimeIdentity expected) => + expected.RuntimeGeneration == _runtimeGeneration + && string.Equals(expected.ProjectId, _projectId, StringComparison.Ordinal); + + internal void AdvanceTick() + { + _clock.Advance(); + _tickIndex++; + } + + internal SimulationSnapshot CreateSnapshot() => + SimulationSnapshotFactory.Create( + new SimulationSnapshotFactoryContext( + _clock.Time, + _tickIndex, + _runMode, + _controlOwner, + _timeScale, + _axes, + _signalHub, + _sequenceRuntime.SequenceExecutors, + _cameras, + new AutomaticRunSnapshot( + _automaticRunRuntime.Configuration is not null, + _automaticRunRuntime.IsActive, + _automaticRunRuntime.WaitingForRepeat, + _automaticRunRuntime.CompletedCycleCount, + _automaticRunRuntime.RemainingDelayTicks), + _machineLayout, + _faultRuntime.Values, + _conditionScenarioRuntime.CreateSnapshot(), + _pickPlaceWorkpiece, + _sequenceRuntime.DebugState.CreateSnapshot(), + _projectId, + _runtimeGeneration)); + + internal (PickPlaceWorkpieceTransition Transition, string WorkpieceId)? + AdvancePickPlaceWorkpiece() + { + if (_pickPlaceWorkpiece is null) + { + return null; + } + + var x = _axes.Single(axis => string.Equals( + axis.Id, + _pickPlaceWorkpiece.XAxisId, + StringComparison.Ordinal)).Position; + var y = _axes.Single(axis => string.Equals( + axis.Id, + _pickPlaceWorkpiece.YAxisId, + StringComparison.Ordinal)).Position; + var gripper = _signalHub.ReadDigitalSignal(_pickPlaceWorkpiece.GripperSignalId); + var transition = _pickPlaceWorkpiece.Tick(x, y, gripper.Value == true); + return transition is null + ? null + : (transition, _pickPlaceWorkpiece.CaptureSnapshot().Id); + } +} diff --git a/src/OpenVisionLab.Machine.Simulation/Engine/SimulationSettings.cs b/src/OpenVisionLab.Machine.Simulation/Engine/SimulationSettings.cs index 7d71bd0..c55ca2e 100644 --- a/src/OpenVisionLab.Machine.Simulation/Engine/SimulationSettings.cs +++ b/src/OpenVisionLab.Machine.Simulation/Engine/SimulationSettings.cs @@ -11,4 +11,10 @@ public sealed class SimulationSettings public int Seed { get; init; } = 1001; public int CommandQueueCapacity { get; init; } = DefaultCommandQueueCapacity; public int EventBufferCapacity { get; init; } = DefaultEventBufferCapacity; + + /// + /// Optional canonical journal capacity. When omitted, the presentation + /// event-buffer capacity remains the compatibility default. + /// + public int? CanonicalEventJournalCapacity { get; init; } } diff --git a/src/OpenVisionLab.Machine.Simulation/Events/SimulationEventJournal.cs b/src/OpenVisionLab.Machine.Simulation/Events/SimulationEventJournal.cs new file mode 100644 index 0000000..4ffc209 --- /dev/null +++ b/src/OpenVisionLab.Machine.Simulation/Events/SimulationEventJournal.cs @@ -0,0 +1,179 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace OpenVisionLab.Machine.Simulation.Events; + +/// +/// Describes the canonical event records retained by one simulation session. +/// The journal is finite. An incomplete snapshot is never promoted to complete +/// evidence merely because the UI event window reached its terminal state. +/// +public sealed record SimulationEventJournalSnapshot( + int Capacity, + long StoredEventCount, + long TotalEventCount, + long FirstEventIndex, + long LastEventIndex, + bool IsCompleted, + bool IsComplete, + long? FirstMissingEventIndex); + +/// +/// Optional loss-aware event source implemented by simulation engines that +/// expose the canonical journal separately from the presentation event window. +/// +public interface ISimulationEventJournalSource +{ + SimulationEventJournalSnapshot EventJournal { get; } + + IAsyncEnumerable ReadCanonicalEventsAsync( + CancellationToken cancellationToken = default); +} + +internal sealed class SimulationEventJournal : IDisposable +{ + private readonly object _gate = new(); + private readonly int _capacity; + private readonly List _events; + private readonly SemaphoreSlim _signal = new(0); + private long _totalEventCount; + private long _firstEventIndex; + private long _lastEventIndex; + private long? _firstMissingEventIndex; + private bool _completed; + private bool _disposed; + + internal SimulationEventJournal(int capacity) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + _capacity = capacity; + _events = new List(capacity); + } + + internal SimulationEventJournalSnapshot Snapshot + { + get + { + lock (_gate) + { + return CreateSnapshot(); + } + } + } + + internal SimulationEvent? TryAppend( + long tickIndex, + TimeSpan simulationTime, + string category, + string code, + string message, + string? commandId = null) + { + lock (_gate) + { + if (_disposed || _completed) + { + return null; + } + + var runtimeEvent = new SimulationEvent( + ++_totalEventCount, + tickIndex, + simulationTime, + category, + code, + message, + commandId); + _firstEventIndex = _firstEventIndex == 0 + ? runtimeEvent.EventIndex + : _firstEventIndex; + _lastEventIndex = runtimeEvent.EventIndex; + + if (_events.Count < _capacity) + { + _events.Add(runtimeEvent); + _signal.Release(); + } + else if (_firstMissingEventIndex is null) + { + _firstMissingEventIndex = runtimeEvent.EventIndex; + } + + return runtimeEvent; + } + } + + internal void Complete() + { + lock (_gate) + { + if (_disposed || _completed) + { + return; + } + + _completed = true; + _signal.Release(); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + _completed = true; + } + + _signal.Dispose(); + } + + internal async IAsyncEnumerable ReadAllAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var nextEventIndex = 0; + while (true) + { + SimulationEvent? runtimeEvent = null; + bool completed; + lock (_gate) + { + if (nextEventIndex < _events.Count) + { + runtimeEvent = _events[nextEventIndex++]; + } + + completed = _completed && nextEventIndex >= _events.Count; + } + + if (runtimeEvent is not null) + { + yield return runtimeEvent; + continue; + } + + if (completed) + { + yield break; + } + + await _signal.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + + private SimulationEventJournalSnapshot CreateSnapshot() => + new( + _capacity, + _events.Count, + _totalEventCount, + _firstEventIndex, + _lastEventIndex, + _completed, + _completed && _firstMissingEventIndex is null, + _firstMissingEventIndex); +} diff --git a/src/OpenVisionLab.Machine.Simulation/Events/SimulationEventJournalExportPackage.cs b/src/OpenVisionLab.Machine.Simulation/Events/SimulationEventJournalExportPackage.cs new file mode 100644 index 0000000..c550d69 --- /dev/null +++ b/src/OpenVisionLab.Machine.Simulation/Events/SimulationEventJournalExportPackage.cs @@ -0,0 +1,327 @@ +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenVisionLab.Machine.Simulation.Scenarios; + +namespace OpenVisionLab.Machine.Simulation.Events; + +/// +/// Deterministic, file-backed representation of one canonical event journal. +/// Incomplete journals can be inspected after loading, but they cannot be +/// created or saved as valid evidence. +/// +public sealed record SimulationEventJournalExportPackage( + int SchemaVersion, + SimulationEventJournalSnapshot Journal, + ImmutableArray Events, + string JournalHash) +{ + public const int CurrentSchemaVersion = 1; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + public bool CanExport => TryValidateSnapshotAndEvents( + Journal, + Events, + requireCompleted: true, + requireComplete: true, + out _); + + public bool HasValidJournalHash() + { + var events = Events.IsDefault + ? ImmutableArray.Empty + : Events; + return TryValidateSnapshotAndEvents( + Journal, + events, + requireCompleted: false, + requireComplete: false, + out _) + && string.Equals( + JournalHash, + ComputeJournalHash(SchemaVersion, Journal, events), + StringComparison.Ordinal); + } + + public static SimulationEventJournalExportPackage Create( + SimulationEventJournalSnapshot journal, + IEnumerable events) + { + ArgumentNullException.ThrowIfNull(events); + var materialized = events.ToImmutableArray(); + if (!TryValidateSnapshotAndEvents( + journal, + materialized, + requireCompleted: true, + requireComplete: true, + out var error)) + { + throw new InvalidOperationException(error); + } + + return new( + CurrentSchemaVersion, + journal, + materialized, + ComputeJournalHash(CurrentSchemaVersion, journal, materialized)); + } + + public static async Task CaptureAsync( + ISimulationEventJournalSource source, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + var events = new List(); + await foreach (var runtimeEvent in source + .ReadCanonicalEventsAsync(cancellationToken) + .ConfigureAwait(false)) + { + events.Add(runtimeEvent); + } + + return Create(source.EventJournal, events); + } + + public static string SaveToJson(SimulationEventJournalExportPackage package) + { + ArgumentNullException.ThrowIfNull(package); + if (!package.HasValidJournalHash() || !package.CanExport) + { + throw new InvalidOperationException( + "Only complete, integrity-checked canonical event journals can be exported."); + } + + return JsonSerializer.Serialize(package, JsonOptions); + } + + public static void SaveToJson( + SimulationEventJournalExportPackage package, + string path) + { + SaveToJson(package, path, new SimulationEventJournalExportOptions()); + } + + public static void SaveToJson( + SimulationEventJournalExportPackage package, + string path, + SimulationEventJournalExportOptions options) + { + ArgumentNullException.ThrowIfNull(package); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(options); + + var json = SaveToJson(package); + var fullPath = Path.GetFullPath(path); + options.Validate(json, fullPath); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + AtomicEvidenceFile.Write(fullPath, temporaryPath => File.WriteAllText(temporaryPath, json, Encoding.UTF8)); + } + + public static SimulationEventJournalExportPackage? LoadFromJson(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!File.Exists(path)) + { + return null; + } + + try + { + var package = JsonSerializer.Deserialize( + File.ReadAllText(path), + JsonOptions); + return package is not null && package.HasValidJournalHash() + ? package + : null; + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or JsonException) + { + return null; + } + } + + private static bool TryValidateSnapshotAndEvents( + SimulationEventJournalSnapshot journal, + IReadOnlyList events, + bool requireCompleted, + bool requireComplete, + out string error) + { + if (journal.Capacity <= 0) + { + error = "Journal capacity must be positive."; + return false; + } + + if (journal.StoredEventCount < 0 + || journal.StoredEventCount > journal.Capacity + || journal.TotalEventCount < journal.StoredEventCount) + { + error = "Journal event counts are inconsistent."; + return false; + } + + if (journal.StoredEventCount != events.Count) + { + error = "Journal stored count does not match the exported records."; + return false; + } + + if (journal.StoredEventCount == 0) + { + if (journal.FirstEventIndex != 0 || journal.LastEventIndex != 0) + { + error = "An empty journal must have zero first and last indexes."; + return false; + } + } + else if (journal.FirstEventIndex <= 0 + || journal.LastEventIndex < journal.FirstEventIndex + || journal.LastEventIndex - journal.FirstEventIndex + 1 + != journal.StoredEventCount) + { + error = "Journal first, last, and stored indexes are inconsistent."; + return false; + } + + if (journal.IsComplete != (journal.IsCompleted && journal.FirstMissingEventIndex is null)) + { + error = "Journal completion flags are inconsistent."; + return false; + } + + if (journal.FirstMissingEventIndex is { } firstMissingEventIndex) + { + if (journal.StoredEventCount != journal.Capacity + || journal.StoredEventCount == 0 + || firstMissingEventIndex != journal.LastEventIndex + 1 + || firstMissingEventIndex > journal.TotalEventCount) + { + error = "Journal overflow watermark is inconsistent."; + return false; + } + } + else if (journal.TotalEventCount != journal.StoredEventCount) + { + error = "A journal without an overflow watermark cannot hide records."; + return false; + } + + if (requireCompleted && !journal.IsCompleted) + { + error = "The canonical event journal must be completed before export."; + return false; + } + + if (requireComplete && !journal.IsComplete) + { + error = "Incomplete canonical event journal evidence cannot be exported."; + return false; + } + + for (var index = 0; index < events.Count; index++) + { + var runtimeEvent = events[index]; + var expectedEventIndex = journal.FirstEventIndex + index; + if (runtimeEvent.EventIndex != expectedEventIndex) + { + error = "Canonical event indexes contain a gap or duplicate."; + return false; + } + } + + error = string.Empty; + return true; + } + + private static string ComputeJournalHash( + int schemaVersion, + SimulationEventJournalSnapshot journal, + IEnumerable events) + { + var builder = new StringBuilder() + .Append(schemaVersion).Append('|') + .Append(journal.Capacity).Append('|') + .Append(journal.StoredEventCount).Append('|') + .Append(journal.TotalEventCount).Append('|') + .Append(journal.FirstEventIndex).Append('|') + .Append(journal.LastEventIndex).Append('|') + .Append(journal.IsCompleted).Append('|') + .Append(journal.IsComplete).Append('|') + .Append(journal.FirstMissingEventIndex).Append('\n'); + foreach (var runtimeEvent in events) + { + builder.Append(JsonSerializer.Serialize(runtimeEvent, JsonOptions)).Append('\n'); + } + + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()))); + } +} + +/// +/// Explicit resource guards for one canonical journal export. A zero value +/// leaves that guard disabled; no deletion or age policy is implied. +/// +public sealed record SimulationEventJournalExportOptions( + long MaximumFileBytes = 0, + long MinimumFreeBytes = 0) +{ + internal void Validate(string json, string exportPath) + { + ArgumentNullException.ThrowIfNull(json); + ArgumentException.ThrowIfNullOrWhiteSpace(exportPath); + if (MaximumFileBytes < 0) + { + throw new ArgumentOutOfRangeException(nameof(MaximumFileBytes)); + } + if (MinimumFreeBytes < 0) + { + throw new ArgumentOutOfRangeException(nameof(MinimumFreeBytes)); + } + + var fileBytes = Encoding.UTF8.GetByteCount(json); + if (MaximumFileBytes > 0 && fileBytes > MaximumFileBytes) + { + throw new InvalidOperationException( + $"Canonical event journal export is {fileBytes} bytes, exceeding the configured " + + $"maximum of {MaximumFileBytes} bytes."); + } + + if (MinimumFreeBytes > 0) + { + var root = Path.GetPathRoot(exportPath); + if (string.IsNullOrWhiteSpace(root)) + { + throw new IOException("The export path has no resolvable drive root."); + } + + var availableFreeBytes = new DriveInfo(root).AvailableFreeSpace; + if (MinimumFreeBytes > long.MaxValue - fileBytes) + { + throw new IOException("The configured minimum free-space guard is too large."); + } + + var requiredFreeBytes = fileBytes + MinimumFreeBytes; + if (availableFreeBytes < requiredFreeBytes) + { + throw new IOException( + $"Canonical event journal export requires {requiredFreeBytes} " + + $"free bytes, but only {availableFreeBytes} are available."); + } + } + } +} diff --git a/src/OpenVisionLab.Machine.Simulation/Events/SimulationEventPublisher.cs b/src/OpenVisionLab.Machine.Simulation/Events/SimulationEventPublisher.cs index 9dff5f0..5dcc18e 100644 --- a/src/OpenVisionLab.Machine.Simulation/Events/SimulationEventPublisher.cs +++ b/src/OpenVisionLab.Machine.Simulation/Events/SimulationEventPublisher.cs @@ -2,16 +2,23 @@ namespace OpenVisionLab.Machine.Simulation.Events; -internal sealed class SimulationEventPublisher +internal sealed class SimulationEventPublisher : IDisposable { private readonly Channel _channel; - private long _eventIndex; + private readonly SimulationEventJournal _journal; internal SimulationEventPublisher(int capacity) + : this(capacity, capacity) { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + } + + internal SimulationEventPublisher(int presentationCapacity, int canonicalCapacity) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(presentationCapacity); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(canonicalCapacity); + _journal = new SimulationEventJournal(canonicalCapacity); _channel = Channel.CreateBounded( - new BoundedChannelOptions(capacity) + new BoundedChannelOptions(presentationCapacity) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, @@ -21,21 +28,35 @@ internal SimulationEventPublisher(int capacity) internal ChannelReader Reader => _channel.Reader; + internal SimulationEventJournalSnapshot JournalSnapshot => _journal.Snapshot; + + internal IAsyncEnumerable ReadJournalAsync( + CancellationToken cancellationToken = default) => + _journal.ReadAllAsync(cancellationToken); + internal bool TryPublish( long tickIndex, TimeSpan simulationTime, string category, string code, string message, - string? commandId = null) => - _channel.Writer.TryWrite(new SimulationEvent( - ++_eventIndex, + string? commandId = null) + { + var runtimeEvent = _journal.TryAppend( tickIndex, simulationTime, category, code, message, - commandId)); + commandId); + return runtimeEvent is not null && _channel.Writer.TryWrite(runtimeEvent); + } + + internal void Complete() + { + _journal.Complete(); + _channel.Writer.TryComplete(); + } - internal void Complete() => _channel.Writer.TryComplete(); + public void Dispose() => _journal.Dispose(); } diff --git a/src/OpenVisionLab.Machine.Simulation/Scenarios/AtomicEvidenceFile.cs b/src/OpenVisionLab.Machine.Simulation/Scenarios/AtomicEvidenceFile.cs new file mode 100644 index 0000000..ef4e90a --- /dev/null +++ b/src/OpenVisionLab.Machine.Simulation/Scenarios/AtomicEvidenceFile.cs @@ -0,0 +1,26 @@ +namespace OpenVisionLab.Machine.Simulation.Scenarios; + +/// +/// Owns temporary-file lifetime and replacement for Simulation evidence. +/// Callers retain validation, path preparation, serialization, and encoding. +/// +internal static class AtomicEvidenceFile +{ + internal static void Write(string fullPath, Action writeTemporaryFile) + { + var temporaryPath = $"{fullPath}.{Guid.NewGuid():N}.tmp"; + try + { + // The synchronous writer must finish and close its file before replacement. + writeTemporaryFile(temporaryPath); + File.Move(temporaryPath, fullPath, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } +} diff --git a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationBatchResultPackage.cs b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationBatchResultPackage.cs index d0a4ac9..fac7140 100644 --- a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationBatchResultPackage.cs +++ b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationBatchResultPackage.cs @@ -133,19 +133,7 @@ public static void SaveToJson(DeterministicSimulationBatchResultPackage package, Directory.CreateDirectory(directory); } - var temporaryPath = $"{fullPath}.{Guid.NewGuid():N}.tmp"; - try - { - File.WriteAllText(temporaryPath, SaveToJson(package)); - File.Move(temporaryPath, fullPath, overwrite: true); - } - finally - { - if (File.Exists(temporaryPath)) - { - File.Delete(temporaryPath); - } - } + AtomicEvidenceFile.Write(fullPath, temporaryPath => File.WriteAllText(temporaryPath, SaveToJson(package))); } public static DeterministicSimulationBatchResultPackage? LoadFromJson(string path) diff --git a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationCommandTrace.cs b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationCommandTrace.cs index 6370d3e..4a4c022 100644 --- a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationCommandTrace.cs +++ b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationCommandTrace.cs @@ -36,6 +36,13 @@ internal static DeterministicSimulationCommandTraceEntry Capture( command, out var arguments, out var replayabilityReason); + if (command.ExpectedRuntime.HasValue) + { + // Keep arguments/results for diagnosis. A portable replay cannot infer + // the live session identity or silently drop this admission condition. + replayable = false; + replayabilityReason = "Runtime-bound commands require their original runtime identity and cannot be replayed portably."; + } return new( sequence, command.GetType().Name, @@ -161,19 +168,7 @@ public static void SaveToJson( Directory.CreateDirectory(directory); } - var temporaryPath = $"{fullPath}.{Guid.NewGuid():N}.tmp"; - try - { - File.WriteAllText(temporaryPath, SaveToJson(package)); - File.Move(temporaryPath, fullPath, overwrite: true); - } - finally - { - if (File.Exists(temporaryPath)) - { - File.Delete(temporaryPath); - } - } + AtomicEvidenceFile.Write(fullPath, temporaryPath => File.WriteAllText(temporaryPath, SaveToJson(package))); } public static DeterministicSimulationCommandTracePackage? LoadFromJson(string path) diff --git a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationEvidenceExchangePackage.cs b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationEvidenceExchangePackage.cs index 3b197b2..19990ba 100644 --- a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationEvidenceExchangePackage.cs +++ b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationEvidenceExchangePackage.cs @@ -252,19 +252,7 @@ public static void SaveToJson( Directory.CreateDirectory(directory); } - var temporaryPath = $"{fullPath}.{Guid.NewGuid():N}.tmp"; - try - { - File.WriteAllText(temporaryPath, SaveToJson(package)); - File.Move(temporaryPath, fullPath, overwrite: true); - } - finally - { - if (File.Exists(temporaryPath)) - { - File.Delete(temporaryPath); - } - } + AtomicEvidenceFile.Write(fullPath, temporaryPath => File.WriteAllText(temporaryPath, SaveToJson(package))); } public static DeterministicSimulationEvidenceExchangePackage? LoadFromJson(string path) diff --git a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationRunResultPackage.cs b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationRunResultPackage.cs index d0b0204..7415161 100644 --- a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationRunResultPackage.cs +++ b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationRunResultPackage.cs @@ -322,19 +322,7 @@ public static void SaveToJson(DeterministicSimulationRunResultPackage package, s Directory.CreateDirectory(directory); } - var temporaryPath = $"{fullPath}.{Guid.NewGuid():N}.tmp"; - try - { - File.WriteAllText(temporaryPath, SaveToJson(package)); - File.Move(temporaryPath, fullPath, overwrite: true); - } - finally - { - if (File.Exists(temporaryPath)) - { - File.Delete(temporaryPath); - } - } + AtomicEvidenceFile.Write(fullPath, temporaryPath => File.WriteAllText(temporaryPath, SaveToJson(package))); } } diff --git a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicUnifiedCommissioningEvidencePackage.cs b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicUnifiedCommissioningEvidencePackage.cs index a876bac..523e761 100644 --- a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicUnifiedCommissioningEvidencePackage.cs +++ b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicUnifiedCommissioningEvidencePackage.cs @@ -257,19 +257,7 @@ public static void SaveToJson( Directory.CreateDirectory(directory); } - var temporaryPath = $"{fullPath}.{Guid.NewGuid():N}.tmp"; - try - { - File.WriteAllText(temporaryPath, SaveToJson(package)); - File.Move(temporaryPath, fullPath, overwrite: true); - } - finally - { - if (File.Exists(temporaryPath)) - { - File.Delete(temporaryPath); - } - } + AtomicEvidenceFile.Write(fullPath, temporaryPath => File.WriteAllText(temporaryPath, SaveToJson(package))); } public static DeterministicUnifiedCommissioningEvidencePackage? LoadFromJson( diff --git a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicVisionExecutionEvidencePackage.cs b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicVisionExecutionEvidencePackage.cs index 52d140e..7745dc5 100644 --- a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicVisionExecutionEvidencePackage.cs +++ b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicVisionExecutionEvidencePackage.cs @@ -272,19 +272,7 @@ public static void SaveToJson( var fullPath = Path.GetFullPath(path); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); - var temporaryPath = $"{fullPath}.{Guid.NewGuid():N}.tmp"; - try - { - File.WriteAllText(temporaryPath, SaveToJson(package)); - File.Move(temporaryPath, fullPath, overwrite: true); - } - finally - { - if (File.Exists(temporaryPath)) - { - File.Delete(temporaryPath); - } - } + AtomicEvidenceFile.Write(fullPath, temporaryPath => File.WriteAllText(temporaryPath, SaveToJson(package))); } public static DeterministicVisionExecutionEvidencePackage? LoadFromJson(string path) diff --git a/src/OpenVisionLab.Machine.Simulation/Scenarios/SimulationEvidenceRetentionPolicy.cs b/src/OpenVisionLab.Machine.Simulation/Scenarios/SimulationEvidenceRetentionPolicy.cs new file mode 100644 index 0000000..286e6cd --- /dev/null +++ b/src/OpenVisionLab.Machine.Simulation/Scenarios/SimulationEvidenceRetentionPolicy.cs @@ -0,0 +1,428 @@ +using System.Collections.Immutable; + +namespace OpenVisionLab.Machine.Simulation.Scenarios; + +/// +/// Identifies how an evidence file grows over its lifetime. Fixed sidecars are +/// overwritten by the owning project, while append-only exports accumulate +/// files and can participate in age, count, and byte retention. +/// +public enum SimulationEvidenceArtifactStorageMode +{ + FixedSidecar, + AppendOnlyExport +} + +/// +/// File metadata captured by the retention owner. Current references and an +/// accepted baseline are always protected from deletion. +/// +public sealed record SimulationEvidenceArtifactDescriptor( + string Path, + SimulationEvidenceArtifactStorageMode StorageMode, + long SizeBytes, + DateTimeOffset LastWriteTimeUtc, + bool IsCurrentReference = false, + bool IsAcceptedBaseline = false) +{ + public bool IsProtected => IsCurrentReference || IsAcceptedBaseline; +} + +/// +/// Explicit limits for append-only evidence. Zero disables an individual +/// limit; fixed sidecars are retained because their owner overwrites them. +/// +public sealed record SimulationEvidenceRetentionPolicy( + TimeSpan MaximumAppendOnlyExportAge = default, + long MaximumAppendOnlyExportBytes = 0, + int MaximumAppendOnlyExportCount = 0, + long MinimumFreeBytes = 0) +{ + internal void Validate() + { + if (MaximumAppendOnlyExportAge < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(MaximumAppendOnlyExportAge)); + } + + if (MaximumAppendOnlyExportBytes < 0) + { + throw new ArgumentOutOfRangeException(nameof(MaximumAppendOnlyExportBytes)); + } + + if (MaximumAppendOnlyExportCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(MaximumAppendOnlyExportCount)); + } + + if (MinimumFreeBytes < 0) + { + throw new ArgumentOutOfRangeException(nameof(MinimumFreeBytes)); + } + } +} + +/// +/// Deterministic retention decision for one captured evidence index. The plan +/// never mutates the file system; callers must explicitly apply it. +/// +public sealed record SimulationEvidenceRetentionPlan( + SimulationEvidenceRetentionPolicy Policy, + ImmutableArray RetainedArtifacts, + ImmutableArray DeletionCandidates, + long TotalBytes, + long RetainedBytes, + long AvailableFreeBytes, + string? UnsatisfiedConstraint) +{ + public bool IsSatisfiable => UnsatisfiedConstraint is null; + + public long BytesToRelease + { + get + { + long total = 0; + foreach (var artifact in DeletionCandidates) + { + if (artifact.SizeBytes < 0 || artifact.SizeBytes > long.MaxValue - total) + { + throw new InvalidOperationException("Retention plan byte count is invalid."); + } + + total += artifact.SizeBytes; + } + + return total; + } + } +} + +/// +/// Applies an already reviewed retention plan after a fresh preflight. It is +/// intentionally explicit so project save, export, and UI confirmation can +/// choose when deletion is appropriate. +/// +public static class SimulationEvidenceRetentionExecutor +{ + public static SimulationEvidenceRetentionExecutionResult Apply( + SimulationEvidenceRetentionPlan plan, + Func? getAvailableFreeBytes = null, + Func? isCurrentReference = null) + { + ArgumentNullException.ThrowIfNull(plan); + getAvailableFreeBytes ??= GetAvailableFreeBytes; + isCurrentReference ??= static _ => false; + + if (!plan.IsSatisfiable) + { + return Failure(plan.UnsatisfiedConstraint!); + } + + var candidates = plan.DeletionCandidates; + if (candidates.Any(item => item.StorageMode != SimulationEvidenceArtifactStorageMode.AppendOnlyExport + || item.IsProtected)) + { + return Failure("The retention plan contains a protected or fixed-sidecar deletion candidate."); + } + + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var candidate in candidates) + { + var fullPath = Path.GetFullPath(candidate.Path); + if (!paths.Add(fullPath)) + { + return Failure("The retention plan contains duplicate deletion paths."); + } + + if (isCurrentReference(fullPath)) + { + return Failure($"The evidence path is referenced by the current state: {fullPath}"); + } + + if (!File.Exists(fullPath)) + { + return Failure($"The evidence path is missing: {fullPath}"); + } + + if (new FileInfo(fullPath).Length != candidate.SizeBytes) + { + return Failure($"The evidence path changed since planning: {fullPath}"); + } + } + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or ArgumentException) + { + return Failure(exception.Message); + } + + if (plan.Policy.MinimumFreeBytes > 0 && candidates.Length > 0) + { + long availableFreeBytes; + try + { + availableFreeBytes = getAvailableFreeBytes(candidates[0].Path); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or ArgumentException) + { + return Failure(exception.Message); + } + if (availableFreeBytes < 0) + { + return Failure("The target drive free-space measurement was invalid."); + } + + if (plan.BytesToRelease > long.MaxValue - availableFreeBytes + || availableFreeBytes + plan.BytesToRelease < plan.Policy.MinimumFreeBytes) + { + return Failure("The planned deletion cannot satisfy the configured minimum free-space policy."); + } + } + + var deletedPaths = ImmutableArray.CreateBuilder(candidates.Length); + try + { + foreach (var candidate in candidates) + { + var fullPath = Path.GetFullPath(candidate.Path); + File.Delete(fullPath); + deletedPaths.Add(fullPath); + } + + return new(true, deletedPaths.ToImmutable(), null); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or ArgumentException) + { + return new(false, deletedPaths.ToImmutable(), exception.Message); + } + } + + private static long GetAvailableFreeBytes(string path) + { + var root = Path.GetPathRoot(Path.GetFullPath(path)); + if (string.IsNullOrWhiteSpace(root)) + { + return -1; + } + + try + { + return new DriveInfo(root).AvailableFreeSpace; + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or ArgumentException) + { + return -1; + } + } + + private static SimulationEvidenceRetentionExecutionResult Failure(string reason) => + new(false, ImmutableArray.Empty, reason); +} + +public sealed record SimulationEvidenceRetentionExecutionResult( + bool IsComplete, + ImmutableArray DeletedPaths, + string? FailureReason); + +/// +/// Produces a deterministic, fail-closed retention plan from a captured file +/// index. Oldest append-only exports are selected first; protected references +/// and fixed sidecars stay in the retained set. +/// +public static class SimulationEvidenceRetentionPlanner +{ + public static SimulationEvidenceRetentionPlan Plan( + IEnumerable artifacts, + SimulationEvidenceRetentionPolicy policy, + DateTimeOffset now, + long availableFreeBytes) + { + ArgumentNullException.ThrowIfNull(artifacts); + ArgumentNullException.ThrowIfNull(policy); + policy.Validate(); + if (availableFreeBytes < 0) + { + throw new ArgumentOutOfRangeException(nameof(availableFreeBytes)); + } + + var normalized = NormalizeArtifacts(artifacts); + var ordered = normalized + .OrderBy(item => item.LastWriteTimeUtc) + .ThenBy(item => item.Path, StringComparer.Ordinal) + .ToArray(); + var eligible = ordered + .Where(item => item.StorageMode == SimulationEvidenceArtifactStorageMode.AppendOnlyExport + && !item.IsProtected) + .ToArray(); + var deletions = new Dictionary(StringComparer.OrdinalIgnoreCase); + + void MarkForDeletion(SimulationEvidenceArtifactDescriptor artifact) => + deletions.TryAdd(artifact.Path, artifact); + + if (policy.MaximumAppendOnlyExportAge > TimeSpan.Zero) + { + foreach (var artifact in eligible) + { + if (IsOlderThan(artifact.LastWriteTimeUtc, now, policy.MaximumAppendOnlyExportAge)) + { + MarkForDeletion(artifact); + } + } + } + + var appendOnlyBytes = SumBytes(ordered + .Where(item => IsAppendOnlyExport(item) && !deletions.ContainsKey(item.Path))); + var appendOnlyCount = ordered.Count( + item => IsAppendOnlyExport(item) && !deletions.ContainsKey(item.Path)); + foreach (var artifact in eligible) + { + if (policy.MaximumAppendOnlyExportBytes > 0 + && appendOnlyBytes > policy.MaximumAppendOnlyExportBytes + && !deletions.ContainsKey(artifact.Path)) + { + MarkForDeletion(artifact); + appendOnlyBytes -= artifact.SizeBytes; + appendOnlyCount--; + } + + if (policy.MaximumAppendOnlyExportCount > 0 + && appendOnlyCount > policy.MaximumAppendOnlyExportCount + && !deletions.ContainsKey(artifact.Path)) + { + MarkForDeletion(artifact); + appendOnlyBytes -= artifact.SizeBytes; + appendOnlyCount--; + } + } + + var bytesToRelease = SumBytes(deletions.Values); + if (policy.MinimumFreeBytes > 0 + && availableFreeBytes < policy.MinimumFreeBytes) + { + foreach (var artifact in eligible) + { + if (availableFreeBytes > long.MaxValue - bytesToRelease + || availableFreeBytes + bytesToRelease >= policy.MinimumFreeBytes) + { + break; + } + + if (deletions.ContainsKey(artifact.Path)) + { + continue; + } + + MarkForDeletion(artifact); + bytesToRelease += artifact.SizeBytes; + } + } + + var deletionCandidates = ordered + .Where(item => deletions.ContainsKey(item.Path)) + .ToImmutableArray(); + var retainedArtifacts = ordered + .Where(item => !deletions.ContainsKey(item.Path)) + .ToImmutableArray(); + var retainedBytes = SumBytes(retainedArtifacts); + var retainedAppendOnlyBytes = SumBytes(retainedArtifacts.Where(IsAppendOnlyExport)); + var retainedAppendOnlyCount = retainedArtifacts.Count(IsAppendOnlyExport); + var unsatisfied = new List(); + if (policy.MaximumAppendOnlyExportBytes > 0 + && retainedAppendOnlyBytes > policy.MaximumAppendOnlyExportBytes) + { + unsatisfied.Add("maximum append-only export bytes"); + } + + if (policy.MaximumAppendOnlyExportCount > 0 + && retainedAppendOnlyCount > policy.MaximumAppendOnlyExportCount) + { + unsatisfied.Add("maximum append-only export count"); + } + + if (policy.MinimumFreeBytes > 0 + && (bytesToRelease > long.MaxValue - availableFreeBytes + || availableFreeBytes + bytesToRelease < policy.MinimumFreeBytes)) + { + unsatisfied.Add("minimum free space"); + } + + return new( + policy, + retainedArtifacts, + deletionCandidates, + SumBytes(ordered), + retainedBytes, + availableFreeBytes, + unsatisfied.Count == 0 ? null : string.Join(", ", unsatisfied)); + } + + private static ImmutableArray NormalizeArtifacts( + IEnumerable artifacts) + { + var normalized = ImmutableArray.CreateBuilder(); + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var artifact in artifacts) + { + ArgumentNullException.ThrowIfNull(artifact); + if (string.IsNullOrWhiteSpace(artifact.Path)) + { + throw new InvalidOperationException("Retention index contains an empty evidence path."); + } + + if (artifact.SizeBytes < 0) + { + throw new InvalidOperationException("Retention index contains a negative file size."); + } + + if (!Enum.IsDefined(artifact.StorageMode)) + { + throw new InvalidOperationException("Retention index contains an unknown storage mode."); + } + + var fullPath = Path.GetFullPath(artifact.Path); + if (!paths.Add(fullPath)) + { + throw new InvalidOperationException( + $"Retention index contains duplicate evidence paths: {fullPath}"); + } + + normalized.Add(artifact with + { + Path = fullPath, + LastWriteTimeUtc = artifact.LastWriteTimeUtc.ToUniversalTime() + }); + } + + return normalized.ToImmutable(); + } + + private static bool IsAppendOnlyExport(SimulationEvidenceArtifactDescriptor artifact) => + artifact.StorageMode == SimulationEvidenceArtifactStorageMode.AppendOnlyExport; + + private static bool IsOlderThan( + DateTimeOffset lastWriteTimeUtc, + DateTimeOffset now, + TimeSpan maximumAge) => + lastWriteTimeUtc <= now && now - lastWriteTimeUtc > maximumAge; + + private static long SumBytes(IEnumerable artifacts) + { + long total = 0; + foreach (var artifact in artifacts) + { + if (artifact.SizeBytes > long.MaxValue - total) + { + throw new InvalidOperationException("Retention index byte count overflowed."); + } + + total += artifact.SizeBytes; + } + + return total; + } +} diff --git a/src/OpenVisionLab.Machine.Simulation/Snapshots/SimulationSnapshot.cs b/src/OpenVisionLab.Machine.Simulation/Snapshots/SimulationSnapshot.cs index 61e8a75..fc6364a 100644 --- a/src/OpenVisionLab.Machine.Simulation/Snapshots/SimulationSnapshot.cs +++ b/src/OpenVisionLab.Machine.Simulation/Snapshots/SimulationSnapshot.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Text.Json.Serialization; using OpenVisionLab.Machine.IO.Channels; using OpenVisionLab.Machine.Sequence.Runtime; using OpenVisionLab.Machine.Simulation.Camera; @@ -12,6 +13,10 @@ namespace OpenVisionLab.Machine.Simulation.Snapshots; public sealed class SimulationSnapshot { + [JsonIgnore] + public string? ProjectId { get; } + [JsonIgnore] + public long RuntimeGeneration { get; } public TimeSpan SimulationTime { get; } public long TickIndex { get; } public SimulationRunMode RunMode { get; } @@ -140,8 +145,12 @@ public SimulationSnapshot( IEnumerable? ohtHandoffs = null, IEnumerable? prealigners = null, SequenceDebugSnapshot? sequenceDebug = null, - IEnumerable? analogSignals = null) + IEnumerable? analogSignals = null, + string? projectId = null, + long runtimeGeneration = 0) { + ProjectId = projectId; + RuntimeGeneration = runtimeGeneration; SimulationTime = simulationTime; TickIndex = tickIndex; RunMode = runMode; diff --git a/src/OpenVisionLab.Machine.Simulation/Snapshots/SimulationSnapshotFactory.cs b/src/OpenVisionLab.Machine.Simulation/Snapshots/SimulationSnapshotFactory.cs index 83f3b66..7fbf538 100644 --- a/src/OpenVisionLab.Machine.Simulation/Snapshots/SimulationSnapshotFactory.cs +++ b/src/OpenVisionLab.Machine.Simulation/Snapshots/SimulationSnapshotFactory.cs @@ -63,7 +63,9 @@ context.MachineLayout is null ? Array.Empty() : context.MachineLayout.CapturePrealignerSnapshots(), context.SequenceDebug, - analogSignals: signals.AnalogSignals); + analogSignals: signals.AnalogSignals, + projectId: context.ProjectId, + runtimeGeneration: context.RuntimeGeneration); } } @@ -82,4 +84,6 @@ internal sealed record SimulationSnapshotFactoryContext( IEnumerable ActiveFaults, DeterministicConditionScenarioSnapshot ConditionScenario, DeterministicPickPlaceWorkpiece? PickPlaceWorkpiece, - SequenceDebugSnapshot SequenceDebug); + SequenceDebugSnapshot SequenceDebug, + string? ProjectId = null, + long RuntimeGeneration = 0); diff --git a/src/OpenVisionLab.MachineStudio/Models/Simulation/SimulationOperationalDiagnostic.cs b/src/OpenVisionLab.MachineStudio/Models/Simulation/SimulationOperationalDiagnostic.cs index c174c7f..2488664 100644 --- a/src/OpenVisionLab.MachineStudio/Models/Simulation/SimulationOperationalDiagnostic.cs +++ b/src/OpenVisionLab.MachineStudio/Models/Simulation/SimulationOperationalDiagnostic.cs @@ -11,7 +11,8 @@ public enum SimulationOperationalDiagnosticKind ShutdownRequested, ShutdownCompleted, ShutdownFaulted, - ShutdownTimedOut + ShutdownTimedOut, + ShutdownIncomplete } public sealed record SimulationOperationalDiagnostic( diff --git a/src/OpenVisionLab.MachineStudio/Smoke/DirectExeSmokeHost.cs b/src/OpenVisionLab.MachineStudio/Smoke/DirectExeSmokeHost.cs index 0610519..7249f81 100644 --- a/src/OpenVisionLab.MachineStudio/Smoke/DirectExeSmokeHost.cs +++ b/src/OpenVisionLab.MachineStudio/Smoke/DirectExeSmokeHost.cs @@ -1149,13 +1149,13 @@ await SmokeCameraCommissioningVerifier.ApplyStateAsync( window, vm, root => FindVisualDescendant(root), - () => SmokeAxisCommissioningVerifier.ScrollIntoViewAsync(window)); + () => SmokeAxisCommissioningStateVerifier.ScrollIntoViewAsync(window)); axisCommissioningReport.Save(smokeOptions.AxisCommissioningReportPath); } if (!string.IsNullOrWhiteSpace(smokeOptions.AxisCommissioningState)) { - await SmokeAxisCommissioningVerifier.ApplyStateAsync( + await SmokeAxisCommissioningStateVerifier.ApplyStateAsync( window, vm, smokeOptions.AxisCommissioningState, @@ -1174,7 +1174,7 @@ await SmokeAxisCommissioningVerifier.ApplyStateAsync( if (!string.IsNullOrWhiteSpace(smokeOptions.MultiAxisRecipeState)) { - await SmokeMultiAxisCommissioningVerifier.ApplyStateAsync( + await SmokeMultiAxisCommissioningStateVerifier.ApplyStateAsync( window, vm, smokeOptions.MultiAxisRecipeState, diff --git a/src/OpenVisionLab.MachineStudio/Smoke/SmokeAxisCommissioningStateVerifier.cs b/src/OpenVisionLab.MachineStudio/Smoke/SmokeAxisCommissioningStateVerifier.cs new file mode 100644 index 0000000..f39d020 --- /dev/null +++ b/src/OpenVisionLab.MachineStudio/Smoke/SmokeAxisCommissioningStateVerifier.cs @@ -0,0 +1,269 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Threading; +using OpenVisionLab.Machine.Simulation.Axis; +using OpenVisionLab.Machine.Simulation.Commands; +using OpenVisionLab.Machine.Simulation.Faults; +using OpenVisionLab.MachineStudio.View.Inspector; +using OpenVisionLab.MachineStudio.View.Shell; +using OpenVisionLab.MachineStudio.ViewModel; + +namespace OpenVisionLab.MachineStudio; + +internal static class SmokeAxisCommissioningStateVerifier +{ + private const uint MouseEventLeftDown = 0x0002; + public static async Task ApplyStateAsync( + ShellWindow window, + MainViewModel viewModel, + string state, + SmokeUiInteraction interaction) + { + if (!viewModel.IsRunMode) + { + throw new ArgumentException( + "--smoke-axis-commissioning-state requires --smoke-run-layout."); + } + + async Task WaitForAsync(Func condition, string failureMessage) + { + for (var attempt = 0; attempt < 80; attempt++) + { + if (condition()) + { + return; + } + await Task.Delay(50); + } + throw new InvalidOperationException(failureMessage); + } + + await ScrollIntoViewAsync(window); + var inspector = SmokeVisualTreeQuery.FindVisualDescendant(window) + ?? throw new InvalidOperationException("Run inspector was unavailable."); + bool isReadyState = state.Equals("ready", StringComparison.OrdinalIgnoreCase) + || state.Equals("interlocked", StringComparison.OrdinalIgnoreCase) + || state.Equals("invalid-target", StringComparison.OrdinalIgnoreCase) + || state.Equals("focus-target", StringComparison.OrdinalIgnoreCase) + || state.Equals("invalid-relative", StringComparison.OrdinalIgnoreCase) + || state.Equals("focus-relative", StringComparison.OrdinalIgnoreCase) + || state.Equals("invalid-velocity", StringComparison.OrdinalIgnoreCase) + || state.Equals("focus-velocity", StringComparison.OrdinalIgnoreCase) + || state.Equals("following-error-ready", StringComparison.OrdinalIgnoreCase); + if (!isReadyState) + { + viewModel.AxisTargetPositionText = "40"; + viewModel.AxisRelativeDistanceText = "40"; + viewModel.AxisCommandVelocityText = "50"; + if (!viewModel.StartManualEquipmentControlCommand.CanExecute(null)) + { + throw new InvalidOperationException("Manual axis control was unavailable for the smoke state."); + } + viewModel.StartManualEquipmentControlCommand.Execute(null); + await WaitForAsync( + () => viewModel.IsRunning && viewModel.CanJogAxis, + "Manual axis control did not start for the smoke state."); + } + + if (state.Equals("target", StringComparison.OrdinalIgnoreCase)) + { + viewModel.MoveAxisAbsoluteCommand.Execute(null); + await WaitForAsync( + () => viewModel.SceneSnapshots.Latest!.Axes[0].State == AxisState.Idle && + Math.Abs(viewModel.SceneSnapshots.Latest.Axes[0].Position - 40) < 1e-6, + "The axis did not reach the target smoke state."); + } + else if (state.Equals("relative-target", StringComparison.OrdinalIgnoreCase)) + { + viewModel.MoveAxisRelativeCommand.Execute(null); + await WaitForAsync( + () => viewModel.SceneSnapshots.Latest!.Axes[0].State == AxisState.Idle && + Math.Abs(viewModel.SceneSnapshots.Latest.Axes[0].Position - 40) < 1e-6, + "The axis did not reach the relative target smoke state."); + } + else if (state.Equals("invalid-target", StringComparison.OrdinalIgnoreCase)) + { + viewModel.AxisTargetPositionText = "invalid"; + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + } + else if (state.Equals("focus-target", StringComparison.OrdinalIgnoreCase)) + { + viewModel.AxisTargetPositionText = "125.500"; + inspector.AxisTargetPositionTextBox.Focus(); + } + else if (state.Equals("invalid-relative", StringComparison.OrdinalIgnoreCase)) + { + viewModel.AxisRelativeDistanceText = "0"; + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + } + else if (state.Equals("focus-relative", StringComparison.OrdinalIgnoreCase)) + { + viewModel.AxisRelativeDistanceText = "-25.500"; + inspector.AxisRelativeDistanceTextBox.Focus(); + } + else if (state.Equals("invalid-velocity", StringComparison.OrdinalIgnoreCase)) + { + viewModel.AxisCommandVelocityText = "0"; + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + } + else if (state.Equals("focus-velocity", StringComparison.OrdinalIgnoreCase)) + { + viewModel.AxisCommandVelocityText = "-50.000"; + inspector.AxisCommandVelocityTextBox.Focus(); + } + else if (state.Equals("velocity-running", StringComparison.OrdinalIgnoreCase)) + { + viewModel.MoveAxisVelocityCommand.Execute(null); + await WaitForAsync( + () => viewModel.SceneSnapshots.Latest!.Axes[0].State == AxisState.Moving && + viewModel.SceneSnapshots.Latest.Axes[0].Velocity > 0, + "The axis did not enter the velocity-running smoke state."); + } + else if (state.Equals("velocity-limited", StringComparison.OrdinalIgnoreCase)) + { + viewModel.AxisCommandVelocityText = "180"; + viewModel.MoveAxisVelocityCommand.Execute(null); + await WaitForAsync( + () => viewModel.SceneSnapshots.Latest!.Axes[0].State == AxisState.Limited, + "The axis did not reach the velocity-limited smoke state."); + } + else if (state.Equals("following-error-alarm", StringComparison.OrdinalIgnoreCase)) + { + viewModel.AxisCommandVelocityText = "5"; + viewModel.MoveAxisVelocityCommand.Execute(null); + await WaitForAsync( + () => viewModel.SceneSnapshots.Latest!.Axes[0].State == AxisState.Moving, + "Following-error smoke motion did not start."); + viewModel.PauseCommand.Execute(null); + await WaitForAsync(() => !viewModel.IsRunning, "Following-error smoke motion did not pause."); + var manager = viewModel.FaultManager; + manager.SelectedKind = manager.AvailableKinds.Single(option => + option.Kind == SimulationFaultKind.AxisFollowingError); + manager.SelectedTarget = manager.Targets.Single(target => + string.Equals(target.Id, viewModel.SceneSnapshots.Latest!.Axes[0].Id, StringComparison.Ordinal)); + manager.InjectCommand.Execute(null); + await WaitForAsync( + () => !manager.IsOperationPending && viewModel.SceneSnapshots.Latest!.Faults.Any(fault => + fault.Kind == SimulationFaultKind.AxisFollowingError), + "Following-error smoke fault did not activate."); + for (var step = 0; step < 10 && !viewModel.IsCurrentAxisDriveAlarmActive; step++) + { + var beforeStep = viewModel.SceneSnapshots.Latest!.TickIndex; + viewModel.StepCommand.Execute(null); + await WaitForAsync( + () => viewModel.SceneSnapshots.Latest!.TickIndex > beforeStep, + "Following-error smoke Step did not advance."); + } + await WaitForAsync( + () => viewModel.IsCurrentAxisDriveAlarmActive, + "Following-error smoke alarm did not latch."); + } + else if (state.Equals("hover-velocity", StringComparison.OrdinalIgnoreCase) || + state.Equals("pressed-velocity", StringComparison.OrdinalIgnoreCase)) + { + interaction.MovePointerToCenter(inspector.MoveAxisVelocityButton); + if (state.StartsWith("pressed", StringComparison.OrdinalIgnoreCase)) + { + interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); + interaction.MarkSmokePointerHeld(); + } + } + else if (state.Equals("hover-relative", StringComparison.OrdinalIgnoreCase) || + state.Equals("pressed-relative", StringComparison.OrdinalIgnoreCase)) + { + interaction.MovePointerToCenter(inspector.MoveAxisRelativeButton); + if (state.StartsWith("pressed", StringComparison.OrdinalIgnoreCase)) + { + interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); + interaction.MarkSmokePointerHeld(); + } + } + else if (state.Equals("hover-move", StringComparison.OrdinalIgnoreCase) || + state.Equals("pressed-move", StringComparison.OrdinalIgnoreCase)) + { + interaction.MovePointerToCenter(inspector.MoveAxisAbsoluteButton); + if (state.StartsWith("pressed", StringComparison.OrdinalIgnoreCase)) + { + interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); + interaction.MarkSmokePointerHeld(); + } + } + else if (state.Equals("homed", StringComparison.OrdinalIgnoreCase)) + { + if (!viewModel.BeginAxisJog(AxisJogDirection.Positive)) + { + throw new InvalidOperationException("Jog+ did not start for the homed state."); + } + var start = viewModel.SceneSnapshots.Latest!.Axes[0].Position; + await WaitForAsync( + () => viewModel.SceneSnapshots.Latest!.Axes[0].Position > start + 0.05, + "Jog+ did not advance before Home."); + await viewModel.EndAxisJogAsync(); + await WaitForAsync( + () => viewModel.HomeAxisCommand.CanExecute(null), + "Home remained unavailable after Jog stop."); + viewModel.HomeAxisCommand.Execute(null); + await WaitForAsync( + () => viewModel.SceneSnapshots.Latest!.Axes[0].State == AxisState.Idle && + Math.Abs(viewModel.SceneSnapshots.Latest.Axes[0].Position) < 1e-9, + "Home did not complete for the smoke state."); + } + else if (state.Equals("interlocked", StringComparison.OrdinalIgnoreCase)) + { + var manager = viewModel.FaultManager; + manager.SelectedKind = manager.AvailableKinds.Single(option => + option.Kind == SimulationFaultKind.AxisMotionBlocked); + manager.SelectedTarget = manager.Targets.Single(target => + string.Equals(target.Id, viewModel.SceneSnapshots.Latest!.Axes[0].Id, StringComparison.Ordinal)); + manager.InjectCommand.Execute(null); + await WaitForAsync( + () => !manager.IsOperationPending && viewModel.IsCurrentAxisInterlocked && + viewModel.SceneSnapshots.Latest!.Axes[0].State == AxisState.Error, + "Blocked-axis fault did not activate for the smoke state."); + } + else if (state.Equals("focus-home", StringComparison.OrdinalIgnoreCase)) + { + inspector.HomeAxisButton.Focus(); + } + else if (state.Equals("hover-jog-positive", StringComparison.OrdinalIgnoreCase) || + state.Equals("pressed-jog-positive", StringComparison.OrdinalIgnoreCase)) + { + interaction.MovePointerToCenter(inspector.JogPositiveButton); + if (state.StartsWith("pressed", StringComparison.OrdinalIgnoreCase)) + { + interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); + interaction.MarkSmokePointerHeld(); + await WaitForAsync( + () => viewModel.SceneSnapshots.Latest!.Axes[0].Velocity > 0, + "Pressed Jog+ did not move the axis."); + } + } + else if (!state.Equals("ready", StringComparison.OrdinalIgnoreCase) && + !state.Equals("following-error-ready", StringComparison.OrdinalIgnoreCase) && + !state.Equals("manual", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Unsupported --smoke-axis-commissioning-state '{state}'. Expected ready, manual, " + + "target, relative-target, invalid-target, focus-target, invalid-relative, focus-relative, " + + "invalid-velocity, focus-velocity, velocity-running, velocity-limited, hover-velocity, " + + "following-error-ready, following-error-alarm, " + + "pressed-velocity, hover-move, pressed-move, hover-relative, pressed-relative, homed, interlocked, " + + "focus-home, hover-jog-positive, or pressed-jog-positive."); + } + + await ScrollIntoViewAsync(window); + await Task.Delay(150); + } + + public static async Task ScrollIntoViewAsync(ShellWindow window) + { + var inspector = SmokeVisualTreeQuery.FindVisualDescendant(window) + ?? throw new InvalidOperationException("Run inspector was unavailable."); + var scrollViewer = inspector.RunInspectorScrollViewer; + var targetPosition = inspector.SelectedEquipmentRuntimeCard.TranslatePoint(new Point(), scrollViewer); + scrollViewer.ScrollToVerticalOffset(scrollViewer.VerticalOffset + targetPosition.Y - 8); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + } +} diff --git a/src/OpenVisionLab.MachineStudio/Smoke/SmokeAxisCommissioningVerifier.cs b/src/OpenVisionLab.MachineStudio/Smoke/SmokeAxisCommissioningVerifier.cs index 68efc0b..0d3ee9b 100644 --- a/src/OpenVisionLab.MachineStudio/Smoke/SmokeAxisCommissioningVerifier.cs +++ b/src/OpenVisionLab.MachineStudio/Smoke/SmokeAxisCommissioningVerifier.cs @@ -39,7 +39,6 @@ public void Save(string path) internal static class SmokeAxisCommissioningVerifier { - private const uint MouseEventLeftDown = 0x0002; public static async Task VerifyAsync( ShellWindow window, MainViewModel viewModel, @@ -581,255 +580,4 @@ await WaitForAsync( }; } - public static async Task ApplyStateAsync( - ShellWindow window, - MainViewModel viewModel, - string state, - SmokeUiInteraction interaction) - { - if (!viewModel.IsRunMode) - { - throw new ArgumentException( - "--smoke-axis-commissioning-state requires --smoke-run-layout."); - } - - async Task WaitForAsync(Func condition, string failureMessage) - { - for (var attempt = 0; attempt < 80; attempt++) - { - if (condition()) - { - return; - } - await Task.Delay(50); - } - throw new InvalidOperationException(failureMessage); - } - - await ScrollIntoViewAsync(window); - var inspector = SmokeVisualTreeQuery.FindVisualDescendant(window) - ?? throw new InvalidOperationException("Run inspector was unavailable."); - bool isReadyState = state.Equals("ready", StringComparison.OrdinalIgnoreCase) - || state.Equals("interlocked", StringComparison.OrdinalIgnoreCase) - || state.Equals("invalid-target", StringComparison.OrdinalIgnoreCase) - || state.Equals("focus-target", StringComparison.OrdinalIgnoreCase) - || state.Equals("invalid-relative", StringComparison.OrdinalIgnoreCase) - || state.Equals("focus-relative", StringComparison.OrdinalIgnoreCase) - || state.Equals("invalid-velocity", StringComparison.OrdinalIgnoreCase) - || state.Equals("focus-velocity", StringComparison.OrdinalIgnoreCase) - || state.Equals("following-error-ready", StringComparison.OrdinalIgnoreCase); - if (!isReadyState) - { - viewModel.AxisTargetPositionText = "40"; - viewModel.AxisRelativeDistanceText = "40"; - viewModel.AxisCommandVelocityText = "50"; - if (!viewModel.StartManualEquipmentControlCommand.CanExecute(null)) - { - throw new InvalidOperationException("Manual axis control was unavailable for the smoke state."); - } - viewModel.StartManualEquipmentControlCommand.Execute(null); - await WaitForAsync( - () => viewModel.IsRunning && viewModel.CanJogAxis, - "Manual axis control did not start for the smoke state."); - } - - if (state.Equals("target", StringComparison.OrdinalIgnoreCase)) - { - viewModel.MoveAxisAbsoluteCommand.Execute(null); - await WaitForAsync( - () => viewModel.SceneSnapshots.Latest!.Axes[0].State == AxisState.Idle && - Math.Abs(viewModel.SceneSnapshots.Latest.Axes[0].Position - 40) < 1e-6, - "The axis did not reach the target smoke state."); - } - else if (state.Equals("relative-target", StringComparison.OrdinalIgnoreCase)) - { - viewModel.MoveAxisRelativeCommand.Execute(null); - await WaitForAsync( - () => viewModel.SceneSnapshots.Latest!.Axes[0].State == AxisState.Idle && - Math.Abs(viewModel.SceneSnapshots.Latest.Axes[0].Position - 40) < 1e-6, - "The axis did not reach the relative target smoke state."); - } - else if (state.Equals("invalid-target", StringComparison.OrdinalIgnoreCase)) - { - viewModel.AxisTargetPositionText = "invalid"; - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - } - else if (state.Equals("focus-target", StringComparison.OrdinalIgnoreCase)) - { - viewModel.AxisTargetPositionText = "125.500"; - inspector.AxisTargetPositionTextBox.Focus(); - } - else if (state.Equals("invalid-relative", StringComparison.OrdinalIgnoreCase)) - { - viewModel.AxisRelativeDistanceText = "0"; - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - } - else if (state.Equals("focus-relative", StringComparison.OrdinalIgnoreCase)) - { - viewModel.AxisRelativeDistanceText = "-25.500"; - inspector.AxisRelativeDistanceTextBox.Focus(); - } - else if (state.Equals("invalid-velocity", StringComparison.OrdinalIgnoreCase)) - { - viewModel.AxisCommandVelocityText = "0"; - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - } - else if (state.Equals("focus-velocity", StringComparison.OrdinalIgnoreCase)) - { - viewModel.AxisCommandVelocityText = "-50.000"; - inspector.AxisCommandVelocityTextBox.Focus(); - } - else if (state.Equals("velocity-running", StringComparison.OrdinalIgnoreCase)) - { - viewModel.MoveAxisVelocityCommand.Execute(null); - await WaitForAsync( - () => viewModel.SceneSnapshots.Latest!.Axes[0].State == AxisState.Moving && - viewModel.SceneSnapshots.Latest.Axes[0].Velocity > 0, - "The axis did not enter the velocity-running smoke state."); - } - else if (state.Equals("velocity-limited", StringComparison.OrdinalIgnoreCase)) - { - viewModel.AxisCommandVelocityText = "180"; - viewModel.MoveAxisVelocityCommand.Execute(null); - await WaitForAsync( - () => viewModel.SceneSnapshots.Latest!.Axes[0].State == AxisState.Limited, - "The axis did not reach the velocity-limited smoke state."); - } - else if (state.Equals("following-error-alarm", StringComparison.OrdinalIgnoreCase)) - { - viewModel.AxisCommandVelocityText = "5"; - viewModel.MoveAxisVelocityCommand.Execute(null); - await WaitForAsync( - () => viewModel.SceneSnapshots.Latest!.Axes[0].State == AxisState.Moving, - "Following-error smoke motion did not start."); - viewModel.PauseCommand.Execute(null); - await WaitForAsync(() => !viewModel.IsRunning, "Following-error smoke motion did not pause."); - var manager = viewModel.FaultManager; - manager.SelectedKind = manager.AvailableKinds.Single(option => - option.Kind == SimulationFaultKind.AxisFollowingError); - manager.SelectedTarget = manager.Targets.Single(target => - string.Equals(target.Id, viewModel.SceneSnapshots.Latest!.Axes[0].Id, StringComparison.Ordinal)); - manager.InjectCommand.Execute(null); - await WaitForAsync( - () => !manager.IsOperationPending && viewModel.SceneSnapshots.Latest!.Faults.Any(fault => - fault.Kind == SimulationFaultKind.AxisFollowingError), - "Following-error smoke fault did not activate."); - for (var step = 0; step < 10 && !viewModel.IsCurrentAxisDriveAlarmActive; step++) - { - var beforeStep = viewModel.SceneSnapshots.Latest!.TickIndex; - viewModel.StepCommand.Execute(null); - await WaitForAsync( - () => viewModel.SceneSnapshots.Latest!.TickIndex > beforeStep, - "Following-error smoke Step did not advance."); - } - await WaitForAsync( - () => viewModel.IsCurrentAxisDriveAlarmActive, - "Following-error smoke alarm did not latch."); - } - else if (state.Equals("hover-velocity", StringComparison.OrdinalIgnoreCase) || - state.Equals("pressed-velocity", StringComparison.OrdinalIgnoreCase)) - { - interaction.MovePointerToCenter(inspector.MoveAxisVelocityButton); - if (state.StartsWith("pressed", StringComparison.OrdinalIgnoreCase)) - { - interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); - interaction.MarkSmokePointerHeld(); - } - } - else if (state.Equals("hover-relative", StringComparison.OrdinalIgnoreCase) || - state.Equals("pressed-relative", StringComparison.OrdinalIgnoreCase)) - { - interaction.MovePointerToCenter(inspector.MoveAxisRelativeButton); - if (state.StartsWith("pressed", StringComparison.OrdinalIgnoreCase)) - { - interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); - interaction.MarkSmokePointerHeld(); - } - } - else if (state.Equals("hover-move", StringComparison.OrdinalIgnoreCase) || - state.Equals("pressed-move", StringComparison.OrdinalIgnoreCase)) - { - interaction.MovePointerToCenter(inspector.MoveAxisAbsoluteButton); - if (state.StartsWith("pressed", StringComparison.OrdinalIgnoreCase)) - { - interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); - interaction.MarkSmokePointerHeld(); - } - } - else if (state.Equals("homed", StringComparison.OrdinalIgnoreCase)) - { - if (!viewModel.BeginAxisJog(AxisJogDirection.Positive)) - { - throw new InvalidOperationException("Jog+ did not start for the homed state."); - } - var start = viewModel.SceneSnapshots.Latest!.Axes[0].Position; - await WaitForAsync( - () => viewModel.SceneSnapshots.Latest!.Axes[0].Position > start + 0.05, - "Jog+ did not advance before Home."); - await viewModel.EndAxisJogAsync(); - await WaitForAsync( - () => viewModel.HomeAxisCommand.CanExecute(null), - "Home remained unavailable after Jog stop."); - viewModel.HomeAxisCommand.Execute(null); - await WaitForAsync( - () => viewModel.SceneSnapshots.Latest!.Axes[0].State == AxisState.Idle && - Math.Abs(viewModel.SceneSnapshots.Latest.Axes[0].Position) < 1e-9, - "Home did not complete for the smoke state."); - } - else if (state.Equals("interlocked", StringComparison.OrdinalIgnoreCase)) - { - var manager = viewModel.FaultManager; - manager.SelectedKind = manager.AvailableKinds.Single(option => - option.Kind == SimulationFaultKind.AxisMotionBlocked); - manager.SelectedTarget = manager.Targets.Single(target => - string.Equals(target.Id, viewModel.SceneSnapshots.Latest!.Axes[0].Id, StringComparison.Ordinal)); - manager.InjectCommand.Execute(null); - await WaitForAsync( - () => !manager.IsOperationPending && viewModel.IsCurrentAxisInterlocked && - viewModel.SceneSnapshots.Latest!.Axes[0].State == AxisState.Error, - "Blocked-axis fault did not activate for the smoke state."); - } - else if (state.Equals("focus-home", StringComparison.OrdinalIgnoreCase)) - { - inspector.HomeAxisButton.Focus(); - } - else if (state.Equals("hover-jog-positive", StringComparison.OrdinalIgnoreCase) || - state.Equals("pressed-jog-positive", StringComparison.OrdinalIgnoreCase)) - { - interaction.MovePointerToCenter(inspector.JogPositiveButton); - if (state.StartsWith("pressed", StringComparison.OrdinalIgnoreCase)) - { - interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); - interaction.MarkSmokePointerHeld(); - await WaitForAsync( - () => viewModel.SceneSnapshots.Latest!.Axes[0].Velocity > 0, - "Pressed Jog+ did not move the axis."); - } - } - else if (!state.Equals("ready", StringComparison.OrdinalIgnoreCase) && - !state.Equals("following-error-ready", StringComparison.OrdinalIgnoreCase) && - !state.Equals("manual", StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException( - $"Unsupported --smoke-axis-commissioning-state '{state}'. Expected ready, manual, " + - "target, relative-target, invalid-target, focus-target, invalid-relative, focus-relative, " + - "invalid-velocity, focus-velocity, velocity-running, velocity-limited, hover-velocity, " + - "following-error-ready, following-error-alarm, " + - "pressed-velocity, hover-move, pressed-move, hover-relative, pressed-relative, homed, interlocked, " + - "focus-home, hover-jog-positive, or pressed-jog-positive."); - } - - await ScrollIntoViewAsync(window); - await Task.Delay(150); - } - - public static async Task ScrollIntoViewAsync(ShellWindow window) - { - var inspector = SmokeVisualTreeQuery.FindVisualDescendant(window) - ?? throw new InvalidOperationException("Run inspector was unavailable."); - var scrollViewer = inspector.RunInspectorScrollViewer; - var targetPosition = inspector.SelectedEquipmentRuntimeCard.TranslatePoint(new Point(), scrollViewer); - scrollViewer.ScrollToVerticalOffset(scrollViewer.VerticalOffset + targetPosition.Y - 8); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - } } diff --git a/src/OpenVisionLab.MachineStudio/Smoke/SmokeCameraCommissioningVerifier.cs b/src/OpenVisionLab.MachineStudio/Smoke/SmokeCameraCommissioningVerifier.cs index fac8585..a90c64c 100644 --- a/src/OpenVisionLab.MachineStudio/Smoke/SmokeCameraCommissioningVerifier.cs +++ b/src/OpenVisionLab.MachineStudio/Smoke/SmokeCameraCommissioningVerifier.cs @@ -3,6 +3,11 @@ using System.Security.Cryptography; using System.Text.Json; using System.Windows; +using System.Windows.Automation; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Input; using System.Windows.Threading; using OpenVisionLab; using OpenVisionLab.Machine.Core.Devices; @@ -76,17 +81,19 @@ async Task WaitForAsync(Func condition, string failureMessage) await Task.Delay(50); await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); } - throw new InvalidOperationException(failureMessage); + throw new InvalidOperationException($"{failureMessage} Observed tick: {viewModel.SceneSnapshots.Latest?.TickIndex}."); } async Task StepAsync() { + // Snapshot publication can precede AsyncRelayCommand completion. + await WaitForAsync(() => viewModel.StepCommand.CanExecute(null), "Camera Step command remained busy."); var beforeTick = viewModel.SceneSnapshots.Latest?.TickIndex ?? throw new InvalidOperationException("Camera snapshot was unavailable before Step."); viewModel.StepCommand.Execute(null); await WaitForAsync( () => viewModel.SceneSnapshots.Latest?.TickIndex == beforeTick + 1, - "Camera commissioning Step did not advance exactly one tick."); + $"Camera commissioning Step did not advance exactly one tick from {beforeTick}."); } await WaitForAsync( @@ -103,6 +110,9 @@ await WaitForAsync( && viewModel.SceneSnapshots.Latest.Cameras[0].State == VirtualCameraState.Idle, "Camera runtime did not reset before commissioning."); + await scrollIntoView(); + await VerifyBindingsAsync(window, viewModel, Path.GetDirectoryName(projectPath)!, Check); + if (editImageSource) { var editor = viewModel.CameraImageSourceEditor; @@ -343,7 +353,17 @@ await WaitForAsync( && viewModel.SceneSnapshots.Latest?.ControlOwner == SimulationControlOwner.Manual, "Manual camera control did not restart for evidence comparison."); viewModel.PauseCommand.Execute(null); - await WaitForAsync(() => !viewModel.IsRunning, "Repeat camera control did not pause."); + await WaitForAsync( + () => !viewModel.IsRunning + && viewModel.SceneSnapshots.Latest?.RunMode == SimulationRunMode.Paused + && viewModel.StepCommand.CanExecute(null), + "Repeat camera control did not pause."); + // Equal trigger ticks correctly produce equal deterministic inspection IDs. + // This mismatch scenario must deliberately choose a different input tick. + if (viewModel.SceneSnapshots.Latest!.TickIndex == triggered.TickIndex) + { + await StepAsync(); + } viewModel.TriggerCameraCommand.Execute(null); await WaitForAsync( () => viewModel.SceneSnapshots.Latest?.Cameras[0].State == VirtualCameraState.Exposing, @@ -397,6 +417,130 @@ viewModel.VisionEvidenceComparison is }; } + private static async Task VerifyBindingsAsync( + ShellWindow window, + MainViewModel viewModel, + string evidenceDirectory, + Action check) + { + var inspector = SmokeVisualTreeQuery.FindVisualDescendant(window) + ?? throw new InvalidOperationException("Camera inspector was unavailable."); + var camera = viewModel.Camera; + var editor = camera.ImageSourceEditor; + var originalCameraId = camera.SelectedCameraId; + var originalRecipe = camera.SelectedCameraRecipe; + var originalWidth = editor.Width; + var snapshot = viewModel.SceneSnapshots.Latest!; + var capture = new SmokeWindowCapture(); + + bool UsesCamera(DependencyObject target, DependencyProperty property) + { + var binding = BindingOperations.GetBindingExpression(target, property); + return binding?.Status == BindingStatus.Active && ReferenceEquals(binding.DataItem, camera); + } + + check("cameraControlsResolveDirectOwner", + UsesCamera(inspector.CameraSelectionComboBox, ItemsControl.ItemsSourceProperty) + && UsesCamera(inspector.CameraSelectionComboBox, Selector.SelectedValueProperty) + && UsesCamera(inspector.CameraRecipeComboBox, Selector.SelectedItemProperty) + && UsesCamera(inspector.CameraSourcePixelFormatTextBox, TextBox.TextProperty) + && UsesCamera(inspector.CameraSourcePathTextBox, TextBox.TextProperty) + && UsesCamera(inspector.StartCameraManualControlButton, Button.CommandProperty) + && UsesCamera(inspector.TriggerCameraButton, Button.CommandProperty) + && UsesCamera(inspector.CameraExecutionEvidenceDetailsTextBlock, TextBlock.TextProperty)); + check("cameraCommandsUseOwnerInstances", + ReferenceEquals(inspector.StartCameraManualControlButton.Command, camera.StartManualCameraControlCommand) + && ReferenceEquals(inspector.TriggerCameraButton.Command, camera.TriggerCameraCommand) + && ReferenceEquals(inspector.ApplyCameraSourceButton.Command, editor.ApplyCommand) + && ReferenceEquals(inspector.RevertCameraSourceButton.Command, editor.RevertCommand) + && ReferenceEquals(inspector.BrowseCameraSourceButton.Command, editor.BrowseCommand)); + + if (camera.VirtualCameras.Count > 1) + { + var otherId = camera.VirtualCameras.First(item => item.Id != originalCameraId).Id; + inspector.CameraSelectionComboBox.SetCurrentValue(Selector.SelectedValueProperty, otherId); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + check("cameraSelectionControlToOwner", camera.SelectedCameraId == otherId); + camera.SelectedCameraId = originalCameraId; + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + check("cameraSelectionOwnerToControl", Equals(inspector.CameraSelectionComboBox.SelectedValue, originalCameraId)); + } + + if (camera.CurrentCameraRecipes.Count > 1) + { + var otherRecipe = camera.CurrentCameraRecipes.First(item => item != originalRecipe); + inspector.CameraRecipeComboBox.SetCurrentValue(Selector.SelectedItemProperty, otherRecipe); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + check("recipeSelectionControlToOwner", camera.SelectedCameraRecipe == otherRecipe); + camera.SelectedCameraRecipe = originalRecipe; + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + check("recipeSelectionOwnerToControl", Equals(inspector.CameraRecipeComboBox.SelectedItem, originalRecipe)); + } + + var widthEditor = SmokeVisualTreeQuery.FindVisualDescendant( + inspector, item => AutomationProperties.GetAutomationId(item) == "CameraSourceWidthNumberBox") + ?? throw new InvalidOperationException("Camera source width control was unavailable."); + widthEditor.SetCurrentValue(global::Wpf.Ui.Controls.NumberBox.ValueProperty, (double)(originalWidth + 1)); + // NumberBox commits on focus loss; commit this programmatic binding probe explicitly. + widthEditor.GetBindingExpression(global::Wpf.Ui.Controls.NumberBox.ValueProperty)!.UpdateSource(); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + check("sourceWidthControlToOwner", editor.Width == originalWidth + 1); + editor.Width = originalWidth; + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + check("sourceWidthOwnerToControl", widthEditor.Value == originalWidth); + + inspector.CameraSourcePixelFormatTextBox.SetCurrentValue(TextBox.TextProperty, "Mono16"); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + check("sourceTextControlToOwner", editor.PixelFormatText == "Mono16"); + editor.PixelFormatText = string.Empty; + // Programmatic draft changes do not generate the input events that normally requery commands. + CommandManager.InvalidateRequerySuggested(); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + check("sourceValidationOwnerToControl", + inspector.CameraSourcePixelFormatTextBox.Text.Length == 0 + && editor.HasError + && !inspector.ApplyCameraSourceButton.IsEnabled + && inspector.CameraSourceValidationText.Text == editor.ValidationText); + capture.Capture(window, Path.Combine(evidenceDirectory, "bindings-validation.png")); + + using var input = new SmokeNativeInput(); + window.Activate(); + input.ActivateWindow(window); + inspector.RevertCameraSourceButton.BringIntoView(); + inspector.RevertCameraSourceButton.UpdateLayout(); + for (var attempt = 0; attempt < 3 && !inspector.RevertCameraSourceButton.IsKeyboardFocused; attempt++) + { + await Task.Delay(50); + inspector.RevertCameraSourceButton.Focus(); + Keyboard.Focus(inspector.RevertCameraSourceButton); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + } + check("sourceRevertKeyboardFocused", inspector.RevertCameraSourceButton.IsKeyboardFocused); + if (!inspector.RevertCameraSourceButton.IsKeyboardFocused) + { + Console.Error.WriteLine($"Camera Revert focus unavailable: WindowActive={window.IsActive}; Visible={inspector.RevertCameraSourceButton.IsVisible}; Enabled={inspector.RevertCameraSourceButton.IsEnabled}; Focusable={inspector.RevertCameraSourceButton.Focusable}; Focused={Keyboard.FocusedElement}."); + } + capture.Capture(window, Path.Combine(evidenceDirectory, "bindings-keyboard-focus.png")); + if (inspector.RevertCameraSourceButton.IsKeyboardFocused) + { + input.SendKey(0x20); + } + await Task.Delay(100); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + check("sourceRevertKeyboardRestoresRenderedValues", + inspector.CameraSourcePixelFormatTextBox.Text == "Mono8" + && !editor.IsDirty && !editor.HasError + && !inspector.ApplyCameraSourceButton.IsEnabled + && !inspector.RevertCameraSourceButton.IsEnabled); + editor.RevertCommand.Execute(null); + check("bindingRoundTripDoesNotAcquireOrAdvance", + viewModel.SceneSnapshots.Latest!.TickIndex == snapshot.TickIndex + && viewModel.SceneSnapshots.Latest.Cameras.All(item => item.AcquisitionOrdinal == 0 && item.FrameEvidence is null)); + check("sourcePathIsReadOnlyAndHasAppliedValue", + inspector.CameraSourcePathTextBox.IsReadOnly && inspector.CameraSourcePathTextBox.Text == editor.PathText); + await ScrollIntoViewAsync(window); + } + public static async Task ApplyStateAsync( ShellWindow window, MainViewModel viewModel, @@ -425,6 +569,7 @@ async Task WaitForAsync(Func condition, string failureMessage) async Task StepAsync() { + await WaitForAsync(() => viewModel.StepCommand.CanExecute(null), "Camera Step command remained busy."); var before = viewModel.SceneSnapshots.Latest!.TickIndex; viewModel.StepCommand.Execute(null); await WaitForAsync( @@ -432,6 +577,17 @@ await WaitForAsync( "Camera smoke Step did not advance one tick."); } + if (state == "automatic") + { + await WaitForAsync( + () => viewModel.IsRunning + && viewModel.SceneSnapshots.Latest?.ControlOwner + == SimulationControlOwner.EmbeddedSequence, + "Automatic camera smoke state did not retain Embedded Sequence control."); + await ScrollIntoViewAsync(window); + return; + } + await WaitForAsync( () => viewModel.SceneSnapshots.Latest?.Cameras.Count > 0, "No virtual camera was published for the smoke state."); @@ -445,7 +601,8 @@ await WaitForAsync( { viewModel.CameraImageSourceEditor.PixelFormatText = string.Empty; } - else if (state is "source-focus" or "source-hover-apply" or "source-pressed-apply") + else if (state is "source-focus" or "source-hover-apply" or "source-pressed-apply" + or "source-hover-revert" or "source-pressed-revert") { viewModel.CameraImageSourceEditor.PixelFormatText = "Mono8"; viewModel.CameraImageSourceEditor.Width += 1; @@ -482,6 +639,7 @@ await WaitForAsync( } } + CommandManager.InvalidateRequerySuggested(); await ScrollIntoViewAsync(window); var inspector = SmokeVisualTreeQuery.FindVisualDescendant(window) ?? throw new InvalidOperationException("Run inspector was unavailable."); @@ -567,7 +725,19 @@ await WaitForAsync( interaction.MarkSmokePointerHeld(); } } - else if (state is not "ready" and not "manual" and not "exposing" + else if (state is "source-hover-revert" or "source-pressed-revert") + { + inspector.RevertCameraSourceButton.BringIntoView(); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + interaction.ActivateWindow(); + interaction.MovePointerToCenter(inspector.RevertCameraSourceButton); + if (state == "source-pressed-revert") + { + interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); + interaction.MarkSmokePointerHeld(); + } + } + else if (state is not "ready" and not "manual" and not "automatic" and not "exposing" and not "transferring" and not "frame-ready" and not "source-hover-browse" and not "source-pressed-browse") { diff --git a/src/OpenVisionLab.MachineStudio/Smoke/SmokeMultiAxisCommissioningStateVerifier.cs b/src/OpenVisionLab.MachineStudio/Smoke/SmokeMultiAxisCommissioningStateVerifier.cs new file mode 100644 index 0000000..eab5c35 --- /dev/null +++ b/src/OpenVisionLab.MachineStudio/Smoke/SmokeMultiAxisCommissioningStateVerifier.cs @@ -0,0 +1,288 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Threading; +using OpenVisionLab.Machine.Simulation.Axis; +using OpenVisionLab.MachineStudio.View.Inspector; +using OpenVisionLab.MachineStudio.View.Shell; +using OpenVisionLab.MachineStudio.ViewModel; + +namespace OpenVisionLab.MachineStudio; + +internal static class SmokeMultiAxisCommissioningStateVerifier +{ + private const uint MouseEventLeftDown = 0x0002; + public static async Task ApplyStateAsync( + ShellWindow window, + MainViewModel viewModel, + string state, + SmokeUiInteraction interaction) + { + var inspector = SmokeVisualTreeQuery.FindVisualDescendant(window) + ?? throw new InvalidOperationException("Right inspector was unavailable."); + switch (state.ToLowerInvariant()) + { + case "design": + case "design-focus": + case "design-popup": + if (!viewModel.IsDesignMode) + { + throw new InvalidOperationException("Recipe design state requires Design mode."); + } + viewModel.ProjectTree.SelectedNode = viewModel.ProjectTree.Roots.Single(); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + inspector.MultiAxisRecipeDesignPanel.BringIntoView(); + if (state.Equals("design-focus", StringComparison.OrdinalIgnoreCase)) + { + inspector.MultiAxisRecipeNameTextBox.Text = "Pick position smoke"; + inspector.MultiAxisRecipeNameTextBox.GetBindingExpression(TextBox.TextProperty)?.UpdateSource(); + interaction.ActivateWindow(); + inspector.MultiAxisRecipeNameTextBox.Focus(); + Keyboard.Focus(inspector.MultiAxisRecipeNameTextBox); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + if (!inspector.MultiAxisRecipeNameTextBox.IsKeyboardFocusWithin + || inspector.MultiAxisRecipeNameTextBox.Text != "Pick position smoke") + { + throw new InvalidOperationException("Recipe name did not render its focused non-empty value."); + } + } + else if (state.Equals("design-popup", StringComparison.OrdinalIgnoreCase)) + { + var comboBox = SmokeVisualTreeQuery.FindVisualDescendant( + inspector.MultiAxisRecipeDesignPanel, + candidate => candidate.IsVisible && candidate.IsEnabled) + ?? throw new InvalidOperationException("Recipe axis selector was unavailable."); + interaction.ActivateWindow(); + comboBox.Focus(); + comboBox.IsDropDownOpen = true; + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + if (!comboBox.IsDropDownOpen) + { + throw new InvalidOperationException("Recipe axis selector popup did not open."); + } + } + break; + case "ready": + case "run-hover": + case "run-pressed": + case "validation-focus": + case "validation-hover": + case "validation-pressed": + case "validated": + case "validating": + case "history-selected": + case "baseline-pressed": + case "baseline-accepted": + case "baseline-mismatch": + case "baseline-mismatch-x": + viewModel.IsRunMode = true; + inspector.RunInspectorScrollViewer.ScrollToTop(); + inspector.MultiAxisRecipeRunPanel.BringIntoView(); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + if (!viewModel.RunMultiAxisCommissioningRecipeCommand.CanExecute(null)) + { + throw new InvalidOperationException("Recipe Run was unavailable in its ready state."); + } + if (state.Equals("validation-focus", StringComparison.OrdinalIgnoreCase)) + { + viewModel.MultiAxisCommissioningRecipe.ValidationRepetitions = 4; + interaction.ActivateWindow(); + inspector.CommissioningValidationRepetitionsTextBox.Focus(); + Keyboard.Focus(inspector.CommissioningValidationRepetitionsTextBox); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + if (!inspector.CommissioningValidationRepetitionsTextBox.IsKeyboardFocusWithin + || inspector.CommissioningValidationRepetitionsTextBox.Text != "4") + { + throw new InvalidOperationException("Commissioning repetitions did not render its focused value."); + } + } + else if (state.Equals("validation-hover", StringComparison.OrdinalIgnoreCase) + || state.Equals("validation-pressed", StringComparison.OrdinalIgnoreCase)) + { + interaction.ActivateWindow(); + interaction.MovePointerToCenter(inspector.ValidateMultiAxisRecipeButton); + await Task.Delay(100); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + if (!inspector.ValidateMultiAxisRecipeButton.IsMouseOver) + { + throw new InvalidOperationException("Recipe validation did not enter pointer-hover state."); + } + if (state.Equals("validation-pressed", StringComparison.OrdinalIgnoreCase)) + { + interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); + interaction.MarkSmokePointerHeld(); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + if (!inspector.ValidateMultiAxisRecipeButton.IsPressed) + { + throw new InvalidOperationException("Recipe validation did not enter pointer-down state."); + } + } + } + else if (state.Equals("history-selected", StringComparison.OrdinalIgnoreCase) + || state.Equals("baseline-pressed", StringComparison.OrdinalIgnoreCase) + || state.Equals("baseline-accepted", StringComparison.OrdinalIgnoreCase) + || state.Equals("baseline-mismatch", StringComparison.OrdinalIgnoreCase) + || state.Equals("baseline-mismatch-x", StringComparison.OrdinalIgnoreCase)) + { + viewModel.MultiAxisCommissioningRecipe.ValidationRepetitions = 2; + var initialHistoryCount = viewModel.CommissioningResultHistory.Entries.Length; + viewModel.ValidateMultiAxisCommissioningRecipeCommand.Execute(null); + for (var attempt = 0; attempt < 200 + && (viewModel.IsCommissioningValidationRunning + || viewModel.CommissioningResultHistory.Entries.Length <= initialHistoryCount); + attempt++) + { + await Task.Delay(25); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + } + viewModel.SelectedCommissioningHistoryEntry = + viewModel.CommissioningResultHistory.Entries.LastOrDefault(); + if (viewModel.SelectedCommissioningHistoryEntry is null) + { + throw new InvalidOperationException("Commissioning history selection was unavailable."); + } + + if (!state.Equals("history-selected", StringComparison.OrdinalIgnoreCase)) + { + if (state.Equals("baseline-pressed", StringComparison.OrdinalIgnoreCase)) + { + inspector.AcceptCommissioningBaselineButton.BringIntoView(); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + interaction.ActivateWindow(); + interaction.MovePointerToCenter(inspector.AcceptCommissioningBaselineButton); + await Task.Delay(100); + interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); + interaction.MarkSmokePointerHeld(); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + if (!inspector.AcceptCommissioningBaselineButton.IsPressed) + { + throw new InvalidOperationException("Baseline accept did not enter pointer-down state."); + } + } + else + { + viewModel.AcceptCommissioningBaselineCommand.Execute(null); + if (viewModel.AcceptedCommissioningBaseline is null) + { + throw new InvalidOperationException("Commissioning baseline was not accepted."); + } + if (state.Equals("baseline-mismatch", StringComparison.OrdinalIgnoreCase) + || state.Equals("baseline-mismatch-x", StringComparison.OrdinalIgnoreCase)) + { + var targetIndex = state.Equals("baseline-mismatch-x", StringComparison.OrdinalIgnoreCase) + ? 1 + : 0; + viewModel.MultiAxisCommissioningRecipe.Targets[targetIndex].TargetPosition += 1; + var changedHistoryCount = viewModel.CommissioningResultHistory.Entries.Length; + viewModel.ValidateMultiAxisCommissioningRecipeCommand.Execute(null); + for (var attempt = 0; attempt < 200 + && (viewModel.IsCommissioningValidationRunning + || viewModel.CommissioningResultHistory.Entries.Length <= changedHistoryCount); + attempt++) + { + await Task.Delay(25); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + } + var expectedAxisId = viewModel.MultiAxisCommissioningRecipe.Targets[targetIndex].AxisId; + if (viewModel.CommissioningBaselineComparison?.FirstMismatch?.TargetId != expectedAxisId) + { + throw new InvalidOperationException( + $"Commissioning baseline mismatch did not target axis '{expectedAxisId}'."); + } + viewModel.NavigateToCommissioningMismatchCommand.Execute(null); + if (viewModel.Layout.SelectedItem?.Id != expectedAxisId) + { + throw new InvalidOperationException( + $"Commissioning mismatch navigation did not select axis '{expectedAxisId}'."); + } + } + } + } + if (state.Equals("baseline-mismatch", StringComparison.OrdinalIgnoreCase) + || state.Equals("baseline-mismatch-x", StringComparison.OrdinalIgnoreCase)) + { + inspector.NavigateCommissioningMismatchButton.BringIntoView(); + } + else if (state.Equals("baseline-accepted", StringComparison.OrdinalIgnoreCase)) + { + inspector.AcceptCommissioningBaselineButton.BringIntoView(); + } + else + { + inspector.CommissioningResultHistoryList.BringIntoView(); + } + } + else if (state.Equals("validated", StringComparison.OrdinalIgnoreCase) + || state.Equals("validating", StringComparison.OrdinalIgnoreCase)) + { + viewModel.MultiAxisCommissioningRecipe.ValidationRepetitions = + state.Equals("validating", StringComparison.OrdinalIgnoreCase) ? 100 : 3; + viewModel.ValidateMultiAxisCommissioningRecipeCommand.Execute(null); + for (var attempt = 0; attempt < 200; attempt++) + { + var ready = state.Equals("validating", StringComparison.OrdinalIgnoreCase) + ? viewModel.IsCommissioningValidationRunning + : !viewModel.IsCommissioningValidationRunning + && viewModel.LatestCommissioningResult is not null; + if (ready) + { + break; + } + await Task.Delay(25); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + } + if (state.Equals("validating", StringComparison.OrdinalIgnoreCase) + ? !viewModel.IsCommissioningValidationRunning + : viewModel.LatestCommissioningResult is not { IsSuccess: true }) + { + throw new InvalidOperationException("Recipe validation smoke state was not reached."); + } + } + else if (!state.Equals("ready", StringComparison.OrdinalIgnoreCase)) + { + interaction.ActivateWindow(); + interaction.MovePointerToCenter(inspector.RunMultiAxisRecipeButton); + await Task.Delay(100); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + if (!inspector.RunMultiAxisRecipeButton.IsMouseOver) + { + throw new InvalidOperationException("Recipe Run did not enter pointer-hover state."); + } + if (state.Equals("run-pressed", StringComparison.OrdinalIgnoreCase)) + { + interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); + interaction.MarkSmokePointerHeld(); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + if (!inspector.RunMultiAxisRecipeButton.IsPressed) + { + throw new InvalidOperationException("Recipe Run did not enter pointer-down state."); + } + } + } + break; + case "running": + viewModel.IsRunMode = true; + viewModel.RunMultiAxisCommissioningRecipeCommand.Execute(null); + for (var attempt = 0; attempt < 80 && + !viewModel.MultiAxisCommissioningRecipe.Targets.Any(target => + target.RuntimeState == AxisState.Moving); attempt++) + { + await Task.Delay(25); + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + } + inspector.RunInspectorScrollViewer.ScrollToTop(); + break; + default: + throw new ArgumentException( + $"Unsupported --smoke-multi-axis-recipe-state '{state}'. " + + "Expected design, design-focus, design-popup, ready, run-hover, run-pressed, running, " + + "validation-focus, validation-hover, validation-pressed, validated, validating, " + + "history-selected, baseline-pressed, baseline-accepted, baseline-mismatch, or baseline-mismatch-x."); + } + + await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); + await Task.Delay(150); + } +} diff --git a/src/OpenVisionLab.MachineStudio/Smoke/SmokeMultiAxisCommissioningVerifier.cs b/src/OpenVisionLab.MachineStudio/Smoke/SmokeMultiAxisCommissioningVerifier.cs index 2f09da2..fe51a3e 100644 --- a/src/OpenVisionLab.MachineStudio/Smoke/SmokeMultiAxisCommissioningVerifier.cs +++ b/src/OpenVisionLab.MachineStudio/Smoke/SmokeMultiAxisCommissioningVerifier.cs @@ -2,14 +2,11 @@ using System.Linq; using System.Text.Json; using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; using System.Windows.Threading; using OpenVisionLab.Machine.Core.Projects; using OpenVisionLab.Machine.Simulation.Axis; using OpenVisionLab.Machine.Simulation.Commissioning; using OpenVisionLab.Machine.Simulation.Engine; -using OpenVisionLab.MachineStudio.View.Inspector; using OpenVisionLab.MachineStudio.View.Scene; using OpenVisionLab.MachineStudio.View.Shell; using OpenVisionLab.MachineStudio.ViewModel; @@ -41,7 +38,6 @@ public void Save(string path) internal static class SmokeMultiAxisCommissioningVerifier { - private const uint MouseEventLeftDown = 0x0002; public static async Task VerifyAsync( ShellWindow window, MainViewModel viewModel, @@ -294,276 +290,4 @@ await WaitForAsync( Failures = failures }; } - - public static async Task ApplyStateAsync( - ShellWindow window, - MainViewModel viewModel, - string state, - SmokeUiInteraction interaction) - { - var inspector = SmokeVisualTreeQuery.FindVisualDescendant(window) - ?? throw new InvalidOperationException("Right inspector was unavailable."); - switch (state.ToLowerInvariant()) - { - case "design": - case "design-focus": - case "design-popup": - if (!viewModel.IsDesignMode) - { - throw new InvalidOperationException("Recipe design state requires Design mode."); - } - viewModel.ProjectTree.SelectedNode = viewModel.ProjectTree.Roots.Single(); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - inspector.MultiAxisRecipeDesignPanel.BringIntoView(); - if (state.Equals("design-focus", StringComparison.OrdinalIgnoreCase)) - { - inspector.MultiAxisRecipeNameTextBox.Text = "Pick position smoke"; - inspector.MultiAxisRecipeNameTextBox.GetBindingExpression(TextBox.TextProperty)?.UpdateSource(); - interaction.ActivateWindow(); - inspector.MultiAxisRecipeNameTextBox.Focus(); - Keyboard.Focus(inspector.MultiAxisRecipeNameTextBox); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - if (!inspector.MultiAxisRecipeNameTextBox.IsKeyboardFocusWithin - || inspector.MultiAxisRecipeNameTextBox.Text != "Pick position smoke") - { - throw new InvalidOperationException("Recipe name did not render its focused non-empty value."); - } - } - else if (state.Equals("design-popup", StringComparison.OrdinalIgnoreCase)) - { - var comboBox = SmokeVisualTreeQuery.FindVisualDescendant( - inspector.MultiAxisRecipeDesignPanel, - candidate => candidate.IsVisible && candidate.IsEnabled) - ?? throw new InvalidOperationException("Recipe axis selector was unavailable."); - interaction.ActivateWindow(); - comboBox.Focus(); - comboBox.IsDropDownOpen = true; - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - if (!comboBox.IsDropDownOpen) - { - throw new InvalidOperationException("Recipe axis selector popup did not open."); - } - } - break; - case "ready": - case "run-hover": - case "run-pressed": - case "validation-focus": - case "validation-hover": - case "validation-pressed": - case "validated": - case "validating": - case "history-selected": - case "baseline-pressed": - case "baseline-accepted": - case "baseline-mismatch": - case "baseline-mismatch-x": - viewModel.IsRunMode = true; - inspector.RunInspectorScrollViewer.ScrollToTop(); - inspector.MultiAxisRecipeRunPanel.BringIntoView(); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - if (!viewModel.RunMultiAxisCommissioningRecipeCommand.CanExecute(null)) - { - throw new InvalidOperationException("Recipe Run was unavailable in its ready state."); - } - if (state.Equals("validation-focus", StringComparison.OrdinalIgnoreCase)) - { - viewModel.MultiAxisCommissioningRecipe.ValidationRepetitions = 4; - interaction.ActivateWindow(); - inspector.CommissioningValidationRepetitionsTextBox.Focus(); - Keyboard.Focus(inspector.CommissioningValidationRepetitionsTextBox); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - if (!inspector.CommissioningValidationRepetitionsTextBox.IsKeyboardFocusWithin - || inspector.CommissioningValidationRepetitionsTextBox.Text != "4") - { - throw new InvalidOperationException("Commissioning repetitions did not render its focused value."); - } - } - else if (state.Equals("validation-hover", StringComparison.OrdinalIgnoreCase) - || state.Equals("validation-pressed", StringComparison.OrdinalIgnoreCase)) - { - interaction.ActivateWindow(); - interaction.MovePointerToCenter(inspector.ValidateMultiAxisRecipeButton); - await Task.Delay(100); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - if (!inspector.ValidateMultiAxisRecipeButton.IsMouseOver) - { - throw new InvalidOperationException("Recipe validation did not enter pointer-hover state."); - } - if (state.Equals("validation-pressed", StringComparison.OrdinalIgnoreCase)) - { - interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); - interaction.MarkSmokePointerHeld(); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - if (!inspector.ValidateMultiAxisRecipeButton.IsPressed) - { - throw new InvalidOperationException("Recipe validation did not enter pointer-down state."); - } - } - } - else if (state.Equals("history-selected", StringComparison.OrdinalIgnoreCase) - || state.Equals("baseline-pressed", StringComparison.OrdinalIgnoreCase) - || state.Equals("baseline-accepted", StringComparison.OrdinalIgnoreCase) - || state.Equals("baseline-mismatch", StringComparison.OrdinalIgnoreCase) - || state.Equals("baseline-mismatch-x", StringComparison.OrdinalIgnoreCase)) - { - viewModel.MultiAxisCommissioningRecipe.ValidationRepetitions = 2; - var initialHistoryCount = viewModel.CommissioningResultHistory.Entries.Length; - viewModel.ValidateMultiAxisCommissioningRecipeCommand.Execute(null); - for (var attempt = 0; attempt < 200 - && (viewModel.IsCommissioningValidationRunning - || viewModel.CommissioningResultHistory.Entries.Length <= initialHistoryCount); - attempt++) - { - await Task.Delay(25); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - } - viewModel.SelectedCommissioningHistoryEntry = - viewModel.CommissioningResultHistory.Entries.LastOrDefault(); - if (viewModel.SelectedCommissioningHistoryEntry is null) - { - throw new InvalidOperationException("Commissioning history selection was unavailable."); - } - - if (!state.Equals("history-selected", StringComparison.OrdinalIgnoreCase)) - { - if (state.Equals("baseline-pressed", StringComparison.OrdinalIgnoreCase)) - { - inspector.AcceptCommissioningBaselineButton.BringIntoView(); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - interaction.ActivateWindow(); - interaction.MovePointerToCenter(inspector.AcceptCommissioningBaselineButton); - await Task.Delay(100); - interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); - interaction.MarkSmokePointerHeld(); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - if (!inspector.AcceptCommissioningBaselineButton.IsPressed) - { - throw new InvalidOperationException("Baseline accept did not enter pointer-down state."); - } - } - else - { - viewModel.AcceptCommissioningBaselineCommand.Execute(null); - if (viewModel.AcceptedCommissioningBaseline is null) - { - throw new InvalidOperationException("Commissioning baseline was not accepted."); - } - if (state.Equals("baseline-mismatch", StringComparison.OrdinalIgnoreCase) - || state.Equals("baseline-mismatch-x", StringComparison.OrdinalIgnoreCase)) - { - var targetIndex = state.Equals("baseline-mismatch-x", StringComparison.OrdinalIgnoreCase) - ? 1 - : 0; - viewModel.MultiAxisCommissioningRecipe.Targets[targetIndex].TargetPosition += 1; - var changedHistoryCount = viewModel.CommissioningResultHistory.Entries.Length; - viewModel.ValidateMultiAxisCommissioningRecipeCommand.Execute(null); - for (var attempt = 0; attempt < 200 - && (viewModel.IsCommissioningValidationRunning - || viewModel.CommissioningResultHistory.Entries.Length <= changedHistoryCount); - attempt++) - { - await Task.Delay(25); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - } - var expectedAxisId = viewModel.MultiAxisCommissioningRecipe.Targets[targetIndex].AxisId; - if (viewModel.CommissioningBaselineComparison?.FirstMismatch?.TargetId != expectedAxisId) - { - throw new InvalidOperationException( - $"Commissioning baseline mismatch did not target axis '{expectedAxisId}'."); - } - viewModel.NavigateToCommissioningMismatchCommand.Execute(null); - if (viewModel.Layout.SelectedItem?.Id != expectedAxisId) - { - throw new InvalidOperationException( - $"Commissioning mismatch navigation did not select axis '{expectedAxisId}'."); - } - } - } - } - if (state.Equals("baseline-mismatch", StringComparison.OrdinalIgnoreCase) - || state.Equals("baseline-mismatch-x", StringComparison.OrdinalIgnoreCase)) - { - inspector.NavigateCommissioningMismatchButton.BringIntoView(); - } - else if (state.Equals("baseline-accepted", StringComparison.OrdinalIgnoreCase)) - { - inspector.AcceptCommissioningBaselineButton.BringIntoView(); - } - else - { - inspector.CommissioningResultHistoryList.BringIntoView(); - } - } - else if (state.Equals("validated", StringComparison.OrdinalIgnoreCase) - || state.Equals("validating", StringComparison.OrdinalIgnoreCase)) - { - viewModel.MultiAxisCommissioningRecipe.ValidationRepetitions = - state.Equals("validating", StringComparison.OrdinalIgnoreCase) ? 100 : 3; - viewModel.ValidateMultiAxisCommissioningRecipeCommand.Execute(null); - for (var attempt = 0; attempt < 200; attempt++) - { - var ready = state.Equals("validating", StringComparison.OrdinalIgnoreCase) - ? viewModel.IsCommissioningValidationRunning - : !viewModel.IsCommissioningValidationRunning - && viewModel.LatestCommissioningResult is not null; - if (ready) - { - break; - } - await Task.Delay(25); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - } - if (state.Equals("validating", StringComparison.OrdinalIgnoreCase) - ? !viewModel.IsCommissioningValidationRunning - : viewModel.LatestCommissioningResult is not { IsSuccess: true }) - { - throw new InvalidOperationException("Recipe validation smoke state was not reached."); - } - } - else if (!state.Equals("ready", StringComparison.OrdinalIgnoreCase)) - { - interaction.ActivateWindow(); - interaction.MovePointerToCenter(inspector.RunMultiAxisRecipeButton); - await Task.Delay(100); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - if (!inspector.RunMultiAxisRecipeButton.IsMouseOver) - { - throw new InvalidOperationException("Recipe Run did not enter pointer-hover state."); - } - if (state.Equals("run-pressed", StringComparison.OrdinalIgnoreCase)) - { - interaction.MouseEvent(MouseEventLeftDown, 0, 0, 0, UIntPtr.Zero); - interaction.MarkSmokePointerHeld(); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - if (!inspector.RunMultiAxisRecipeButton.IsPressed) - { - throw new InvalidOperationException("Recipe Run did not enter pointer-down state."); - } - } - } - break; - case "running": - viewModel.IsRunMode = true; - viewModel.RunMultiAxisCommissioningRecipeCommand.Execute(null); - for (var attempt = 0; attempt < 80 && - !viewModel.MultiAxisCommissioningRecipe.Targets.Any(target => - target.RuntimeState == AxisState.Moving); attempt++) - { - await Task.Delay(25); - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - } - inspector.RunInspectorScrollViewer.ScrollToTop(); - break; - default: - throw new ArgumentException( - $"Unsupported --smoke-multi-axis-recipe-state '{state}'. " + - "Expected design, design-focus, design-popup, ready, run-hover, run-pressed, running, " + - "validation-focus, validation-hover, validation-pressed, validated, validating, " + - "history-selected, baseline-pressed, baseline-accepted, baseline-mismatch, or baseline-mismatch-x."); - } - - await window.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle); - await Task.Delay(150); - } } diff --git a/src/OpenVisionLab.MachineStudio/View/Inspector/RightToolRegionView.xaml b/src/OpenVisionLab.MachineStudio/View/Inspector/RightToolRegionView.xaml index 99e0dd1..c945886 100644 --- a/src/OpenVisionLab.MachineStudio/View/Inspector/RightToolRegionView.xaml +++ b/src/OpenVisionLab.MachineStudio/View/Inspector/RightToolRegionView.xaml @@ -1731,7 +1731,7 @@ - + - + diff --git a/src/OpenVisionLab.MachineStudio/View/Project/RecipeConnectionQuickAddView.xaml b/src/OpenVisionLab.MachineStudio/View/Project/RecipeConnectionQuickAddView.xaml index 76dfe3d..9a1aba5 100644 --- a/src/OpenVisionLab.MachineStudio/View/Project/RecipeConnectionQuickAddView.xaml +++ b/src/OpenVisionLab.MachineStudio/View/Project/RecipeConnectionQuickAddView.xaml @@ -45,14 +45,14 @@ - + - + @@ -343,14 +343,14 @@ - + - + diff --git a/src/OpenVisionLab.MachineStudio/View/Project/RecipeConnectionWorkbenchView.xaml b/src/OpenVisionLab.MachineStudio/View/Project/RecipeConnectionWorkbenchView.xaml index 8425111..8d15dfb 100644 --- a/src/OpenVisionLab.MachineStudio/View/Project/RecipeConnectionWorkbenchView.xaml +++ b/src/OpenVisionLab.MachineStudio/View/Project/RecipeConnectionWorkbenchView.xaml @@ -38,7 +38,7 @@ - + @@ -48,7 +48,7 @@ BasedOn="{StaticResource LoadLockSetupFieldStyle}"> - + @@ -140,7 +140,7 @@ - + @@ -190,7 +190,7 @@ - + @@ -384,7 +384,7 @@