Skip to content

Introduction to rMTP Engineering

This example demonstrates the workflow of engineering a Process Equipment Assembly (PEA) with a given requirements MTP (rMTP). The workflow consists of four basic steps:

  1. Understanding the task
    • Input: Control Narrative, Process Flow Diagram, or P&ID.
    • Result: Overview of automation tasks, conditions, and test criteria, plus open questions.
  2. Inspecting the rMTP
    • Input: Control Narrative and open questions from Step 1.
    • Result: Confirmed service, procedure, parameter, and node names.
  3. Mapping the required behavior to the MTP service state model
    • Input: rMTP structure and the automation tasks from Step 1.
    • Result: Application logic registered to the correct state actions.
  4. Implementing and testing the resulting PEA
    • Input: Application logic and the rMTP.
    • Result: A running, tested PEA exposed through its OPC UA endpoint.

The rMTP defines the published structure of the PEA: its data assemblies, services, procedures, parameters, and report values. MTPPy2.0 creates those objects directly from the rMTP. Your Python module supplies the application logic that is executed by the services.

This tutorial uses the following tools of the MTPPy2.0 package:

  1. The script rmpt_pea reads the MTP, imports the application logic module, creates the PEA, and starts the OPC UA server.

  2. The script minimal_recipe emulates the Process Orchestration Layer (POL) and runs a very simple recipe on your PEA. That is, it connects to the OPC UA server of your PEA as a client and writes commands and values to the PEA.

Figure 1 shows how the two processes interact: the PEA process is built from the rMTP and the application logic and exposes the service over OPC UA, while the POL process (the recipe client) drives it.

flowchart LR
    subgraph PEA["PEA process (mttpy.pea.rmpt_pea)"]
        MTP["rMTP<br>(minimal.mtp)"]
        Logic["Application Logic<br>(python)"]
        PEAcore["PEA with<br>Services<br>Data Assemblies<br>OPC UA server"]
        MTP --> PEAcore
        Logic --> PEAcore
    end

    subgraph POL["POL"]
        Recipe["recipe client<BR>(minimal_recipe)"]
    end

    PEAcore -- "OPC UA (opc.tcp://localhost:4840)" --> Recipe
    Recipe -- "commands & values" --> PEAcore

Figure 1: The PEA process is built from the rMTP and the application logic and exposes the service over OPC UA to the POL.

Step 1: Understanding the Task

The use case is described with the following brief control narrative:

"The example PEA shall provide a random-number generator. Its lower and upper bounds shall be configurable parameters, while the generated random-number shall be returned to the POL cyclically. The interfaces and their exact names are provided by the rMTP."

The control narrative provides a rough idea of the bill of work. Obviously, the task is under-specified, it misses information on

  • Generation rate: how many numbers / second?
  • Random distribution: uniform, normal, ...?
  • Quality metrics for the randomness: repeatable or unpredictable?
  • Expected behavior in pause/halt/abort/stop

To come up with an implementation, we need to make assumptions. Let's specify: - one random number / cycle, control rate via cycle-time - uniform distribution - unpredictable: run the random generator unseeded - pause/halt/abort/stop: simply generate no more numbers

Clarifying requirements in a real project

In a real use case these assumptions should not be guessed – they should be clarified with the client or project stakeholders before implementation. In practice this is done in a short requirements workshop where the open questions listed above are resolved and the agreed results are written down as testable acceptance criteria (one criterion per open question). This turns the assumptions made here into documented, verifiable requirements.

If you want to learn more about such requirements-clarification processes, two good starting points are:

  • IREB (International Requirements Engineering Board) defines a widely used competency framework and structured methods for requirements elicitation, analysis, and validation. Its introductory material and courses are a practical on-ramp to the discipline.
  • K. E. Wiegers & L. MacNair, Software Requirements (3rd ed.) – the chapters on requirements elicitation and analysis workshops give practical, technique-level guidance on turning vague narratives into a clear, agreed requirements set.

Step 2: Understanding the Requirements MTP

The rMTP resolves some of the details that are usually missing from the control narrative, such as interface names, data types, service and procedure structures, and communication mappings.

For the tutorial example, the rMTP defines the service rand_num_gen, the procedure cont, the parameters lower_bound and upper_bound, and the report value generated_value. This formal interface specification can be easily mapped on the control narrative given in Step 1.

Inspecting the MTP

Before writing or changing application logic, inspect the MTP to confirm the service, procedure, data-assembly, and node names. The mtp_inspector utility can filter output by name, attribute, service, data assembly, and node ID.

Let's find the exact names for the items mentioned in the control narrative. Asking for only Name and RefB (the RefBaseSystemUnitPath, which tells us the MTP class of each element) keeps the output short:

mtp_inspector -f data/pea/minimal/04_MTP/minimal.mtp -s -a Name -a RefB

The services section lists the services, their procedures, and each element they use. The relevant part for the control narrative is:

{ "rand_num_gen": { 
  "Name": "rand_num_gen",
  "RefBaseSystemUnitPath": "MTPServiceSUCLib/Service",
  "cont": { 
    "Name": "cont",
    "RefBaseSystemUnitPath": "MTPServiceSUCLib/Service/Procedure",
    "generated_value": { "Name": "generated_value",
      "RefBaseSystemUnitPath": [ 
        "MTPServiceSUCLib/ReportValue",
        "MTPServiceSUCLib/RequiredEquipment"]},
    "lower_bound": { 
      "Name": "lower_bound",
      "RefBaseSystemUnitPath": [ 
        "MTPServiceSUCLib/ServiceParameter/ProcedureParameter",
        "MTPServiceSUCLib/RequiredEquipment"]},
    "pid_ctrl": { 
      "Name": "pid_ctrl",
      "RefBaseSystemUnitPath": "MTPServiceSUCLib/RequiredEquipment"},
    "upper_bound": { 
      "Name": "upper_bound",
      "RefBaseSystemUnitPath": [ 
          "MTPServiceSUCLib/ServiceParameter/ProcedureParameter",
          "MTPServiceSUCLib/RequiredEquipment"
      ]
    }
  }
}

Matching the items from the control narrative against the names defined in the rMTP gives the following mapping (Table 1):

Table 1: Mapping from the control-narrative statements to the rMTP names and their MTP element types.

Control-narrative statement rMTP name MTP element (from RefBaseSystemUnitPath)
"provide a random-number generator" rand_num_gen Service (MTPServiceSUCLib/Service)
(its procedure) cont Procedure (.../Service/Procedure)
"lower ... bounds shall be configurable parameters" lower_bound Procedure parameter (.../ServiceParameter/ProcedureParameter)
"upper bounds shall be configurable parameters" upper_bound Procedure parameter (.../ServiceParameter/ProcedureParameter)
"the generated random-number shall be returned to the POL" generated_value Report value (MTPServiceSUCLib/ReportValue)

These exact names — rand_num_gen, cont, lower_bound, upper_bound, and generated_value — are what we must use in the callback registration name and in context.get_base_interface(...) calls.

Step 3: Mapping the Application Logic

The major engineering task is to map the process-specific behavior to the standardized service state model. For the tutorial case, this is almost trivial, as the system functional description can be decomposed into three distinct states:

  1. Initializing the random number generator
  2. generating a random number, and
  3. doing nothing.

As the default behavior of MTPPy2.0 states is to "do nothing", we need to define application logic only for the (cyclic) execute state of the procedure state machine.

Listing 1 illustrates the minimal application logic that implements the requested behavior.

Implementation minimal.py

"""Application logic for the ``rand_num_gen`` minimal example."""

__copyright__ = (
    "Copyright (c) 2026 Dresden University of Technology, Process-to-Order Group"
)
__license__ = "MIT"
__requirements_mtp__ = "minimal.mtp"
import random

from mtppy.part4.automation_services.service import Service
from mtppy.pea.application_logic.application_logic_registry import register
from mtppy.utils.logging import logger

"""
Prompt für rand_num_gen.cont.execute.do:
    Lese obere und untere Grenze aus den Parametern lower_bound
    und upper_bound aus, erzeuge eine Integerzufallszahl in diesen
    Grenzen und schreibe das Ergebnis in die Reportvariable
    generated_value.
    Lade erst alle parameter-objekte und rechne anschließend.
"""


@register("rand_num_gen.cont.execute.do")
def random_number(context: Service) -> bool:
    """Generate a random integer within the configured bounds."""
    lb = context.get_base_interface("lower_bound")
    ub = context.get_base_interface("upper_bound")
    gn = context.get_base_interface("generated_value")

    gn.V = random.randint(lb.VOut, ub.VOut)  # noqa: S311

    logger.app(f"--- generated: {gn.V} in [{lb.VOut},{ub.VOut}]")

    return True

Listing 1: The minimal application logic registered to rand_num_gen.cont.execute.do.

Step-by-step explanation

Imports (lines 6–10)

  • random is Python's random-number generator package.
  • logger is MTPPy2.0's typed custom logger and provides application-level logging through logger.app(...), logger.mtp(...) etc., see logger for details.
  • Service provides the context for this procedure and methods such as get_base_interface() for looking up data in the active procedure, see Service for details
  • The register decorator adds the function to 2.0's global user-logic registry.

Register (line 22)

The decorator @register("rand_num_gen.cont.execute.do") connects the function to the execute action of procedure cont in service rand_num_gen. Registration names have four parts:

<service>.<procedure>.<state>.<action>

The service and procedure names must match the names given in the rMTP exactly, see inspecting the MTP, the defined names of states and actions are explained in the next section.

States and actions

The PNO MTP 2.0.0 service model contains the operational states idle, starting, execute, completing, completed, pausing, paused, resuming, holding, held, unholding, stopping, stopped, aborting, aborted, and resetting, see Basics for more information. A service moves between these states in response to commands and its internal state logic (Figure 2).

The final part of a registration name identifies a UML State Machine action:

  • entry: runs exactly once when entering a state,
  • do: executes cycle as long as the service remains in the state, and
  • exit: runs once when we are leaving the state.

Thus, registering the function random_number under the name rand_num_gen.cont.execute.do will effectively call this function in a cyclic mode whenever the procedure cont of Service rand_num_gen is in the execute.

Note

All states without a registered function are simply skipped.

sequenceDiagram
    autonumber
    participant POL as POL <br> (minimal_recipe)
    participant Svc as Service <br> rand_num_gen

    POL->>Svc: Activate rand_num_gen.cont, <br> Set parameters
    Svc-->>POL: I'm in IDLE
    POL->>Svc: START command

    rect rgb(200, 220, 255)
    Svc->>Svc: entering starting: starting.entry
    Svc-->>POL: I'm in STARTING
    Svc->>Svc: starting.do
    Svc->>Svc: leaving starting: starting.exit
    end

    rect rgb(200, 255, 200)
    Svc->>Svc: entering execute: execute.entry (once)
    Svc-->>POL: I'm in EXECUTE
    loop every cycle while in execute
        Svc->>Svc: execute.do: generate and report a single random number
    end
    POL->>Svc: COMPLETE command
    Svc->>Svc: leaving execute: execute.exit (once)
    end

    rect rgb(255, 230, 200)
    Svc->>Svc: entering completing: completing.entry (once)
    Svc-->>POL: I'm in COMPLETING
    Svc->>Svc: completing.do (once)
    Svc->>Svc: leaving completing: completing.exit (once)
    end

    rect rgb(230, 200, 255)
    Svc->>Svc: entering completed: completed.entry (once)
    Svc-->>POL: I'm in COMPLETED
    loop every cycle while in completed
        Svc->>Svc: completed.do: (do nothing)
    end
    end

Figure 2: Sequence of a single run — the POL drives the service through START, the cyclic execute.do generates and reports one random number per cycle, and COMPLETE resets it.

Retrieving the interfaces for parameters and report values (lines 24–26)

The callback receives the active service as context. Retrieve the allocated equipments as defined in the MTP:

lb = context.get_base_interface("lower_bound")
ub = context.get_base_interface("upper_bound")
gn = context.get_base_interface("generated_value")

Listing 2: Retrieving the procedure parameters and report value from the active service context.

lower_bound and upper_bound are procedure parameters. The value currently applied to the procedure can be read from their VOut items.

generated_value is a report value. The reported value is set through gn.V.

Application logic (line 28)

gn.V = random.randint(lb.VOut, ub.VOut)

Listing 3: Generate one random number within the bounds and report it.

In short, VOut reads an applied value and V writes a value to a data assembly.

Logging (line 30)

logger.app(...) writes an application-level message using MTPPy2.0's custom logger. The f-string reports the generated value and the bounds used:

logger.app(f"--- generated: {gn.V} in [{lb.VOut},{ub.VOut}]")

Listing 4: Log the generated value and the bounds at application level.

Use the PEA or recipe --log-level option to control output. For example, --log-level APP includes application messages and more severe levels, while --log-level MTP also includes MTP-level messages.

Return value (line 32)

Returning True tells the user-logic dispatcher that the callback completed successfully.

Full State Diagram of the Application

Figure 3 shows the complete state model of the rand_num_gen.cont procedure. The hierarchical nesting of composites encodes which states each command is available in — a transition drawn out of a composite applies from any of its inner states:

  • The innermost composite (holdable states) groups execute with the pause and complete path; hold is available from these.
  • The middle composite (stoppable states) adds starting and the hold states around that core; stop is available from all of them, but not from the stop path itself (which sits one level out).
  • The outermost composite (abortable states) wraps everything, including the stop path, so abort can be issued from any active state.

Only execute (highlighted with a thicker, darker outline) has registered cyclic application logic in this example; every other state has no application logic at all. Nevertheless, the POL sees a PEA that implements the full MTP PNO 2.0.0 state model.

stateDiagram-v2
  direction LR
  [*] --> idle : off/op
  idle --> starting : start
  resetting --> idle
  state "abortable states" as stop_path {
    state "stoppable states" as hold_path {
      starting --> execute
      state "holdable states" as normal {
        execute --> pausing : pause
        pausing --> paused
        paused --> resuming : resume
        resuming --> execute
        execute --> completing : complete
        unholding --> execute
      }
      normal --> holding : hold
      holding --> held
      held --> unholding : unhold
    }
    hold_path --> stopping : stop
    stopping --> stopped
  }
  stop_path --> aborting : abort
  aborting --> aborted
  completing --> completed
  completed --> resetting : reset
  stopped --> resetting : reset
  aborted --> resetting : reset
  classDef transient fill:#fff3cd,stroke:#b7791f,stroke-width:2px
  classDef nontransient fill:#e6f4ea,stroke:#2f855a,stroke-width:2px
  classDef nontransient_code fill:#7dc98f,stroke:#1e5a38,stroke-width:4px
  classDef control fill:#e8eef7,stroke:#405a7d,stroke-width:2px
  class starting,holding,unholding,completing,stopping,aborting transient
  class pausing,resuming,resetting transient
  class idle,held,paused,completed,stopped,aborted nontransient
  class execute nontransient_code
  class normal,stop_path,hold_path control
  note right of stop_path
    stop enters stopping,
    then ends in stopped
  end note
  note right of aborting
    abort enters aborting,
    then ends in aborted
  end note

Figure 3: Full service state model of the rand_num_gen.cont procedure. execute is the only state with registered cyclic logic.

Step 4: Executing and Testing the PEA

With the code of Listing 1 and the rMTP, a first implementation is almost complete. What remains is the OPC UA endpoint and the cycle time — both configured on the rmtp_pea command line.

Start the PEA

Run this command from the project root. Pass the MTP path without its .mtp extension because the module CLI appends .mtp for the rMTP:

rmtp_pea --rmtp data/pea/minimal/04_MTP/minimal --user-logic examples.rmtp.minimal `
  --endpoint opc.tcp://localhost:4840 `
  --log-level MTP `
  --daemon
Note

The user-logic argument is a Python import name, not a file path.

Note

Without --daemon, the PEA runs for --duration seconds and then stops. With --daemon, it keeps running until interrupted with Ctrl+C.

Run the recipe client

The standalone recipe connects to the running PEA as an OPC UA client, drives a service through START → run → COMPLETE → RESET, and can write arbitrary procedure parameters. It is generalized, so it can target any service and procedure and set any parameter from the command line (Table 2):

Table 2: Command-line flags of the minimal_recipe recipe client.

Flag Description
-e, --endpoint The OPC UA endpoint of the running PEA (required).
-n, --namespace-index The namespace of the PEA nodes, as a numeric index (e.g. 2) or a namespace URI (e.g. https://tu-dresden.de/p2o); a URI is resolved to its index after connecting.
-s, --service The service to start (default rand_num_gen).
-p, --procedure-id The procedure ID to select on that service (default 1).
-P, --param NAME=INT A parameter value to write; repeatable, so several arbitrary parameters can be set (e.g. -P lower_bound=40 -P upper_bound=60).
-d, --duration Seconds to leave the service running (default 10).
-l, --log-level Logging level (default MTP).

The bounds in this example are optional parameters of the cont procedure, so they are supplied with -P rather than dedicated flags:

python -m tools.minimal_recipe `
  --endpoint opc.tcp://localhost:4840 `
  --namespace-index 2 `
  --service rand_num_gen `
  --procedure-id 1 `
  --param lower_bound=40 `
  --param upper_bound=60 `
  --duration 10 `
  --log-level POL

Summary

You followed the four-step rMTP based engineering workflow and should now be able to:

  • Read a control narrative and identify its under-specification, then resolve open questions into testable acceptance criteria.
  • Inspect an rMTP with mtp_inspector and map the narrative's statements to the exact service, procedure, parameter, and report-value names the MTP defines.
  • Implement application logic by registering callbacks to the correct state action (<service>.<procedure>.<state>.<action>), where entry/do/exit correspond to the UML State Machine actions.
  • Exchange data with the MTP interfaces using context.get_base_interface(...), reading applied values from .VOut and writing reported values through .V.
  • Run and test the resulting PEA: start it with rmtp_pea (passing the rMTP path without the .mtp extension and the logic module import name), then drive it as a client with minimal_recipe, selecting the service and procedure and writing arbitrary parameter values from the command line.

The core idea: the rMTP defines what interfaces exist, and your application logic defines how they behave — wired together by the registration name that must match the MTP exactly.

Extending the Application

A random sequence that is repeatable within a run

Suppose the requirement is strengthened: the numbers may be random, but every run — the interval between a START and the matching COMPLETE — shall produce the same sequence. One run is deterministic; a new run reproduces it exactly.

As defined in the state model, execute.entry fires once per run and execute.do fires once per cycle. So the pattern is: reseed the generator in execute.entry, then draw from it in execute.do. Because the seed is applied once at the start of each run, the sequence it defines restarts identically on every run, while the cyclic do callback consumes it one value at a time.

Register a second callback for the entry action and share the generator with the do callback through a module-level instance:

import random
from mtppy.part4.automation_services.service import Service

_rng = random.Random()
_SEED = 12345  # fixed so that every run yields the same sequence

@register("rand_num_gen.cont.execute.entry")
def seed_generator(context: Service) -> bool:
    """Run once per run (when entering `execute`): reset the sequence."""
    _rng.seed(_SEED)
    return True

@register("rand_num_gen.cont.execute.do")
def random_number(context: Service) -> bool:
    """Run every cycle while in `execute`: draw the next value."""
    lb = context.get_base_interface("lower_bound")
    ub = context.get_base_interface("upper_bound")
    gn = context.get_base_interface("generated_value")
    gn.V = _rng.randint(lb.VOut, ub.VOut)
    return True

Listing 5: A repeatable random sequence — reseed once in execute.entry, draw once per cycle in execute.do.

Two things make this work:

  1. One shared generator. Both callbacks use the same module-level _rng. If each callback created its own random.Random, the do cycle would not continue the sequence started by entry.
  2. Seed only in entry. Seeding inside execute.do would re-seed every cycle and produce the same single value repeatedly — the opposite of what we want. Seeding once in execute.entry sets up the sequence; the cyclic do callback then advances it.

Notes and variations

  • Pause / resume, hold/unhold. If the service is paused and resumed it re-enters execute, so execute.entry re-fires and the sequence restarts from the beginning. If a "run" should survive a pause instead, move the seeding out of execute.entry into starting.do (or starting.entry or starting.entry), so it is applied once per START, not once per entry into execute.
  • Same sequence, different bounds. The sequence of draws is fixed by the seed; the values still depend on lower_bound / upper_bound, so changing the bounds changes the reported numbers even though the underlying draw order is identical.
  • Different seed per PEA, still repeatable. Replace the fixed _SEED constant with a value derived per PEA (for example read from a parameter) if each PEA should have its own repeatable sequence while any single PEA still reproduces it run after run.

Troubleshooting

No module named mtppy.PEA

Use the full module path:

python -m mtppy.pea.rmtp_pea

PEA is the low-level class for assembling a PEA in Python. RMTPPea loads an MTP and application logic.

A required equipment item is missing

Check the MTP manifest, not only the service-description JSON. The name in the usapplicationer logic must exactly match the MTP name, including capitalization and underscores.

The user callback is never called

Check all four parts of its registration name and ensure the module was imported by --user-logic. Service and procedure names come from the MTP; use mtp_inspector when they are uncertain.

The recipe cannot connect

Start the PEA command first, verify the endpoint, and use the namespace index from the running server. The recipe is a separate client process; starting it does not start the PEA server.