Worklist and Procedure Step Workflows

Worklist and procedure step workflows

UPS Worklist and Modality Performed Procedure Step (MPPS) handlers validate their respective DICOM state transitions. Your provider must store a complete dataset and an opaque revision together. TryReplaceAsync must compare the expected revision and replace the dataset atomically; returning false lets the standard handler retry or report a conflict. UPS additionally needs stable search candidates. The following process-local store implements both contracts so the comparison is visible. Register different instances for the two services; it does not persist data across restarts.

public sealed class InMemoryWorkflowDatasets :
    Aspose.Medical.Dicom.Web.Providers.IDicomWebWorkitemProvider,
    Aspose.Medical.Dicom.Web.Providers.IDicomWebMppsProvider
{
    private readonly object gate = new();
    private readonly System.Collections.Generic.Dictionary<string,
        Aspose.Medical.Dicom.Web.Providers.DicomWebVersionedDataset> items =
        new(System.StringComparer.Ordinal);
    private long nextRevision;

    public async System.Collections.Generic.IAsyncEnumerable<Aspose.Medical.Dicom.Dataset> FindCandidatesAsync(
        Aspose.Medical.Dicom.Web.Providers.DicomWebSearchContext search,
        [System.Runtime.CompilerServices.EnumeratorCancellation] System.Threading.CancellationToken cancellationToken = default)
    {
        System.Collections.Generic.List<Aspose.Medical.Dicom.Dataset> snapshot;
        lock (gate)
        {
            snapshot = new();
            System.Collections.Generic.List<string> uids = new(items.Keys);
            uids.Sort(System.StringComparer.Ordinal);
            foreach (string uid in uids)
                snapshot.Add(new Aspose.Medical.Dicom.Dataset(items[uid].Dataset));
        }
        foreach (Aspose.Medical.Dicom.Dataset candidate in snapshot)
        {
            cancellationToken.ThrowIfCancellationRequested();
            yield return candidate;
        }
        await System.Threading.Tasks.Task.CompletedTask;
    }

    public System.Threading.Tasks.ValueTask<Aspose.Medical.Dicom.Web.Providers.DicomWebVersionedDataset?> GetAsync(
        string uid, System.Threading.CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        lock (gate)
        {
            Aspose.Medical.Dicom.Web.Providers.DicomWebVersionedDataset? result =
                items.TryGetValue(uid, out DicomWebVersionedDataset? stored)
                    ? new() { Dataset = new(stored.Dataset), Revision = stored.Revision }
                    : null;
            return System.Threading.Tasks.ValueTask.FromResult(result);
        }
    }

    public System.Threading.Tasks.ValueTask<bool> TryCreateAsync(
        string uid, Aspose.Medical.Dicom.Dataset dataset,
        System.Threading.CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        lock (gate)
        {
            if (items.ContainsKey(uid))
                return System.Threading.Tasks.ValueTask.FromResult(false);
            items.Add(uid, new() { Dataset = new(dataset), Revision = (++nextRevision).ToString(System.Globalization.CultureInfo.InvariantCulture) });
            return System.Threading.Tasks.ValueTask.FromResult(true);
        }
    }

    public System.Threading.Tasks.ValueTask<bool> TryReplaceAsync(
        string uid, string expectedRevision, Aspose.Medical.Dicom.Dataset replacement,
        System.Threading.CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        lock (gate)
        {
            if (!items.TryGetValue(uid, out Aspose.Medical.Dicom.Web.Providers.DicomWebVersionedDataset? current) ||
                !System.StringComparer.Ordinal.Equals(current.Revision, expectedRevision))
                return System.Threading.Tasks.ValueTask.FromResult(false);
            items[uid] = new() { Dataset = new(replacement), Revision = (++nextRevision).ToString(System.Globalization.CultureInfo.InvariantCulture) };
            return System.Threading.Tasks.ValueTask.FromResult(true);
        }
    }
}

The lock makes creation and revision replacement atomic within this one process. The Dataset copy constructor clones elements, including nested sequence datasets, when storing and retrieving a value. This keeps caller mutations from changing stored state before TryReplaceAsync. A real backend must provide the same guarantee across all of its workers. The search snapshot is ordered by UID; the standard engine applies final DICOM matching, projection, and pagination.

UPS subscriptions and notification delivery

IDicomWebSubscriptionProvider owns subscription state and the event queues. This small example supports worklist subscriptions made before workitems are created. It declines workitem and filtered subscriptions; it has no authenticated identity mapping, persistence, replay, or late-subscriber initial reports. Those are application policies to implement when needed. Because this provider cannot resolve an authenticated AE Title, supply the requester query parameter for Change Workitem State and Request Workitem Cancellation.

public sealed class InMemoryWorklistSubscriptions :
    Aspose.Medical.Dicom.Web.Providers.IDicomWebSubscriptionProvider
{
    private readonly object gate = new();
    private readonly System.Collections.Generic.Dictionary<string, Aspose.Medical.Dicom.Web.Providers.DicomWebSubscription> subscriptions =
        new(System.StringComparer.Ordinal);
    private readonly System.Collections.Generic.Dictionary<string, System.Threading.Channels.Channel<Aspose.Medical.Dicom.Dataset>> queues =
        new(System.StringComparer.Ordinal);

    public bool SupportsFilteredWorklistSubscriptions => false;

    public System.Threading.Tasks.ValueTask<Aspose.Medical.Dicom.Web.Providers.DicomWebSubscriptionUpdateResult> UpsertAsync(
        Aspose.Medical.Dicom.Web.Providers.DicomWebSubscription subscription,
        System.Threading.CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        lock (gate)
        {
            if (subscription.WorkitemUid is not null || subscription.Filter is not null)
                return System.Threading.Tasks.ValueTask.FromResult(new Aspose.Medical.Dicom.Web.Providers.DicomWebSubscriptionUpdateResult
                {
                    Outcome = Aspose.Medical.Dicom.Web.Providers.DicomWebSubscriptionUpdateOutcome.Forbidden
                });
            subscriptions[subscription.Subscriber] = subscription;
            GetQueue(subscription.Subscriber);
            return System.Threading.Tasks.ValueTask.FromResult(new Aspose.Medical.Dicom.Web.Providers.DicomWebSubscriptionUpdateResult
            {
                Outcome = Aspose.Medical.Dicom.Web.Providers.DicomWebSubscriptionUpdateOutcome.Applied,
                InitialWorkitemUids = []
            });
        }
    }

    public System.Threading.Tasks.ValueTask<bool> RemoveAsync(
        Aspose.Medical.Dicom.Web.Providers.DicomWebSubscription subscription,
        System.Threading.CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        if (subscription.WorkitemUid is not null || subscription.Filter is not null)
            return System.Threading.Tasks.ValueTask.FromResult(false);
        lock (gate)
            return System.Threading.Tasks.ValueTask.FromResult(subscriptions.Remove(subscription.Subscriber));
    }

    public System.Threading.Tasks.ValueTask<bool> SuspendAsync(
        Aspose.Medical.Dicom.Web.Providers.DicomWebSubscription subscription,
        System.Threading.CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        lock (gate)
        {
            if (!subscriptions.TryGetValue(subscription.Subscriber, out Aspose.Medical.Dicom.Web.Providers.DicomWebSubscription? current))
                return System.Threading.Tasks.ValueTask.FromResult(false);
            subscriptions[subscription.Subscriber] = new()
            {
                Subscriber = current.Subscriber,
                WorkitemUid = current.WorkitemUid,
                Filter = current.Filter,
                HasDeletionLock = current.HasDeletionLock,
                IsSuspended = true
            };
            return System.Threading.Tasks.ValueTask.FromResult(true);
        }
    }

    public System.Threading.Tasks.ValueTask<string?> ResolveRequestingAeTitleAsync(
        Aspose.Medical.Dicom.Web.DicomWebRequest request,
        System.Threading.CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        return System.Threading.Tasks.ValueTask.FromResult<string?>(null);
    }

    public System.Threading.Tasks.ValueTask PublishAsync(
        string workitemUid, Aspose.Medical.Dicom.Dataset eventReport,
        System.Threading.CancellationToken cancellationToken = default)
        => PublishToSubscribersAsync(eventReport, cancellationToken);

    public System.Threading.Tasks.ValueTask QueueAsync(
        string subscriber, Aspose.Medical.Dicom.Dataset eventReport,
        System.Threading.CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        lock (gate)
            GetQueue(subscriber).Writer.TryWrite(new Aspose.Medical.Dicom.Dataset(eventReport));
        return System.Threading.Tasks.ValueTask.CompletedTask;
    }

    public System.Threading.Tasks.ValueTask PublishStatusAsync(
        Aspose.Medical.Dicom.Dataset eventReport,
        System.Threading.CancellationToken cancellationToken = default)
        => PublishToSubscribersAsync(eventReport, cancellationToken);

    public System.Collections.Generic.IAsyncEnumerable<Aspose.Medical.Dicom.Dataset> OpenNotificationsAsync(
        string requester, System.Threading.CancellationToken cancellationToken = default)
    {
        lock (gate)
            return GetQueue(requester).Reader.ReadAllAsync(cancellationToken);
    }

    private System.Threading.Tasks.ValueTask PublishToSubscribersAsync(
        Aspose.Medical.Dicom.Dataset eventReport, System.Threading.CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        lock (gate)
            foreach (Aspose.Medical.Dicom.Web.Providers.DicomWebSubscription subscription in subscriptions.Values)
                if (!subscription.IsSuspended)
                    GetQueue(subscription.Subscriber).Writer.TryWrite(new Aspose.Medical.Dicom.Dataset(eventReport));
        return System.Threading.Tasks.ValueTask.CompletedTask;
    }

    private System.Threading.Channels.Channel<Aspose.Medical.Dicom.Dataset> GetQueue(string subscriber)
    {
        if (!queues.TryGetValue(subscriber, out System.Threading.Channels.Channel<Aspose.Medical.Dicom.Dataset>? queue))
        {
            queue = System.Threading.Channels.Channel.CreateUnbounded<Aspose.Medical.Dicom.Dataset>();
            queues.Add(subscriber, queue);
        }
        return queue;
    }
}

QueueAsync is direct delivery to one subscriber; PublishAsync selects subscriptions for a workitem; PublishStatusAsync carries an SCP lifecycle report. The sample treats every active worklist subscription as matching. When a subscriber enrolls after workitems already exist, calculate InitialWorkitemUids as part of the atomic UpsertAsync change and provide those initial State Reports. For a filtered subscription, apply its compiled Filter to candidate workitems. Store queues durably if notifications must survive process restart. DicomWebWorklistEventPublisher.PublishRestartedAsync and PublishGoingDownAsync create status reports and call this provider’s PublishStatusAsync.

Each queued report is cloned so mutations by one receiver cannot change another subscriber’s report. This sample uses unbounded in-memory queues, retains unread reports after unsubscribe, and removes an event when a receiver reads it. It has no delivery acknowledgment or replay after a failed send. Use one notification connection per requester with this implementation; concurrent readers for the same requester would consume different reports from its shared queue. A production provider must choose queue capacity, retention, and reconnect behavior appropriate to its delivery requirements.

The standard handler persists a workitem change before calling the subscription provider to publish its reports. These calls do not share a library-managed transaction. If publication fails, the stored change may already be visible; retrieve the workitem before retrying a failed operation and provide a durable recovery policy for notifications that could not be queued.

Register the providers with the hosting-neutral builder; ASP.NET Core uses the corresponding DI registrations and AddStandardWorklistService():

InMemoryWorkflowDatasets workitems = new();
InMemoryWorklistSubscriptions subscriptions = new();
Aspose.Medical.Dicom.Web.DicomWebApplication application =
    DicomWebApplication.CreateBuilder()
        .AddStandardWorklistService(workitems, subscriptions)
        .Build();

With that service exposed under /dicomweb, the following client subscribes to the worklist, creates a scheduled workitem, changes its state, and receives reports until that workitem reaches IN PROGRESS. Creation also publishes a State Report, so the first queued report is not necessarily the requested state change. Use valid UIDs assigned by your application, and place UseWebSockets before mapping the ASP.NET Core endpoint.

using System.Net.Http.HttpClient transport = new();
Aspose.Medical.Dicom.Web.DicomWebClient client = new(transport,
    new DicomWebClientOptions
    {
        BaseUri = new System.Uri("https://example.org/dicomweb/")
    });

await client.Worklist.SubscribeWorklistAsync("VIEWER_AE");
await using Aspose.Medical.Dicom.Web.DicomWebNotificationConnection connection =
    await client.ConnectNotificationsAsync("VIEWER_AE");

Aspose.Medical.Dicom.Dataset workitem = [];
workitem.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.SOPInstanceUID, "1.2.840.1");
workitem.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.ProcedureStepState, "SCHEDULED");
workitem.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.TransactionUID, string.Empty);
workitem.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.InputReadinessState, "READY");
Aspose.Medical.Dicom.Web.DicomWebClientResult<Aspose.Medical.Dicom.Web.Transactions.CreateWorkitemResult> created =
    await client.Worklist.CreateAsync(workitem);

Aspose.Medical.Dicom.Dataset stateChange = [];
stateChange.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.ProcedureStepState, "IN PROGRESS");
stateChange.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.TransactionUID, "1.2.840.2");
Aspose.Medical.Dicom.Web.DicomWebRequestOptions changeOptions = new()
{
    Query = Aspose.Medical.Dicom.Web.DicomWebQueryParameters.Parse("requester=MODALITY_AE")
};
await client.Worklist.ChangeStateAsync(created.Value.WorkitemUid, stateChange, changeOptions);

using System.Threading.CancellationTokenSource wait = new(System.TimeSpan.FromSeconds(30));
while (await connection.ReceiveAsync(wait.Token) is Aspose.Medical.Dicom.Dataset report)
{
    string affectedUid = report.GetSingleValueOrDefault(Aspose.Medical.Dicom.Tags.Tag.AffectedSOPInstanceUID, string.Empty);
    string state = report.GetSingleValueOrDefault(Aspose.Medical.Dicom.Tags.Tag.ProcedureStepState, string.Empty);
    if (affectedUid == created.Value.WorkitemUid && state == "IN PROGRESS")
    {
        System.Console.WriteLine(state);
        break;
    }
}

The timeout is only for this example’s wait for an event. A canceled receive or invalid event aborts the connection; dispose it and call ConnectNotificationsAsync again before receiving more reports. A production consumer should continue receiving until close or its own cancellation and decide how to process or persist each report. The typed ChangeStateAsync(workitemUid, WorkitemState.InProgress, transactionUid) overload builds the payload; use the dataset overload when you also need request options such as the explicit requester shown here.

UPS updates and final states

The standard worklist service accepts SCHEDULED → IN PROGRESS → COMPLETED or CANCELED. The transition to IN PROGRESS establishes the Transaction UID that owns subsequent updates and final-state transitions. Repeating an already completed or canceled state with its owning Transaction UID succeeds. Disallowed transitions to IN PROGRESS, COMPLETED, or CANCELED return HTTP 409; unsupported target states or invalid Transaction UIDs return HTTP 400.

For UpdateAsync, a scheduled workitem must have no transaction query parameter and no TransactionUID attribute in the update payload. An in-progress workitem requires exactly one transaction query parameter and a TransactionUID payload attribute, both equal to the stored Transaction UID. Completed and canceled workitems cannot be updated.

An update must omit ProcedureStepState, SOPInstanceUID, SOPClassUID, PatientName, PatientID, PatientBirthDate, PatientSex, AdmissionID, IssuerOfAdmissionIDSequence, AdmittingDiagnosesDescription, AdmittingDiagnosesCodeSequence, ReferencedRequestSequence, and ReplacedProcedureStepSequence. Including any of these attributes returns HTTP 400, even if its value is unchanged. Change the state through ChangeStateAsync.

Before requesting a final state, use UpdateAsync to store its required attributes in the workitem. The state-change payload supplies the requested state and Transaction UID; it does not merge other attributes into the stored dataset.

Final state Required stored attributes
COMPLETED Exactly one item in UnifiedProcedureStepPerformedProcedureSequence. That item must contain nonempty PerformedStationNameCodeSequence and PerformedWorkitemCodeSequence, populated PerformedProcedureStepStartDateTime and PerformedProcedureStepEndDateTime, and an OutputInformationSequence, which may be empty.
CANCELED Exactly one item in ProcedureStepProgressInformationSequence. That item must contain a nonempty ProcedureStepDiscontinuationReasonCodeSequence. The handler fills a missing or blank ProcedureStepCancellationDateTime in this item when changing the state.

Each item in these required code sequences needs CodeMeaning and a code value (CodeValue, LongCodeValue, or URNCodeValue). CodeValue and LongCodeValue also require CodingSchemeDesignator.

MPPS uses the same revision contract

Pass a different InMemoryWorkflowDatasets instance to AddStandardModalityPerformedProcedureStepService. The standard MPPS handler checks the procedure step state and required attributes, while the provider supplies atomic storage. This client creates an in-progress step, retrieves it, updates its description, and completes it. The empty values and sequences illustrate required attributes that may be empty; supply real patient, scheduling, and performed-series details when they are available.

InMemoryWorkflowDatasets procedureSteps = new();
Aspose.Medical.Dicom.Web.DicomWebApplication mppsApplication =
    DicomWebApplication.CreateBuilder()
        .AddStandardModalityPerformedProcedureStepService(procedureSteps)
        .Build();
using System.Net.Http.HttpClient transport = new();
Aspose.Medical.Dicom.Web.DicomWebClient client = new(transport,
    new DicomWebClientOptions
    {
        BaseUri = new System.Uri("https://example.org/dicomweb/")
    });
Aspose.Medical.Dicom.Dataset scheduled = [];
scheduled.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.StudyInstanceUID, "1.2.840.100");
scheduled.AddOrUpdate(new Aspose.Medical.Dicom.Elements.Sequence(Aspose.Medical.Dicom.Tags.Tag.ReferencedStudySequence, []));
scheduled.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.AccessionNumber, string.Empty);
scheduled.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.RequestedProcedureID, string.Empty);
scheduled.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.RequestedProcedureDescription, string.Empty);
scheduled.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.ScheduledProcedureStepID, string.Empty);
scheduled.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.ScheduledProcedureStepDescription, string.Empty);
scheduled.AddOrUpdate(new Aspose.Medical.Dicom.Elements.Sequence(Aspose.Medical.Dicom.Tags.Tag.ScheduledProtocolCodeSequence, []));

Aspose.Medical.Dicom.Dataset initial = [];
initial.AddOrUpdate(new Aspose.Medical.Dicom.Elements.Sequence(Aspose.Medical.Dicom.Tags.Tag.ScheduledStepAttributesSequence, [scheduled]));
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PatientName, string.Empty);
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PatientID, string.Empty);
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PatientBirthDate, string.Empty);
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PatientSex, string.Empty);
initial.AddOrUpdate(new Aspose.Medical.Dicom.Elements.Sequence(Aspose.Medical.Dicom.Tags.Tag.ReferencedPatientSequence, []));
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedProcedureStepID, "STEP-1");
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedStationAETitle, "MODALITY_AE");
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedStationName, string.Empty);
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedLocation, string.Empty);
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedProcedureStepStartDate, "20260925");
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedProcedureStepStartTime, "110000");
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedProcedureStepStatus, "IN PROGRESS");
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedProcedureStepDescription, string.Empty);
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedProcedureTypeDescription, string.Empty);
initial.AddOrUpdate(new Aspose.Medical.Dicom.Elements.Sequence(Aspose.Medical.Dicom.Tags.Tag.ProcedureCodeSequence, []));
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedProcedureStepEndDate, string.Empty);
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedProcedureStepEndTime, string.Empty);
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.Modality, "CT");
initial.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.StudyID, string.Empty);
initial.AddOrUpdate(new Aspose.Medical.Dicom.Elements.Sequence(Aspose.Medical.Dicom.Tags.Tag.PerformedProtocolCodeSequence, []));
initial.AddOrUpdate(new Aspose.Medical.Dicom.Elements.Sequence(Aspose.Medical.Dicom.Tags.Tag.PerformedSeriesSequence, []));
await client.ModalityPerformedProcedureSteps.CreateAsync("1.2.840.3", initial);

Aspose.Medical.Dicom.Web.DicomWebClientResult<Aspose.Medical.Dicom.Web.Transactions.RetrieveModalityPerformedProcedureStepResult> current =
    await client.ModalityPerformedProcedureSteps.RetrieveAsync("1.2.840.3");
Aspose.Medical.Dicom.Dataset changes = [];
changes.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedProcedureStepDescription, "Acquisition reviewed");
Aspose.Medical.Dicom.Web.DicomWebClientResult<Aspose.Medical.Dicom.Web.Transactions.UpdateModalityPerformedProcedureStepResult> updated =
    await client.ModalityPerformedProcedureSteps.UpdateAsync("1.2.840.3", changes);
System.Console.WriteLine(updated.Value.StatusReport?.Count ?? current.Value.ProcedureStep.Count);

Aspose.Medical.Dicom.Dataset series = [];
series.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformingPhysicianName, string.Empty);
series.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.ProtocolName, "HEAD");
series.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.OperatorsName, string.Empty);
series.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.SeriesInstanceUID, "1.2.840.100.1");
series.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.SeriesDescription, string.Empty);
series.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.RetrieveAETitle, string.Empty);
series.AddOrUpdate(new Aspose.Medical.Dicom.Elements.Sequence(Aspose.Medical.Dicom.Tags.Tag.ReferencedImageSequence, []));
series.AddOrUpdate(new Aspose.Medical.Dicom.Elements.Sequence(Aspose.Medical.Dicom.Tags.Tag.ReferencedNonImageCompositeSOPInstanceSequence, []));
Aspose.Medical.Dicom.Dataset completed = [];
completed.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedProcedureStepStatus, "COMPLETED");
completed.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedProcedureStepEndDate, "20260925");
completed.AddOrUpdate(Aspose.Medical.Dicom.Tags.Tag.PerformedProcedureStepEndTime, "120000");
completed.AddOrUpdate(new Aspose.Medical.Dicom.Elements.Sequence(Aspose.Medical.Dicom.Tags.Tag.PerformedSeriesSequence, [series]));
await client.ModalityPerformedProcedureSteps.UpdateAsync("1.2.840.3", completed);

The standard handler validates the initial and final attributes and prevents an invalid state transition. Use identifiers and values from the actual performed procedure in an application.