Condition Monitoring with Modbus TCP, Python, and the Modicon M221 PLC
Image Source:
leonidkos/Stock.adobe.com
By Michael Parks, PE, for Mouser Electronics
Published June 2, 2026
In modern industrial environments, the ability to capture and analyze process data in real time is no longer a luxury reserved for large-scale supervisory control and data acquisition (SCADA) installations. It’s rapidly becoming a baseline expectation even at the machine level. Small, cost-effective programmable logic controllers (PLCs), such as the Schneider Electric Modicon TM221CE16R (Figure 1), are widely deployed to handle discrete control tasks on packaging lines, conveyor systems, pump stations, and similar equipment.
Figure 1: Schneider’s Modicon TM221CE16R programmable logic controller. (Source: Mouser Electronics)
PLCs have been the backbone of industrial automation for decades. At their core, PLCs are ruggedized computing devices purpose-built to execute deterministic control logic in environments where standard computers would fail. Dangers to computers can include exposure to electrical noise, temperature extremes, vibration, and dust. PLCs read physical inputs from sensors and switches, process those data through a user-written program, and drive outputs to actuators, motors, relays, and indicators. Unlike general-purpose computers, PLCs are designed around a scan cycle, meaning the program runs in a continuous, predictable loop that enables near real-time responsiveness. This deterministic behavior is what makes them trusted across manufacturing, water treatment, building automation, and countless other sectors where reliability is not negotiable.
To get the most out of these devices, engineers need a built-in path to meaningful data visibility. This project achieves that goal by pairing the Schneider M221’s native Modbus Transmission Control Protocol (TCP) server with a lightweight Python application that continuously polls process variables, logs them to a structured data store, and presents live trend visualizations, turning an affordable compact PLC into the foundation of a practical condition-monitoring system.
This guide covers the project materials and resources, a technology overview of the Modicon M221, and a three-step development process for source-code customization, PLC configuration, and system tuning.
Project Materials and Resources
Project Bill of Materials (BOM)
Project Code/Software
Additional Resources and Accounts
Additional Hardware
- PC running Windows 10/11
- Ethernet adapter
- Ethernet cable
- Mini USB cable
- Standard three-prong appliance cord (NEMA 5-15P for US-based developers)
- Waveform generator (to inject signals to analog inputs during benchtop testing)
- Digital multimeter
- DC power supply
Project Technology Overview
The TM221CE16R runs a straightforward control program in Schneider’s EcoStruxure Machine Expert Basic (formerly SoMachine Basic), managing a small set of digital and analog input/output (I/O) channels. Simultaneously, it exposes key process variables, such as motor run states, cycle counts, and analog sensor readings, through its integrated Modbus TCP interface. On the software side, a Python application built around the PyModbus library handles the polling loop, writes every sample to a time-series log, and uses Matplotlib to render rolling trend charts. Simple threshold logic flags abnormal conditions, such as a temperature creeping above a setpoint or a cycle time drifting outside its expected window. The result is a repeatable, low-cost architecture that mirrors the core principles behind industrial condition monitoring and predictive maintenance, without requiring proprietary middleware or an enterprise historian.
Hardware Overview
The controller at the center of this project is the Schneider Electric Modicon TM221CE16R, a compact logic controller from the Modicon M221 series designed for small- to medium-scale machine automation. It measures just 95 × 70 × 90mm and mounts on a standard DIN rail, making it easy to fit into even the smallest enclosures. The TM221CE16R includes 16 integrated I/O points: nine 24VDC discrete inputs (four of which can serve as high-speed counters) and seven normally open relay outputs rated at 2A. It also includes two embedded 0 to 10V analog inputs with 10-bit resolution. The unit runs on 100 to 240VAC mains power, eliminating the need for an external 24V supply to power the controller.
WARNING: HIGH VOLTAGE: Wiring a 120VAC or 240VAC cord directly to the PLC for benchtop testing presents an electrical shock hazard. Always verify the circuit is completely de-energized and disconnected from the power source before adjusting wiring.
Internally, the TM221CE16R provides 256kB of RAM for user application data and 256kB for internal variables, with an additional 256kB of built-in flash memory and support for up to 2GB of storage via an SD card. The TM221CE16R features a built-in Ethernet port that supports both Modbus TCP server and client roles, a serial RJ45 port capable of RS232 or RS485 communication for Modbus remote terminal unit (RTU), and a Mini USB 2.0 port (used primarily for programming). The Ethernet interface is the critical piece for this project’s purposes: It allows a properly configured Modbus TCP client with permitted network access to read and write PLC registers over Modbus TCP without any additional hardware, gateways, or licensing.
EcoStruxure Machine Expert Basic supports Instruction List, Ladder Diagram, and GRAFCET programming languages. A sample EcoStruxure project (M221_Example_Project.smbp) is provided in the software folder of the GitHub repository.
Software Overview
This project’s application will simulate monitoring a mixing tank with two analog sensors (temperature and pressure), four digital actuators (heater, mixer motor, inlet valve, outlet valve), operator setpoints, and a 16-bit alarm word. All Modbus addresses and engineering-unit scaling factors are defined in editable tag tables at the top of the source file.
The architecture is intentionally simple: The application serializes all Modbus TCP transactions through a single polling loop, then hands off data to logging and visualization via queues. This architecture makes the application easy to deploy on anything from a full workstation to a Raspberry Pi mounted in a control cabinet.
We will use Visual Studio Code as our code editor; Figure 2 shows process_monitor.py open during development.
Figure 2: The source code of the Python script written in Visual Studio Code. (Source: Green Shoe Garage)
The script assumes the PLC is controlling a mixing tank with these I/O mappings (which are configured in EcoStruxure Machine Expert on the PLC side):
- Analog Inputs (sensors; %IW): Temperature (%IW0) and Pressure (%IW1), scaled from raw 0–1000 to engineering units
- Digital Outputs (actuators; %Q): Heater (%Q0.0), Mixer Motor (%Q0.1), Inlet Valve (%Q0.2), Outlet Valve (%Q0.3)
- Holding Registers (%MW): Setpoints (%MW0, %MW1), Alarm word (%MW10, bit-packed), PLC cycle counter (%MW20)
The Python monitoring application includes several important features:
- Live dashboard: four-panel Matplotlib window showing analog trends, digital output states, and alarm status, updating in real time
- CSV data logging: auto-rotating daily log files in a logs/ directory
- Setpoint overlay: temperature setpoint shown as a dashed line on the trend
- Alarm decoding: the 16-bit alarm word from %MW10 is decoded into named alarm flags
- Auto-reconnect: retries on comm failures, shuts down gracefully after 10 consecutive errors
- Headless mode: for running on a server or Raspberry Pi with no display
Developing the Project
This document describes the development, deployment, and tuning of a Python-based process monitoring application for the Schneider Electric Modicon TM221CE16R programmable logic controller. The application collects real-time process data from the PLC over Modbus TCP, logs every sample to daily CSV files, and renders a live four-panel trend dashboard using Matplotlib. The full source code (process_monitor.py) is available in the GitHub project repository.
This project guide is organized into three development steps: editing and customizing the source code to match your field wiring, uploading and configuring the PLC program so that the Modbus address map aligns with the Python tag definitions, and tuning the polling parameters and alarm thresholds to match your process requirements. Each section is written both as a walkthrough for this specific mixing-tank example and as a template you can adapt for any custom M221 installation.
Development Step 1: Customizing the Source Code
The Python source code is structured so that all process-specific configuration lives in four tag-definition tables at the top of the file. You should never need to modify the PLCReader, CSVLogger, or dashboard classes unless you are adding an entirely new data type. The following subsections describe the four tag-definition tables and their purposes.
Analog Tag Table (ANALOG_TAGS)
Each entry in the analog tag table defines an analog sensor input. Table 1 provides information on the analog tag table’s tuple fields (i.e., built-in data types used to store ordered, immutable collections of items).
Table 1. ANALOG_TAGS tuple fields, types, example, purposes, indexes, and notes
|
Tuple Field
|
Type
|
Example
|
Purpose
|
Index
|
Notes
|
|
name
|
str
|
Temperature
|
CSV header & chart title
|
0
|
No spaces; underscores OK
|
|
register_type
|
str
|
input
|
used by the script to select the Modbus function
|
1
|
input = FC4, holding = FC3
|
|
address
|
int
|
0
|
Starting register address
|
2
|
0-based
|
|
scale_min
|
int
|
0
|
Raw ADC low count
|
3
|
From the sensor datasheet
|
|
scale_max
|
int
|
1000
|
Raw ADC high count
|
4
|
From the sensor datasheet
|
|
unit
|
str
|
°C
|
Engineering unit label
|
5
|
Shown on the chart axis
|
|
eng_min
|
float
|
0.0
|
Scaled low value
|
6
|
Maps from scale_min
|
|
eng_max
|
float
|
100.0
|
Scaled high value
|
7
|
Maps from scale_max
|
The default configuration assumes that both sensors are wired to consecutive input registers %IW0 and %IW1 and that the PLC’s analog-to-digital converter (ADC) produces a 0–1000 count range. If your sensors use a different raw range (for example, 0–4095 for a 12-bit ADC or 6400–32000 for a 4–20mA transmitter), adjust scale_min and scale_max accordingly. Scale() is a helper function in process_monitor.py that converts raw counts to engineering units.
The following code snippet provides the default analog tag definitions:
ANALOG_TAGS = [
# (name, register_type, address, scale_min, scale_max, unit, eng_min, eng_max)
("Temperature", "input", 0, 0, 1023, "°C", 0.0, 100.0),
("Pressure", "input", 1, 0, 1023, "bar", 0.0, 10.0),
]
Digital Tag Table (DIGITAL_TAGS)
Each entry in the digital tag table maps a coil address to a named digital output. The application reads these via Modbus function code 1 (Read Coils). The default configuration assumes active-high wiring so that a coil value of True means the output is energized. If your field wiring is active-low, you can invert the logic in the read_all() method by toggling the logic before storing it in the record dictionary.
DIGITAL_INPUT_TAGS = [
("Start_Button", 0),
("Stop_Button", 1),
("HiTemp_Switch", 2),
("HiPress_Switch", 3),
("Level_High", 4),
("Level_Low", 5),
]
Setpoint (SETPOINT_TAGS)
Setpoints are read from holding registers (%MW0 and %MW1) and are typically written by an operator human-machine interface (HMI) or SCADA system. The temperature setpoint is overlaid on the temperature trend chart as a dashed reference line.
SETPOINT_TAGS = [
("Temp_Setpoint", 0, "°C"),
("Pressure_Setpoint", 1, "bar"),
]
Alarm Tag Table (ALARM_BITS)
The alarm word is a single 16-bit holding register at %MW10. Each bit corresponds to a named alarm condition defined in ALARM_BITS. The PLC program is responsible for setting and clearing these bits; the Python application only reads and decodes them.
ALARM_BITS = [
"Over_Temp", # bit 0
"Over_Pressure", # bit 1
"Low_Level", # bit 2
"High_Level", # bit 3
"Flow_Fault", # bit 4
"Heater_Fault", # bit 5
"Motor_Fault", # bit 6
"E_Stop", # bit 7
]
Adding or Removing Tags
To add a new sensor, append a tuple to ANALOG_TAGS with the correct register address and scaling. The dashboard updates plots based on the configured tag list for each analog tag. Similarly, adding entries to DIGITAL_TAGS or ALARM_BITS extends the status panels without any other code changes. To remove a tag, delete its tuple from the relevant list.
Note: The Modbus addresses in the Python tag tables must exactly match the addresses configured in the PLC program. The PyModbus library uses 0-based addressing, while some Schneider documentation uses 1-based addressing. A one-off mismatch (common when mixing 0-based and 1-based addressing conventions) will cause the application to read the wrong register and display useless data.
Development Step 2: Configuring and Deploying the PLC
Before running the Python application, the PLC must be programmed with a matching Modbus address layout, and the Ethernet interface must be configured. The following steps use EcoStruxure Machine Expert Basic, the free programming environment for the M221 series.
PLC Program Requirements
The PLC control program must perform the following tasks to expose data to the Python monitor.
- Read analog input channels and store scaled values in input registers %IW0 and %IW1.
- Drive digital outputs %Q0.0 through %Q0.3 based on process logic (PID, sequencing, interlocks).
- Accept operator setpoints in holding registers %MW0 (temperature) and %MW1 (pressure).
- Maintain a 16-bit alarm word in %MW10, setting individual bits when alarm conditions are detected.
- Increment a cycle counter in %MW20 on each PLC scan (useful for confirming communication is live).
Figure 3 shows a representative implementation in EcoStruxure Machine Expert Basic.
Figure 3: Building out the ladder logic in EcoStruxure Machine Expert Basic. (Source: Schneider Electronics)
Ethernet and Modbus TCP Setup
The TM221CE16R has Modbus TCP enabled by default on its built-in Ethernet port. However, because a factory-default PLC derives its initial IP address from its MAC address, you must perform the initial network configuration via a direct USB connection. The following steps configure the static IP address and verify connectivity.
- Open EcoStruxure Machine Expert Basic and connect to the PLC via USB.
- Navigate to the Configuration tab and select the ETH1 section in the left panel (Figure 4).
- Set the IP address to a static address on your subnet (e.g., 192.168.1.100). Set the subnet mask to 255.255.255.0. Leave the gateway blank unless routing to another subnet.
- Confirm that Modbus TCP Server is enabled (this is the default; the checkbox appears under the Protocol section).
- Click Login and then click Run to upload the program and configuration to the PLC.
- Disconnect the USB cable and connect the PLC to your local network via an Ethernet cable.
- On your PC, set a static IP on the same subnet (e.g., 192.168.1.50) and verify connectivity:
> ping 192.168.1.100
# Expected output
# Reply from 192.168.1.100: bytes=32 time<1ms TTL=64
- Optionally verify the Modbus TCP port is open using netcat or a similar tool:
- Linux/macOS: nc -zv 192.168.1.100 502
- Windows PowerShell: Test-NetConnection 192.168.1.100 -Port 502
Figure 4: Network function setup screen in EcoStruxure Machine Expert Basic. (Source: Schneider Electronics)
Installing Python Packages (Dependencies)
The application requires Python 3.8 or later and two third-party packages, PyModbus and Matplotlib. Install them in a virtual environment or globally:
- Create and activate a virtual environment (recommended).
python -m venv plc-env
- Linux/macOS: source plc-env/bin/activate
- Windows: plc-env\Scripts\activate
- Install the packages.
pip install pymodbus matplotlib
The Matplotlib package is optional; if it is not installed, the application automatically falls back to headless (console-only) mode. For headless deployments on a Raspberry Pi or a server, only PyModbus is required.
Launching the Application
Run the monitor with the PLC’s IP address:
- Basic (connect and show dashboard)
python process_monitor.py --ip 192.0.2.1
- Headless (console-only logging, no GUI)
python process_monitor.py --ip 192.0.2.1 --headless
- Fast polling (1-hour run)
python process_monitor.py --interval 0.5 --duration 3600
- Multiple argument example
python process_monitor.py --ip 192.0.2.1 --port 502 --interval 2 --logdir ./data --history 600
Table 2 lists the complete set of command-line arguments:
Table 2. Process_monitor.py command-line arguments
|
Argument
|
Default
|
Type
|
Description
|
|
--ip
|
192.168.1.100
|
str
|
PLC IP address
|
|
--port
|
502
|
int
|
Modbus TCP port
|
|
--slave
|
1
|
int
|
Modbus slave/unit ID
|
|
--interval
|
1.0
|
float
|
Polling interval in seconds
|
|
--duration
|
0 (forever)
|
int
|
Run time in seconds; 0 = infinite
|
|
--headless
|
false
|
flag
|
Disable GUI; console and CSV only
|
|
--logdir
|
logs
|
str
|
Directory for CSV log files
|
|
--history
|
300
|
int
|
Max data points retained in the chart
|
Dashboard Output
When the application is running with a display, and Matplotlib is installed, it renders a four-panel real-time dashboard. Figure 5 shows typical outputs during a mixing-tank fill-and-heat cycle.
Figure 5: Live trend dashboard showing temperature and pressure trends with digital output states and alarm status. (Source: Green Shoe Garage)
The two trend charts in the upper panels scroll continuously as new data arrives. Each chart displays the most recent data points (controlled by the --history argument) with light fill shading beneath the trace for visual clarity. The bottom-left panel shows real-time digital output states as horizontal status bars, and the bottom-right panel decodes the 16-bit alarm word into individual named alarm indicators. A status bar at the bottom of the window displays the last scan timestamp, the active setpoint, the total sample count, and the PLC cycle counter.
Development Step 3: Tuning and Calibrating the Parameters
Once the application is communicating with the PLC and logging data, the final step is to calibrate the sensor scaling, adjust polling rates, and verify alarm behavior.
Sensor Scaling Calibration
The scale() function performs a linear interpolation between raw ADC counts and engineering units. Use the following steps to calibrate the sensor scaling:
- Apply a known reference to the sensor (e.g., ice bath at 0°C, boiling water at 100°C for a thermocouple).
- Read the raw register value in the PLC (use the Animation Table in EcoStruxure Machine Expert Basic, or the companion plc_terminal.py tool with the rmw or riw command).
- Manually record the raw value at each reference point and update scale_min and scale_max in the ANALOG_TAGS tuple.
- For sensors with non-linear response curves (e.g., thermistors, RTDs with wide ranges), replace the scale() function with a lookup table or polynomial fit.
The following code snippet provides example calibration for a 4–20mA pressure transmitter wired through a TM2AMI2HT analog expansion module (12-bit signal conditioner, 0–10 bar range):
# Before calibration (default):
(“Pressure”, “input”, 1, 0, 1000, “bar”, 0.0, 10.0),
# After calibration with 12-bit expansion module:
(“Pressure”, “input”, 1, 6400, 32000, “bar”, 0.0, 10.0),
# ^^^^^ ^^^^^
# 4 mA 20 mA raw counts from datasheet
Polling Interval Tuning
The polling interval determines how frequently the application reads the PLC and affects data resolution, network load, and the size of the CSV file. Table 3 summarizes typical intervals and their use cases.
Table 3. General guidelines for setting the polling interval
|
Interval
|
Samples/hour
|
CSV rows/day
|
Use case
|
|
0.1s
|
36,000
|
864,000
|
High-speed transient capture
|
|
0.5s
|
7,200
|
172,800
|
Process control trending
|
|
1.0s
|
3,600
|
86,400
|
General monitoring (default)
|
|
5.0s
|
720
|
17,280
|
Long-term environmental logging
|
|
30s
|
120
|
2,880
|
Utility metering, slow processes
|
The M221 has a typical scan time of 1 to 5ms, so the PLC can comfortably serve Modbus requests at 100ms intervals. However, at intervals below 200ms, network jitter may cause occasional missed samples. The application handles this gracefully with its error counter and auto-reconnect logic (up to 10 consecutive failures before shutdown).
Note: The --history argument controls how many data points are retained in memory for the live chart. At a 1-second interval, the default of 300 gives a five-minute scrolling window. For a 30-minute window, use --history 1800. Larger values consume more RAM and may slow down chart rendering on low-powered devices.
Alarm Verification
Each bit in the alarm word (%MW10) corresponds to a named alarm in the ALARM_BITS list. To verify correct alarm operation:
- In the PLC program, force each alarm condition one at a time (e.g., raise the temperature above the over-temp threshold).
- Observe the alarm panel in the dashboard; the corresponding row should turn red and display ACTIVE.
- Clear the alarm condition and verify the panel returns to its inactive (dark) state.
- Check the CSV log to confirm the alarms_hex column reflects the correct bit pattern (e.g., 0x0004 for bit 2, Low_Level).
- If alarms appear in the wrong row, the bit ordering in ALARM_BITS does not match the PLC program. Reorder the list to match the PLC’s bit assignments.
CSV Log Verification
The logger creates daily CSV files in the specified log directory with automatic rotation at midnight. Each file contains a header row followed by one row per sample. Figure 6 shows the format of a typical row:
Figure 6: The format of an entry in the output log CSV file. (Source: Green Shoe Garage)
For post-processing, the CSV files can be loaded directly into pandas, Excel, or any time-series analysis tool. The timestamp column parses with standard datetime formatters.
Adapting to Your Actual Process
The tag definitions at the top of the script (e.g., ANALOG_TAGS, DIGITAL_TAGS) are the only things you need to change to match your actual wiring. Each entry maps a PLC address to a name, unit, and scaling range. For instance, if your pressure sensor on %IW1 uses a 0 to 10V transmitter with a different range (raw 0–10005 → 0–200 psi), you would change that one tuple accordingly.
Table 4 summarizes the complete Modbus address map used by the application. All addresses are 0-based (PyModbus convention).
Table 4. Modbus address map
|
PLC Variable
|
Modbus FC
|
Address
|
Count
|
Direction
|
Description
|
|
%MW0–%MW1
|
FC 3
|
0–1
|
2
|
Read
|
Analog inputs (PLC copies %IW0–%IW1 here)
|
|
%M10–%M13
|
FC 1
|
10–13
|
4
|
Read
|
Digital outputs (PLC copies %Q0.0–%Q0.3 here)
|
|
%M0–%M5
|
FC 2
|
0–5
|
6
|
Read
|
Digital inputs (PLC copies %I0.0–%I0.5 here)
|
|
%MW2–%MW3
|
FC 3
|
2–3
|
2
|
Read/Write
|
Operator setpoints
|
|
%MW10
|
FC 3
|
10
|
1
|
Read
|
Alarm word (bit-packed)
|
|
%MW20
|
FC 3
|
20
|
1
|
Read
|
PLC cycle counter
|
For additional registers beyond the defaults, extend the PLCReader class’s read_all() method to issue the appropriate Modbus read call, then add the returned values to the record dictionary using a descriptive key name. The CSV logger and dashboard will automatically update plots based on the configured tag list.
Conclusion
The goal of this project was to use a compact, entry-level PLC as the foundation for a condition-monitoring system without requiring expensive middleware, proprietary historians, or enterprise software licenses (Figure 7). By combining the native Modbus TCP server on the Modicon TM221CE16R PLC with a lightweight Python application, we built a complete data pipeline that polls live process variables, logs them with timestamps, visualizes trends in near real time, and flags threshold violations that could indicate developing equipment problems.
Figure 7: Final assembly for benchtop testing. (Source: Green Shoe Garage)
In the context of this project, PyModbus served as the critical translation layer between the PLC’s Modbus register map and the Python ecosystem of data logging, analysis, and visualization tools. What makes this approach particularly valuable is that it mirrors the fundamental principles of condition-monitoring platforms in large-scale industrial monitoring systems: Collect data from controllers, store them in a time-series format, present them visually, and generate alerts when values drift outside acceptable ranges. The difference lies in scale, redundancy, and the depth of the analytical tooling built on top. For a single machine, a small packaging line, or a standalone pump station, such a high level of infrastructure is unnecessary. The M221 and Python stack delivers the functionality that matters most with a fraction of the complexity, using open protocols and standard tools that the operator can understand and maintain.
Whether this project is a bench demonstration for a small installation or as a learning platform to build greater skills in industrial data acquisition, the combination of the Schneider Electric M221 PLC and Python over Modbus TCP is a reliable and repeatable starting point.
Author Bio
Michael Parks, PE, is the co-founder of Green Shoe Garage, a custom electronics design studio and embedded security research firm located in Western Maryland. He produces the Gears of Resistance Podcast to help raise public awareness of technical and scientific matters. Michael is also a licensed Professional Engineer in the state of Maryland and holds a Master’s degree in systems engineering from Johns Hopkins University.