diff --git a/Directory.Build.props b/Directory.Build.props index 16ff7c4..167c8f4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@  - 0.2.0-dev.7 + 0.2.0-dev.19 false 12.0 enable diff --git a/README.md b/README.md index 8bddd29..781406e 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.19` 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,70 +21,56 @@ package. ### Recent version history -#### `v0.2.0-dev.7` (2026-09-07) +#### `v0.2.0-dev.19` (2026-09-09) -- Consolidated concrete ownership boundaries across simulation, integration, - smoke verification, and WPF ViewModel responsibilities while preserving the - existing project, recipe, simulation, and integration contracts. -- Hardened asynchronous lifetime boundaries for commissioning, runtime, - integration, and Recipe Connection workflows with focused regression tests. -- Added late-completion protection for Test Scenario execution and sequence-step - preview during shell/workbench disposal; no release package or deployment was - published. +- Preserved cancellation requests until the next bounded observation so a just-completed batch or commissioning validation is reported as `Cancelled`, not `Idle`. +- Kept one-shot cleanup, duplicate-start sharing, timeout mapping, and caller exception behavior unchanged. -#### `v0.2.0-dev.6` (2026-09-05) +#### `v0.2.0-dev.18` (2026-09-09) -- Made unified commissioning evidence tests deterministic across the supported - Korean and English language settings by asserting localized catalog values. -- Retained the `v0.2.0-dev.5` structural refactor and its explicitly recorded - partial 31-state Direct EXE matrix. +- Separated deterministic sequence-step preview compilation and vacuum-prerequisite policy from fixed-step preview execution into `DeterministicSequenceStepPreviewCompiler.cs`. +- Preserved preview contracts, sequence schema, timing, cancellation, and callers; focused preview tests 14/14, full Simulation 530/530, full MachineStudio 439/439, and Release warning-as-error gates pass. -#### `v0.2.0-dev.5` (2026-09-05) +#### `v0.2.0-dev.17` (2026-09-09) -- Consolidated the Machine Studio refactoring into explicit ownership boundaries - across simulation, ViewModel, and Direct EXE smoke verification code. -- Preserved the existing project, recipe, simulation, and integration contracts - while adding focused unit coverage for the extracted responsibilities. -- The 31-state Direct EXE Recipe Connection matrix remains partial (16/31); this - development candidate does not claim full runtime UI verification. +- Separated deterministic command-trace replay execution from trace contracts, validation, and JSON persistence into `DeterministicSimulationCommandTraceReplayRunner.cs`. +- Preserved public names, record shapes, replay timing, cancellation, schema, and callers; focused command-trace tests 7/7, full Simulation 530/530, full MachineStudio 439/439, and Release warning-as-error gates pass. -#### `v0.2.0-dev.4` (2026-09-04) +#### `v0.2.0-dev.16` (2026-09-09) -- Aligned the release-candidate validation script with the active `-dev.N` - development-candidate version convention. -- Kept the release-candidate workflow limited to build, test, audit, and package - verification; no release or download was published. +- Separated immutable recipe dry-run result contracts from the deterministic execution owner in the Simulation Sequences module. +- Preserved public names, record shapes, timing, and callers; focused runner tests 14/14, full Simulation 530/530, full MachineStudio 439/439, and Release warning-as-error gates pass. -#### `v0.2.0-dev.3` (2026-09-04) +#### `v0.2.0-dev.15` (2026-09-09) -- Corrected the canonical product-version source after the preceding public - metadata commit left `Directory.Build.props` at the previous development - version. -- Retained the independently tested `SimulationRunLoopTiming` refactor with - no additional runtime behavior change. +- Split machine-layout snapshot/state contracts from fixed-tick transition/result contracts into two WPF-neutral owners. +- Preserved public names, namespace, constructor overloads, collection ordering, and call paths; focused layout 50/50, related MachineStudio 129/129, full Simulation 530/530, full MachineStudio 439/439, and Release warning-as-error gates pass. -#### `v0.2.0-dev.2` (2026-09-04) +#### `v0.2.0-dev.14` (2026-09-09) -- Isolated real-time wall-clock accumulation, catch-up limiting, and delay - calculation behind the independently tested `SimulationRunLoopTiming` owner. -- Preserved the existing deterministic fixed-step engine and Release 2 public - development branch contract. +- Split authored deterministic condition profile and recovery contracts from runtime sample, transition, snapshot, and state-machine contracts. +- Preserved public names, JSON schema, and call paths; focused 11/11, related MachineStudio Simulation 74/74, full Simulation 530/530, and Release warning-as-error gates pass. -#### `v0.2.0-dev.1` (2026-09-01) +#### `v0.2.0-dev.13` (2026-09-09) -- Aligned the Release 2 development branch with immutable Contracts `0.2.0-alpha.3` and TCP Transport `0.1.0-alpha.3` package bytes. -- Recorded the clean shared-package source commit and preserved the previous alpha.2 package bytes for traceability. +- Separated authored scenario assertion definitions from the WPF-neutral runtime evaluator and evidence hash owner. +- Preserved schema-5 run-package and recipe dry-run call paths; focused 4/4, related MachineStudio Scenario 33/33, full Simulation 530/530, and Release warning-as-error gates pass. -#### `v0.2.0-dev` (2026-08-31) +#### `v0.2.0-dev.12` (2026-09-09) -- Added authenticated TCP transfer for explicit Machine/consumer transaction - exchange, with session-only shared keys and persisted endpoint settings. -- Preserved the existing explicit Handoff export and Result refresh workflow. +- Extracted active-layout component materialization into the WPF-neutral `MachineProjectRuntimeComponentCompiler`. +- Layout compilation now keeps active-layout selection and device-family orchestration in their existing owners; focused 59/59 and full Simulation 529/529 gates pass. -#### `v0.1.0-rc.4` (2026-08-22) +#### `v0.2.0-dev.11` (2026-09-09) + +- Extracted wafer-handler runtime compilation into the WPF-neutral `MachineLayoutWaferHandlerRuntimeCompiler`. +- Preserved layout-device orchestration and verified focused, full Simulation, MachineStudio Sequence, and Release build gates. + +#### `v0.2.0-dev.10` (2026-09-09) + +- Extracted cross-sequence identity and cycle validation into the WPF-neutral `SequenceCompositionValidator`. +- Preserved the `SequenceCompiler` public compatibility entry point and verified Sequence, MachineStudio, full Simulation, and Release build gates. -- Release 1 public release-candidate baseline for local deterministic - simulation and virtual commissioning. ## Download diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fd20655..5936d51 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,3 +1,61 @@ +## P3-20 Deterministic step preview owners (2026-09-09) + +`DeterministicSequenceStepPreviewCompiler.cs` owns deterministic synthetic +preview-sequence construction, compilation targets, preview IDs, and the +load-lock vacuum prerequisite policy. It consumes explicit step/runtime inputs +and has no engine, WPF, file, network, or mutable lifetime dependency. +`DeterministicSequenceStepPreviewRunner.cs` owns project/step lookup, isolated +`FixedStepSimulationEngine` start/stop and fixed-tick execution, settling/fault +handling, cancellation, and `SequenceStepPreviewResult` creation. + +`RecipeConnectionSimulationWorkflow` and the smoke verifier keep their existing +calls to the runner. The runner calls the compiler directly, then the sequence +compiler and engine through their existing owners. The source is now a 296-line +execution runner plus a 94-line compiler; public names, result shape, preview +schema, timing, cancellation, error text, and callers are unchanged. A junior +navigation path is workflow → runner → compiler or engine, within three modules. +Do not re-split these owners or any completed owner without new evidence. + +## P3-19 Deterministic command trace replay owner (2026-09-09) + +`DeterministicSimulationCommandTrace.cs` owns the trace entry/package/mismatch/result contracts, validation, SHA-256 hash, and JSON persistence. `DeterministicSimulationCommandTraceReplayRunner.cs` owns replay admission, paused fixed-step progression, command enqueueing, and mismatch reduction. `DeterministicSimulationCommandTraceCommandCodec.cs` remains the typed argument codec owner, while `FixedStepSimulationEngine.cs` remains the mutable tick and lifetime owner. + +The existing `SimulationCommandTraceViewModel` caller and public names, record shapes, schema, timing, cancellation, and serialization remain unchanged. A junior navigation path is trace contracts/persistence -> replay runner -> engine, within three modules. Do not re-split this owner or any completed owner without a new defect, requirement, responsibility conflict, or measured constraint. + + +## P3-18 Recipe dry-run contract owner (2026-09-09) + +`RecipeDryRunContracts.cs` is the navigation owner for the immutable +`RecipeDryRunOutcome`, issue, checkpoint, mismatch, step-trace, and result +values. It has no engine lifetime, file I/O, WPF, or mutable runtime state. +`DeterministicRecipeDryRunRunner.cs` retains project lookup, compilation, +fixed-tick command execution, trace/checkpoint reduction, and engine start/stop. +The runner and all existing callers use the same public names and namespace. + +A junior path is Simulation sequence → RecipeDryRunContracts for result meaning, or +Simulation sequence → DeterministicRecipeDryRunRunner → FixedStepSimulationEngine +for execution, within three modules. No API, schema, timing, cancellation, or +serialization change was made. Do not re-split this owner or earlier completed +owners without new evidence. + +`MachineProjectRuntimeComponentCompiler` owns conversion of the selected +active layout's component definitions into typed runtime component records, +including sensor target/delay and cylinder timing validation. The surrounding +`MachineProjectRuntimeLayoutCompiler` keeps project validation and active-layout +selection, then delegates device-family runtime compilation to +`MachineLayoutDeviceRuntimeCompiler`. This keeps the junior navigation path +project compiler → layout compiler → component compiler explicit. + +`DeterministicScenarioAssertion` is the project-owned normalized assertion +contract. `DeterministicScenarioAssertionEvaluator` is the internal WPF-neutral +runtime evidence policy: it evaluates captured snapshots/events, resolves final +equipment state for checkpoints, and owns the definition/outcome hashes used by +portable run evidence. `DeterministicSimulationRunResultPackage` and +`DeterministicRecipeDryRunRunner` call that owner directly. Definition mapping, +normalization, and validation remain with the assertion contract. Do not split +these owners again without a newly measured defect, requirement, responsibility +conflict, or constraint. + # OpenVisionLab Machine Studio Architecture ## Overview @@ -13,6 +71,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 +104,97 @@ 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. + +## Machine layout runtime contract ownership + +Machine-layout immutable state contracts are in +`MachineLayoutRuntimeSnapshots.cs`. It owns the general component snapshot, +specialized equipment snapshots, and the enums that describe their current +runtime state. `MachineLayoutRuntimeTransitions.cs` owns accepted sensor, +cylinder, and conveyor transitions plus the immutable `MachineLayoutTickResult` +that groups one fixed-tick output. `DeterministicMachineLayout` remains the +mutable tick/reset owner; `SimulationSnapshotFactory` reads snapshots and +`SimulationPhysicalRuntimeTick` reads transitions. Both files remain +WPF-neutral, use the existing namespace, and preserve all public contracts and +collection ordering. + +## Deterministic condition scenario ownership + +The authored condition profile is discoverable in `DeterministicConditionScenarioProfile.cs`. It owns profile data, recovery schedules, JSON persistence, normalization, and validation. Runtime samples, transitions, snapshots, and mutable deterministic progression are discoverable in `DeterministicConditionScenarioRuntime.cs`, which owns `DeterministicConditionStateMachine`. Both files remain WPF-neutral in the existing `Scenarios` namespace, and existing engine command/progress/runtime/replay owners keep their boundaries. Do not re-split these owners without a new defect, requirement, responsibility conflict, or measured constraint. ## Authoring-to-runtime flow @@ -56,8 +206,30 @@ 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 + coordinates LoadLock, Inspection, OHT, and Prealigner bindings + delegates WaferHandler validation to MachineLayoutWaferHandlerRuntimeCompiler + 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 +239,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 +251,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 +512,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 +534,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 +603,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 +621,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 +649,158 @@ 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. + +`MachineLayoutDeviceRuntimeCompiler` remains the layout-device orchestration owner. +`MachineLayoutWaferHandlerRuntimeCompiler` owns the separate wafer-handler contract: +handler filtering, axis/workpiece/channel validation, soft-limit checks, and typed +runtime configuration/error construction. It has no WPF, file, network, engine, or +shared mutable state dependency; the coordinator keeps the other device-family +compilers and final `MachineLayoutRuntimeConfiguration` assembly. The active application shell is `ShellWindow`; the retired `MainWindow`, `WorkspaceView`, and `SimulationWorkspaceView` resources are not part of the compiled UI path. @@ -244,20 +826,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 +841,487 @@ 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. +`SequenceCompiler` owns the one-definition contract: authored fields, target lookup, +step parameter parsing, and typed `CompiledSequence` construction. +`SequenceCompositionValidator` owns the separate post-compilation graph contract: +sequence-id lookup, unknown `CallSubsequenceStep` targets, and cycle detection. +`MachineProjectRuntimeSequenceCompiler` and `SimulationRuntimeConfigurationBuilder` +call the validator directly; `SequenceCompiler.ValidateComposition` remains only as +a public compatibility forwarder. + +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. A cancellation request is retained +until the next bounded observation, so synchronous task completion cannot be +misreported as `Idle`; the request is consumed once that observation captures +the task. 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 two concrete owners. +`SimulationRunControlAdmissionPolicy` consumes the immutable +`SimulationRunControlState` snapshot and returns the seven shell-facing `Can*` +decisions without an engine, WPF, or callback dependency. +`SimulationRunControlWorkflow` keeps command construction, active-sequence +preconditions, cross-command serialization, busy admission, callbacks, and +disposal. Main keeps the public `ICommand` names and mode, runtime-definition, +status, log, and snapshot callbacks. The workflow rechecks the policy 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 +1329,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 +1391,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 +1408,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..1c7e5d4 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.19` 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.Sequence/Compilation/SequenceCompiler.cs b/src/OpenVisionLab.Machine.Sequence/Compilation/SequenceCompiler.cs index 980d5d9..c75d3da 100644 --- a/src/OpenVisionLab.Machine.Sequence/Compilation/SequenceCompiler.cs +++ b/src/OpenVisionLab.Machine.Sequence/Compilation/SequenceCompiler.cs @@ -148,87 +148,13 @@ public SequenceCompilationResult Compile( }; } + /// + /// Compatibility entry point; composition graph validation lives in + /// . + /// public static IReadOnlyList ValidateComposition( - IEnumerable sequences) - { - ArgumentNullException.ThrowIfNull(sequences); - - var byId = sequences - .Where(sequence => sequence is not null && !string.IsNullOrWhiteSpace(sequence.Id)) - .GroupBy(sequence => sequence.Id, StringComparer.Ordinal) - .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); - var errors = new List(); - foreach (var sequence in byId.Values.OrderBy(item => item.Id, StringComparer.Ordinal)) - { - foreach (var call in sequence.Steps.OfType()) - { - if (!byId.ContainsKey(call.SequenceId)) - { - errors.Add(new SequenceCompositionError( - SequenceCompilationErrorCode.UnknownSubsequence, - sequence.Id, - call.Id, - $"Subsequence '{call.SequenceId}' is not configured.")); - } - } - } - - var visitState = new Dictionary(StringComparer.Ordinal); - var path = new List(); - var reportedCycles = new HashSet(StringComparer.Ordinal); - foreach (var sequence in byId.Values.OrderBy(item => item.Id, StringComparer.Ordinal)) - { - Visit(sequence.Id); - } - - return errors; - - void Visit(string sequenceId) - { - if (visitState.TryGetValue(sequenceId, out var state)) - { - if (state == 2) - { - return; - } - - var cycleStart = path.IndexOf(sequenceId); - if (cycleStart < 0) - { - return; - } - - var cycle = path.Skip(cycleStart).Append(sequenceId).ToArray(); - var cycleKey = string.Join("\u001f", cycle.Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal)); - if (reportedCycles.Add(cycleKey)) - { - errors.Add(new SequenceCompositionError( - SequenceCompilationErrorCode.SubsequenceCycle, - cycle[0], - null, - $"Subsequence call cycle detected: {string.Join(" -> ", cycle)}.")); - } - - return; - } - - visitState[sequenceId] = 1; - path.Add(sequenceId); - if (byId.TryGetValue(sequenceId, out var sequence)) - { - foreach (var call in sequence.Steps.OfType()) - { - if (byId.ContainsKey(call.SequenceId)) - { - Visit(call.SequenceId); - } - } - } - - path.RemoveAt(path.Count - 1); - visitState[sequenceId] = 2; - } - } + IEnumerable sequences) => + SequenceCompositionValidator.Validate(sequences); private static void ValidateExpectedStateCheckpoint( SequenceStepDefinition source, diff --git a/src/OpenVisionLab.Machine.Sequence/Compilation/SequenceCompositionValidator.cs b/src/OpenVisionLab.Machine.Sequence/Compilation/SequenceCompositionValidator.cs new file mode 100644 index 0000000..7e441ae --- /dev/null +++ b/src/OpenVisionLab.Machine.Sequence/Compilation/SequenceCompositionValidator.cs @@ -0,0 +1,89 @@ +namespace OpenVisionLab.Machine.Sequence.Compilation; + +/// +/// Owns cross-sequence composition checks after individual sequences are compiled. +/// +public static class SequenceCompositionValidator +{ + public static IReadOnlyList Validate( + IEnumerable sequences) + { + ArgumentNullException.ThrowIfNull(sequences); + + var byId = sequences + .Where(sequence => sequence is not null && !string.IsNullOrWhiteSpace(sequence.Id)) + .GroupBy(sequence => sequence.Id, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); + var errors = new List(); + foreach (var sequence in byId.Values.OrderBy(item => item.Id, StringComparer.Ordinal)) + { + foreach (var call in sequence.Steps.OfType()) + { + if (!byId.ContainsKey(call.SequenceId)) + { + errors.Add(new SequenceCompositionError( + SequenceCompilationErrorCode.UnknownSubsequence, + sequence.Id, + call.Id, + $"Subsequence '{call.SequenceId}' is not configured.")); + } + } + } + + var visitState = new Dictionary(StringComparer.Ordinal); + var path = new List(); + var reportedCycles = new HashSet(StringComparer.Ordinal); + foreach (var sequence in byId.Values.OrderBy(item => item.Id, StringComparer.Ordinal)) + { + Visit(sequence.Id); + } + + return errors; + + void Visit(string sequenceId) + { + if (visitState.TryGetValue(sequenceId, out var state)) + { + if (state == 2) + { + return; + } + + var cycleStart = path.IndexOf(sequenceId); + if (cycleStart < 0) + { + return; + } + + var cycle = path.Skip(cycleStart).Append(sequenceId).ToArray(); + var cycleKey = string.Join("\u001f", cycle.Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal)); + if (reportedCycles.Add(cycleKey)) + { + errors.Add(new SequenceCompositionError( + SequenceCompilationErrorCode.SubsequenceCycle, + cycle[0], + null, + $"Subsequence call cycle detected: {string.Join(" -> ", cycle)}.")); + } + + return; + } + + visitState[sequenceId] = 1; + path.Add(sequenceId); + if (byId.TryGetValue(sequenceId, out var sequence)) + { + foreach (var call in sequence.Steps.OfType()) + { + if (byId.ContainsKey(call.SequenceId)) + { + Visit(call.SequenceId); + } + } + } + + path.RemoveAt(path.Count - 1); + visitState[sequenceId] = 2; + } + } +} 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/Compilation/MachineLayoutDeviceRuntimeCompiler.cs b/src/OpenVisionLab.Machine.Simulation/Compilation/MachineLayoutDeviceRuntimeCompiler.cs index 67462ba..0f4368b 100644 --- a/src/OpenVisionLab.Machine.Simulation/Compilation/MachineLayoutDeviceRuntimeCompiler.cs +++ b/src/OpenVisionLab.Machine.Simulation/Compilation/MachineLayoutDeviceRuntimeCompiler.cs @@ -10,6 +10,7 @@ internal sealed class MachineLayoutDeviceRuntimeCompiler { private readonly FixedStepDelayConverter _delayConverter; private readonly MachineLayoutInspectionRuntimeCompiler _inspectionRuntimeCompiler = new(); + private readonly MachineLayoutWaferHandlerRuntimeCompiler _waferHandlerRuntimeCompiler = new(); internal MachineLayoutDeviceRuntimeCompiler(FixedStepDelayConverter delayConverter) { @@ -37,7 +38,7 @@ internal MachineLayoutDeviceRuntimeCompiler(FixedStepDelayConverter delayConvert return null; } - IReadOnlyList waferHandlers = BuildWaferHandlers( + IReadOnlyList waferHandlers = _waferHandlerRuntimeCompiler.Compile( devices, runtimeComponents, axesById, @@ -227,103 +228,6 @@ private static bool IsCylinder( && componentsById.TryGetValue(componentId, out var component) && component is PneumaticCylinderRuntimeConfiguration; - private static IReadOnlyList BuildWaferHandlers( - IEnumerable devices, - IReadOnlyCollection runtimeComponents, - IReadOnlyDictionary axesById, - IReadOnlyDictionary? channelKinds, - ICollection errors) - { - var workpieceIds = runtimeComponents - .OfType() - .Select(workpiece => workpiece.Id) - .ToHashSet(StringComparer.Ordinal); - var handlers = new List(); - - foreach (DeviceDefinition device in devices - .Where(device => device.Kind == DeviceKind.Handler) - .OrderBy(device => device.Id, StringComparer.Ordinal)) - { - string targetId = string.IsNullOrWhiteSpace(device.Id) ? "devices.waferHandler" : device.Id; - WaferHandlerDefinition? definition = device.WaferHandler; - if (definition is null) - { - AddWaferHandlerError(errors, targetId, "Wafer-handler settings are required."); - continue; - } - - if (!axesById.TryGetValue(definition.HorizontalAxisId, out VirtualAxisDefinition? horizontal) - || !axesById.TryGetValue(definition.VerticalAxisId, out VirtualAxisDefinition? vertical) - || horizontal.Kind != AxisKind.Linear - || vertical.Kind != AxisKind.Linear - || string.Equals(horizontal.Id, vertical.Id, StringComparison.Ordinal)) - { - AddWaferHandlerError(errors, targetId, "Wafer-handler axes must identify two distinct configured linear axes."); - continue; - } - - if (!workpieceIds.Contains(definition.WorkpieceComponentId)) - { - AddWaferHandlerError(errors, targetId, "Wafer-handler workpiece must identify a workpiece in the active layout."); - continue; - } - - if (!PositionWithin(horizontal, definition.PickHorizontalPosition) - || !PositionWithin(vertical, definition.PickVerticalPosition) - || !PositionWithin(horizontal, definition.PlaceHorizontalPosition) - || !PositionWithin(vertical, definition.PlaceVerticalPosition)) - { - AddWaferHandlerError(errors, targetId, "Wafer-handler pick and place positions must be finite and within their axis soft limits."); - continue; - } - - if (channelKinds is null - || !HasChannelKind(definition.SourcePresentSensorChannelId, ChannelKind.DigitalInput, channelKinds) - || !HasChannelKind(definition.GateOpenSensorChannelId, ChannelKind.DigitalInput, channelKinds) - || !HasChannelKind(definition.PickCommandChannelId, ChannelKind.DigitalOutput, channelKinds) - || !HasChannelKind(definition.PlaceCommandChannelId, ChannelKind.DigitalOutput, channelKinds) - || !HasChannelKind(definition.HoldingFeedbackChannelId, ChannelKind.DigitalInput, channelKinds) - || !HasChannelKind(definition.PlacedFeedbackChannelId, ChannelKind.DigitalInput, channelKinds)) - { - AddWaferHandlerError(errors, targetId, "Wafer-handler conditions/feedback must be DigitalInput and pick/place commands must be DigitalOutput."); - continue; - } - - try - { - handlers.Add(new WaferHandlerRuntimeConfiguration( - device.Id, - device.Name, - definition.HorizontalAxisId, - definition.VerticalAxisId, - definition.WorkpieceComponentId, - definition.SourcePresentSensorChannelId, - definition.GateOpenSensorChannelId, - definition.PickCommandChannelId, - definition.PlaceCommandChannelId, - definition.HoldingFeedbackChannelId, - definition.PlacedFeedbackChannelId, - definition.PickHorizontalPosition, - definition.PickVerticalPosition, - definition.PlaceHorizontalPosition, - definition.PlaceVerticalPosition)); - } - catch (ArgumentException exception) - { - AddWaferHandlerError(errors, targetId, exception.Message); - } - } - - return handlers; - } - - private static bool PositionWithin(VirtualAxisDefinition axis, double position) => - double.IsFinite(position) - && axis.SoftLimitMin.HasValue - && axis.SoftLimitMax.HasValue - && position >= axis.SoftLimitMin.Value - && position <= axis.SoftLimitMax.Value; - private static IReadOnlyList BuildPrealigners( IEnumerable devices, IReadOnlyCollection runtimeComponents, @@ -499,15 +403,6 @@ private static void AddOhtHandoffError( targetId, message)); - private static void AddWaferHandlerError( - ICollection errors, - string targetId, - string message) => - errors.Add(Error( - MachineProjectRuntimeCompilationErrorCode.WaferHandlerConfigurationInvalid, - targetId, - message)); - private static bool HasChannelKind( string channelId, ChannelKind expectedKind, diff --git a/src/OpenVisionLab.Machine.Simulation/Compilation/MachineLayoutWaferHandlerRuntimeCompiler.cs b/src/OpenVisionLab.Machine.Simulation/Compilation/MachineLayoutWaferHandlerRuntimeCompiler.cs new file mode 100644 index 0000000..bc6b4e1 --- /dev/null +++ b/src/OpenVisionLab.Machine.Simulation/Compilation/MachineLayoutWaferHandlerRuntimeCompiler.cs @@ -0,0 +1,126 @@ +using OpenVisionLab.Machine.Core.Axes; +using OpenVisionLab.Machine.Core.Channels; +using OpenVisionLab.Machine.Core.Devices; +using OpenVisionLab.Machine.Simulation.Layout; + +namespace OpenVisionLab.Machine.Simulation.Compilation; + +/// +/// Compiles wafer-handler device definitions into immutable runtime configurations. +/// +internal sealed class MachineLayoutWaferHandlerRuntimeCompiler +{ + internal IReadOnlyList Compile( + IEnumerable devices, + IReadOnlyCollection runtimeComponents, + IReadOnlyDictionary axesById, + IReadOnlyDictionary? channelKinds, + ICollection errors) + { + var workpieceIds = runtimeComponents + .OfType() + .Select(workpiece => workpiece.Id) + .ToHashSet(StringComparer.Ordinal); + var handlers = new List(); + + foreach (DeviceDefinition device in devices + .Where(device => device.Kind == DeviceKind.Handler) + .OrderBy(device => device.Id, StringComparer.Ordinal)) + { + string targetId = string.IsNullOrWhiteSpace(device.Id) ? "devices.waferHandler" : device.Id; + WaferHandlerDefinition? definition = device.WaferHandler; + if (definition is null) + { + AddError(errors, targetId, "Wafer-handler settings are required."); + continue; + } + + if (!axesById.TryGetValue(definition.HorizontalAxisId, out VirtualAxisDefinition? horizontal) + || !axesById.TryGetValue(definition.VerticalAxisId, out VirtualAxisDefinition? vertical) + || horizontal.Kind != AxisKind.Linear + || vertical.Kind != AxisKind.Linear + || string.Equals(horizontal.Id, vertical.Id, StringComparison.Ordinal)) + { + AddError(errors, targetId, "Wafer-handler axes must identify two distinct configured linear axes."); + continue; + } + + if (!workpieceIds.Contains(definition.WorkpieceComponentId)) + { + AddError(errors, targetId, "Wafer-handler workpiece must identify a workpiece in the active layout."); + continue; + } + + if (!PositionWithin(horizontal, definition.PickHorizontalPosition) + || !PositionWithin(vertical, definition.PickVerticalPosition) + || !PositionWithin(horizontal, definition.PlaceHorizontalPosition) + || !PositionWithin(vertical, definition.PlaceVerticalPosition)) + { + AddError(errors, targetId, "Wafer-handler pick and place positions must be finite and within their axis soft limits."); + continue; + } + + if (channelKinds is null + || !HasChannelKind(definition.SourcePresentSensorChannelId, ChannelKind.DigitalInput, channelKinds) + || !HasChannelKind(definition.GateOpenSensorChannelId, ChannelKind.DigitalInput, channelKinds) + || !HasChannelKind(definition.PickCommandChannelId, ChannelKind.DigitalOutput, channelKinds) + || !HasChannelKind(definition.PlaceCommandChannelId, ChannelKind.DigitalOutput, channelKinds) + || !HasChannelKind(definition.HoldingFeedbackChannelId, ChannelKind.DigitalInput, channelKinds) + || !HasChannelKind(definition.PlacedFeedbackChannelId, ChannelKind.DigitalInput, channelKinds)) + { + AddError(errors, targetId, "Wafer-handler conditions/feedback must be DigitalInput and pick/place commands must be DigitalOutput."); + continue; + } + + try + { + handlers.Add(new WaferHandlerRuntimeConfiguration( + device.Id, + device.Name, + definition.HorizontalAxisId, + definition.VerticalAxisId, + definition.WorkpieceComponentId, + definition.SourcePresentSensorChannelId, + definition.GateOpenSensorChannelId, + definition.PickCommandChannelId, + definition.PlaceCommandChannelId, + definition.HoldingFeedbackChannelId, + definition.PlacedFeedbackChannelId, + definition.PickHorizontalPosition, + definition.PickVerticalPosition, + definition.PlaceHorizontalPosition, + definition.PlaceVerticalPosition)); + } + catch (ArgumentException exception) + { + AddError(errors, targetId, exception.Message); + } + } + + return handlers; + } + + private static bool PositionWithin(VirtualAxisDefinition axis, double position) => + double.IsFinite(position) + && axis.SoftLimitMin.HasValue + && axis.SoftLimitMax.HasValue + && position >= axis.SoftLimitMin.Value + && position <= axis.SoftLimitMax.Value; + + private static bool HasChannelKind( + string channelId, + ChannelKind expectedKind, + IReadOnlyDictionary channelKinds) => + !string.IsNullOrWhiteSpace(channelId) + && channelKinds.TryGetValue(channelId, out ChannelKind kind) + && kind == expectedKind; + + private static void AddError( + ICollection errors, + string targetId, + string message) => + errors.Add(new( + MachineProjectRuntimeCompilationErrorCode.WaferHandlerConfigurationInvalid, + targetId, + message)); +} diff --git a/src/OpenVisionLab.Machine.Simulation/Compilation/MachineProjectRuntimeComponentCompiler.cs b/src/OpenVisionLab.Machine.Simulation/Compilation/MachineProjectRuntimeComponentCompiler.cs new file mode 100644 index 0000000..7a58915 --- /dev/null +++ b/src/OpenVisionLab.Machine.Simulation/Compilation/MachineProjectRuntimeComponentCompiler.cs @@ -0,0 +1,200 @@ +using System.Globalization; +using OpenVisionLab.Machine.Core.Axes; +using OpenVisionLab.Machine.Core.Devices; +using OpenVisionLab.Machine.Core.Layouts; +using OpenVisionLab.Machine.Simulation.Layout; + +namespace OpenVisionLab.Machine.Simulation.Compilation; + +/// +/// Converts active layout component definitions into runtime component records. +/// +internal sealed class MachineProjectRuntimeComponentCompiler +{ + private readonly FixedStepDelayConverter _delayConverter; + + internal MachineProjectRuntimeComponentCompiler(FixedStepDelayConverter delayConverter) + { + ArgumentNullException.ThrowIfNull(delayConverter); + _delayConverter = delayConverter; + } + + internal IReadOnlyList Compile( + MachineLayoutDefinition activeLayout, + IReadOnlyDictionary axesById, + IReadOnlyDictionary devicesById, + ICollection errors) + { + ArgumentNullException.ThrowIfNull(activeLayout); + ArgumentNullException.ThrowIfNull(axesById); + ArgumentNullException.ThrowIfNull(devicesById); + ArgumentNullException.ThrowIfNull(errors); + + var activeComponentIds = activeLayout.Components + .Select(component => component.Id) + .ToHashSet(StringComparer.Ordinal); + var runtimeComponents = new List(); + + foreach (LayoutComponentDefinition component in activeLayout.Components) + { + var transform = new LayoutRuntimeTransform( + component.Transform.X, + component.Transform.Y, + component.Transform.RotationDegrees); + var size = new LayoutRuntimeSize(component.Size.Width, component.Size.Height); + + switch (component.Kind) + { + case LayoutComponentKind.MachineFrame: + runtimeComponents.Add(new MachineFrameRuntimeConfiguration( + component.Id, + component.Name, + transform, + size)); + break; + + case LayoutComponentKind.LinearStage: + VirtualAxisDefinition axis = axesById[component.BehaviorBindingId!]; + runtimeComponents.Add(new LinearStageRuntimeConfiguration( + component.Id, + component.Name, + axis.Id, + axis.HomePosition, + transform, + size)); + break; + + case LayoutComponentKind.RotaryStage: + VirtualAxisDefinition rotaryAxis = axesById[component.BehaviorBindingId!]; + runtimeComponents.Add(new RotaryStageRuntimeConfiguration( + component.Id, + component.Name, + rotaryAxis.Id, + rotaryAxis.HomePosition, + transform, + size)); + break; + + case LayoutComponentKind.DigitalSensor: + DeviceDefinition device = devicesById[component.BehaviorBindingId!]; + DigitalSensorDefinition sensor = device.Sensor!; + if (!activeComponentIds.Contains(sensor.TargetComponentId)) + { + errors.Add(Error( + MachineProjectRuntimeCompilationErrorCode.LayoutTargetOutsideActiveLayout, + component.Id, + $"Sensor '{component.Id}' target '{sensor.TargetComponentId}' is not part of active layout '{activeLayout.Id}'.")); + break; + } + + bool onValid = _delayConverter.TryConvertDelayToTicks( + sensor.OnDelayMilliseconds, + allowZero: true, + out int onDelayTicks); + bool offValid = _delayConverter.TryConvertDelayToTicks( + sensor.OffDelayMilliseconds, + allowZero: true, + out int offDelayTicks); + if (!onValid || !offValid) + { + errors.Add(Error( + MachineProjectRuntimeCompilationErrorCode.SensorDelayInvalid, + component.Id, + $"Digital sensor '{component.Id}' on/off delays must be zero or exact multiples of {_delayConverter.FixedStep.TotalMilliseconds.ToString("0.###", CultureInfo.InvariantCulture)} ms.")); + break; + } + + runtimeComponents.Add(new DigitalSensorRuntimeConfiguration( + component.Id, + component.Name, + sensor.OutputChannelId, + sensor.TargetComponentId, + onDelayTicks, + offDelayTicks, + transform, + size)); + break; + + case LayoutComponentKind.PneumaticCylinder: + DeviceDefinition cylinderDevice = devicesById[component.BehaviorBindingId!]; + PneumaticCylinderDefinition cylinder = cylinderDevice.Cylinder!; + bool extendValid = _delayConverter.TryConvertDelayToTicks( + cylinder.ExtendDurationMilliseconds, + allowZero: false, + out int extendDurationTicks); + bool retractValid = _delayConverter.TryConvertDelayToTicks( + cylinder.RetractDurationMilliseconds, + allowZero: false, + out int retractDurationTicks); + bool extendedDelayValid = _delayConverter.TryConvertDelayToTicks( + cylinder.ExtendedSensorDelayMilliseconds, + allowZero: true, + out int extendedSensorDelayTicks); + bool retractedDelayValid = _delayConverter.TryConvertDelayToTicks( + cylinder.RetractedSensorDelayMilliseconds, + allowZero: true, + out int retractedSensorDelayTicks); + if (!extendValid || !retractValid || !extendedDelayValid || !retractedDelayValid) + { + errors.Add(Error( + MachineProjectRuntimeCompilationErrorCode.CylinderTimingInvalid, + component.Id, + $"Pneumatic cylinder '{component.Id}' durations must be positive and " + + $"sensor delays must be non-negative exact multiples of " + + $"{_delayConverter.FixedStep.TotalMilliseconds.ToString("0.###", CultureInfo.InvariantCulture)} ms.")); + break; + } + + runtimeComponents.Add(new PneumaticCylinderRuntimeConfiguration( + component.Id, + component.Name, + cylinder.ExtendCommandChannelId, + cylinder.ExtendedSensorChannelId, + cylinder.RetractedSensorChannelId, + extendDurationTicks, + retractDurationTicks, + extendedSensorDelayTicks, + retractedSensorDelayTicks, + cylinder.Stroke, + transform, + size)); + break; + + case LayoutComponentKind.Conveyor: + DeviceDefinition conveyorDevice = devicesById[component.BehaviorBindingId!]; + ConveyorDefinition conveyor = conveyorDevice.Conveyor!; + runtimeComponents.Add(new ConveyorRuntimeConfiguration( + component.Id, + component.Name, + conveyor.RunCommandChannelId, + conveyor.ReverseCommandChannelId, + conveyor.SpeedUnitsPerSecond, + _delayConverter.FixedStep.TotalSeconds, + transform, + size)); + break; + + case LayoutComponentKind.Workpiece: + DeviceDefinition workpieceDevice = devicesById[component.BehaviorBindingId!]; + WorkpieceDefinition workpiece = workpieceDevice.Workpiece!; + runtimeComponents.Add(new WorkpieceRuntimeConfiguration( + component.Id, + component.Name, + workpiece.Type, + workpiece.ConveyorComponentId, + workpiece.InspectionState, + transform, + size)); + break; + } + } + + return runtimeComponents; + } + + private static MachineProjectRuntimeCompilationError Error( + MachineProjectRuntimeCompilationErrorCode code, + string? targetId, + string message) => + new(code, targetId, message); +} diff --git a/src/OpenVisionLab.Machine.Simulation/Compilation/MachineProjectRuntimeLayoutCompiler.cs b/src/OpenVisionLab.Machine.Simulation/Compilation/MachineProjectRuntimeLayoutCompiler.cs index 026cd82..ac0fe1b 100644 --- a/src/OpenVisionLab.Machine.Simulation/Compilation/MachineProjectRuntimeLayoutCompiler.cs +++ b/src/OpenVisionLab.Machine.Simulation/Compilation/MachineProjectRuntimeLayoutCompiler.cs @@ -1,4 +1,3 @@ -using System.Globalization; using OpenVisionLab.Machine.Core.Axes; using OpenVisionLab.Machine.Core.Channels; using OpenVisionLab.Machine.Core.Devices; @@ -10,14 +9,14 @@ namespace OpenVisionLab.Machine.Simulation.Compilation; internal sealed class MachineProjectRuntimeLayoutCompiler { - private readonly FixedStepDelayConverter _delayConverter; private readonly MachineLayoutDeviceRuntimeCompiler _layoutDeviceRuntimeCompiler; + private readonly MachineProjectRuntimeComponentCompiler _componentCompiler; internal MachineProjectRuntimeLayoutCompiler(FixedStepDelayConverter delayConverter) { ArgumentNullException.ThrowIfNull(delayConverter); - _delayConverter = delayConverter; _layoutDeviceRuntimeCompiler = new MachineLayoutDeviceRuntimeCompiler(delayConverter); + _componentCompiler = new MachineProjectRuntimeComponentCompiler(delayConverter); } internal MachineLayoutRuntimeConfiguration? Compile( @@ -105,164 +104,11 @@ internal MachineProjectRuntimeLayoutCompiler(FixedStepDelayConverter delayConver var devicesById = project.Devices .GroupBy(device => device.Id, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); - var activeComponentIds = activeLayout.Components - .Select(component => component.Id) - .ToHashSet(StringComparer.Ordinal); - var runtimeComponents = new List(); - - foreach (LayoutComponentDefinition component in activeLayout.Components) - { - var transform = new LayoutRuntimeTransform( - component.Transform.X, - component.Transform.Y, - component.Transform.RotationDegrees); - var size = new LayoutRuntimeSize(component.Size.Width, component.Size.Height); - - switch (component.Kind) - { - case LayoutComponentKind.MachineFrame: - runtimeComponents.Add(new MachineFrameRuntimeConfiguration( - component.Id, - component.Name, - transform, - size)); - break; - - case LayoutComponentKind.LinearStage: - VirtualAxisDefinition axis = axesById[component.BehaviorBindingId!]; - runtimeComponents.Add(new LinearStageRuntimeConfiguration( - component.Id, - component.Name, - axis.Id, - axis.HomePosition, - transform, - size)); - break; - - case LayoutComponentKind.RotaryStage: - VirtualAxisDefinition rotaryAxis = axesById[component.BehaviorBindingId!]; - runtimeComponents.Add(new RotaryStageRuntimeConfiguration( - component.Id, - component.Name, - rotaryAxis.Id, - rotaryAxis.HomePosition, - transform, - size)); - break; - - case LayoutComponentKind.DigitalSensor: - DeviceDefinition device = devicesById[component.BehaviorBindingId!]; - DigitalSensorDefinition sensor = device.Sensor!; - if (!activeComponentIds.Contains(sensor.TargetComponentId)) - { - errors.Add(Error( - MachineProjectRuntimeCompilationErrorCode.LayoutTargetOutsideActiveLayout, - component.Id, - $"Sensor '{component.Id}' target '{sensor.TargetComponentId}' is not part of active layout '{activeLayout.Id}'.")); - break; - } - - bool onValid = _delayConverter.TryConvertDelayToTicks( - sensor.OnDelayMilliseconds, - allowZero: true, - out int onDelayTicks); - bool offValid = _delayConverter.TryConvertDelayToTicks( - sensor.OffDelayMilliseconds, - allowZero: true, - out int offDelayTicks); - if (!onValid || !offValid) - { - errors.Add(Error( - MachineProjectRuntimeCompilationErrorCode.SensorDelayInvalid, - component.Id, - $"Digital sensor '{component.Id}' on/off delays must be zero or exact multiples of {_delayConverter.FixedStep.TotalMilliseconds.ToString("0.###", CultureInfo.InvariantCulture)} ms.")); - break; - } - - runtimeComponents.Add(new DigitalSensorRuntimeConfiguration( - component.Id, - component.Name, - sensor.OutputChannelId, - sensor.TargetComponentId, - onDelayTicks, - offDelayTicks, - transform, - size)); - break; - - case LayoutComponentKind.PneumaticCylinder: - DeviceDefinition cylinderDevice = devicesById[component.BehaviorBindingId!]; - PneumaticCylinderDefinition cylinder = cylinderDevice.Cylinder!; - bool extendValid = _delayConverter.TryConvertDelayToTicks( - cylinder.ExtendDurationMilliseconds, - allowZero: false, - out int extendDurationTicks); - bool retractValid = _delayConverter.TryConvertDelayToTicks( - cylinder.RetractDurationMilliseconds, - allowZero: false, - out int retractDurationTicks); - bool extendedDelayValid = _delayConverter.TryConvertDelayToTicks( - cylinder.ExtendedSensorDelayMilliseconds, - allowZero: true, - out int extendedSensorDelayTicks); - bool retractedDelayValid = _delayConverter.TryConvertDelayToTicks( - cylinder.RetractedSensorDelayMilliseconds, - allowZero: true, - out int retractedSensorDelayTicks); - if (!extendValid || !retractValid || !extendedDelayValid || !retractedDelayValid) - { - errors.Add(Error( - MachineProjectRuntimeCompilationErrorCode.CylinderTimingInvalid, - component.Id, - $"Pneumatic cylinder '{component.Id}' durations must be positive and " + - $"sensor delays must be non-negative exact multiples of " + - $"{_delayConverter.FixedStep.TotalMilliseconds.ToString("0.###", CultureInfo.InvariantCulture)} ms.")); - break; - } - - runtimeComponents.Add(new PneumaticCylinderRuntimeConfiguration( - component.Id, - component.Name, - cylinder.ExtendCommandChannelId, - cylinder.ExtendedSensorChannelId, - cylinder.RetractedSensorChannelId, - extendDurationTicks, - retractDurationTicks, - extendedSensorDelayTicks, - retractedSensorDelayTicks, - cylinder.Stroke, - transform, - size)); - break; - - case LayoutComponentKind.Conveyor: - DeviceDefinition conveyorDevice = devicesById[component.BehaviorBindingId!]; - ConveyorDefinition conveyor = conveyorDevice.Conveyor!; - runtimeComponents.Add(new ConveyorRuntimeConfiguration( - component.Id, - component.Name, - conveyor.RunCommandChannelId, - conveyor.ReverseCommandChannelId, - conveyor.SpeedUnitsPerSecond, - _delayConverter.FixedStep.TotalSeconds, - transform, - size)); - break; - - case LayoutComponentKind.Workpiece: - DeviceDefinition workpieceDevice = devicesById[component.BehaviorBindingId!]; - WorkpieceDefinition workpiece = workpieceDevice.Workpiece!; - runtimeComponents.Add(new WorkpieceRuntimeConfiguration( - component.Id, - component.Name, - workpiece.Type, - workpiece.ConveyorComponentId, - workpiece.InspectionState, - transform, - size)); - break; - } - } + IReadOnlyList runtimeComponents = _componentCompiler.Compile( + activeLayout, + axesById, + devicesById, + errors); if (errors.Any(error => error.Code is MachineProjectRuntimeCompilationErrorCode.LayoutTargetOutsideActiveLayout or diff --git a/src/OpenVisionLab.Machine.Simulation/Compilation/MachineProjectRuntimeSequenceCompiler.cs b/src/OpenVisionLab.Machine.Simulation/Compilation/MachineProjectRuntimeSequenceCompiler.cs index 925c7f9..c9e97a4 100644 --- a/src/OpenVisionLab.Machine.Simulation/Compilation/MachineProjectRuntimeSequenceCompiler.cs +++ b/src/OpenVisionLab.Machine.Simulation/Compilation/MachineProjectRuntimeSequenceCompiler.cs @@ -55,7 +55,7 @@ internal IReadOnlyList Compile( compiled.Add(result.Sequence!); } - foreach (SequenceCompositionError error in SequenceCompiler.ValidateComposition(compiled)) + foreach (SequenceCompositionError error in SequenceCompositionValidator.Validate(compiled)) { errors.Add(Error( MachineProjectRuntimeCompilationErrorCode.SubsequenceCompositionInvalid, 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/SimulationRuntimeConfigurationBuilder.cs b/src/OpenVisionLab.Machine.Simulation/Engine/SimulationRuntimeConfigurationBuilder.cs index d5680f9..4161330 100644 --- a/src/OpenVisionLab.Machine.Simulation/Engine/SimulationRuntimeConfigurationBuilder.cs +++ b/src/OpenVisionLab.Machine.Simulation/Engine/SimulationRuntimeConfigurationBuilder.cs @@ -78,7 +78,7 @@ public bool TryBuild( } } - var compositionErrors = SequenceCompiler.ValidateComposition(compiled.Values); + var compositionErrors = SequenceCompositionValidator.Validate(compiled.Values); if (compositionErrors.Count != 0) { var compositionError = compositionErrors[0]; 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/Layout/MachineLayoutRuntimeSnapshots.cs b/src/OpenVisionLab.Machine.Simulation/Layout/MachineLayoutRuntimeSnapshots.cs new file mode 100644 index 0000000..385d3ca --- /dev/null +++ b/src/OpenVisionLab.Machine.Simulation/Layout/MachineLayoutRuntimeSnapshots.cs @@ -0,0 +1,186 @@ +using System.Collections.ObjectModel; +using OpenVisionLab.Machine.Core.Devices; +using OpenVisionLab.Machine.Core.Layouts; + +namespace OpenVisionLab.Machine.Simulation.Layout; + +/// +/// Immutable current layout state. Behavior-specific fields are null when they +/// do not apply to the component kind. +/// +public sealed record LayoutComponentSnapshot( + string Id, + string Name, + LayoutComponentKind Kind, + double X, + double Y, + double RotationDegrees, + double Width, + double Height, + bool? IsDetected, + int? PendingTransitionTicks, + PneumaticCylinderState? CylinderState = null, + double? MotionProgress = null, + bool? ConveyorRunning = null, + ConveyorDirection? ConveyorDirection = null, + double? ConveyorSpeedUnitsPerSecond = null, + string? CarrierComponentId = null, + double? CarrierPosition = null, + string? WorkpieceType = null, + WorkpieceInspectionState? InspectionState = null, + string? SensorOutputChannelId = null, + string? TransferOwnerId = null, + WaferHandlerOwnershipState? TransferOwnershipState = null); + +public enum ConveyorDirection +{ + Forward, + Reverse +} + +public enum PneumaticCylinderState +{ + Retracted, + Extending, + Extended, + Retracting, + Fault +} + +public enum LoadLockState +{ + Atmosphere, + PumpingDown, + Vacuum, + Venting, + InterlockFault +} + +public sealed record LoadLockSnapshot( + string Id, + string Name, + LoadLockState State, + int RemainingTransitionTicks, + bool IsVacuumReady, + bool IsAtmosphereReady, + bool IsOuterDoorPermitted, + bool IsInnerDoorPermitted, + string OuterDoorComponentId, + string InnerDoorComponentId); + +public enum WaferHandlerOwnershipState +{ + Source, + Handler, + Destination, + InterlockFault +} + +public sealed record WaferHandlerSnapshot( + string Id, + string Name, + WaferHandlerOwnershipState State, + string HorizontalAxisId, + string VerticalAxisId, + string WorkpieceComponentId, + double HorizontalPosition, + double VerticalPosition, + bool IsSourcePresent, + bool IsGateOpen, + bool IsPickPermitted, + bool IsPlacePermitted); + +public enum InspectionSortRouteState +{ + AwaitingDecision, + PassReady, + NgReady, + PassRouted, + NgRouted, + InterlockFault +} + +public sealed record InspectionSortRouterSnapshot( + string Id, + string Name, + InspectionSortRouteState State, + string CameraId, + PlaceholderInspectionDecision? Decision, + string PassConveyorComponentId, + string NgConveyorComponentId, + bool IsPassConveyorRunning, + bool IsNgConveyorRunning, + bool IsPassRoutePermitted, + bool IsNgRoutePermitted); + +public enum InspectionHandoffState +{ + AwaitingMaterial, + Ready, + Inspecting, + ResultAvailable, + Complete, + InterlockFault +} + +public sealed record InspectionHandoffSnapshot( + string Id, + string Name, + InspectionHandoffState State, + string CameraId, + PlaceholderInspectionDecision? Decision, + long AcquisitionOrdinal, + bool IsMaterialPresent, + bool IsResultAccepted, + bool IsInspectionReady, + bool IsInspectionComplete); + +public enum OhtHandoffOwnershipState +{ + Vehicle, + Ready, + Transferring, + LoadPort, + InterlockFault +} + +public sealed record OhtHandoffSnapshot( + string Id, + string Name, + OhtHandoffOwnershipState State, + string TransportConveyorComponentId, + bool IsRouteAvailable, + bool IsVehicleDocked, + bool IsLoadPortReady, + bool IsCarrierReceived, + bool IsForwardCommanded, + bool IsReverseCommanded, + bool IsTransferPermitted); + +public enum PrealignerState +{ + AwaitingWafer, + AwaitingClamp, + Ready, + Aligning, + Aligned, + Released, + InterlockFault +} + +public sealed record PrealignerSnapshot( + string Id, + string Name, + PrealignerState State, + string RotaryStageComponentId, + string RotaryAxisId, + string ClampCylinderComponentId, + double AlignmentTargetDegrees, + double AlignmentToleranceDegrees, + double RotaryPositionDegrees, + bool IsWaferPresent, + PneumaticCylinderState ClampState, + bool IsAlignmentAccepted, + bool IsAlignmentReady, + bool IsAlignmentComplete); + diff --git a/src/OpenVisionLab.Machine.Simulation/Layout/MachineLayoutRuntimeContracts.cs b/src/OpenVisionLab.Machine.Simulation/Layout/MachineLayoutRuntimeTransitions.cs similarity index 57% rename from src/OpenVisionLab.Machine.Simulation/Layout/MachineLayoutRuntimeContracts.cs rename to src/OpenVisionLab.Machine.Simulation/Layout/MachineLayoutRuntimeTransitions.cs index ae2154b..fc447db 100644 --- a/src/OpenVisionLab.Machine.Simulation/Layout/MachineLayoutRuntimeContracts.cs +++ b/src/OpenVisionLab.Machine.Simulation/Layout/MachineLayoutRuntimeTransitions.cs @@ -1,189 +1,7 @@ using System.Collections.ObjectModel; -using OpenVisionLab.Machine.Core.Devices; -using OpenVisionLab.Machine.Core.Layouts; namespace OpenVisionLab.Machine.Simulation.Layout; -/// -/// Immutable current layout state. Behavior-specific fields are null when they -/// do not apply to the component kind. -/// -public sealed record LayoutComponentSnapshot( - string Id, - string Name, - LayoutComponentKind Kind, - double X, - double Y, - double RotationDegrees, - double Width, - double Height, - bool? IsDetected, - int? PendingTransitionTicks, - PneumaticCylinderState? CylinderState = null, - double? MotionProgress = null, - bool? ConveyorRunning = null, - ConveyorDirection? ConveyorDirection = null, - double? ConveyorSpeedUnitsPerSecond = null, - string? CarrierComponentId = null, - double? CarrierPosition = null, - string? WorkpieceType = null, - WorkpieceInspectionState? InspectionState = null, - string? SensorOutputChannelId = null, - string? TransferOwnerId = null, - WaferHandlerOwnershipState? TransferOwnershipState = null); - -public enum ConveyorDirection -{ - Forward, - Reverse -} - -public enum PneumaticCylinderState -{ - Retracted, - Extending, - Extended, - Retracting, - Fault -} - -public enum LoadLockState -{ - Atmosphere, - PumpingDown, - Vacuum, - Venting, - InterlockFault -} - -public sealed record LoadLockSnapshot( - string Id, - string Name, - LoadLockState State, - int RemainingTransitionTicks, - bool IsVacuumReady, - bool IsAtmosphereReady, - bool IsOuterDoorPermitted, - bool IsInnerDoorPermitted, - string OuterDoorComponentId, - string InnerDoorComponentId); - -public enum WaferHandlerOwnershipState -{ - Source, - Handler, - Destination, - InterlockFault -} - -public sealed record WaferHandlerSnapshot( - string Id, - string Name, - WaferHandlerOwnershipState State, - string HorizontalAxisId, - string VerticalAxisId, - string WorkpieceComponentId, - double HorizontalPosition, - double VerticalPosition, - bool IsSourcePresent, - bool IsGateOpen, - bool IsPickPermitted, - bool IsPlacePermitted); - -public enum InspectionSortRouteState -{ - AwaitingDecision, - PassReady, - NgReady, - PassRouted, - NgRouted, - InterlockFault -} - -public sealed record InspectionSortRouterSnapshot( - string Id, - string Name, - InspectionSortRouteState State, - string CameraId, - PlaceholderInspectionDecision? Decision, - string PassConveyorComponentId, - string NgConveyorComponentId, - bool IsPassConveyorRunning, - bool IsNgConveyorRunning, - bool IsPassRoutePermitted, - bool IsNgRoutePermitted); - -public enum InspectionHandoffState -{ - AwaitingMaterial, - Ready, - Inspecting, - ResultAvailable, - Complete, - InterlockFault -} - -public sealed record InspectionHandoffSnapshot( - string Id, - string Name, - InspectionHandoffState State, - string CameraId, - PlaceholderInspectionDecision? Decision, - long AcquisitionOrdinal, - bool IsMaterialPresent, - bool IsResultAccepted, - bool IsInspectionReady, - bool IsInspectionComplete); - -public enum OhtHandoffOwnershipState -{ - Vehicle, - Ready, - Transferring, - LoadPort, - InterlockFault -} - -public sealed record OhtHandoffSnapshot( - string Id, - string Name, - OhtHandoffOwnershipState State, - string TransportConveyorComponentId, - bool IsRouteAvailable, - bool IsVehicleDocked, - bool IsLoadPortReady, - bool IsCarrierReceived, - bool IsForwardCommanded, - bool IsReverseCommanded, - bool IsTransferPermitted); - -public enum PrealignerState -{ - AwaitingWafer, - AwaitingClamp, - Ready, - Aligning, - Aligned, - Released, - InterlockFault -} - -public sealed record PrealignerSnapshot( - string Id, - string Name, - PrealignerState State, - string RotaryStageComponentId, - string RotaryAxisId, - string ClampCylinderComponentId, - double AlignmentTargetDegrees, - double AlignmentToleranceDegrees, - double RotaryPositionDegrees, - bool IsWaferPresent, - PneumaticCylinderState ClampState, - bool IsAlignmentAccepted, - bool IsAlignmentReady, - bool IsAlignmentComplete); - public enum MachineLayoutTransitionKind { SensorActivated, 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/DeterministicConditionScenarioContracts.cs b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicConditionScenarioProfile.cs similarity index 65% rename from src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicConditionScenarioContracts.cs rename to src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicConditionScenarioProfile.cs index 25512d1..73182ed 100644 --- a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicConditionScenarioContracts.cs +++ b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicConditionScenarioProfile.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using OpenVisionLab.Machine.Simulation.Faults; @@ -242,142 +241,3 @@ public sealed record DeterministicFaultRecoverySchedule( int HoldTicks, bool? ForcedValue = null, string? RestartSequenceId = null); - -public sealed record DeterministicConditionSample( - long TickIndex, - string TargetId, - DeterministicConditionState State, - int HealthScore); - -public sealed record DeterministicConditionTransition( - long TickIndex, - string TargetId, - DeterministicConditionState From, - DeterministicConditionState To, - string Reason); - -public sealed record DeterministicConditionScenarioSnapshot( - bool IsConfigured, - bool IsActive, - string? ScenarioId, - string? TargetId, - int Seed, - long DurationTicks, - long ExecutedTicks, - DeterministicConditionState InitialState, - DeterministicConditionState State, - int HealthScore, - DeterministicConditionTransition? LastTransition) -{ - public static readonly DeterministicConditionScenarioSnapshot NotConfigured = new( - false, - false, - null, - null, - 0, - 0, - 0, - DeterministicConditionState.Normal, - DeterministicConditionState.Normal, - 100, - null); -} - -public sealed class DeterministicConditionStateMachine -{ - private readonly DeterministicConditionScenarioProfile profile; - private long lastTick = -1; - private long nextTransitionTick; - private int phaseIndex; - private DeterministicConditionState state; - private int healthScore; - - public DeterministicConditionStateMachine(DeterministicConditionScenarioProfile profile) - { - this.profile = DeterministicConditionScenarioProfile.Normalize(profile); - state = this.profile.InitialState; - healthScore = HealthFor(state); - nextTransitionTick = PhaseDuration(phaseIndex); - } - - public DeterministicConditionState State => state; - public int HealthScore => healthScore; - - public DeterministicConditionSample Advance( - long tick, - out DeterministicConditionTransition? transition) - { - if (tick < 0 || tick <= lastTick) - { - throw new ArgumentOutOfRangeException(nameof(tick), "Ticks must be non-negative and strictly increasing."); - } - - transition = null; - if (tick >= nextTransitionTick) - { - var previous = state; - state = NextState(state); - phaseIndex++; - nextTransitionTick += PhaseDuration(phaseIndex); - transition = new DeterministicConditionTransition( - tick, - profile.TargetId, - previous, - state, - $"Deterministic phase {phaseIndex} reached for {profile.TargetId}."); - } - - int targetHealth = HealthFor(state); - int adjustment = 3 + (int)(Mix(profile.Seed, profile.TargetId, tick) % 4); - healthScore = MoveToward(healthScore, targetHealth, adjustment); - lastTick = tick; - return new DeterministicConditionSample( - tick, - profile.TargetId, - state, - healthScore); - } - - private int PhaseDuration(int phase) => - profile.MinimumStateTicks - + (profile.JitterTicks == 0 - ? 0 - : (int)(Mix(profile.Seed, profile.TargetId, phase) % (uint)profile.JitterTicks)); - - private static DeterministicConditionState NextState(DeterministicConditionState current) => current switch - { - DeterministicConditionState.Normal => DeterministicConditionState.Degraded, - DeterministicConditionState.Degraded => DeterministicConditionState.Fault, - DeterministicConditionState.Fault => DeterministicConditionState.Recovering, - _ => DeterministicConditionState.Normal - }; - - public static int HealthFor(DeterministicConditionState state) => state switch - { - DeterministicConditionState.Normal => 100, - DeterministicConditionState.Degraded => 68, - DeterministicConditionState.Fault => 18, - DeterministicConditionState.Recovering => 72, - _ => 100 - }; - - private static int MoveToward(int current, int target, int amount) => - current < target - ? Math.Min(target, current + amount) - : Math.Max(target, current - amount); - - private static uint Mix(int seed, string targetId, long value) - { - unchecked - { - uint hash = 2166136261; - foreach (var character in $"{seed.ToString(CultureInfo.InvariantCulture)}|{targetId}|{value}") - { - hash ^= character; - hash *= 16777619; - } - - return hash; - } - } -} diff --git a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicConditionScenarioRuntime.cs b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicConditionScenarioRuntime.cs new file mode 100644 index 0000000..275eda6 --- /dev/null +++ b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicConditionScenarioRuntime.cs @@ -0,0 +1,142 @@ +using System.Globalization; + +namespace OpenVisionLab.Machine.Simulation.Scenarios; + +public sealed record DeterministicConditionSample( + long TickIndex, + string TargetId, + DeterministicConditionState State, + int HealthScore); + +public sealed record DeterministicConditionTransition( + long TickIndex, + string TargetId, + DeterministicConditionState From, + DeterministicConditionState To, + string Reason); + +public sealed record DeterministicConditionScenarioSnapshot( + bool IsConfigured, + bool IsActive, + string? ScenarioId, + string? TargetId, + int Seed, + long DurationTicks, + long ExecutedTicks, + DeterministicConditionState InitialState, + DeterministicConditionState State, + int HealthScore, + DeterministicConditionTransition? LastTransition) +{ + public static readonly DeterministicConditionScenarioSnapshot NotConfigured = new( + false, + false, + null, + null, + 0, + 0, + 0, + DeterministicConditionState.Normal, + DeterministicConditionState.Normal, + 100, + null); +} + +public sealed class DeterministicConditionStateMachine +{ + private readonly DeterministicConditionScenarioProfile profile; + private long lastTick = -1; + private long nextTransitionTick; + private int phaseIndex; + private DeterministicConditionState state; + private int healthScore; + + public DeterministicConditionStateMachine(DeterministicConditionScenarioProfile profile) + { + this.profile = DeterministicConditionScenarioProfile.Normalize(profile); + state = this.profile.InitialState; + healthScore = HealthFor(state); + nextTransitionTick = PhaseDuration(phaseIndex); + } + + public DeterministicConditionState State => state; + public int HealthScore => healthScore; + + public DeterministicConditionSample Advance( + long tick, + out DeterministicConditionTransition? transition) + { + if (tick < 0 || tick <= lastTick) + { + throw new ArgumentOutOfRangeException(nameof(tick), "Ticks must be non-negative and strictly increasing."); + } + + transition = null; + if (tick >= nextTransitionTick) + { + var previous = state; + state = NextState(state); + phaseIndex++; + nextTransitionTick += PhaseDuration(phaseIndex); + transition = new DeterministicConditionTransition( + tick, + profile.TargetId, + previous, + state, + $"Deterministic phase {phaseIndex} reached for {profile.TargetId}."); + } + + int targetHealth = HealthFor(state); + int adjustment = 3 + (int)(Mix(profile.Seed, profile.TargetId, tick) % 4); + healthScore = MoveToward(healthScore, targetHealth, adjustment); + lastTick = tick; + return new DeterministicConditionSample( + tick, + profile.TargetId, + state, + healthScore); + } + + private int PhaseDuration(int phase) => + profile.MinimumStateTicks + + (profile.JitterTicks == 0 + ? 0 + : (int)(Mix(profile.Seed, profile.TargetId, phase) % (uint)profile.JitterTicks)); + + private static DeterministicConditionState NextState(DeterministicConditionState current) => current switch + { + DeterministicConditionState.Normal => DeterministicConditionState.Degraded, + DeterministicConditionState.Degraded => DeterministicConditionState.Fault, + DeterministicConditionState.Fault => DeterministicConditionState.Recovering, + _ => DeterministicConditionState.Normal + }; + + public static int HealthFor(DeterministicConditionState state) => state switch + { + DeterministicConditionState.Normal => 100, + DeterministicConditionState.Degraded => 68, + DeterministicConditionState.Fault => 18, + DeterministicConditionState.Recovering => 72, + _ => 100 + }; + + private static int MoveToward(int current, int target, int amount) => + current < target + ? Math.Min(target, current + amount) + : Math.Max(target, current - amount); + + private static uint Mix(int seed, string targetId, long value) + { + unchecked + { + uint hash = 2166136261; + foreach (var character in $"{seed.ToString(CultureInfo.InvariantCulture)}|{targetId}|{value}") + { + hash ^= character; + hash *= 16777619; + } + + return hash; + } + } +} diff --git a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicScenarioAssertionEvaluator.cs b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicScenarioAssertionEvaluator.cs new file mode 100644 index 0000000..1b88193 --- /dev/null +++ b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicScenarioAssertionEvaluator.cs @@ -0,0 +1,239 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using OpenVisionLab.Machine.Simulation.Events; +using OpenVisionLab.Machine.Simulation.Snapshots; + +namespace OpenVisionLab.Machine.Simulation.Scenarios; + +/// +/// Contains the runtime result for one normalized scenario assertion. +/// +public sealed record DeterministicScenarioAssertionOutcome( + string AssertionId, + DeterministicScenarioAssertionKind Kind, + string? TargetId, + string ExpectedValue, + string ActualValue, + long MinimumCount, + bool IsPassed, + long ObservedTickIndex, + string Detail); + +/// +/// Evaluates normalized assertions against captured runtime evidence and owns +/// the definition/outcome hashes used by the portable run package. +/// +internal static class DeterministicScenarioAssertionEvaluator +{ + public static ImmutableArray Evaluate( + ImmutableArray assertions, + IReadOnlyList snapshots, + IReadOnlyList events) + { + if (assertions.IsDefaultOrEmpty) + { + return ImmutableArray.Empty; + } + + SimulationSnapshot? finalSnapshot = snapshots.LastOrDefault(); + long finalTick = finalSnapshot?.TickIndex ?? 0; + var outcomes = ImmutableArray.CreateBuilder( + assertions.Length); + foreach (var assertion in assertions) + { + outcomes.Add(assertion.Kind switch + { + DeterministicScenarioAssertionKind.AutomaticCycleCompleted => + EvaluateAutomaticCycles(assertion, events, finalTick), + DeterministicScenarioAssertionKind.NoActiveFaults => + EvaluateFinalFaults(assertion, finalSnapshot, finalTick), + DeterministicScenarioAssertionKind.FinalEquipmentState => + EvaluateFinalEquipmentState(assertion, finalSnapshot, finalTick), + _ => throw new InvalidOperationException( + $"Unsupported scenario assertion kind '{assertion.Kind}'.") + }); + } + + return outcomes.ToImmutable(); + } + + public static string HashDefinitions( + IEnumerable assertions) + { + var builder = new StringBuilder(); + foreach (var assertion in assertions) + { + builder.Append(assertion.AssertionId).Append('|') + .Append(assertion.Kind).Append('|') + .Append(assertion.TargetId).Append('|') + .Append(assertion.ExpectedState).Append('|') + .Append(assertion.MinimumCount).Append('\n'); + } + + return Hash(builder.ToString()); + } + + public static string HashDefinitions( + IEnumerable outcomes) => + HashDefinitions(outcomes.Select(outcome => new DeterministicScenarioAssertion( + outcome.AssertionId, + outcome.Kind, + outcome.TargetId, + outcome.Kind == DeterministicScenarioAssertionKind.FinalEquipmentState + ? outcome.ExpectedValue + : null, + outcome.MinimumCount))); + + public static string HashOutcomes( + IEnumerable outcomes) + { + var builder = new StringBuilder(); + foreach (var outcome in outcomes) + { + builder.Append(outcome.AssertionId).Append('|') + .Append(outcome.Kind).Append('|') + .Append(outcome.TargetId).Append('|') + .Append(outcome.ExpectedValue).Append('|') + .Append(outcome.ActualValue).Append('|') + .Append(outcome.MinimumCount).Append('|') + .Append(outcome.IsPassed).Append('|') + .Append(outcome.ObservedTickIndex).Append('|') + .Append(outcome.Detail).Append('\n'); + } + + return Hash(builder.ToString()); + } + + private static DeterministicScenarioAssertionOutcome EvaluateAutomaticCycles( + DeterministicScenarioAssertion assertion, + IReadOnlyList events, + long finalTick) + { + SimulationEvent[] completed = events + .Where(item => item.Code == "AutomaticRunCycleCompleted") + .ToArray(); + bool passed = completed.LongLength >= assertion.MinimumCount; + long observedTick = passed + ? completed[(int)assertion.MinimumCount - 1].TickIndex + : finalTick; + string expected = $">={assertion.MinimumCount.ToString(CultureInfo.InvariantCulture)}"; + string actual = completed.LongLength.ToString(CultureInfo.InvariantCulture); + return Outcome( + assertion, + expected, + actual, + passed, + observedTick, + passed + ? $"Observed {actual} completed automatic cycle event(s)." + : $"Expected {expected} completed automatic cycle event(s), observed {actual}."); + } + + private static DeterministicScenarioAssertionOutcome EvaluateFinalFaults( + DeterministicScenarioAssertion assertion, + SimulationSnapshot? finalSnapshot, + long finalTick) + { + int count = finalSnapshot?.Faults.Count ?? 0; + bool passed = finalSnapshot is not null && count == 0; + string actual = finalSnapshot is null + ? "Unavailable" + : count.ToString(CultureInfo.InvariantCulture); + return Outcome( + assertion, + "0", + actual, + passed, + finalTick, + passed ? "Final snapshot has no active faults." : $"Final active fault count is {actual}."); + } + + private static DeterministicScenarioAssertionOutcome EvaluateFinalEquipmentState( + DeterministicScenarioAssertion assertion, + SimulationSnapshot? finalSnapshot, + long finalTick) + { + string actual = ResolveEquipmentState(finalSnapshot, assertion.TargetId!) ?? "Unavailable"; + string expected = assertion.ExpectedState!; + bool passed = string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase); + return Outcome( + assertion, + expected, + actual, + passed, + finalTick, + passed + ? $"Equipment '{assertion.TargetId}' finished in state '{actual}'." + : $"Equipment '{assertion.TargetId}' expected state '{expected}', observed '{actual}'."); + } + + internal static string? ResolveEquipmentState( + SimulationSnapshot? snapshot, + string targetId) + { + if (snapshot is null) + { + return null; + } + + var axis = snapshot.Axes.SingleOrDefault(item => item.Id == targetId); + if (axis is not null) + { + return axis.State.ToString(); + } + + var component = snapshot.LayoutComponents.SingleOrDefault(item => item.Id == targetId); + if (component is not null) + { + if (component.CylinderState.HasValue) + { + return component.CylinderState.Value.ToString(); + } + if (component.ConveyorRunning.HasValue) + { + return component.ConveyorRunning.Value + ? $"{component.ConveyorDirection}Running" + : "Stopped"; + } + if (component.IsDetected.HasValue) + { + return component.IsDetected.Value ? "Detected" : "Clear"; + } + if (component.InspectionState.HasValue) + { + return component.InspectionState.Value.ToString(); + } + } + + var loadLock = snapshot.LoadLocks.SingleOrDefault(item => item.Id == targetId); + if (loadLock is not null) + { + return loadLock.State.ToString(); + } + + return snapshot.Workpieces.SingleOrDefault(item => item.Id == targetId)?.State.ToString(); + } + + private static DeterministicScenarioAssertionOutcome Outcome( + DeterministicScenarioAssertion assertion, + string expectedValue, + string actualValue, + bool isPassed, + long observedTickIndex, + string detail) => + new( + assertion.AssertionId, + assertion.Kind, + assertion.TargetId, + expectedValue, + actualValue, + assertion.MinimumCount, + isPassed, + observedTickIndex, + detail); + + private static string Hash(string value) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))); +} diff --git a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicScenarioAssertions.cs b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicScenarioAssertions.cs index 9e20cd6..49fbb14 100644 --- a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicScenarioAssertions.cs +++ b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicScenarioAssertions.cs @@ -1,10 +1,5 @@ using System.Collections.Immutable; -using System.Globalization; -using System.Security.Cryptography; -using System.Text; using OpenVisionLab.Machine.Core.Projects; -using OpenVisionLab.Machine.Simulation.Events; -using OpenVisionLab.Machine.Simulation.Snapshots; namespace OpenVisionLab.Machine.Simulation.Scenarios; @@ -108,227 +103,3 @@ internal static IReadOnlyList Validate( return errors; } } - -public sealed record DeterministicScenarioAssertionOutcome( - string AssertionId, - DeterministicScenarioAssertionKind Kind, - string? TargetId, - string ExpectedValue, - string ActualValue, - long MinimumCount, - bool IsPassed, - long ObservedTickIndex, - string Detail); - -internal static class DeterministicScenarioAssertionEvaluator -{ - public static ImmutableArray Evaluate( - ImmutableArray assertions, - IReadOnlyList snapshots, - IReadOnlyList events) - { - if (assertions.IsDefaultOrEmpty) - { - return ImmutableArray.Empty; - } - - SimulationSnapshot? finalSnapshot = snapshots.LastOrDefault(); - long finalTick = finalSnapshot?.TickIndex ?? 0; - var outcomes = ImmutableArray.CreateBuilder( - assertions.Length); - foreach (var assertion in assertions) - { - outcomes.Add(assertion.Kind switch - { - DeterministicScenarioAssertionKind.AutomaticCycleCompleted => - EvaluateAutomaticCycles(assertion, events, finalTick), - DeterministicScenarioAssertionKind.NoActiveFaults => - EvaluateFinalFaults(assertion, finalSnapshot, finalTick), - DeterministicScenarioAssertionKind.FinalEquipmentState => - EvaluateFinalEquipmentState(assertion, finalSnapshot, finalTick), - _ => throw new InvalidOperationException( - $"Unsupported scenario assertion kind '{assertion.Kind}'.") - }); - } - - return outcomes.ToImmutable(); - } - - public static string HashDefinitions( - IEnumerable assertions) - { - var builder = new StringBuilder(); - foreach (var assertion in assertions) - { - builder.Append(assertion.AssertionId).Append('|') - .Append(assertion.Kind).Append('|') - .Append(assertion.TargetId).Append('|') - .Append(assertion.ExpectedState).Append('|') - .Append(assertion.MinimumCount).Append('\n'); - } - - return Hash(builder.ToString()); - } - - public static string HashDefinitions( - IEnumerable outcomes) => - HashDefinitions(outcomes.Select(outcome => new DeterministicScenarioAssertion( - outcome.AssertionId, - outcome.Kind, - outcome.TargetId, - outcome.Kind == DeterministicScenarioAssertionKind.FinalEquipmentState - ? outcome.ExpectedValue - : null, - outcome.MinimumCount))); - - public static string HashOutcomes( - IEnumerable outcomes) - { - var builder = new StringBuilder(); - foreach (var outcome in outcomes) - { - builder.Append(outcome.AssertionId).Append('|') - .Append(outcome.Kind).Append('|') - .Append(outcome.TargetId).Append('|') - .Append(outcome.ExpectedValue).Append('|') - .Append(outcome.ActualValue).Append('|') - .Append(outcome.MinimumCount).Append('|') - .Append(outcome.IsPassed).Append('|') - .Append(outcome.ObservedTickIndex).Append('|') - .Append(outcome.Detail).Append('\n'); - } - - return Hash(builder.ToString()); - } - - private static DeterministicScenarioAssertionOutcome EvaluateAutomaticCycles( - DeterministicScenarioAssertion assertion, - IReadOnlyList events, - long finalTick) - { - SimulationEvent[] completed = events - .Where(item => item.Code == "AutomaticRunCycleCompleted") - .ToArray(); - bool passed = completed.LongLength >= assertion.MinimumCount; - long observedTick = passed - ? completed[(int)assertion.MinimumCount - 1].TickIndex - : finalTick; - string expected = $">={assertion.MinimumCount.ToString(CultureInfo.InvariantCulture)}"; - string actual = completed.LongLength.ToString(CultureInfo.InvariantCulture); - return Outcome( - assertion, - expected, - actual, - passed, - observedTick, - passed - ? $"Observed {actual} completed automatic cycle event(s)." - : $"Expected {expected} completed automatic cycle event(s), observed {actual}."); - } - - private static DeterministicScenarioAssertionOutcome EvaluateFinalFaults( - DeterministicScenarioAssertion assertion, - SimulationSnapshot? finalSnapshot, - long finalTick) - { - int count = finalSnapshot?.Faults.Count ?? 0; - bool passed = finalSnapshot is not null && count == 0; - string actual = finalSnapshot is null - ? "Unavailable" - : count.ToString(CultureInfo.InvariantCulture); - return Outcome( - assertion, - "0", - actual, - passed, - finalTick, - passed ? "Final snapshot has no active faults." : $"Final active fault count is {actual}."); - } - - private static DeterministicScenarioAssertionOutcome EvaluateFinalEquipmentState( - DeterministicScenarioAssertion assertion, - SimulationSnapshot? finalSnapshot, - long finalTick) - { - string actual = ResolveEquipmentState(finalSnapshot, assertion.TargetId!) ?? "Unavailable"; - string expected = assertion.ExpectedState!; - bool passed = string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase); - return Outcome( - assertion, - expected, - actual, - passed, - finalTick, - passed - ? $"Equipment '{assertion.TargetId}' finished in state '{actual}'." - : $"Equipment '{assertion.TargetId}' expected state '{expected}', observed '{actual}'."); - } - - internal static string? ResolveEquipmentState( - SimulationSnapshot? snapshot, - string targetId) - { - if (snapshot is null) - { - return null; - } - - var axis = snapshot.Axes.SingleOrDefault(item => item.Id == targetId); - if (axis is not null) - { - return axis.State.ToString(); - } - - var component = snapshot.LayoutComponents.SingleOrDefault(item => item.Id == targetId); - if (component is not null) - { - if (component.CylinderState.HasValue) - { - return component.CylinderState.Value.ToString(); - } - if (component.ConveyorRunning.HasValue) - { - return component.ConveyorRunning.Value - ? $"{component.ConveyorDirection}Running" - : "Stopped"; - } - if (component.IsDetected.HasValue) - { - return component.IsDetected.Value ? "Detected" : "Clear"; - } - if (component.InspectionState.HasValue) - { - return component.InspectionState.Value.ToString(); - } - } - - var loadLock = snapshot.LoadLocks.SingleOrDefault(item => item.Id == targetId); - if (loadLock is not null) - { - return loadLock.State.ToString(); - } - - return snapshot.Workpieces.SingleOrDefault(item => item.Id == targetId)?.State.ToString(); - } - - private static DeterministicScenarioAssertionOutcome Outcome( - DeterministicScenarioAssertion assertion, - string expectedValue, - string actualValue, - bool isPassed, - long observedTickIndex, - string detail) => - new( - assertion.AssertionId, - assertion.Kind, - assertion.TargetId, - expectedValue, - actualValue, - assertion.MinimumCount, - isPassed, - observedTickIndex, - detail); - - private static string Hash(string value) => - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))); -} 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..8da04eb 100644 --- a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationCommandTrace.cs +++ b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationCommandTrace.cs @@ -4,7 +4,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using OpenVisionLab.Machine.Simulation.Commands; -using OpenVisionLab.Machine.Simulation.Engine; namespace OpenVisionLab.Machine.Simulation.Scenarios; @@ -36,6 +35,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 +167,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) @@ -237,132 +231,3 @@ public sealed record DeterministicSimulationCommandTraceReplayResult( ImmutableArray CommandResults, DeterministicSimulationCommandTraceMismatch? FirstMismatch, string? FailureReason); - -/// -/// Replays a validated command trace through the existing engine queue. The -/// target engine must already contain the same authored/runtime setup and be -/// paused; setup is deliberately not inferred from the trace. -/// -public sealed class DeterministicSimulationCommandTraceReplayRunner -{ - public async Task ReplayAsync( - FixedStepSimulationEngine engine, - DeterministicSimulationCommandTracePackage package, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(engine); - ArgumentNullException.ThrowIfNull(package); - - var commandResults = ImmutableArray.CreateBuilder(); - if (!package.HasValidTraceHash()) - { - return Failure(commandResults, "The command trace hash or schema is invalid."); - } - - if (!package.CanReplay) - { - return Failure( - commandResults, - "The command trace contains a real-time or unsupported command."); - } - - if (engine.FixedStep.Ticks != package.FixedStepTicks) - { - return Failure(commandResults, "The trace fixed step does not match the target engine."); - } - - if (engine.CurrentSnapshot.RunMode != SimulationRunMode.Paused) - { - return Failure(commandResults, "The target engine must be paused before replay."); - } - - foreach (var entry in package.Entries) - { - cancellationToken.ThrowIfCancellationRequested(); - while (engine.CurrentSnapshot.TickIndex < entry.AppliedTick) - { - var step = await engine.EnqueueCommandAsync( - new StepCommand(), - cancellationToken).ConfigureAwait(false); - if (!step.IsAccepted) - { - commandResults.Add(step); - return Failure( - commandResults, - $"Replay could not reach Tick {entry.AppliedTick}: {step.Detail}"); - } - } - - if (engine.CurrentSnapshot.TickIndex > entry.AppliedTick) - { - return Failure( - commandResults, - CreateMismatch( - entry, - engine.CurrentSnapshot.TickIndex, - SimulationCommandErrorCode.None, - "The target engine passed the recorded command boundary.")); - } - - if (!entry.TryCreateCommand(out var command, out var commandError) - || command is null) - { - return Failure(commandResults, commandError ?? "The command could not be reconstructed."); - } - - var actual = await engine.EnqueueCommandAsync(command, cancellationToken) - .ConfigureAwait(false); - commandResults.Add(actual); - if (!Matches(entry, actual)) - { - return Failure( - commandResults, - CreateMismatch( - entry, - actual.AppliedTick, - actual.ErrorCode, - "The replay command result differs from the recorded boundary.")); - } - } - - return new( - true, - package.Entries.Length, - commandResults.ToImmutable(), - null, - null); - } - - private static bool Matches( - DeterministicSimulationCommandTraceEntry expected, - SimulationCommandResult actual) => - expected.AppliedTick == actual.AppliedTick - && expected.SimulationTimeTicks == actual.SimulationTime.Ticks - && expected.IsAccepted == actual.IsAccepted - && expected.ErrorCode == actual.ErrorCode - && string.Equals(expected.Detail, actual.Detail, StringComparison.Ordinal); - - private static DeterministicSimulationCommandTraceReplayResult Failure( - ImmutableArray.Builder commandResults, - string reason) => - new(false, commandResults.Count, commandResults.ToImmutable(), null, reason); - - private static DeterministicSimulationCommandTraceReplayResult Failure( - ImmutableArray.Builder commandResults, - DeterministicSimulationCommandTraceMismatch mismatch) => - new(false, commandResults.Count, commandResults.ToImmutable(), mismatch, mismatch.Detail); - - private static DeterministicSimulationCommandTraceMismatch CreateMismatch( - DeterministicSimulationCommandTraceEntry expected, - long actualTick, - SimulationCommandErrorCode actualErrorCode, - string detail) => - new( - expected.Sequence, - "CommandResultMismatch", - detail, - expected.AppliedTick, - actualTick, - expected.ErrorCode, - actualErrorCode); -} diff --git a/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationCommandTraceReplayRunner.cs b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationCommandTraceReplayRunner.cs new file mode 100644 index 0000000..78932a1 --- /dev/null +++ b/src/OpenVisionLab.Machine.Simulation/Scenarios/DeterministicSimulationCommandTraceReplayRunner.cs @@ -0,0 +1,134 @@ +using System.Collections.Immutable; +using OpenVisionLab.Machine.Simulation.Commands; +using OpenVisionLab.Machine.Simulation.Engine; + +namespace OpenVisionLab.Machine.Simulation.Scenarios; + +/// +/// Replays a validated command trace through the existing engine queue. The +/// target engine must already contain the same authored/runtime setup and be +/// paused; setup is deliberately not inferred from the trace. +/// +public sealed class DeterministicSimulationCommandTraceReplayRunner +{ + public async Task ReplayAsync( + FixedStepSimulationEngine engine, + DeterministicSimulationCommandTracePackage package, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(engine); + ArgumentNullException.ThrowIfNull(package); + + var commandResults = ImmutableArray.CreateBuilder(); + if (!package.HasValidTraceHash()) + { + return Failure(commandResults, "The command trace hash or schema is invalid."); + } + + if (!package.CanReplay) + { + return Failure( + commandResults, + "The command trace contains a real-time or unsupported command."); + } + + if (engine.FixedStep.Ticks != package.FixedStepTicks) + { + return Failure(commandResults, "The trace fixed step does not match the target engine."); + } + + if (engine.CurrentSnapshot.RunMode != SimulationRunMode.Paused) + { + return Failure(commandResults, "The target engine must be paused before replay."); + } + + foreach (var entry in package.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + while (engine.CurrentSnapshot.TickIndex < entry.AppliedTick) + { + var step = await engine.EnqueueCommandAsync( + new StepCommand(), + cancellationToken).ConfigureAwait(false); + if (!step.IsAccepted) + { + commandResults.Add(step); + return Failure( + commandResults, + $"Replay could not reach Tick {entry.AppliedTick}: {step.Detail}"); + } + } + + if (engine.CurrentSnapshot.TickIndex > entry.AppliedTick) + { + return Failure( + commandResults, + CreateMismatch( + entry, + engine.CurrentSnapshot.TickIndex, + SimulationCommandErrorCode.None, + "The target engine passed the recorded command boundary.")); + } + + if (!entry.TryCreateCommand(out var command, out var commandError) + || command is null) + { + return Failure(commandResults, commandError ?? "The command could not be reconstructed."); + } + + var actual = await engine.EnqueueCommandAsync(command, cancellationToken) + .ConfigureAwait(false); + commandResults.Add(actual); + if (!Matches(entry, actual)) + { + return Failure( + commandResults, + CreateMismatch( + entry, + actual.AppliedTick, + actual.ErrorCode, + "The replay command result differs from the recorded boundary.")); + } + } + + return new( + true, + package.Entries.Length, + commandResults.ToImmutable(), + null, + null); + } + + private static bool Matches( + DeterministicSimulationCommandTraceEntry expected, + SimulationCommandResult actual) => + expected.AppliedTick == actual.AppliedTick + && expected.SimulationTimeTicks == actual.SimulationTime.Ticks + && expected.IsAccepted == actual.IsAccepted + && expected.ErrorCode == actual.ErrorCode + && string.Equals(expected.Detail, actual.Detail, StringComparison.Ordinal); + + private static DeterministicSimulationCommandTraceReplayResult Failure( + ImmutableArray.Builder commandResults, + string reason) => + new(false, commandResults.Count, commandResults.ToImmutable(), null, reason); + + private static DeterministicSimulationCommandTraceReplayResult Failure( + ImmutableArray.Builder commandResults, + DeterministicSimulationCommandTraceMismatch mismatch) => + new(false, commandResults.Count, commandResults.ToImmutable(), mismatch, mismatch.Detail); + + private static DeterministicSimulationCommandTraceMismatch CreateMismatch( + DeterministicSimulationCommandTraceEntry expected, + long actualTick, + SimulationCommandErrorCode actualErrorCode, + string detail) => + new( + expected.Sequence, + "CommandResultMismatch", + detail, + expected.AppliedTick, + actualTick, + expected.ErrorCode, + actualErrorCode); +} 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/Sequences/DeterministicRecipeDryRunRunner.cs b/src/OpenVisionLab.Machine.Simulation/Sequences/DeterministicRecipeDryRunRunner.cs index 444881d..255e893 100644 --- a/src/OpenVisionLab.Machine.Simulation/Sequences/DeterministicRecipeDryRunRunner.cs +++ b/src/OpenVisionLab.Machine.Simulation/Sequences/DeterministicRecipeDryRunRunner.cs @@ -9,68 +9,6 @@ namespace OpenVisionLab.Machine.Simulation.Sequences; -public enum RecipeDryRunOutcome -{ - Completed, - CompletedWithIssue, - CompletedWithMismatch, - LimitReached, - Faulted, - Rejected -} - -public sealed record RecipeDryRunIssue( - string StepId, - long Tick, - string Code, - string Detail); - -public sealed record RecipeDryRunStepCheckpoint( - string TargetId, - string ExpectedState, - string ActualState, - bool IsPassed, - string Detail); - -public sealed record RecipeDryRunCheckpointMismatch( - string StepId, - long Tick, - string TargetId, - string ExpectedState, - string ActualState, - string Detail); - -public sealed record RecipeDryRunStepTrace( - string StepId, - string Name, - SequenceStepAction Action, - long StartedTick, - long EndedTick, - bool HasIssue, - SimulationSnapshot BoundarySnapshot, - RecipeDryRunStepCheckpoint? Checkpoint) -{ - public bool HasCheckpoint => Checkpoint is not null; - public bool HasCheckpointMismatch => Checkpoint is { IsPassed: false }; -} - -public sealed record RecipeDryRunResult( - RecipeDryRunOutcome Outcome, - string SequenceId, - string SequenceName, - long ExecutedTicks, - int MaximumTicks, - IReadOnlyList Timeline, - RecipeDryRunIssue? FirstIssue, - RecipeDryRunCheckpointMismatch? FirstCheckpointMismatch, - SimulationSnapshot? FinalSnapshot, - string Detail) -{ - public bool IsCompleted => Outcome is RecipeDryRunOutcome.Completed - or RecipeDryRunOutcome.CompletedWithIssue - or RecipeDryRunOutcome.CompletedWithMismatch; -} - /// /// Executes one complete authored sequence in an isolated production engine and /// reduces its snapshots to a bounded step timeline. diff --git a/src/OpenVisionLab.Machine.Simulation/Sequences/DeterministicSequenceStepPreviewCompiler.cs b/src/OpenVisionLab.Machine.Simulation/Sequences/DeterministicSequenceStepPreviewCompiler.cs new file mode 100644 index 0000000..7e1164d --- /dev/null +++ b/src/OpenVisionLab.Machine.Simulation/Sequences/DeterministicSequenceStepPreviewCompiler.cs @@ -0,0 +1,95 @@ +using OpenVisionLab.Machine.Core.Sequences; +using OpenVisionLab.Machine.Sequence.Compilation; +using OpenVisionLab.Machine.Sequence.Runtime; +using OpenVisionLab.Machine.Simulation.Compilation; +using OpenVisionLab.Machine.Simulation.Engine; +using OpenVisionLab.Machine.Simulation.Layout; + +namespace OpenVisionLab.Machine.Simulation.Sequences; + +internal static class DeterministicSequenceStepPreviewCompiler +{ + internal const string PreviewSequenceId = "__connection-step-preview"; + internal const string PreviewStepId = "__preview-step"; + internal const string CompleteStepId = "__preview-complete"; + + internal static SequenceCompilationResult CompilePreview( + SequenceStepDefinition source, + SimulationRuntimeConfiguration runtime, + string componentId) + { + var steps = new List(); + LoadLockRuntimeConfiguration? loadLock = runtime.Layout?.LoadLocks.FirstOrDefault(candidate => + string.Equals(candidate.InnerDoorComponentId, componentId, StringComparison.Ordinal)); + if (loadLock is not null && RequiresVacuumPrerequisite(source, runtime, componentId)) + { + steps.Add(new SequenceStepDefinition + { + Id = "__preview-evacuate", + Name = "Preview load-lock pump down", + Action = SequenceStepAction.SetSignal, + TargetId = loadLock.EvacuateCommandChannelId, + Parameter = "true", + NextStepId = "__preview-wait-vacuum" + }); + steps.Add(new SequenceStepDefinition + { + Id = "__preview-wait-vacuum", + Name = "Preview wait for vacuum", + Action = SequenceStepAction.WaitSignal, + TargetId = loadLock.VacuumReadySensorChannelId, + Parameter = "true", + TimeoutMs = 10000, + NextStepId = PreviewStepId + }); + } + + steps.Add(new SequenceStepDefinition + { + Id = PreviewStepId, + Name = source.Name, + Action = source.Action, + TargetId = source.TargetId, + Parameter = source.Parameter, + TimeoutMs = source.TimeoutMs, + NextStepId = CompleteStepId + }); + steps.Add(new SequenceStepDefinition + { + Id = CompleteStepId, + Name = "Preview complete", + Action = SequenceStepAction.Complete + }); + + var definition = new SequenceDefinition + { + Id = PreviewSequenceId, + Name = "Connection step preview", + Steps = steps + }; + var targets = new SequenceCompilationTargets( + runtime.Channels.ToDictionary(channel => channel.Id, channel => channel.Kind, StringComparer.Ordinal), + runtime.Axes.Select(axis => axis.Id), + runtime.Cameras.Select(camera => camera.Id)); + return new SequenceCompiler().Compile(definition, targets); + } + + private static bool RequiresVacuumPrerequisite( + SequenceStepDefinition source, + SimulationRuntimeConfiguration runtime, + string componentId) + { + var cylinder = runtime.Layout?.Components + .OfType() + .SingleOrDefault(candidate => string.Equals(candidate.Id, componentId, StringComparison.Ordinal)); + if (cylinder is null) + { + return false; + } + + return source.Action is SequenceStepAction.SetSignal or SequenceStepAction.SetChannel + && string.Equals(source.TargetId, cylinder.ExtendCommandChannelId, StringComparison.Ordinal) + && bool.TryParse(source.Parameter, out bool requested) + && requested; + } +} diff --git a/src/OpenVisionLab.Machine.Simulation/Sequences/DeterministicSequenceStepPreviewRunner.cs b/src/OpenVisionLab.Machine.Simulation/Sequences/DeterministicSequenceStepPreviewRunner.cs index cc26a65..b5a6df8 100644 --- a/src/OpenVisionLab.Machine.Simulation/Sequences/DeterministicSequenceStepPreviewRunner.cs +++ b/src/OpenVisionLab.Machine.Simulation/Sequences/DeterministicSequenceStepPreviewRunner.cs @@ -1,7 +1,6 @@ using System.Globalization; using OpenVisionLab.Machine.Core.Projects; using OpenVisionLab.Machine.Core.Sequences; -using OpenVisionLab.Machine.Sequence.Compilation; using OpenVisionLab.Machine.Sequence.Runtime; using OpenVisionLab.Machine.Simulation.Axis; using OpenVisionLab.Machine.Simulation.Commands; @@ -39,9 +38,9 @@ public sealed record SequenceStepPreviewResult( public sealed class DeterministicSequenceStepPreviewRunner { public const int DefaultMaximumTicks = 2000; - private const string PreviewSequenceId = "__connection-step-preview"; - private const string PreviewStepId = "__preview-step"; - private const string CompleteStepId = "__preview-complete"; + private const string PreviewSequenceId = DeterministicSequenceStepPreviewCompiler.PreviewSequenceId; + private const string PreviewStepId = DeterministicSequenceStepPreviewCompiler.PreviewStepId; + private const string CompleteStepId = DeterministicSequenceStepPreviewCompiler.CompleteStepId; private static readonly HashSet SupportedActions = [ @@ -99,7 +98,8 @@ public async Task RunAsync( } var sourceRuntime = projectCompilation.Configuration!; - var previewCompilation = CompilePreview(sourceStep, sourceRuntime, componentId); + var previewCompilation = DeterministicSequenceStepPreviewCompiler.CompilePreview( + sourceStep, sourceRuntime, componentId); if (!previewCompilation.IsSuccess) { return Rejected(sourceStep.Action, sourceStep.TargetId, maximumTicks, @@ -222,86 +222,6 @@ public async Task RunAsync( } } - private static SequenceCompilationResult CompilePreview( - SequenceStepDefinition source, - SimulationRuntimeConfiguration runtime, - string componentId) - { - var steps = new List(); - LoadLockRuntimeConfiguration? loadLock = runtime.Layout?.LoadLocks.FirstOrDefault(candidate => - string.Equals(candidate.InnerDoorComponentId, componentId, StringComparison.Ordinal)); - if (loadLock is not null && RequiresVacuumPrerequisite(source, runtime, componentId)) - { - steps.Add(new SequenceStepDefinition - { - Id = "__preview-evacuate", - Name = "Preview load-lock pump down", - Action = SequenceStepAction.SetSignal, - TargetId = loadLock.EvacuateCommandChannelId, - Parameter = "true", - NextStepId = "__preview-wait-vacuum" - }); - steps.Add(new SequenceStepDefinition - { - Id = "__preview-wait-vacuum", - Name = "Preview wait for vacuum", - Action = SequenceStepAction.WaitSignal, - TargetId = loadLock.VacuumReadySensorChannelId, - Parameter = "true", - TimeoutMs = 10000, - NextStepId = PreviewStepId - }); - } - - steps.Add(new SequenceStepDefinition - { - Id = PreviewStepId, - Name = source.Name, - Action = source.Action, - TargetId = source.TargetId, - Parameter = source.Parameter, - TimeoutMs = source.TimeoutMs, - NextStepId = CompleteStepId - }); - steps.Add(new SequenceStepDefinition - { - Id = CompleteStepId, - Name = "Preview complete", - Action = SequenceStepAction.Complete - }); - - var definition = new SequenceDefinition - { - Id = PreviewSequenceId, - Name = "Connection step preview", - Steps = steps - }; - var targets = new SequenceCompilationTargets( - runtime.Channels.ToDictionary(channel => channel.Id, channel => channel.Kind, StringComparer.Ordinal), - runtime.Axes.Select(axis => axis.Id), - runtime.Cameras.Select(camera => camera.Id)); - return new SequenceCompiler().Compile(definition, targets); - } - - private static bool RequiresVacuumPrerequisite( - SequenceStepDefinition source, - SimulationRuntimeConfiguration runtime, - string componentId) - { - var cylinder = runtime.Layout?.Components - .OfType() - .SingleOrDefault(candidate => string.Equals(candidate.Id, componentId, StringComparison.Ordinal)); - if (cylinder is null) - { - return false; - } - - return source.Action is SequenceStepAction.SetSignal or SequenceStepAction.SetChannel - && string.Equals(source.TargetId, cylinder.ExtendCommandChannelId, StringComparison.Ordinal) - && bool.TryParse(source.Parameter, out bool requested) - && requested; - } - private static bool HasSettled( SequenceStepDefinition source, string componentId, diff --git a/src/OpenVisionLab.Machine.Simulation/Sequences/RecipeDryRunContracts.cs b/src/OpenVisionLab.Machine.Simulation/Sequences/RecipeDryRunContracts.cs new file mode 100644 index 0000000..fe83df2 --- /dev/null +++ b/src/OpenVisionLab.Machine.Simulation/Sequences/RecipeDryRunContracts.cs @@ -0,0 +1,66 @@ +using OpenVisionLab.Machine.Core.Sequences; +using OpenVisionLab.Machine.Simulation.Snapshots; + +namespace OpenVisionLab.Machine.Simulation.Sequences; + +public enum RecipeDryRunOutcome +{ + Completed, + CompletedWithIssue, + CompletedWithMismatch, + LimitReached, + Faulted, + Rejected +} + +public sealed record RecipeDryRunIssue( + string StepId, + long Tick, + string Code, + string Detail); + +public sealed record RecipeDryRunStepCheckpoint( + string TargetId, + string ExpectedState, + string ActualState, + bool IsPassed, + string Detail); + +public sealed record RecipeDryRunCheckpointMismatch( + string StepId, + long Tick, + string TargetId, + string ExpectedState, + string ActualState, + string Detail); + +public sealed record RecipeDryRunStepTrace( + string StepId, + string Name, + SequenceStepAction Action, + long StartedTick, + long EndedTick, + bool HasIssue, + SimulationSnapshot BoundarySnapshot, + RecipeDryRunStepCheckpoint? Checkpoint) +{ + public bool HasCheckpoint => Checkpoint is not null; + public bool HasCheckpointMismatch => Checkpoint is { IsPassed: false }; +} + +public sealed record RecipeDryRunResult( + RecipeDryRunOutcome Outcome, + string SequenceId, + string SequenceName, + long ExecutedTicks, + int MaximumTicks, + IReadOnlyList Timeline, + RecipeDryRunIssue? FirstIssue, + RecipeDryRunCheckpointMismatch? FirstCheckpointMismatch, + SimulationSnapshot? FinalSnapshot, + string Detail) +{ + public bool IsCompleted => Outcome is RecipeDryRunOutcome.Completed + or RecipeDryRunOutcome.CompletedWithIssue + or RecipeDryRunOutcome.CompletedWithMismatch; +} 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 @@