mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-19 07:02:16 +08:00
1bb4bdca1d
# Description Implement the released [OPC 40100-1](https://reference.opcfoundation.org/specs/OPC-40100-1) and [OPC 40100-2](https://reference.opcfoundation.org/specs/OPC-40100-2) specifications with generated models, provider-backed runtimes, typed clients, MCP workflows, and DI/Machinery integration. - **Part 1 stays in `Opc.Ua.Vision`** and its client/server packages alongside the distinct Perception model. Provide control/acquisition state machines, jobs and synchronized steps, recipe/product/configuration/result management, file transfers, diagnostics, events, and conditions. - **Part 2 is in `Opc.Ua.MachineVision`, `.Client`, and `.Server`.** Provide asset categories, identification, image-handling aspects, health/maintenance data, topology, and transactional updates. The base Vision assemblies do not reference these optional packages; the Part 2 wire model does not depend on Part 1. - Preserve the release-pinned model bytes, wire namespaces and NodeIds. Perception types are in `Opc.Ua.Vision.Perception`; the AMCM generated namespace remains `Opc.Ua.Vision.MachineVision.AMCM`, and asset client/server extension namespaces remain `Opc.Ua.Vision.Client` and `Opc.Ua.Vision.Server`. - Use `AddOpcUaMachineVision` and `AddOpcUaMachineVisionAMCM` for model loading and factory registration. Each has node-collection, encodeable-factory-builder and node-state-factory-builder overloads. These short entry points delegate to the generated implementations; existing namespace-derived generated names remain available for compatibility. - Support multiple independent named systems/assets under one namespace-owning manager. Reuse injectable providers, file binding, bounded memory stores, optional transactional `IFileSystemProvider` persistence, state machines, telemetry and conformance infrastructure. - Keep one `docs/Vision.md` guide, titled `Machine Vision (OPC 40100-1 and OPC 40100-2)`, and one `Opc.Ua.Mcp.Vision` package. Include deterministic server/client samples. - Expose 17 Machine Vision MCP tools, including capabilities/status, bounded relationships and observation, complete small-file uploads/downloads, management, explicit control, conditions, and chunked transfers. Catalogue counts: 39 Vision-specific, 43 with connection tools, 81 Vision+Robotics, 155 Full. Every Machine Vision wire parameter has a schema description. **Compatibility and limits:** `AddVision()` remains Perception-only unless released parts are selected. Standalone Part 2 uses `AddVision().AddMachineVision()`; Machinery composition uses `AddVisionToMachinery()`. Existing tool defaults and command signatures are preserved. Observation owns and cleans up its subscription: 5 seconds/50 notifications by default, capped at 60 seconds/100 notifications. Complete-file tools are capped at 1 MiB; larger files use chunked transfers. Domain Error values remain distinct from UA service failures. **Versioning:** the seven Vision/MachineVision/MCP packages and their Debug variants follow the repository root version instead of being forced to preview. Vision.OpenUsd and its descendants remain preview-only. Development builds still inherit the root prerelease version. No release version, tag, release branch, publication policy, or feed promotion is changed. **Review actions:** all fourteen review threads have action/evidence replies and are resolved. Changes include the unified guide, Perception namespace, separate Part 2 packages and final naming, multi-system ownership explanation, interrupted-write cleanup, observer failure isolation, complete MCP parameter descriptions, and explicit package inventory. The preview-layout migration topic and the requested ReleaseProcess caveat were removed. The test Publish collector now retries recognized transient failures within one cancellation budget and propagates other exceptions. Commit69b1f5db9applies the requested short loader names and renames standalone hosting to `AddMachineVision`, with callers, tests and documentation updated. The intervening master merge is preserved. ### Verification **Latest API naming changes, verified locally:** | Scope | Result | | --- | --- | | Full Vision suite, net10 | 572 passed, 1 existing skip, 0 failed | | Full Vision suite, net48 | 515 passed, 0 skipped, 0 failed | | Final loader/hosting cases after cross-TFM null-guard adjustment | 9 passed, 0 failed, 0 skipped | | Machine Vision MCP and profile cases | 198 passed, 0 failed, 0 skipped | | Main and MCP AOT test-project compilation | Both built with 0 warnings/errors | | MCP AOT companion, managed execution | 5 passed, 0 failed, 0 skipped; not claimed as native execution | `ModelLoaderNamesRegisterTypedFactories`, `ModelLoaderNamesRejectNullArguments`, and `GeneratedModelsLoadTogetherAndCreateTypedInstances` cover all six loader overloads, fluent return identities, typed instances, namespace coexistence and null guards. Existing standalone Part 2 tests verify idempotent registration, one-owner enforcement and null handling through `AddMachineVision`. No source-generator implementation, CLR namespace, wire identity or model payload was changed for these naming updates. The current69b1f5db9revision is running [CI](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826937), [package validation](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165823359), [MCP usability](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826874), [CodeQL](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826906), and [Images CI](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826884). These runs are not claimed as completed results. **Earlier completed verification:** all seven default-TFM package archives were inspected after the package rename: exact IDs, 27 clean DLLs, correct dependencies/readmes/licenses, and unchanged pinned model hashes. [CI onc75ec4fa6](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38142519760) passed, including the formerly failing Linux batch and required summary. Its unchanged coverage gate passed at **88.56% lines, 78.96% branches, 84.28% patch** against the 75% patch floor. The later bounded-Publish-helper regressions reproduced 13 failures before the fix and passed afterward; the full mainline triggering fixture passed 67 tests on each of net10/net48. These are historical results, not current full-matrix results. <details> <summary>Executed API naming validation commands and outcomes</summary> Run from the worktree root using the established artifact trees. Dependency builds were performed before the no-build full net10 run; the focused loader/hosting selection was rebuilt and rerun after the final null-guard adjustment. ```powershell $files = 'C:\Users\mschier\.copilot\session-state\deb2ba10-b0e9-4a44-8783-8331bc850814\files' $env:DOTNET_CLI_USE_MSBUILD_SERVER = '0' $env:TEMP = 'C:\Temp\mv-deb2\followup' $env:TMP = $env:TEMP # Passed: 572; Failed: 0; Skipped: 1. dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Release -f net10.0 --no-build --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-net10" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup' # Passed: 9; Failed: 0; Skipped: 0, on the final source. dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Release -f net10.0 --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --filter 'FullyQualifiedName~MachineVisionModelTests|FullyQualifiedName~StandalonePartTwoRegistrationUsesOneOwnerAndOneModelProvider|FullyQualifiedName~StandalonePartTwoRequiresRegistrationAndRejectsCompetingOwnership' --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-final-model" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup' # Passed: 198; Failed: 0; Skipped: 0. dotnet test tests\Opc.Ua.Tools.Tests\Opc.Ua.Tools.Tests.csproj -c Release -f net10.0 --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --filter 'FullyQualifiedName~VisionMachineToolsTests|FullyQualifiedName~McpHostBuilderTests|FullyQualifiedName~McpToolProfileCompositionTests' --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-final-mcp" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup' $env:TEMP = 'C:\Temp\mv-deb2\followup48' $env:TMP = $env:TEMP # Passed: 515; Failed: 0; Skipped: 0. dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Debug -f net48 --no-restore --artifacts-path "$files\solution-net48-artifacts" -p:CustomTestTarget=net48 -p:UseSharedCompilation=false -m:1 -nr:false --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-net48-final" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup48' ``` Opt-in long-running/stress/durable tiers have not been rerun locally; the unqualified **all tests** checklist item remains open. Original implementation matrix counts are not presented as current results. </details> ## Related Issues - Fixes #4732. The requester reviewed and approved the implementation plan and subsequent packaging/API decisions in the development session. The tracking issue records the design; maintainer architectural discussion/sign-off there is still pending. This PR remains a draft. ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [x] I have addressed **all** PR feedback received.
922 lines
40 KiB
C#
922 lines
40 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Opc.Ua;
|
|
using Opc.Ua.AI.Client;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Client.Alarms;
|
|
using Opc.Ua.ISA95.Client;
|
|
using Opc.Ua.Vision.Client;
|
|
using Opc.Ua.Vision.Perception;
|
|
using V2 = Opc.Ua.ISA95.JobControl.V2;
|
|
|
|
namespace Vision.VisualInspectionAgent
|
|
{
|
|
internal static class Program
|
|
{
|
|
public static async Task<int> Main(string[] args)
|
|
{
|
|
using var cts = new CancellationTokenSource();
|
|
Console.CancelKeyPress += (_, e) =>
|
|
{
|
|
e.Cancel = true;
|
|
cts.Cancel();
|
|
};
|
|
|
|
VisualInspectionAgentOptions options;
|
|
try
|
|
{
|
|
options = VisualInspectionAgentOptions.Parse(args);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
Console.Error.WriteLine(ex.Message);
|
|
return 2;
|
|
}
|
|
if (options.Mode == VisualInspectionAgentMode.LiveAI && string.IsNullOrWhiteSpace(options.AIEndpoint))
|
|
{
|
|
Console.Error.WriteLine("live-ai requires --ai-endpoint and exits before creating any job.");
|
|
return 2;
|
|
}
|
|
|
|
VisualInspectionAgentSession sample = await VisualInspectionAgentSession
|
|
.ConnectAsync(options, cts.Token)
|
|
.ConfigureAwait(false);
|
|
await using (sample.ConfigureAwait(false))
|
|
{
|
|
var runner = new VisualInspectionAgentRunner(sample, options);
|
|
try
|
|
{
|
|
await runner.RunAsync(cts.Token).ConfigureAwait(false);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
// Business refusals - an ISA-95 ReturnStatus that is not success, or a
|
|
// deployment that cannot serve live-ai - are expected outcomes of the demo.
|
|
// Report them as a diagnostic rather than a crash dump.
|
|
Console.Error.WriteLine(ex.Message);
|
|
return 3;
|
|
}
|
|
return 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
internal sealed class VisualInspectionAgentRunner
|
|
{
|
|
public VisualInspectionAgentRunner(VisualInspectionAgentSession sample, VisualInspectionAgentOptions options)
|
|
{
|
|
m_sample = sample ?? throw new ArgumentNullException(nameof(sample));
|
|
m_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
}
|
|
|
|
public async Task RunAsync(CancellationToken cancellationToken)
|
|
{
|
|
VisualInspectionCellContext cell = await DiscoverAsync(cancellationToken).ConfigureAwait(false);
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"Connected to {m_options.ServerUrl}; mode={m_options.Mode}; cycles={m_options.Cycles}."));
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"Discovered pipeline={cell.Pipeline.PipelineNodeId}, deployment={cell.Snapshot.DeploymentId}, learningJob={cell.Snapshot.LearningJobId}."));
|
|
|
|
await VerifyLiveAIBeforeJobsAsync(cell, cancellationToken).ConfigureAwait(false);
|
|
for (int cycle = 1; cycle <= m_options.Cycles; cycle++)
|
|
{
|
|
string cycleId = StableCycleId(cycle);
|
|
string fixture = FixtureFor(cycle);
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] State=Capture; fixture={fixture}; cycle/order/result id are correlated."));
|
|
|
|
InspectionEvidence evidence = await CaptureAndMeasureWithFallbackAsync(
|
|
cell,
|
|
cycleId,
|
|
fixture,
|
|
cancellationToken).ConfigureAwait(false);
|
|
InspectionDecision decision = m_policy.Judge(fixture, evidence.Measurements);
|
|
await cell.Feedback.SubmitImageReferenceAsync(
|
|
VisionFeedbackPurposeEnum.Reconciliation,
|
|
evidence.Frame,
|
|
cycleId,
|
|
cancellationToken).ConfigureAwait(false);
|
|
await cell.Feedback.SubmitInspectionResultAsync(
|
|
cycleId,
|
|
decision.Evaluation,
|
|
decision.Characteristics,
|
|
cancellationToken).ConfigureAwait(false);
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] Verdict={decision.Evaluation}; result submitted to Vision Feedback."));
|
|
|
|
if (decision.Evaluation == VisionResultEvaluationEnum.NotDecidable)
|
|
{
|
|
await HoldForOperatorAsync(cell, cycleId, decision, cancellationToken).ConfigureAwait(false);
|
|
continue;
|
|
}
|
|
|
|
await CompleteInspectionJobAsync(cell, cycleId, cancellationToken).ConfigureAwait(false);
|
|
string nextOrder = decision.Evaluation == VisionResultEvaluationEnum.Ok
|
|
? InspectionOrderId
|
|
: ReworkRejectOrderId;
|
|
await StoreAndStartIdempotentAsync(cell.JobControl, nextOrder, cycleId, verifyRetry: true,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] Scheduled {(decision.Evaluation == VisionResultEvaluationEnum.Ok ? "next inspection" : "rework/reject")} order {nextOrder}."));
|
|
}
|
|
}
|
|
|
|
private async Task<VisualInspectionCellContext> DiscoverAsync(CancellationToken cancellationToken)
|
|
{
|
|
var vision = new VisionClient(m_sample.Session, m_sample.Telemetry);
|
|
VisionNodeEntry? pipelineEntry = null;
|
|
await foreach (VisionNodeEntry entry in vision.EnumeratePipelinesAsync(cancellationToken)
|
|
.ConfigureAwait(false))
|
|
{
|
|
if (string.Equals(entry.BrowseName.Name, PipelineBrowseName, StringComparison.Ordinal))
|
|
{
|
|
pipelineEntry = entry;
|
|
break;
|
|
}
|
|
}
|
|
if (pipelineEntry == null)
|
|
{
|
|
throw new InvalidOperationException("The visual-inspection pipeline was not found.");
|
|
}
|
|
|
|
VisionPipelineClient pipeline = vision.Pipeline(pipelineEntry.NodeId);
|
|
VisionPipelineSnapshot snapshot = await pipeline.ReadAsync(cancellationToken).ConfigureAwait(false);
|
|
VisionFeedbackClient feedback = await pipeline.OpenFeedbackAsync(cancellationToken).ConfigureAwait(false)
|
|
?? throw new InvalidOperationException("The pipeline has no Feedback object.");
|
|
VisionSensorClient sensor = vision.Sensor(snapshot.SensorId);
|
|
VisionMediaClient media = await sensor.OpenMediaAsync(cancellationToken).ConfigureAwait(false)
|
|
?? throw new InvalidOperationException("The image sensor has no Media object.");
|
|
NodeId clipEndpoint = NodeId.Null;
|
|
await foreach (VisionNodeEntry endpoint in media.EnumerateClipEndpointsAsync(cancellationToken)
|
|
.ConfigureAwait(false))
|
|
{
|
|
clipEndpoint = endpoint.NodeId;
|
|
break;
|
|
}
|
|
if (clipEndpoint.IsNull)
|
|
{
|
|
throw new InvalidOperationException("The image sensor has no clip endpoint.");
|
|
}
|
|
|
|
var ai = new AIClient(m_sample.Session, m_sample.Telemetry);
|
|
var deployment = new AIDeploymentClient(ai, snapshot.DeploymentId);
|
|
var isa95 = new Isa95Client(m_sample.Session, m_sample.Telemetry);
|
|
Isa95JobControlDiscovery discovery = await isa95.DiscoverJobControlAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
NodeId receiver = FindFacet(discovery, Isa95JobControlFacet.JobOrderReceiver);
|
|
NodeId provider = FindFacet(discovery, Isa95JobControlFacet.JobResponseProvider);
|
|
NodeId responseReceiver = FindFacet(discovery, Isa95JobControlFacet.JobResponseReceiver);
|
|
Isa95JobControlV2Client jobControl = isa95.CreateJobControlV2Client(receiver, provider, responseReceiver);
|
|
NodeId dialog = await FindObjectByBrowseNameAsync(OperatorDialogBrowseName, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return new VisualInspectionCellContext(
|
|
pipeline,
|
|
snapshot,
|
|
media,
|
|
clipEndpoint,
|
|
feedback,
|
|
deployment,
|
|
jobControl,
|
|
dialog);
|
|
}
|
|
|
|
private async Task VerifyLiveAIBeforeJobsAsync(
|
|
VisualInspectionCellContext cell,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (m_options.Mode != VisualInspectionAgentMode.LiveAI)
|
|
{
|
|
return;
|
|
}
|
|
AIDeploymentSnapshot snapshot = await cell.Deployment.ReadAsync(cancellationToken).ConfigureAwait(false);
|
|
if (string.IsNullOrWhiteSpace(snapshot.EndpointUri))
|
|
{
|
|
throw new InvalidOperationException("live-ai deployment has no endpoint URI; no jobs were created.");
|
|
}
|
|
}
|
|
|
|
private async Task<InspectionEvidence> CaptureAndMeasureAsync(
|
|
VisualInspectionCellContext cell,
|
|
string cycleId,
|
|
string fixture,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
VisionClipResult clip = await cell.Media.GetClipAsync(
|
|
cell.ClipEndpoint,
|
|
fixture,
|
|
DateTimeUtc.From(DateTime.UnixEpoch),
|
|
VisionClipFormatEnum.Png,
|
|
requestInline: true,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
if (clip.HasInlineImage)
|
|
{
|
|
ArrayOf<MeasuredCharacteristic> measurements = await InvokeAIForMeasurementsAsync(
|
|
cell,
|
|
cycleId,
|
|
fixture,
|
|
clip.Image,
|
|
cancellationToken).ConfigureAwait(false);
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] Measurements from AI Invoke: {FormatMeasurements(measurements)}."));
|
|
return new InspectionEvidence(clip.Image, measurements);
|
|
}
|
|
|
|
throw new InvalidOperationException("The cell did not return an inline fixture image.");
|
|
}
|
|
|
|
private async Task<InspectionEvidence> CaptureAndMeasureFromFixtureAsync(
|
|
VisualInspectionCellContext cell,
|
|
string cycleId,
|
|
string fixture,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
VisionImageReferenceDataType frame = CreateFixtureImageReference(fixture);
|
|
ArrayOf<MeasuredCharacteristic> measurements = await InvokeAIForMeasurementsAsync(
|
|
cell,
|
|
cycleId,
|
|
fixture,
|
|
frame,
|
|
cancellationToken).ConfigureAwait(false);
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] Measurements from AI Invoke: {FormatMeasurements(measurements)}."));
|
|
return new InspectionEvidence(frame, measurements);
|
|
}
|
|
|
|
private async Task<InspectionEvidence> CaptureAndMeasureWithFallbackAsync(
|
|
VisualInspectionCellContext cell,
|
|
string cycleId,
|
|
string fixture,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
return await CaptureAndMeasureAsync(cell, cycleId, fixture, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (ServiceResultException ex) when (ex.StatusCode == StatusCodes.BadNodeIdUnknown)
|
|
{
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] Cell clip endpoint rejected direct fixture capture ({ex.StatusCode}); using the packaged fixture image reference and still invoking the cell AI deployment."));
|
|
return await CaptureAndMeasureFromFixtureAsync(cell, cycleId, fixture, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task<ArrayOf<MeasuredCharacteristic>> InvokeAIForMeasurementsAsync(
|
|
VisualInspectionCellContext cell,
|
|
string cycleId,
|
|
string fixture,
|
|
VisionImageReferenceDataType frame,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (cell.Snapshot.DeploymentId.IsNull)
|
|
{
|
|
throw new InvalidOperationException("The pipeline does not name an AI deployment.");
|
|
}
|
|
string payload = BuildAIPayload(cycleId, fixture, frame);
|
|
for (int attempt = 1; attempt <= MaxOperationalAttempts; attempt++)
|
|
{
|
|
try
|
|
{
|
|
AIInvokeResult result = await cell.Deployment.InvokeAsync(
|
|
ByteString.From(Encoding.UTF8.GetBytes(payload)),
|
|
"application/vnd.opcfoundation.visual-inspection.measurements+json",
|
|
[],
|
|
5000,
|
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] AI deployment Invoke returned {result.ResponseContentType}; evidence only."));
|
|
return ParseMeasurements(result.ResponsePayload);
|
|
}
|
|
catch (ServiceResultException ex) when (ex.StatusCode == StatusCodes.BadNotImplemented)
|
|
{
|
|
AIInvokeResult result = await InvokeAIMethodByInstanceNodeAsync(
|
|
cell.Snapshot.DeploymentId,
|
|
ByteString.From(Encoding.UTF8.GetBytes(payload)),
|
|
"application/vnd.opcfoundation.visual-inspection.measurements+json",
|
|
cancellationToken).ConfigureAwait(false);
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] AI deployment Invoke instance method returned {result.ResponseContentType}; evidence only."));
|
|
return ParseMeasurements(result.ResponsePayload);
|
|
}
|
|
catch (Exception ex) when (attempt < MaxOperationalAttempts && IsOperationalFailure(ex))
|
|
{
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] Operational AI/camera error attempt {attempt}: {ex.Message}; retrying."));
|
|
}
|
|
}
|
|
|
|
throw new InvalidOperationException("AI Invoke failed after bounded retries; holding without a quality verdict.");
|
|
}
|
|
|
|
private async Task<AIInvokeResult> InvokeAIMethodByInstanceNodeAsync(
|
|
NodeId deploymentId,
|
|
ByteString payload,
|
|
string contentType,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
NodeId methodId = await FindChildByBrowseNameAsync(
|
|
deploymentId,
|
|
"Invoke",
|
|
NodeClass.Method,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (methodId.IsNull)
|
|
{
|
|
throw new ServiceResultException(StatusCodes.BadMethodInvalid);
|
|
}
|
|
var request = new CallMethodRequest
|
|
{
|
|
ObjectId = deploymentId,
|
|
MethodId = methodId,
|
|
InputArguments =
|
|
[
|
|
Variant.From(payload),
|
|
Variant.From(string.Empty),
|
|
Variant.From(contentType),
|
|
Variant.FromStructure(ArrayOf<Opc.Ua.KeyValuePair>.Empty),
|
|
Variant.From(5000.0)
|
|
]
|
|
};
|
|
CallResponse response = await m_sample.Session.CallAsync(
|
|
null,
|
|
[request],
|
|
cancellationToken).ConfigureAwait(false);
|
|
CallMethodResult result = response.Results[0];
|
|
if (StatusCode.IsBad(result.StatusCode))
|
|
{
|
|
throw new ServiceResultException(result.StatusCode);
|
|
}
|
|
ArrayOf<Variant> output = result.OutputArguments;
|
|
return new AIInvokeResult
|
|
{
|
|
ResponsePayload = output.Count > 0 && output[0].TryGetValue(out ByteString responsePayload)
|
|
? responsePayload
|
|
: ByteString.Empty,
|
|
ResponseContentType = output.Count > 1 && output[1].TryGetValue(out string responseContentType)
|
|
? responseContentType
|
|
: string.Empty
|
|
};
|
|
}
|
|
|
|
private async Task CompleteInspectionJobAsync(
|
|
VisualInspectionCellContext cell,
|
|
string cycleId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await EnsureInspectionOrderExecutingAsync(cell, cycleId, cancellationToken).ConfigureAwait(false);
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] Completing ISA-95 inspection order {InspectionOrderId} from the execution state."));
|
|
await EnsureJobMethodSucceededAsync(
|
|
cell.JobControl.StopAsync(InspectionOrderId, Comment(cycleId, "complete"), cancellationToken),
|
|
"Complete inspection",
|
|
cycleId).ConfigureAwait(false);
|
|
await EnsureJobMethodSucceededAsync(
|
|
cell.JobControl.ClearAsync(InspectionOrderId, Comment(cycleId, "close"), cancellationToken),
|
|
"Close inspection",
|
|
cycleId).ConfigureAwait(false);
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] Inspection job completed and closed; no ReturnStatus=0x8."));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-establishes the inspection order when an earlier run left none executing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A run that ends on <c>NotDecidable</c> holds without scheduling the next inspection,
|
|
/// so the order the cell seeded at start-up is already closed by the time the next run
|
|
/// begins. Completing it then fails, which used to end the sample on an unhandled
|
|
/// exception rather than simply carrying on.
|
|
/// </remarks>
|
|
private static async Task EnsureInspectionOrderExecutingAsync(
|
|
VisualInspectionCellContext cell,
|
|
string cycleId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ulong returnStatus = await cell.JobControl.StoreAndStartAsync(
|
|
NewCatalogueOrder(InspectionOrderId, cycleId),
|
|
Comment(cycleId, "ensure-executing"),
|
|
cancellationToken).ConfigureAwait(false);
|
|
if ((returnStatus & Isa95ReturnStatusSuccess) != 0)
|
|
{
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] No inspection order was executing; re-established {InspectionOrderId}."));
|
|
return;
|
|
}
|
|
if ((returnStatus & Isa95ReturnStatusUnableToAccept) == 0)
|
|
{
|
|
ThrowJobMethodFailure("Ensure inspection order", cycleId, returnStatus);
|
|
}
|
|
}
|
|
|
|
private async Task StoreAndStartIdempotentAsync(
|
|
Isa95JobControlV2Client jobControl,
|
|
string orderId,
|
|
string cycleId,
|
|
bool verifyRetry,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (await OrderExistsAsync(jobControl, orderId, cancellationToken).ConfigureAwait(false))
|
|
{
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] Order {orderId} already exists; retry suppressed to avoid a duplicate."));
|
|
return;
|
|
}
|
|
ulong returnStatus = await jobControl.StoreAndStartAsync(
|
|
NewCatalogueOrder(orderId, cycleId),
|
|
Comment(cycleId, "store-start"),
|
|
cancellationToken).ConfigureAwait(false);
|
|
if ((returnStatus & Isa95ReturnStatusSuccess) == 0)
|
|
{
|
|
if ((returnStatus & Isa95ReturnStatusUnableToAccept) != 0)
|
|
{
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] StoreAndStart ReturnStatus=0x{returnStatus:X}; stable order {orderId} already exists, so no duplicate was created."));
|
|
return;
|
|
}
|
|
ThrowJobMethodFailure("StoreAndStart", cycleId, returnStatus);
|
|
}
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] StoreAndStart ReturnStatus=0x{returnStatus:X}."));
|
|
if (verifyRetry)
|
|
{
|
|
await StoreAndStartIdempotentAsync(
|
|
jobControl,
|
|
orderId,
|
|
FormattableString.Invariant($"{cycleId}-retry"),
|
|
verifyRetry: false,
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task<bool> OrderExistsAsync(
|
|
Isa95JobControlV2Client jobControl,
|
|
string orderId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
(V2.ISA95JobResponseDataType response, ulong returnStatus) = await jobControl
|
|
.RequestJobResponseByJobOrderIdAsync(orderId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return (returnStatus & Isa95ReturnStatusSuccess) != 0 && response != null;
|
|
}
|
|
catch (ServiceResultException ex) when (StatusCode.IsUncertain(ex.StatusCode))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async Task HoldForOperatorAsync(
|
|
VisualInspectionCellContext cell,
|
|
string cycleId,
|
|
InspectionDecision decision,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] NotDecidable: holding; no job is scheduled until the dialog is answered."));
|
|
ulong before = await ReadSamplesCollectedAsync(cell, cancellationToken).ConfigureAwait(false);
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] SamplesCollected before operator correction: {before}."));
|
|
OperatorDisposition disposition = await m_operatorPolicy.GetDispositionAsync(
|
|
m_options.Mode,
|
|
m_options.OperatorTimeout,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (disposition == OperatorDisposition.Stop)
|
|
{
|
|
Console.WriteLine(FormattableString.Invariant($"[{cycleId}] Operator requested stop."));
|
|
return;
|
|
}
|
|
if (!cell.OperatorDialogId.IsNull)
|
|
{
|
|
await new AlarmClient(m_sample.Session, m_sample.Telemetry)
|
|
.RespondAsync(cell.OperatorDialogId, ToDialogResponse(disposition), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
if (disposition == OperatorDisposition.Reinspect)
|
|
{
|
|
await StoreAndStartIdempotentAsync(
|
|
cell.JobControl,
|
|
InspectionOrderId,
|
|
cycleId,
|
|
verifyRetry: false,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
bool retractAll = disposition == OperatorDisposition.AcceptAsNotOk;
|
|
await cell.Feedback.SubmitCorrectionAsync(
|
|
cycleId,
|
|
VisionFeedbackPurposeEnum.GroundTruthLabel,
|
|
[],
|
|
retractAll ? [] : decision.Characteristics,
|
|
LocalizedText.From(disposition.ToString()),
|
|
ByteString.Empty,
|
|
retractAll,
|
|
cancellationToken).ConfigureAwait(false);
|
|
ulong afterFirst = await ReadSamplesCollectedAsync(cell, cancellationToken).ConfigureAwait(false);
|
|
await cell.Feedback.SubmitCorrectionAsync(
|
|
cycleId,
|
|
VisionFeedbackPurposeEnum.GroundTruthLabel,
|
|
[],
|
|
retractAll ? [] : decision.Characteristics,
|
|
LocalizedText.From(disposition.ToString()),
|
|
ByteString.Empty,
|
|
retractAll,
|
|
cancellationToken).ConfigureAwait(false);
|
|
ulong afterDuplicate = await ReadSamplesCollectedAsync(cell, cancellationToken).ConfigureAwait(false);
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] Operator {disposition}; SamplesCollected {before}->{afterFirst}->{afterDuplicate} after duplicate correction."));
|
|
}
|
|
|
|
private async Task<NodeId> FindObjectByBrowseNameAsync(
|
|
string browseName,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var pending = new Queue<NodeId>();
|
|
var visited = new HashSet<NodeId>();
|
|
pending.Enqueue(Opc.Ua.ObjectIds.ObjectsFolder);
|
|
visited.Add(Opc.Ua.ObjectIds.ObjectsFolder);
|
|
while (pending.Count > 0)
|
|
{
|
|
NodeId current = pending.Dequeue();
|
|
(ArrayOf<ArrayOf<ReferenceDescription>> descriptions, ArrayOf<ServiceResult> errors) =
|
|
await m_sample.Session.ManagedBrowseAsync(
|
|
requestHeader: null,
|
|
view: null,
|
|
nodesToBrowse: [current],
|
|
maxResultsToReturn: 0,
|
|
browseDirection: BrowseDirection.Forward,
|
|
referenceTypeId: default,
|
|
includeSubtypes: true,
|
|
nodeClassMask: (uint)NodeClass.Object,
|
|
ct: cancellationToken).ConfigureAwait(false);
|
|
if (errors.Count > 0 && StatusCode.IsBad(errors[0].StatusCode))
|
|
{
|
|
throw new ServiceResultException(errors[0]);
|
|
}
|
|
if (descriptions.Count == 0)
|
|
{
|
|
continue;
|
|
}
|
|
foreach (ReferenceDescription reference in descriptions[0])
|
|
{
|
|
var nodeId = ExpandedNodeId.ToNodeId(reference.NodeId, m_sample.Session.NamespaceUris);
|
|
if (nodeId.IsNull)
|
|
{
|
|
continue;
|
|
}
|
|
if (string.Equals(reference.BrowseName.Name, browseName, StringComparison.Ordinal))
|
|
{
|
|
return nodeId;
|
|
}
|
|
if (visited.Add(nodeId))
|
|
{
|
|
pending.Enqueue(nodeId);
|
|
}
|
|
}
|
|
}
|
|
return NodeId.Null;
|
|
}
|
|
|
|
private async Task<ulong> ReadSamplesCollectedAsync(
|
|
VisualInspectionCellContext cell,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (cell.Snapshot.LearningJobId.IsNull)
|
|
{
|
|
return 0;
|
|
}
|
|
NodeId samples = await FindChildByBrowseNameAsync(
|
|
cell.Snapshot.LearningJobId,
|
|
"SamplesCollected",
|
|
NodeClass.Variable,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (samples.IsNull)
|
|
{
|
|
return 0;
|
|
}
|
|
DataValue value = await m_sample.Session.ReadValueAsync(samples, cancellationToken).ConfigureAwait(false);
|
|
return value.WrappedValue.TryGetValue(out ulong count) ? count : 0;
|
|
}
|
|
|
|
private async Task<NodeId> FindChildByBrowseNameAsync(
|
|
NodeId parent,
|
|
string browseName,
|
|
NodeClass nodeClass,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
(ArrayOf<ArrayOf<ReferenceDescription>> descriptions, ArrayOf<ServiceResult> errors) =
|
|
await m_sample.Session.ManagedBrowseAsync(
|
|
requestHeader: null,
|
|
view: null,
|
|
nodesToBrowse: [parent],
|
|
maxResultsToReturn: 0,
|
|
browseDirection: BrowseDirection.Forward,
|
|
referenceTypeId: default,
|
|
includeSubtypes: true,
|
|
nodeClassMask: (uint)nodeClass,
|
|
ct: cancellationToken).ConfigureAwait(false);
|
|
if (errors.Count > 0 && StatusCode.IsBad(errors[0].StatusCode))
|
|
{
|
|
throw new ServiceResultException(errors[0]);
|
|
}
|
|
if (descriptions.Count == 0)
|
|
{
|
|
return NodeId.Null;
|
|
}
|
|
foreach (ReferenceDescription reference in descriptions[0])
|
|
{
|
|
if (string.Equals(reference.BrowseName.Name, browseName, StringComparison.Ordinal))
|
|
{
|
|
return ExpandedNodeId.ToNodeId(reference.NodeId, m_sample.Session.NamespaceUris);
|
|
}
|
|
}
|
|
return NodeId.Null;
|
|
}
|
|
|
|
private static NodeId FindFacet(Isa95JobControlDiscovery discovery, Isa95JobControlFacet facet)
|
|
{
|
|
foreach (Isa95JobControlEndpoint endpoint in discovery.V2Endpoints)
|
|
{
|
|
if (endpoint.Facet == facet)
|
|
{
|
|
return endpoint.NodeId;
|
|
}
|
|
}
|
|
throw new InvalidOperationException($"ISA-95 V2 facet {facet} was not found.");
|
|
}
|
|
|
|
private static async Task EnsureJobMethodSucceededAsync(
|
|
ValueTask<ulong> operation,
|
|
string operationName,
|
|
string cycleId)
|
|
{
|
|
ulong returnStatus = await operation.ConfigureAwait(false);
|
|
if ((returnStatus & Isa95ReturnStatusSuccess) == 0)
|
|
{
|
|
ThrowJobMethodFailure(operationName, cycleId, returnStatus);
|
|
}
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[{cycleId}] {operationName} ReturnStatus=0x{returnStatus:X}."));
|
|
}
|
|
|
|
private static void ThrowJobMethodFailure(string operationName, string cycleId, ulong returnStatus)
|
|
{
|
|
throw new InvalidOperationException(FormattableString.Invariant(
|
|
$"[{cycleId}] {operationName} returned ISA-95 ReturnStatus=0x{returnStatus:X}; business failures are not success even when the OPC UA service status is Uncertain."));
|
|
}
|
|
|
|
private static ArrayOf<LocalizedText> Comment(string cycleId, string action)
|
|
{
|
|
return new[] { LocalizedText.From(FormattableString.Invariant($"{cycleId}:{action}")) }.ToArrayOf();
|
|
}
|
|
|
|
private static V2.ISA95JobOrderDataType NewCatalogueOrder(string orderId, string cycleId)
|
|
{
|
|
return new V2.ISA95JobOrderDataType
|
|
{
|
|
JobOrderID = orderId,
|
|
Priority = string.Equals(orderId, InspectionOrderId, StringComparison.Ordinal) ? (short)10 : (short)20,
|
|
Description = new[]
|
|
{
|
|
LocalizedText.From(FormattableString.Invariant($"Allowlisted catalogue order for {cycleId}."))
|
|
}.ToArrayOf()
|
|
};
|
|
}
|
|
|
|
private static string BuildAIPayload(
|
|
string cycleId,
|
|
string fixture,
|
|
VisionImageReferenceDataType frame)
|
|
{
|
|
var builder = new StringBuilder();
|
|
builder.Append(CultureInfo.InvariantCulture,
|
|
$"{{\"cycleId\":\"{cycleId}\",\"fixture\":\"{fixture}\",\"image\":\"")
|
|
.Append(frame.Uri)
|
|
.Append("\"}");
|
|
return builder.ToString();
|
|
}
|
|
|
|
private static ArrayOf<MeasuredCharacteristic> ParseMeasurements(ByteString responsePayload)
|
|
{
|
|
if (responsePayload.Length == 0)
|
|
{
|
|
throw new InvalidDataException("AI Invoke returned an empty response.");
|
|
}
|
|
using var document = JsonDocument.Parse(responsePayload.ToArray());
|
|
JsonElement root = document.RootElement;
|
|
double confidence = ReadRequiredDouble(root, "confidence");
|
|
if (!root.TryGetProperty("measurements", out JsonElement measurements) ||
|
|
measurements.ValueKind != JsonValueKind.Array)
|
|
{
|
|
throw new InvalidDataException("AI Invoke response did not contain a measurements array.");
|
|
}
|
|
var parsed = new List<MeasuredCharacteristic>();
|
|
foreach (JsonElement measurement in measurements.EnumerateArray())
|
|
{
|
|
string characteristicId = ReadRequiredString(measurement, "characteristicId");
|
|
double actual = ReadRequiredDouble(measurement, "actual");
|
|
double uncertainty = ReadRequiredDouble(measurement, "uncertainty");
|
|
parsed.Add(new MeasuredCharacteristic(characteristicId, actual, uncertainty, confidence));
|
|
}
|
|
if (parsed.Count == 0)
|
|
{
|
|
throw new InvalidDataException("AI Invoke response contained no measurements.");
|
|
}
|
|
return parsed.ToArrayOf();
|
|
}
|
|
|
|
private static VisionImageReferenceDataType CreateFixtureImageReference(string fixture)
|
|
{
|
|
string path = Path.Combine(FixtureDirectory, fixture);
|
|
var png = ByteString.From(File.ReadAllBytes(path));
|
|
return new VisionImageReferenceDataType
|
|
{
|
|
Uri = FormattableString.Invariant($"opcua-inline://visual-inspection-cell/fixtures/{fixture}"),
|
|
Digest = ByteString.From(SHA256.HashData(png.Span)),
|
|
DigestAlgorithm = "SHA-256",
|
|
Format = VisionClipFormatEnum.Png,
|
|
PixelFormat = "RGB8",
|
|
Width = 800,
|
|
Height = 600,
|
|
SizeBytes = (uint)png.Length,
|
|
Timestamp = DateTimeUtc.From(DateTime.UnixEpoch)
|
|
};
|
|
}
|
|
|
|
private static string FixtureDirectory
|
|
{
|
|
get
|
|
{
|
|
string output = Path.Combine(AppContext.BaseDirectory, "Fixtures");
|
|
if (Directory.Exists(output))
|
|
{
|
|
return output;
|
|
}
|
|
|
|
string project = Path.GetFullPath(Path.Combine(
|
|
AppContext.BaseDirectory,
|
|
"..",
|
|
"..",
|
|
"..",
|
|
"..",
|
|
"..",
|
|
"VisualInspectionCell",
|
|
"Fixtures"));
|
|
if (Directory.Exists(project))
|
|
{
|
|
return project;
|
|
}
|
|
|
|
return Path.Combine(
|
|
Environment.CurrentDirectory,
|
|
"samples",
|
|
"Vision",
|
|
"VisualInspectionCell",
|
|
"Fixtures");
|
|
}
|
|
}
|
|
|
|
private static string ReadRequiredString(JsonElement element, string name)
|
|
{
|
|
if (element.TryGetProperty(name, out JsonElement property) &&
|
|
property.ValueKind == JsonValueKind.String &&
|
|
property.GetString() is { Length: > 0 } value)
|
|
{
|
|
return value;
|
|
}
|
|
throw new InvalidDataException($"AI Invoke response missing string property '{name}'.");
|
|
}
|
|
|
|
private static double ReadRequiredDouble(JsonElement element, string name)
|
|
{
|
|
if (!element.TryGetProperty(name, out JsonElement property))
|
|
{
|
|
throw new InvalidDataException($"AI Invoke response missing numeric property '{name}'.");
|
|
}
|
|
if (property.ValueKind == JsonValueKind.Number && property.TryGetDouble(out double numeric))
|
|
{
|
|
return numeric;
|
|
}
|
|
if (property.ValueKind == JsonValueKind.String &&
|
|
double.TryParse(
|
|
property.GetString(),
|
|
NumberStyles.Float,
|
|
CultureInfo.InvariantCulture,
|
|
out double text))
|
|
{
|
|
return text;
|
|
}
|
|
throw new InvalidDataException($"AI Invoke response property '{name}' is not numeric.");
|
|
}
|
|
|
|
private static bool IsOperationalFailure(Exception exception)
|
|
{
|
|
return exception is ServiceResultException or JsonException or InvalidDataException or IOException;
|
|
}
|
|
|
|
private static string FormatMeasurements(ArrayOf<MeasuredCharacteristic> measurements)
|
|
{
|
|
var formatted = new List<string>(measurements.Count);
|
|
for (int ii = 0; ii < measurements.Count; ii++)
|
|
{
|
|
MeasuredCharacteristic measurement = measurements[ii];
|
|
formatted.Add(FormattableString.Invariant(
|
|
$"{measurement.CharacteristicId}={measurement.Actual:0.00}mm confidence={measurement.Confidence:0.00}"));
|
|
}
|
|
return string.Join(", ", formatted);
|
|
}
|
|
|
|
private static string StableCycleId(int cycle)
|
|
{
|
|
return FormattableString.Invariant($"vis-agent-cycle-{cycle:000}");
|
|
}
|
|
|
|
private static string FixtureFor(int cycle)
|
|
{
|
|
return ((cycle - 1) % 3) switch
|
|
{
|
|
0 => "bracket-ok.png",
|
|
1 => "bracket-not-ok.png",
|
|
_ => "bracket-ambiguous.png"
|
|
};
|
|
}
|
|
|
|
private static int ToDialogResponse(OperatorDisposition disposition)
|
|
{
|
|
return disposition switch
|
|
{
|
|
OperatorDisposition.AcceptAsOk => 0,
|
|
OperatorDisposition.AcceptAsNotOk => 1,
|
|
OperatorDisposition.Reinspect => 2,
|
|
_ => 3
|
|
};
|
|
}
|
|
|
|
private const string PipelineBrowseName = "BracketInspectionPipeline";
|
|
private const string OperatorDialogBrowseName = "OperatorDispositionDialog";
|
|
private const string InspectionOrderId = "VIS-INSP-BRACKET-001";
|
|
private const string ReworkRejectOrderId = "VIS-REWORK-REJECT-001";
|
|
private const ulong Isa95ReturnStatusSuccess = 1UL;
|
|
private const ulong Isa95ReturnStatusUnableToAccept = 0x10UL;
|
|
private const int MaxOperationalAttempts = 2;
|
|
|
|
private readonly InspectionVerdictPolicy m_policy = new();
|
|
private readonly ScriptedOperatorPolicy m_operatorPolicy = new();
|
|
private readonly VisualInspectionAgentOptions m_options;
|
|
private readonly VisualInspectionAgentSession m_sample;
|
|
}
|
|
|
|
internal sealed record VisualInspectionCellContext(
|
|
VisionPipelineClient Pipeline,
|
|
VisionPipelineSnapshot Snapshot,
|
|
VisionMediaClient Media,
|
|
NodeId ClipEndpoint,
|
|
VisionFeedbackClient Feedback,
|
|
AIDeploymentClient Deployment,
|
|
Isa95JobControlV2Client JobControl,
|
|
NodeId OperatorDialogId);
|
|
|
|
internal sealed record InspectionEvidence(
|
|
VisionImageReferenceDataType Frame,
|
|
ArrayOf<MeasuredCharacteristic> Measurements);
|
|
}
|