Connecting Legacy & Commercial Skids: Modbus, Siemens S7, PLC4X
📍 Where we are: Part II · Capturing the Process — Chapter 11. The clean OPC UA bioreactor of Chapters 7–9 is the exception, not the rule. This chapter reaches the other equipment on the floor — the harvest centrifuge, the TFF skid, the balance — that speak older, insecure protocols, and reads them safely from behind OT (operational-technology) segmentation into the same tag namespace (the one shared canonical name tree, from Chapter 5).
OPC UA is a polite, self-describing protocol: ask a server "what do you have?" and it tells you, with names and units and security. Modbus is the opposite. A Modbus device is a numbered cupboard of 16-bit pigeonholes. Pigeonhole 1 holds 1850. That is all it tells you. Whether 1850 means 1.850 bar, 1850 millibar, or 18.50 of something is written nowhere on the wire — it lives in a PDF datasheet on someone's desk. There is no password on the cupboard and no lock on the door. So we do two things: we put the cupboard inside a guarded room (network segmentation), and we keep our own labelled key-ring (the tag dictionary) that says "pigeonhole 1, multiply by 0.001, call it transmembrane pressure in bar" (transmembrane pressure is the pressure drop across a filter membrane — a normal thing a filtration skid measures). This chapter builds that key-ring in real, runnable code.
What this chapter covers
By now we can capture a modern bioreactor cleanly. But walk any real biomanufacturing floor and most of the equipment is older than OPC UA: a centrifuge from a 2009 install, a tangential-flow-filtration (TFF) skid (a packaged process unit — pumps, valves, sensors and a PLC mounted on one frame) whose PLC (programmable logic controller — the industrial computer that runs the equipment) was specified before "cybersecurity" was a procurement line item, a bench balance with a serial port. These speak Modbus and Siemens S7 — protocols designed for a trusted wire (a private, physically-isolated network where every device was assumed friendly), with no authentication and no encryption.
We will:
- read a (simulated) TFF skid over Modbus TCP with PyModbus, and scale its raw integer registers into the same engineering-unit tags the rest of the platform uses;
- face the brutal truth that Modbus and S7 carry no security at all — and what the Siemens S7-1200/1500 PUT/GET caveat really means;
- treat OT network segmentation as the compensating control that lets us touch these devices at all, and write it down as a risk-based decision;
- and look at how python-snap7 and Apache PLC4X extend the same pattern to Siemens PLCs and mixed legacy fleets.
The runnable code in this chapter is examples/chapters/09-legacy-skids-modbus-s7/modbus_reader.py. The S7 and PLC4X material is shown as realistic, clearly-labelled configuration — there is no Siemens PLC inside a laptop, and we will be honest about exactly where the simulation stops.
Why legacy protocols are a different animal
Modbus was published in 1979 and standardized as a simple request/reply, client/server messaging protocol: a client sends a function code (read holding registers, write a coil — a single on/off bit) and an address, and the server replies with 16-bit register values or single-bit coils [1]. There is nowhere in the frame for a username, a password, a signature, or a session key. None. A device on the wire does whatever any client asks. That is not a bug you can patch — it is the protocol.
The Modbus Organization eventually acknowledged this and published a Modbus/TCP Security specification that wraps the protocol in TLS with X.509 client certificates — but it is a later, optional variant, and the skids we are reaching in this chapter predate it and will never support it [2]. So the honest engineering position is: we cannot make the protocol secure, so we make the network around it secure, and we read the device through a validated edge layer that owns the meaning the device itself does not carry.
Siemens S7comm (and its newer S7CommPlus) is the same story in a different dialect. It rides a TPKT/COTP/S7comm stack over TCP and, like Modbus, was built for a trusted automation network. Security researchers have demonstrated that S7CommPlus's integrity mechanism can be defeated with replay and injection attacks — its "anti-replay" is not robust authentication [9]. The practical takeaway for a data engineer is identical to Modbus: do not rely on the protocol to protect itself; segment, and read it under a controlled layer.
Reading a Modbus skid for real
Here is the device, exactly as a legacy PLC presents it. From examples/chapters/09-legacy-skids-modbus-s7/modbus_reader.py:
# raw holding registers the skid PLC exposes (scaled integers, as legacy PLCs do).
# Modbus holding registers are conventionally numbered 40001.., here at addresses 1-4.
TFF_RAW = [1850, 320, 1240, 78] # TMP, flux, conductivity, recovery
# position -> (tag, scale, unit) — the normalization the edge gateway applies
SCALING = [
("TFF01.TMP.PV", 0.001, "bar"), # 1850 -> 1.850 bar
("TFF01.Flux.PV", 0.1, "LMH"), # 320 -> 32.0 LMH
("TFF01.Cond.PV", 0.01, "mS/cm"), # 1240 -> 12.40 mS/cm
("TFF01.Recovery.PV", 1.0, "%"), # 78 -> 78 %
]
Stare at TFF_RAW for a moment, because it is the whole problem in four integers. 1850 is not 1.850 bar to the device; it is just the number 1850. Legacy PLCs almost never store floating-point engineering units — memory and fieldbus bandwidth were precious, so values are stored as scaled integers: pressure ×1000, flux ×10, conductivity ×100. The scale factor is a convention agreed in a register map, not anything the protocol announces. If the SCALING table is wrong by one decimal place, your transmembrane pressure reads 18.5 bar instead of 1.85, and nothing on the wire will tell you.
That SCALING table is the chapter's quiet hero. It is the bridge from a raw register to the UNS (Unified Namespace) tag namespace we designed in Chapter 5 — the one canonical name tree the whole platform shares: TFF01.TMP.PV is the canonical name for the tangential-flow-filtration skid's transmembrane-pressure process value (PV = the live measured value), in bar. The legacy device knows none of that; the edge layer supplies it. In production gov.tag_dictionary — generated and linted in Chapter 5 — supplies the canonical unit and UNS name, while the scale factor is reviewed edge configuration; either way the mapping is reviewed configuration, not a magic number buried in a script.
The scaling itself is a six-line function — deliberately dumb, easy to test, easy to validate:
def scale(registers: list[int]) -> dict[str, dict]:
out = {}
for value, (tag, factor, unit) in zip(registers, SCALING):
out[tag] = {"value": round(value * factor, 3), "unit": unit}
return out
And here is the part that talks to a real device — the actual PyModbus client call an edge collector makes. PyModbus is a full open-source Modbus client/server for both TCP and serial RTU/ASCII, which is why it is the tool of choice for reaching these skids from a validated edge layer [3]:
async def read_skid(host: str = "127.0.0.1", port: int = 502, unit: int = 1) -> dict:
"""Read a real TFF skid over Modbus TCP and normalize to engineering units.
This is the actual pymodbus client call an edge collector makes; point it at
a real skid by host/port. Holding registers are read
starting at address 0 (40001) — confirm your device's base with its map.
"""
from pymodbus.client import AsyncModbusTcpClient
client = AsyncModbusTcpClient(host, port=port)
await client.connect()
rr = await client.read_holding_registers(address=0, count=len(TFF_RAW), device_id=unit)
client.close()
if rr.isError():
raise OSError(f"Modbus read failed: {rr}")
return scale(list(rr.registers))
Three details in that short function are worth your attention, because each is a classic legacy-integration trap.
The address=0 is the notorious off-by-one of the Modbus world. Holding registers are documented as 40001, 40002, 40003… but the actual on-wire protocol address is zero-based, so register "40001" is read at address 0. Vendors disagree about whether their datasheet means the documentation number or the wire number, so the comment in the code — confirm your device's base with its map — is not boilerplate; it is the single most common reason a first read returns garbage.
The device_id=unit (the Modbus "unit ID" or slave address) matters because one Modbus/TCP gateway often fronts several serial devices daisy-chained behind it; the unit ID picks which physical box answers. And the explicit rr.isError() check exists because Modbus has no quality flag the way OPC UA does (recall the Good/Uncertain/Bad status codes from Chapter 9). A Modbus read either returns numbers or returns an exception response, and it is on us — the edge layer — to turn that into something the historian (the time-series database that is the long-term home of every reading) can trust. Honesty about data quality is something we have to add; the protocol will not.
Anatomy of a Modbus read: the frame, field by field (and the slots that are empty)
It is worth slowing down and dissecting the single transaction that read_skid() performs, because every weakness of the protocol is visible in the bytes themselves. A Modbus/TCP request is two parts: a seven-byte MBAP header (Modbus Application Protocol header) and a PDU (protocol data unit) that carries the actual function and addresses [1]. The MBAP header holds a Transaction ID (so the client can match a reply to a request), a Protocol ID that is always 0x0000 for plain Modbus, a Length, and the Unit ID — which is exactly the device_id=1 from our call. The PDU then carries function code 0x03 (read holding registers), the starting address, and the quantity. The response echoes the function code, states a byte count, and returns the raw 16-bit register words — and that is the entire conversation.
The figure below lays out that transaction field by field. The most instructive part is not the fields that are present but the ones that are absent: the rose strip enumerates the slots a Modbus frame simply has no room for. Set it beside the OPC UA DataValue anatomy from Chapter 7 — which carried identity, type, value, quality, source timestamp, and engineering unit in one self-describing structure — and the contrast is the whole chapter. Where OPC UA volunteers meaning, Modbus volunteers four integers and leaves every other column blank for the edge layer to fill.
Every Modbus weakness is visible in the frame: four bare integers come back, and the unit, timestamp, quality, authentication, and scale factor are all empty slots the validated edge layer must supply.
Original diagram by the authors, created with AI assistance.
Two fields in that picture repay a closer look. The Protocol ID is hard-wired to 0x0000; there is no version negotiation, no capability exchange, nothing that could ever carry a security handshake — the field that might have grown into one is permanently nailed shut. And the starting address is the off-by-one made concrete: the PDU on the wire says 0x0000, but the device's datasheet calls that same register 40001. The convention that "4xxxx" numbers map to holding registers, minus one, onto the wire address is decades old and not universally honoured, which is why the same physical value is read at address 0 by one vendor's tooling and address 1 by another's. Nothing in the frame disambiguates it; only the register map on someone's desk does.
The read lifecycle: connect, route by Unit ID, scale
The anatomy shows a frame frozen in time; the sequence below shows it moving. A single read_skid() call is a five-step round trip across a guarded boundary, and each step is something the edge layer owns rather than something the protocol guarantees.
Walk it once, numbered to the figure:
- Connect.
await client.connect()opens a TCP session on port502(the well-known port reserved for Modbus TCP) — and crucially, it crosses the OT segmentation conduit (the dashed band). That crossing is the only traffic permitted between the collector's zone and the skid's zone, which is what makes reading an unauthenticated device defensible. - Read. The collector sends function
0x03foraddress=0, count=4, device_id=1. The gateway uses the Unit ID to route the request to the one daisy-chained serial box that answers to1. - Respond. Back come
[1850, 320, 1240, 78]— four raw words and nothing else. No unit, no source timestamp, no quality. This is the exact moment the anatomy's empty slots become a runtime problem. - Check.
rr.isError()is the entire quality signal Modbus offers: the read either succeeded or it raised an exception response. There is no "Uncertain"; the edge layer must decide what that binary means for the historian. - Scale and emit.
scale()applies theSCALINGtable, and four named, unit-stamped tags land ints.sensor_reading(the historian's reading table) —TFF01.TMP.PVat 1.85 bar, and so on. Thequalitycolumn defaults to192. That number is the Good code from OPC DA (Classic) — the older quality scheme this historian inherited (the 192/64/0 convention established in Chapter 7), where 192 = Good, 64 = Uncertain, 0 = Bad (OPC UA's newer scheme instead uses0for Good). The wire never sent a quality at all, so the edge layer is asserting it. That assertion is itself a data-integrity decision worth being conscious of.
Modbus vs S7: what each protocol volunteers, side by side
It helps to line the two legacy dialects up against the OPC UA baseline, because the data engineer's compensating work is exactly the difference between the columns.
| What the wire carries | OPC UA (Ch. 9) | Modbus | Siemens S7comm |
|---|---|---|---|
| Value | typed DataValue | raw 16-bit integer | raw bytes in a data block |
| Engineering unit | yes (EUInformation) | no — register map only | no — DB layout only |
| Source timestamp | yes | no | no |
| Quality / status | Good / Uncertain / Bad | error-or-not | error-or-not |
| Identity / name | NodeId + BrowseName | address number | DB number + offset |
| Authentication | sessions + certificates | none | weak; defeated by replay [9] |
| Addressing trap | namespace index (the ns= prefix on a NodeId) | 40001 vs wire 0 | optimized-vs-standard DB access |
Read top to bottom, the table is a worklist: every "no" or "none" is a column the edge layer fills from reviewed configuration — the SCALING table, the unit string, the gov.tag_dictionary name, the segmentation justification — and every legacy protocol leaves roughly the same set blank. That is why one normalization pattern, not a bespoke integration per device, is the right shape.
The scaling table as a shape the graph can check
That worklist has a precise form once the reading reaches the storage layer. The same canonical names this edge layer mints — TFF01.TMP.PV and its siblings — become IRIs (Internationalized Resource Identifiers, web-style global names) the moment they enter the knowledge graph the platform builds in Semantics & the Digital Thread, and the unit the SCALING table supplies becomes a typed fact on that node. A single scaled reading is, in RDF (the Resource Description Framework, where every fact is a subject–predicate–object triple), three triples:
# the reading the edge layer emits, expressed as RDF triples
bp:TFF01-TMP a bp:ProcessValue ;
qudt:hasUnit unit:BAR ; # the unit the SCALING table supplied, now machine-readable
bp:numericValue "1.85"^^xsd:float .
What makes this a control rather than decoration is that the same gap the rose strip drew — no unit on the wire — can now be enforced as a SHACL shape (the Shapes Constraint Language, the W3C standard for validating triples against rules), so a reading that arrives without its unit is rejected at the gate instead of silently stored:
# every ProcessValue MUST carry exactly one unit — the empty slot, enforced
bp:ProcessValueShape a sh:NodeShape ;
sh:targetClass bp:ProcessValue ;
sh:property [ sh:path qudt:hasUnit ; sh:minCount 1 ; sh:maxCount 1 ] .
This is the formal answer to the chapter's recurring worry. "Does every reading carry the unit and canonical name the wire omitted?" is exactly the kind of competency question an ontology must be able to answer — the runnable PASS/FAIL acceptance test Book 4 builds in Competency questions as queries, and the qudt:hasUnit typing is the identifiers-and-units discipline of pinning a quantity's unit as a fact rather than a string. The gov.tag_dictionary is, in this light, a small local ontology aligned to those shared standards — bp:ProcessValue as a class in the classes-and-taxonomy sense, TFF01 as an instance in the instances-and-the-graph sense — so the meaning the legacy protocol drops is recovered as something a machine can validate, not just something a human wrote in a PDF.
Where the signal actually comes from: the PendoTECH case
Reading the skid PLC over Modbus is one way to get the TFF numbers — but it is not always the way the signal reaches you. On a real TFF or UF/DF (ultrafiltration / diafiltration) skid, the pressure and transmembrane-pressure (TMP) channels frequently originate from a dedicated single-use sensor monitor wired to disposable inline pressure sensors on the flow path. Those sensors read the three TFF pressures — feed (the stream pushed into the filter), retentate (what is held back and recirculated), and permeate (what passes through the membrane) — and TMP is itself derived from them as roughly (P_feed + P_retentate)/2 − P_permeate, which is precisely why even the raw 1850 integer is a computed register value rather than a directly measured one. A widely-deployed representative example is the PendoTECH single-use sensor monitor. Such a monitor typically gives you two doors, and they are exactly the two doors this chapter keeps returning to: read it live over Modbus from its register map, or pull what it writes — it logs a delimited text file to local or networked storage every few seconds or publishes OPC UA that an upstream historian such as AVEVA PI subscribes to directly.
The catch is in that logged file, and it is the same catch as TFF_RAW. The native vendor log is not the clean, long/tidy timestamp, tag, value table a historian wants — it is a heterogeneous CSV with a multi-line header block bolted on top: several lines of instrument metadata, channel names, units, and calibration constants before the readings begin, and column layouts that differ between firmware versions and monitor configurations. So even when the data arrives as a file rather than over Modbus, the edge layer's job is unchanged: skip and parse the header block, map each device-native column to its canonical UNS tag, attach the unit the file states (do not assume it), and reshape the wide vendor rows into the same ASSET.Measurement.PV records scale() emits. Whether the path is a Modbus read, a vendor file, or an OPC UA stream, the meaning the device omits is still ours to supply.
Running it with no hardware
Because there is no TFF skid inside a laptop, the file ships a demo() that applies the same scaling to the known register snapshot, so the chapter is runnable end to end with zero hardware and zero network:
def demo() -> dict:
"""Apply the engineering-unit scaling to a known register snapshot (no network)."""
return scale(TFF_RAW)
if __name__ == "__main__":
for tag, v in demo().items():
print(f" {tag:20} = {v['value']} {v['unit']}")
Run it:
$ python chapters/09-legacy-skids-modbus-s7/modbus_reader.py
TFF01.TMP.PV = 1.85 bar
TFF01.Flux.PV = 32.0 LMH
TFF01.Cond.PV = 12.4 mS/cm
TFF01.Recovery.PV = 78.0 %
Those four lines are the entire point of the chapter made concrete. Four meaningless integers went in; four named, scaled, unit-stamped readings came out, in exactly the ASSET.Measurement.PV shape the rest of the platform speaks. A transmembrane pressure of 1.85 bar and a flux of 32 LMH (litres per square metre per hour) are sensible numbers for a mAb (monoclonal antibody) TFF step — flux is permeate flow per unit membrane area, conductivity tracks the buffer/salt level the diafiltration is exchanging, and recovery is the fraction of product retained — and the 78.0 % is a live mid-step process value, an instantaneous reading rather than a final step yield (which would typically land far higher), so do not read it as a healthy TFF recovery figure. Now they can flow into ts.sensor_reading and be contextualized to a batch and phase like any OPC UA tag. Be honest about what this proves: it exercises the integration logic and the data shapes, not a specific vendor's Modbus quirks. The read_skid() path is the real client call; you point it at an actual skid by host and port, and the same scale() runs on what comes back.
From numbered pigeonholes to named records: a legacy Modbus skid carries only raw scaled integers, so the validated edge layer supplies the scale factor, unit, and canonical tag name that the protocol omits — all from behind OT segmentation. Original diagram by the authors, created with AI assistance.
Siemens S7 and the PUT/GET trap
Plenty of commercial skids are built on Siemens S7 PLCs rather than Modbus. The open-source door here is python-snap7, a pure-Python S7 library that implements the TPKT/COTP/S7comm/S7CommPlus stack and can read S7-300/400/1200/1500 controllers natively [4]. The pattern mirrors read_skid(): connect, read a chunk of a data block, then byte-decode and scale it into tags. The following is an illustrative snippet — there is no Siemens PLC on a laptop, so this is the shape of the call, not a tested run:
# Illustrative — requires a real/simulated S7 PLC; not run on a laptop.
import snap7
from snap7.util import get_int
client = snap7.client.Client()
client.connect("192.0.2.50", rack=0, slot=1) # S7-1500: rack 0, slot 1
db = client.db_read(db_number=10, start=0, size=8) # read 8 bytes of DB10
tmp_raw = get_int(db, 0) # offset 0 -> TMP scaled int
client.disconnect()
But there is a specific, infamous gotcha you must know before you wire an S7 PLC into anything. On modern S7-1200 and S7-1500 controllers, snap7's "optimized" data-block access depends on the PLC's PUT/GET communication setting and on data blocks not being marked "optimized block access" in TIA Portal. If PUT/GET is disabled (it is off by default on these families for safety) your reads fail outright; if an automation engineer turns it on so your collector can read, they have just opened a door that — combined with S7comm's weak authentication — lets any client read and write the PLC [9]. That single checkbox is a documented, risk-based decision, not a convenience toggle: enabling it to feed a historian is exactly the kind of trade-off that belongs in a change record and a network-segmentation justification, never something flipped quietly in the field.
Anatomy of an S7 read: TPKT/COTP/S7comm and the DB offset
The S7 read in the snippet above hides the same kind of layered frame Modbus does, just deeper. python-snap7 speaks a stack of three protocols nested inside TCP: TPKT (RFC 1006, which frames the message), COTP (the ISO connection-oriented transport that carries it), and on top S7comm, whose job request names a ROSCTR (remote-operating-service control, "job" for a read), a function (read variable), and then — the part you actually choose — a memory area, a data-block number, a start offset in bytes, and a size. In client.db_read(db_number=10, start=0, size=8) those last three are DB10, byte 0, and 8 bytes; get_int(db, 0) then decodes the first 16-bit integer out of that buffer.
The mapping to Modbus is almost line for line. A Modbus holding-register address becomes an S7 (DB number, byte offset) pair; a Modbus Unit ID becomes an S7 (rack, slot) — the physical position of the CPU module in the PLC's chassis, rack=0, slot=1 for an S7-1500. The off-by-one trap reappears in a new costume: S7 offsets are in bytes, so a 16-bit value at "word 2" lives at byte offset 2, and a value the program calls DBW0 is get_int(db, 0) while DBW2 is get_int(db, 2) — read it at the wrong offset and you decode two adjacent values mashed together. And just like Modbus, the frame carries no unit, no source timestamp, and no quality flag: the raw decoded integer is still a bare number, so the same scale()-and-name step from the lifecycle figure applies unchanged. The anatomy card above could be redrawn for S7 by swapping "Unit ID" for "(rack, slot)" and "starting address 0x0000" for "DB10, byte 0" — every empty slot in the rose strip stays exactly as empty.
One library for a mixed fleet: Apache PLC4X
A real plant is rarely one protocol. You will meet Modbus and S7 and Allen-Bradley (a major Rockwell PLC family) in the same harvest suite, and writing a bespoke client for each is how integration projects rot. Apache PLC4X offers a single, shared API behind per-protocol drivers, so the same connection-string-and-read code reaches a Siemens S7 over TCP or a Modbus device over TCP/RTU/ASCII without your application caring which [5]. The Modbus driver addresses coils, discrete inputs, holding registers, and input registers under that common API; the S7 driver speaks to the S7-300/400/1200/1500 line [6].
In practice you express the fleet as configuration. The block below is an illustrative PLC4X-style connection map for our two legacy assets — the kind of edge/plc4x/plc4x-connect.yaml an edge service would consume — not a tested artifact in this chapter's directory:
# Illustrative PLC4X connection map (not a tested artifact in this chapter dir).
connections:
tff01:
url: "modbus-tcp://10.20.0.11:502?unit-identifier=1"
poll_ms: 1000
tags:
TFF01.TMP.PV: { address: "holding-register:1:INT", scale: 0.001, unit: "bar" }
TFF01.Flux.PV: { address: "holding-register:2:INT", scale: 0.1, unit: "LMH" }
centrifuge01:
url: "s7://10.20.0.21?remote-rack=0&remote-slot=1"
poll_ms: 2000
tags:
CFG01.Speed.PV: { address: "%DB10.DBW0:INT", scale: 1.0, unit: "rpm" }
Notice that the scale and unit keys reappear, now per protocol — the legacy meaning problem never goes away; PLC4X just gives you one consistent place to keep the key-ring. Notice too that PLC4X's Modbus driver uses 1-based register numbers, so holding-register:1 here addresses the very same physical register our raw PyModbus call reaches at wire address=0 — the 40001-vs-0 off-by-one from the anatomy section surfacing a third time, now as a tooling-convention difference rather than a bug, which is exactly why the addressing base must be pinned per tool. The honest trade-off: PLC4X is a powerful, Apache-licensed Java/Go project, but it is heavier than a 60-line PyModbus script and its protocol-driver maturity varies by device. For one Modbus skid, PyModbus is right. For a fleet of mixed legacy controllers feeding one edge service, PLC4X earns its weight.
Why it matters
Legacy integration is where data-integrity ambitions meet the actual floor. Every ALCOA+ attribute we have championed — the data-integrity principles that regulated records must be Attributable, Legible, Contemporaneous, Original and Accurate (the "+" adds Complete, Consistent, Enduring and Available) — has to survive a protocol that volunteers none of them. Modbus will not tell you the unit, will not timestamp the value at source, and will not flag bad data. If the edge layer scales 1850 wrong, the historian faithfully and permanently records an accurate-looking but wrong transmembrane pressure, and a process-validation reviewer downstream has no way to see the error. The scaling table is therefore not plumbing; it is a data-integrity control, and it deserves the review, version control, and qualification (documented proof it works as intended) any GMP (Good Manufacturing Practice) control gets.
It also matters because you usually cannot rip-and-replace this equipment. A qualified TFF skid or centrifuge represents years of validation; "we'll buy OPC UA-native gear" is rarely a real option for an existing line. So the data engineer's job is not to wish the legacy protocol away — it is to read it honestly and wrap it in the controls the protocol lacks.
And the scaling table is a data-integrity control in the formal regulatory sense, not just a metaphor. Asserting quality=192 for a value the wire never qualified, and recovering the unit and canonical name the protocol omitted, is precisely the kind of system behavior 21 CFR Part 11 §11.10(a) demands be validated "to ensure accuracy, reliability, consistent intended performance," and that EU Annex 11 expects to be risk-assessed and change-controlled — the clause-by-clause treatment this book gives the audit trail and signatures in Part 11 / Annex 11 with Open Source. The SCALING table therefore lives under change control, with its scale factors qualified by IQ/OQ/PQ (Installation/Operational/Performance Qualification) like any other GMP function, and the FDA's CSA risk-based posture — already cited below — is what tells you how hard to test it: a register feeding a CQA gets scripted verification, a status flag gets a lighter check.
The captured stream is also the fuel for every downstream model
There is a forward dependency worth naming, because this chapter's quiet correctness decisions propagate into the Machine Learning & AI book. A soft sensor, an MSPC monitor, or a drift detector is only ever as trustworthy as the contextualized tags it learns from — and a mis-scaled register is not a visible error to a model, it is a covariate shift the model silently extrapolates through. Three of this chapter's habits are load-bearing for that downstream work. First, the contextualization to a batch (the same join that lands a reading in ts.sensor_reading under a batch_id) is what makes a leakage-free, batch-grouped split possible at all — scikit-learn's GroupKFold / LeaveOneGroupOut grouping on batch_id, the only honest validation when batches are the unit of evidence, as Models and Validation insists. Second, the qualified operating range that the SCALING factors imply is the seed of a model's applicability domain — the input region a model was calibrated on and outside which it should refuse to answer; a 1850 mis-scaled to 18.5 bar lands a sensor far outside any domain it was fit on, and the model has no way to know. Third, this chapter's distinction between an instantaneous 78.0 % reading and a final yield is the same trap that makes process drift (a real change in the living system) hard to tell from model drift (the sensor going stale) in MLOps and Lifecycle: a residual control chart on the historian stream can only separate the two if the stream it watches is scaled, unit-stamped, and traceable to its batch. Garbage tags do not produce a model that fails loudly; they produce one that is confidently, invisibly wrong — which is why the unglamorous edge-layer correctness this chapter builds is a model-quality control as much as a data-integrity one.
In the real world
The exposure is not hypothetical: legacy OT on the public internet
It is tempting to treat "Modbus has no authentication" as a theoretical concern — surely nobody actually leaves these devices reachable? They do, at scale. A peer-reviewed internet-wide measurement study by Mirian and colleagues extended the ZMap scanner to speak five SCADA (supervisory control and data acquisition) protocols and swept the entire public IPv4 address space; after filtering to genuine application-layer responders, they found roughly 23,000 real Modbus devices answering on port 502 and about 2,800 Siemens S7 controllers across 75 countries, among more than 60,000 publicly accessible ICS (industrial control systems) in total [11]. These are not honeypots filtered out of the count; they are production-grade controllers, of exactly the families this chapter reads, sitting on the open internet with no password between them and any client on Earth.
That number is the empirical case for everything this chapter argues. Recall from the anatomy card that the Modbus frame carries no authentication field at all — there is literally no slot for a credential [1] — and that S7comm's integrity mechanism has been defeated by replay and injection in the lab [9]. Put the two together and a directly-exposed skid is not merely readable by an attacker; on a writable register or an enabled PUT/GET it is controllable. The 23,000 figure is why the chapter's compensating control is segmentation rather than a protocol fix: you cannot add a password to a frame that has no field for one, so the only honest mitigation is to ensure the device is never reachable except through the one documented conduit our read_skid() traverses. The scan study is the field evidence that when that discipline lapses, the devices end up exactly where they should never be.
The blunt reality is that insecure legacy protocols are everywhere in pharma, and the regulatory and security frameworks already expect you to compensate at the network layer. NIST SP 800-82 Rev. 3, the authoritative OT-security guide, is built around exactly this: zone-and-conduit architectures, network segmentation, and compensating controls for protocols that cannot defend themselves [7]. IEC 62443-3-3 makes it a requirement, not advice: its foundational requirement FR5 (Restricted Data Flow), including SR 5.1 Network Segmentation, mandates segmenting insecure OT by security level into zones connected only through controlled conduits [8]. So when our read_skid() reaches a Modbus device, it does so from inside a defined conduit — the edge collector sits in a controlled zone, the skid sits in an OT zone, and the only traffic between them is the specific Modbus read on the specific port we documented.
Crucially, choosing segmentation as the answer is itself a documented, risk-based decision. The FDA's Computer Software Assurance guidance frames assurance for production and quality-system software around intended use and risk: you are expected to identify that the protocol is insecure, decide that network controls plus a validated edge layer are the proportionate mitigation, and write that reasoning down [10]. "We segmented the Modbus skids and read them through a qualified gateway because the protocol has no authentication" is precisely the kind of risk-based statement an inspector wants to see — and precisely what this chapter's code makes concrete.
Now the honest OSS-vs-commercial line. The reading is genuinely, completely solved in open source: PyModbus, python-snap7, and PLC4X will talk to almost any legacy controller, at no licence cost, with code you can read and test. What pure OSS does not give you is the validated-driver accountability a commercial historian's connector ships with (AVEVA PI's interfaces and connectors, Kepware/KEPServerEX, and the like come with vendor qualification packages and support contracts that name a throat to choke). With our PyModbus collector, you own proving the scaling is correct, that reads are reliable, and that the segmentation holds — which is the recurring shape of this book: open source reaches the device cleanly; the GxP (the family of Good-x-Practice regulations — GMP and its siblings) wrapper around it is yours to build or buy. And no amount of either side changes the protocol: Modbus and S7 stay insecure, and the network is the only place that gets fixed.
Key terms
- PLC (programmable logic controller) — the ruggedized industrial computer that runs a piece of process equipment (a skid); the device this chapter reads over Modbus or S7.
- Skid — a packaged process unit — pumps, valves, sensors and a PLC mounted on one frame — delivered and qualified as a single machine (e.g. a TFF skid, a harvest centrifuge skid).
- Historian — the time-series database that permanently stores process readings; the long-term destination (
ts.sensor_reading) of every tag the edge layer emits. - UNS (Unified Namespace) — the single canonical name tree the whole platform shares (designed in Chapter 5), so every signal has one browsable name like
TFF01.TMP.PV. - Modbus — a 1979 request/reply client/server protocol using function codes and 16-bit registers/coils, with no authentication or encryption; common on legacy skids, balances, and pumps.
- Holding register — a 16-bit read/write memory slot in a Modbus device, conventionally numbered from 40001 but addressed from 0 on the wire; legacy PLCs store engineering values here as scaled integers.
- Coil — a single read/write on/off bit in a Modbus device (the binary counterpart of a 16-bit register), used for discrete states like a valve open/closed.
- Scaled integer — a value stored as a whole number times a fixed factor (e.g. pressure ×1000) because the device cannot or does not store floating point; the edge layer must apply the scale to recover engineering units.
- Unit ID / device_id — the Modbus slave address that selects which physical device answers behind a shared TCP gateway.
- MBAP header / PDU — the two parts of a Modbus/TCP request: the seven-byte MBAP (Modbus Application Protocol) header carrying Transaction ID, Protocol ID (always
0x0000), Length, and Unit ID, followed by the PDU (protocol data unit) carrying the function code, starting address, and quantity. Neither has a field for a credential. - TPKT / COTP / S7comm — the three nested layers of the Siemens S7 stack over TCP: TPKT (RFC 1006 message framing), COTP (ISO connection-oriented transport), and S7comm (the job request naming a data-block number, byte offset, and size). The S7 equivalent of Modbus's address is a
(DB number, byte offset)pair, and offsets are counted in bytes. - Single-use sensor monitor — a dedicated monitor (e.g. PendoTECH, used here as a representative industry example) for disposable inline sensors on a TFF/UF-DF flow path; it exposes its pressure/TMP signals either over Modbus, as a delimited log file with a multi-line header block written every few seconds, or as an OPC UA stream a historian subscribes to.
- Siemens S7comm / S7CommPlus — Siemens' proprietary PLC protocol stack (over TPKT/COTP/TCP); insecure by design, with weak authentication that has been defeated by replay/injection.
- PUT/GET communication — a Siemens S7-1200/1500 setting that must be enabled (and optimized block access disabled) for external clients like snap7 to read data blocks; off by default, and enabling it widens the attack surface.
- OT network segmentation — isolating operational-technology equipment into zones connected only through controlled conduits; the IEC 62443 / NIST SP 800-82 compensating control for insecure legacy protocols.
- Zones and conduits — the IEC 62443 model of grouping assets by security level (zones) and permitting traffic only through defined, controlled paths (conduits).
- SHACL shape — a rule in the W3C Shapes Constraint Language that validates RDF triples; here, the constraint that every
bp:ProcessValuemust carry exactly one unit, so a reading scaled without its unit is rejected at the graph gate rather than silently stored. - Competency question — a question an ontology must be able to answer ("does every reading carry the unit and canonical name the wire omitted?"), used as a runnable PASS/FAIL acceptance test for the model.
- Batch-grouped split — the leakage-free cross-validation that holds out whole batches (
GroupKFold/LeaveOneGroupOutonbatch_id), made possible only because the edge layer contextualizes each tag to its batch; the honest way to score a model when batches are the unit of evidence. - Applicability domain — the input region a model was calibrated on and outside which it should not be trusted; a mis-scaled register (1.85 → 18.5 bar) lands a downstream sensor far outside its domain with no signal that it has.
- Covariate shift — a move in the distribution of a model's inputs (a fouling probe, a new lot, or a scaling error) while the underlying physics is unchanged; the silent way a mis-scaled tag degrades a downstream model.
Where this leads
We have reached the messy edges of the upstream world — the legacy skids that speak in numbered pigeonholes — and pulled them into the same tag namespace as everything else, from behind segmentation, with code you can run. But Modbus and S7 still return a number; one layer deeper sit the physical signals that do not. The next chapter, The Signal Layer Below Modbus: 4-20 mA, HART, Fieldbus, and Discrete I/O, reads the analog currents, HART overlays, 24V bits, and fieldbus telegrams beneath the digital protocols — applying NAMUR NE43 fault detection and the same scale-and-unit discipline — before we follow the product downstream into purification.