Skip to content

Functional Equipment Assembly Integration

Integrating a PEM Electrolyzer as a FEA

The previous example built a PEA around a pure software function. This one moves to the other end of the spectrum: integrating a smart physical device — a PEM electrolyzer with a Modbus RTU/TCP interface — as a Functional Equipment Assembly (FEA) that is part of a PEA. Figure 1 shows the overall integration architecture: the PEA process is configured from the rMTP, the application logic, and the register mapping; it communicates with the physical electrolyzer over Modbus on one side and exposes the standardized MTP interface to the POL over OPC UA on the other. At the heart of the abstraction are the Data Assemblies: all the application logic that exposes the FEA as MTP state conformant processing unit is expressed on these base interfaces, the details of handling the modbus registered is abstracted away.

flowchart LR
    MTP["rMTP <br> (M15.mtp)"]
    MTP -.-> PEAcore

    Logic["application logic <br> (m15_logic.py)"]
    Logic -.-> PEAcore

    ModbusCfg["register mapping <br> (m15_modbus.json)"]
    ModbusCfg -.-> PEAcore

    subgraph PEA["PEA process <br> (m15_pea.py)"]
        PEAcore["PEA RunTime"]
        DA["Data Assemblies"]
        SVC["Services"]
        OPCS["OPCUA Server"]
        MBC["Modbus Client"]
        PEAcore -.-> DA
        DA <--> OPCS
        DA <--> MBC
        SVC <--> DA
    end

    subgraph FEA["FEA <br> (PEM electrolyzer)"]
        Dev["Modbus RTU/TCP"]
        PA["Process Automation <br> Physical Process"]
    end

    subgraph POL["POL"]
        Client["OPC UA client"]
        REC["Recipe Engine <br> User Interface"]
    end

    MBC -- "send cmds, read registers" --> Dev
    Dev -- "Modbus (TCP, port 5020)" --> MBC
    OPCS -- "OPC UA (opc.tcp://localhost:4840)" --> Client
    Client -- "send cmds & read values" --> OPCS

Figure 1: Integration architecture — the PEA is configured from the rMTP, the application logic, and the register mapping, and communicates with the physical electrolyzer over Modbus while exposing the MTP interface to the POL over OPC UA.

The engineering task is to wire together two descriptions into one consistent PEA:

  1. The rMTP specifies the interface and behavior the POL expects.
  2. The Modbus register table (device documentation) describes how to read and write the physical electrolyzer.

A device-specific mapping file bridges the two: it binds Modbus registers to MTP Data Assemblies, so the application logic reads and writes MTP Data Assemblies — and the MTPPy2.0 framework handles the Modbus protocol underneath.

The workflow consists of four basic steps:

  1. Understanding the task
    • Input: Control narrative, rMTP, and the Modbus register table.
    • Result: A consistent view of the POL-facing contract and the device-facing registers.
  2. Inspecting the rMTP
    • Input: rMTP (and the control narrative).
    • Result: Confirmed service, procedure, parameter, and data-assembly names.
  3. Mapping the device to the MTP model
    • Input: Modbus register table, rMTP structure, and the service state model.
    • Result: A register→Data Assembly mapping and device behavior registered to the correct state actions.
  4. Implementing and testing the PEA
    • Input: Register mapping, application logic, and the rMTP.
    • Result: A running PEA that talks to the (simulated) device and is exposed through its OPC UA endpoint.

This tutorial uses the following tools of the MTPPy2.0 package and the M15 example in src/examples/modbus/:

  1. The M15Pea.py class assembles the PEA from the rMTP, the Modbus register mappings, and the application logic and starts the I/O loop.
  2. A simple Modbus simulator (m15_simulator, built on pymodbus) emulates the physical electrolyzer, so the whole flow can be exercised without hardware.
  3. mtp_inspector confirms the rMTP names before designing and writing the application logic.

Step 1: Understanding the Task

The control narrative requires the PEA to

start hydrogen production at a certain pressure level and make the most important process values accessible for recording and monitoring. The detailed Modbus register information is provided by the device documentation, whereas the services, process values, and the HMI exposed to the POL are defined in the rMTP.

As in the introductory example, the narrative is under-specified. The open questions here include:

  • Which registers carry the process values the POL should see, and how are they scaled (raw 16-bit vs. engineering units)?
  • What engineering units shall be used? SI, imperial, others?
  • Which registers are commands (start/stop, valve open/close), and which values mean what?
  • What must happen on holding, stopping, and aborting to leave the electrolyzer in a safe state?
  • Which operating modes shall be implemented?

Clarifying these against the register table — and writing the answers down as the mapping and the state actions — is the core of this task, exactly as the IREB / Wiegers & MacNair clarification process suggests in the first example.

Step 2: Understanding the Requirements MTP

The rMTP resolves the POL-facing details: interface names, data types, service and procedure structure, and which Data Assemblies are exposed to the POL.

For this example the rMTP defines the service produce, the procedure auto, the procedure parameter p_out_sp, and a set of AnaView based process-value interfaces. The relevant design view is the HMI of the PEM-electrolyzer requirements MTP as created in Semodia PEA-Designer, shown in Figure 2.

Figure 2: HMI of the PEM-electrolyzer requirements MTP in Semodia PEA-Designer.

Inspecting the MTP

Before writing or changing logic, inspect the MTP to confirm the service, procedure, parameter, and data-assembly names, using the same mtp_inspector utility as in the first example:

mtp_inspector -f src/examples/modbus/M15.mtp -s -a Name -a RefB

The services section lists the service, its procedures, and each element they use. The relevant part for the electrolyzer is:

{ "produce": { "Name": "produce",
               "RefBaseSystemUnitPath": "MTPServiceSUCLib/Service",
               "auto": {
                 "Name": "auto",
                 "RefBaseSystemUnitPath": "MTPServiceSUCLib/Service/Procedure",
                 "p_out_sp": { "Name": "p_out_sp",
                   "RefBaseSystemUnitPath": [
                     "MTPServiceSUCLib/ServiceParameter/ProcedureParameter",
                     "MTPServiceSUCLib/RequiredEquipment"]},
                 "PIC009": { "Name": "PIC009",
                   "RefBaseSystemUnitPath": "MTPServiceSUCLib/RequiredEquipment"},
                 "V010":   { "Name": "V010",
                   "RefBaseSystemUnitPath": "MTPServiceSUCLib/RequiredEquipment"},
                 "XI001":  { "Name": "XI001",
                   "RefBaseSystemUnitPath": "MTPServiceSUCLib/RequiredEquipment"},
                 "XI003":  { "Name": "XI003",
                   "RefBaseSystemUnitPath": "MTPServiceSUCLib/RequiredEquipment"},
                 "XI004":  { "Name": "XI004",
                   "RefBaseSystemUnitPath": "MTPServiceSUCLib/RequiredEquipment"},
                 "TI005":  { "Name": "TI005",
                   "RefBaseSystemUnitPath": "MTPServiceSUCLib/RequiredEquipment"},
                 "FI002":  { "Name": "FI002",
                   "RefBaseSystemUnitPath": "MTPServiceSUCLib/RequiredEquipment"},
                 "FI008":  { "Name": "FI008",
                   "RefBaseSystemUnitPath": "MTPServiceSUCLib/RequiredEquipment"},
                 "P001":   { "Name": "P001",
                   "RefBaseSystemUnitPath": "MTPServiceSUCLib/RequiredEquipment"}
               }
}

Matching the narrative against the rMTP gives the POL-facing contract:

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

Control-narrative statement rMTP name MTP element (from RefBaseSystemUnitPath)
"start and supervise hydrogen production" produce Service (MTPServiceSUCLib/Service)
(its automatic procedure) auto Procedure (.../Service/Procedure)
"outlet pressure setpoint" p_out_sp Procedure parameter (.../ServiceParameter/ProcedureParameter)
outlet pressure (process value) PIC009 Required equipment (.../RequiredEquipment)
outlet valve V010 Required equipment (.../RequiredEquipment)
cell voltage / current, temperature, water quality XI003, XI004, TI005, XI001 Required equipment (.../RequiredEquipment)

These exact names are what must be used in context.get_base_interface(...) calls and in the registration names.

Step 3: Mapping the Device to the MTP Model

Mapping Modbus registers to Data Assemblies

The first concrete step is to bind the Modbus registers to the Data Assemblies defined (or internal to) the PEA. This is done in the mapping file src/examples/modbus/m15_modbus.json; each entry names the Data Assembly class, the register, its access direction, and — where needed — a transform action.

If not already answered in Step 2, the mapping calls for further clarifications, for instance: Which MTP operating commands shall be mapped? For instance, a valve can be opened by the POL (valve OpenOp) as well as by the PEA in automatic mode (valve OpenAut). Shall both ways be considered?

Measurement mappings (process values read from the device):

Table 2: Measurement mappings — Data Assemblies read from the device.

Class TagName Alias Register Access Action / Semantics
AnaView XI003 V 1110 read scale(0, 65535, 0.0, 100.0) — cell voltage
AnaView XI004 V 1111 read scale(0, 65535, 0.0, 100.0) — cell current
AnaView TI005 V 1113 read scale(0, 65535, 0.0, 100.0) — processing board temperature
AnaView XI001 V 1116 read scale(0, 65535, 0.0, 100.0) — water quality
PIDCtrl PIC009 PV 1118 read scale(0, 65535, 0.0, 100.0) — outlet pressure

Control mappings (device functions written from the PEA):

Table 3: Control mappings — Data Assemblies written to the device.

Class TagName Alias Register Access Action / Semantics
PIDCtrl PIC009 SP 400 write scale(0, 15954, 0.0, 100.0) — outlet pressure setpoint
DIntView start_stop V 401 readwrite 0 = start, 1 = stop
DIntView reset V 403 write 0 = reset
BinVlv V010 OpenOp 534 write map(1, 1, 'open') — open (operated)
BinVlv V010 OpenAut 534 write map(1, 1, 'open') — open (automatic)
BinVlv V010 CloseOp 534 write map(1, 0, 'close') — close (operated)
BinVlv V010 CloseAut 534 write map(1, 0, 'close') — close (automatic)
DIntView p_eng_units V 1013 readwrite 0 = barg, 1 = psig, 2 = kPa
DIntView t_eng_units V 1014 readwrite 0 = °C, 1 = °F
DIntView v_eng_units V 1015 readwrite 0 = scm, 1 = scf

Two structural points follow from this:

  • Some Data Assemblies are public, others are internal. The ones named in the rMTP (PIC009, V010, XI001, XI003, XI004, TI005, …) are exposed to the POL; device-only ones (start_stop, reset, *_eng_units) are not in the rMTP. They need to be created from the mapping file and allocated to the procedure as required equipment, so that they can be retrieved through context.get_base_interface(...) just as the rMTP defined ones.
  • One register may back several Data Assembly items (a 1:n relation). The outlet valve V010 writes all four of its aliases to the same register 534, so control issued from the POL (OpenOp/CloseOp) and control issued by the automatic procedure (OpenAut/CloseAut) address the same physical valve.

Because the mapping lives in a single configuration file, the application logic interacts with standardized and device-specific information through the same abstraction as in the introductory example. Instead of writing to Modbus register 401 with a Modbus-library-specific command, the logic simply sets start_stop.V = 0. MTPPy2.0 organizes the communication with the Modbus server and, for interfaces defined in the rMTP, with updating the POL.

Mapping device behavior to the service state model

The application logic then links the relevant states and transitions of the MTP service model to device-specific actions. Figure 3 shows how a single state is specified in the Semodia PEA-Designer

Figure 3: State machine design in the Semodia PEA-Designer.

The simplified sequence of the produce.auto procedure (without stopping and aborting) is shown in Figure 4.

sequenceDiagram
    participant POL as POL <br>(OPC UA)
    participant PEA as IO Service
    participant SVC as Service <br>(produce.auto)
    participant LOG as Application logic
    participant DEV as PEM electrolyzer <br>(Modbus)

    par
        loop every IO cycle (background IO service)
            POL->>PEA: subscribe 
            DEV-->>PEA: read registers → process values (PV, XI, TI, FI, PICV)
            POL->>PEA: where there updates?
            PEA-->>POL: actual process values
        end
    and
        POL->>SVC : cmd START
        rect Lightblue
            SVC->>LOG: starting.entry
            Note over LOG: reset units <br> set PIC009 to auto-int
            LOG->>DEV: write regs 1012/1013/1014 = 0
            SVC-->>POL : ack STARTING
            SVC->>LOG: starting.do
            Note over LOG: PIC009.SPMan = p_out_sp<br>start_stop.V = 0<br>V010.OpenAut = 1
            LOG->>DEV: write reg 400 (SP)<br>write reg 401 (start) = 0<br>write reg 534 (V010) = 1 (open)
            SVC->>LOG: starting.exit
        end
        rect Lightcoral
            SVC->>LOG: execute.entry
            SVC-->>POL : ack EXECUTE
            loop every cycle while in execute
                SVC->>LOG: execute.do
            end
            POL->>SVC : cmd COMPLETE
            SVC->>LOG: execute.exit
        end
        rect Lightgreen
            SVC->>LOG: completing.entry
            SVC->>POL : ack COMPLETING
            SVC->>LOG: completing.do
            Note over LOG: start_stop.V = 1<br>V010.CloseAut = 1
            LOG->>DEV: write reg 401 (start) = 1<br>write reg 534 (V010) = 0 (close)
        end
        rect Lightgray
            SVC->>LOG: completed.entry
            SVC-->>POL: ack COMPLETED
            loop every cycle while in completed
                SVC->>LOG: completed.do: (do nothing)    
            end
            POL->>SVC : cmd RESET
            SVC->>LOG: completed.exit
        end
    end

Figure 4: Simplified sequence of the PEM-Electrolyzer procedure produce.auto.

The guiding principle: put the device in a defined state during initialization (set engineering units, put the pressure controller in automatic mode), issue the commands that begin production on start, and make sure the completing transition returns the device to a defined safe state (production stopped, outlet valve closed).

Attention

Actually, the service's work task execute is empty — it is not even implemented. There is no execute.do callback registered in m15_logic.py, so nothing runs cyclically while the procedure is in the execute state. This is deliberate: once the electrolyzer has been started, it produces autonomously, and the PEA's role during execute is purely supervisory — the background IO service continues to read process values and propagate them to the POL. If additional cyclic work were required during production (e.g. trend sampling or safety checks), it would be registered here as produce.auto.execute.do.

Step 4: Implementing and Testing the PEA

Starting the electrolyzer

The startup sequence (derived from the manual operation handbook) is registered to produce.auto.starting.do. First, the setpoint of the device's internal pressure controller PIC009 is set from the procedure parameter p_out_sp (line 8), then the start command is issued (line 9), and finally the outlet valve V010 is opened (line 10).

@register("produce.auto.starting.do")
def start_generator(context: Service) -> bool:
    start_stop = context.get_base_interface(DA_START_STOP)
    valve = context.get_base_interface(DA_VALVE)
    p_out_sp = context.get_base_interface("p_out_sp")
    pctl: PIDCtrl = context.get_base_interface("PIC009")

    start_stop.V = 0
    valve.OpenAut = 1
    pctl.SPMan = p_out_sp.VOut

    return True

Listing 1: start_generator — registered to produce.auto.starting.do; sets the setpoint, issues start, opens the valve.

Notice what is absent: there is no Modbus call anywhere in the code. The logic writes to Data Assemblies; the device protocol is handled by the framework.

Note

The code above is deliberately simplified for clarity — no abnormal-condition detection, no error handling, no recovery.

Stopping the electrolyzer

Stopping is the exact mirror of starting: the start/stop command is set back to 1 (line 6) and the outlet valve V010 is closed (line 7), returning the electrolyzer to the defined safe state already named in the guiding principle.

@register(
    "produce.auto.completing.do",
    "produce.auto.stopping.do",
    "produce.auto.aborting.do",
)
def stop_generator(context: Service) -> bool:
    start_stop = context.get_base_interface(DA_START_STOP)
    valve = context.get_base_interface(DA_VALVE)

    start_stop.V = 1
    valve.CloseAut = 1

    return True

Listing 2: stop_generator — registered to three state actions (completing.do, stopping.do, aborting.do); stops production and closes the valve.

The notable difference is the registration: @register accepts multiple state-action names, so the single stop_generator callback is bound to three of them — completing.do, stopping.do, and aborting.do. All three transitions drive the procedure out of production, and in each case the device must be left in the same safe state, so they share one handler. This is the service state model doing its work: the where (which transition triggered it) is handled by the registration, while the what (put the device into a defined safe state) is written once.

The full behavior

The complete module registers one callback per relevant state action (src/examples/modbus/m15_logic.py):

Table 4: All registered state-action callbacks in m15_logic.py.

Registration name Action
produce.auto.starting.entry Reset engineering units; put PIC009 in automatic mode and scale.
produce.auto.starting.do Set outlet-pressure setpoint, issue start, open outlet valve.
produce.auto.holding.do Close the outlet valve.
produce.auto.unholding.do Open the outlet valve again.
produce.auto.completing.do / stopping.do / aborting.do Stop production and close the outlet valve (safe state).

The relevant states of produce.auto for this electrolyzer are shown in Figure 5. The state model is hierarchical: three nested composites — holdable, stoppable, and abortable — each adds one command to the states it encloses. A transition drawn out of a composite means the command is available from any of that composite's inner states, which is how the model expresses that hold, stop, and abort do not depend on which state the procedure is currently in.

  • hold is available in the holdable states — execute, and the pause and unhold transitions around it.
  • stop is available in the stoppable states — everything holdable plus starting, holding, held, and unholding — to bring the procedure to a controlled stop.
  • abort is available in the abortable states — everything stoppable plus stopping / stopped. In other words, an operation can be aborted even after it has already begun to stop.

The diagram also shows the off/op service-lifecycle entry into idle and the full pause path (pause → paused → resume), which produce.auto does not itself register code for but which the service state machine still supports.

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 5: Service state model of produce.auto (relevant states).

Yellow states are transient — the application logic runs once in the order entry → do → exit, then the state machine advances. Green states are non-transient — the application logic (if registered) runs cyclically on every IO cycle while the service remains in that state. The blue composites group the holdable, stoppable, and abortable states; a reset returns the terminal states back to idle.

The thick-outlined execute state is the steady-state the procedure returns to after start, resume, and unhold — the state in which its cyclic application logic runs. (For produce.auto the registered callbacks are attached to the transient states starting, holding, unholding, completing, stopping, and aborting; this figure shows the service state machine, not which states a particular PEA registers code for.)

Run the PEA against a simulator

Start the M15 Modbus simulator that emulates the electrolyzer in one terminal (it serves the device registers on TCP port 5020):

python -m examples.modbus.m15_simulator

Then start the PEA in a second terminal. The M15Pea loads m15_modbus.json, exposes the mapped M15 registers as internal Data Assemblies, and continuously synchronizes them through the Modbus IOService:

python -m examples.modbus.m15_pea

With both running, the PEA is exposed on its OPC UA endpoint (opc.tcp://localhost:4044) and the mapped device registers can be read and written through the MTP interface, exactly as in the introductory example.

Summary

You followed the four-step rMTP based workflow applied to a real device and should now be able to:

  • Read two descriptions — the rMTP (POL contract) and the Modbus register table (device access) — and recognize where they must be reconciled.
  • Inspect the rMTP with mtp_inspector and confirm the service, procedure, parameter, and data-assembly names before writing logic.
  • Map the device by binding Modbus registers to Data Assemblies in a configuration file, distinguishing public (rMTP exposed) from internal (device-only) Data Assemblies, and handling the 1:n relation between registers and Data Assembly items.
  • Map behavior to the state model by registering device actions to entry / do / exit, with initialization putting the device in a defined state and every terminating transition returning it to a safe state.
  • Run and test the PEA with a Modbus simulator, with the framework abstracting all protocol details, so the application logic only touches Data Assemblies.

The core idea: a smart FEA is integrated by mapping its registers to MTP Data Assemblies and registering its behavior to the service state model — the same two levers as a software-only PEA, now with the FEA device protocol hidden behind the abstraction.

Optimizing the Application

The solution does not fully use the semantic quality of the MTP approach, in particular the start_stop base interface is a 1:1 mapping of the Modbus logic with its obscure number magic. In the code above, start_stop.V = 0 starts the electrolyzer and start_stop.V = 1 stops it — but why 0 means start and 1 means stop is only documented in the "Action / Semantics" column of the mapping table. The reader of m15_logic.py has to jump to m15_modbus.json to decode each number, so the logic reads as register manipulation rather than as intent.

Sketch: a BinView for start/stop

The BinView is a better fit for a two-state command: its value V is a boolean, and it carries human-readable labels for the two states in the data items VState0 and VState1. Declaring start_stop as a BinView with VState0 = "stopped" and VState1 = "running" turns the command into a named state instead of a code.

Note

Note that V still takes a boolean, not the state label: the framework coerces every write through the boolean type, so start_stop.V = True or start_stop.V = False is valid, while start_stop.V = "start" is rejected. The readable words live in VState0 / VState1, which are looked up from the boolean — you never assign to V by name.

The mapping file still owns the translation to the register. A BinView backed by register 401 uses map actions to write the device code that each state means:

Table 5: Register translation for a BinView-backed start_stop.

Data Assembly item When V is Register written Device meaning
start_stop.V True (VState1 = "running") 401 = 0 start
start_stop.V False (VState0 = "stopped") 401 = 1 stop

The register-level "number magic" moves out of the logic and into the mapping, and the state labels are exposed to the POL through OPC UA, so an HMI can show stopped / running instead of a raw 0 / 1.

The application logic then expresses intent:

@register("produce.auto.starting.do")
def start_generator(context: Service) -> bool:
    start_stop = context.get_base_interface(DA_START_STOP)
    ...
    start_stop.V = True      # running, instead of "0"
    ...

Listing 3: Expressing intent with a BinView — start sets V = True.

@register("produce.auto.completing.do")
def stop_generator(context: Service) -> bool:
    start_stop = context.get_base_interface(DA_START_STOP)
    ...
    start_stop.V = False     # stopped, instead of "1"
    ...

Listing 4: Expressing intent with a BinView — stop sets V = False.

Reading the two callbacks now tells you the purpose of the action — the electrolyzer is put into the running state on start and back to stopped on completing — without decoding a number.

Sketch: a StringView for the unit selectors

The same idea extends, with a different element, to the engineering-unit selectors. p_eng_units, t_eng_units, and v_eng_units are DIntViews whose V is a code (0 = barg, 1 = psig, 2 = kPa, …). A StringView carries the unit as a string directly — p_eng_units.V = "kPa" — so the starting.entry reset that currently sets p_eng_units.V = 0 reads as p_eng_units.V = "kPa": the mapping translates the string to the register code, and the code disappears from the logic entirely. A StringView is preferable to a BinView here because the pressure selector has three states (barg / psig / kPa), and a BinView only models two.

Attention

One caveat: a StringView is a free-form string, not an enum. Its V is coerced to string only, so it accepts any value — nothing stops a mistyped p_eng_units.V = "kpa " from being written, and the framework will not catch it. If the unit selector should be limited to a fixed set of names, that constraint would have to be added as a true enum element (for example a StringView subclass that validates V against a class-level allowed set and raises on anything else), which MTPPy2.0 does not ship today. Until then, the choice is effectively between a readable but unenforced StringView and a numeric, range-checkable DIntView.

The takeaway: choose the Data Assembly class that matches the number of meaningful states, and let the mapping file carry the register codes. The logic then speaks in states and units — the semantic quality the MTP approach is meant to provide — instead of in raw numbers.

Troubleshooting

The logic never reaches the device

Confirm the register mapping (m15_modbus.json) lists the register for the Data Assembly item you are writing, and that the IOService is running. The application logic writes Data Assemblies; only the IO service performs Modbus I/O.

A Data Assembly is not found by get_base_interface(...)

get_base_interface(...) raises a KeyError when no Data Assembly with the given tag name is allocated to the current procedure.

Internal Data Assemblies must be allocated to the procedure as required equipment (via "assign": ["auto"] in the mapping) or be defined in the rMTP. The name must match the tag exactly, including capitalization.

Class mismatch between mapping and rMTP

A Data Assembly that appears in both the mapping and the rMTP must declare the same class in both. A mismatch is logged and skipped; check the class value in m15_modbus.json against the rMTP definition.

The simulator or PEA cannot connect

Start the simulator first and verify the host/port (Modbus TCP defaults to localhost:5020). The simulator and the PEA are separate processes; starting the PEA does not start the device.