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:
- Understanding the task
- Input: Control Narrative, Process Flow Diagram, or P&ID.
- Result: Overview of automation tasks, conditions, and test criteria, plus open questions.
- Inspecting the rMTP
- Input: Control Narrative and open questions from Step 1.
- Result: Confirmed service, procedure, parameter, and node names.
- 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.
- 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:
-
The script
rmpt_peareads the MTP, imports the application logic module, creates the PEA, and starts the OPC UA server. -
The script
minimal_recipeemulates 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
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:
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):
| 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:
- Initializing the random number generator
- generating a random number, and
- 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)¶
randomis Python's random-number generator package.loggeris MTPPy2.0's typed custom logger and provides application-level logging throughlogger.app(...),logger.mtp(...)etc., seeloggerfor details.Serviceprovides the context for this procedure and methods such asget_base_interface()for looking up data in the active procedure, see Service for details- The
registerdecorator 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:
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, andexit: 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
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)¶
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:
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
executewith the pause and complete path;holdis available from these. - The middle composite (stoppable states) adds
startingand the hold states around that core;stopis 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
abortcan 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
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_inspectorand 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>), whereentry/do/exitcorrespond to the UML State Machine actions. - Exchange data with the MTP interfaces using
context.get_base_interface(...), reading applied values from.VOutand writing reported values through.V. - Run and test the resulting PEA: start it with
rmtp_pea(passing the rMTP path without the.mtpextension and the logic module import name), then drive it as a client withminimal_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
Two things make this work:
- One shared generator. Both callbacks use the same module-level
_rng. If each callback created its ownrandom.Random, thedocycle would not continue the sequence started byentry. - Seed only in
entry. Seeding insideexecute.dowould re-seed every cycle and produce the same single value repeatedly — the opposite of what we want. Seeding once inexecute.entrysets up the sequence; the cyclicdocallback then advances it.
Notes and variations¶
- Pause / resume, hold/unhold. If the service is paused and resumed it re-enters
execute, soexecute.entryre-fires and the sequence restarts from the beginning. If a "run" should survive a pause instead, move the seeding out ofexecute.entryintostarting.do(orstarting.entryorstarting.entry), so it is applied once perSTART, not once per entry intoexecute. - 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
_SEEDconstant 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:
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.