DEV Community

Cover image for I Built a Python Dashboard to Monitor My Cement Plant in Real Time
Aminuddin M Khan
Aminuddin M Khan

Posted on

I Built a Python Dashboard to Monitor My Cement Plant in Real Time

From the control room to the code editor — a 40-year veteran's guide to building industrial monitoring with Python and Plotly.

A Little Background
I spent 40 years inside cement plant control rooms — watching gauges, listening to kilns, reading temperatures that most engineers only see on paper. When I retired, I thought I was done with data.
Then I discovered Python.
What started as curiosity became an obsession: could I replicate — and improve — the kind of real-time monitoring I did manually for decades, using nothing but a laptop and open-source tools?
The answer is yes. And in this article, I'm going to show you exactly how I built a live Python dashboard that monitors critical cement plant parameters — kiln temperature, fan RPM, raw mill feed rate, and more — using Plotly Dash, Pandas, and simulated SCADA data streams.
Whether you work in heavy industry or you're a developer curious about industrial IoT, this is a practical, production-inspired tutorial.

What We're Building
A real-time monitoring dashboard with:

Live KPI cards — kiln inlet temp, clinker output, specific heat consumption
Multi-parameter time-series charts — updating every 5 seconds
Alarm panel — color-coded alerts when values exceed safe thresholds
Simulated SCADA data stream — mimicking real OPC-UA/Modbus data feeds

Here's the tech stack:
ToolPurposeDash + PlotlyDashboard UI & chartsPandasData manipulationNumPySensor simulationdcc.IntervalLive data refreshdequeEfficient rolling buffer

Step 1: Install Dependencies
bashpip install dash plotly pandas numpy

Step 2: Simulate Your SCADA Data Feed
In a real plant, data arrives from OPC-UA servers, Modbus RTU, or historian databases like OSIsoft PI. For this tutorial, we simulate a realistic sensor stream with noise and occasional spikes — just like real plant data behaves.
python# sensor_simulator.py

import numpy as np
import pandas as pd
from datetime import datetime

Normal operating ranges for a cement kiln

SENSOR_CONFIG = {
"kiln_inlet_temp": {"base": 950, "noise": 15, "unit": "°C", "min": 880, "max": 1020},
"kiln_outlet_temp": {"base": 1420, "noise": 20, "unit": "°C", "min": 1350, "max": 1480},
"id_fan_rpm": {"base": 740, "noise": 8, "unit": "RPM", "min": 700, "max": 780},
"raw_mill_feed": {"base": 280, "noise": 12, "unit": "t/h", "min": 240, "max": 320},
"cooler_pressure": {"base": 8.5, "noise": 0.3, "unit": "mbar","min": 7.5, "max": 9.5},
"clinker_output": {"base": 185, "noise": 6, "unit": "t/h", "min": 160, "max": 210},
}

def get_sensor_reading(sensor_name: str) -> dict:
"""
Simulate a single sensor reading with realistic noise.
Occasionally injects a spike to simulate process disturbances.
"""
cfg = SENSOR_CONFIG[sensor_name]

# 2% chance of a disturbance spike
spike = np.random.choice([0, 1], p=[0.98, 0.02])
spike_magnitude = cfg["noise"] * 4 if spike else 0

value = cfg["base"] + np.random.normal(0, cfg["noise"]) + spike_magnitude
value = round(float(np.clip(value, cfg["base"] - cfg["noise"]*3,
                                   cfg["base"] + cfg["noise"]*3 + spike_magnitude)), 2)

status = "ALARM" if (value < cfg["min"] or value > cfg["max"]) else "NORMAL"

return {
    "timestamp": datetime.now().strftime("%H:%M:%S"),
    "sensor": sensor_name,
    "value": value,
    "unit": cfg["unit"],
    "status": status,
    "min": cfg["min"],
    "max": cfg["max"],
}
Enter fullscreen mode Exit fullscreen mode

def get_all_readings() -> list[dict]:
"""Fetch one reading from every sensor simultaneously."""
return [get_sensor_reading(name) for name in SENSOR_CONFIG]

Field Note: In real plants, I've seen kiln inlet temperatures swing 80°C in under 3 minutes during raw mix chemistry upsets. The noise model above reflects that reality.

Step 3: Build the Dashboard Layout
python# dashboard.py

import dash
from dash import dcc, html, Input, Output, callback
import plotly.graph_objects as go
import pandas as pd
from collections import deque
from sensor_simulator import get_all_readings, SENSOR_CONFIG

Rolling buffer — keeps last 60 readings (5 minutes at 5s intervals)

BUFFER_SIZE = 60
data_store = {sensor: deque(maxlen=BUFFER_SIZE) for sensor in SENSOR_CONFIG}
time_store = deque(maxlen=BUFFER_SIZE)

app = dash.Dash(name, title="Cement Plant Monitor")

─── Color Scheme ─────────────────────────────────────────────────────────────

COLORS = {
"bg": "#0d1117",
"card": "#161b22",
"border": "#30363d",
"green": "#3fb950",
"red": "#f85149",
"amber": "#d29922",
"blue": "#58a6ff",
"text": "#e6edf3",
"muted": "#8b949e",
}

def make_kpi_card(label, sensor_key, value, unit, status):
color = COLORS["green"] if status == "NORMAL" else COLORS["red"]
return html.Div([
html.P(label, style={"color": COLORS["muted"], "fontSize": "12px",
"margin": "0 0 4px", "textTransform": "uppercase",
"letterSpacing": "0.08em"}),
html.Div([
html.Span(f"{value}", style={"fontSize": "28px", "fontWeight": "700",
"color": color}),
html.Span(f" {unit}", style={"fontSize": "14px", "color": COLORS["muted"],
"marginLeft": "4px"}),
]),
html.Div(status, style={
"fontSize": "11px", "marginTop": "6px", "fontWeight": "600",
"color": color, "letterSpacing": "0.05em"
}),
], style={
"background": COLORS["card"],
"border": f"1px solid {COLORS['border']}",
"borderLeft": f"3px solid {color}",
"borderRadius": "6px",
"padding": "16px 20px",
"minWidth": "160px",
"flex": "1",
})

app.layout = html.Div([

# ── Header ──────────────────────────────────────────────────────────────
html.Div([
    html.Div([
        html.H1("⚙ Cement Plant Monitor",
                style={"margin": 0, "fontSize": "20px", "fontWeight": "600",
                       "color": COLORS["text"]}),
        html.P("Central Control Room — Live Feed",
               style={"margin": "2px 0 0", "fontSize": "13px",
                      "color": COLORS["muted"]}),
    ]),
    html.Div(id="live-clock", style={"fontSize": "13px", "color": COLORS["blue"],
                                      "fontFamily": "monospace"}),
], style={"display": "flex", "justifyContent": "space-between",
          "alignItems": "center", "padding": "20px 28px",
          "borderBottom": f"1px solid {COLORS['border']}"}),

# ── KPI Cards ────────────────────────────────────────────────────────────
html.Div(id="kpi-cards", style={
    "display": "flex", "gap": "14px", "padding": "20px 28px",
    "flexWrap": "wrap",
}),

# ── Charts Row ───────────────────────────────────────────────────────────
html.Div([
    dcc.Graph(id="temp-chart",   style={"flex": "1", "minWidth": "300px"}),
    dcc.Graph(id="flow-chart",   style={"flex": "1", "minWidth": "300px"}),
], style={"display": "flex", "gap": "14px", "padding": "0 28px"}),

# ── Alarm Panel ──────────────────────────────────────────────────────────
html.Div([
    html.H3("Alarm Log", style={"color": COLORS["text"], "fontSize": "14px",
                                 "fontWeight": "600", "margin": "0 0 12px"}),
    html.Div(id="alarm-panel"),
], style={"padding": "16px 28px 28px"}),

# ── Interval ─────────────────────────────────────────────────────────────
dcc.Interval(id="interval", interval=5000, n_intervals=0),
Enter fullscreen mode Exit fullscreen mode

], style={"background": COLORS["bg"], "minHeight": "100vh",
"fontFamily": "'Segoe UI', sans-serif", "color": COLORS["text"]})

Step 4: Wire Up the Live Callbacks
This is the engine room. Every 5 seconds, Dash fires the interval, we fetch new readings, update the buffer, and re-render every component.
python# callbacks.py (add to dashboard.py)

from datetime import datetime

@app.callback(
Output("kpi-cards", "children"),
Output("temp-chart", "figure"),
Output("flow-chart", "figure"),
Output("alarm-panel", "children"),
Output("live-clock", "children"),
Input("interval", "n_intervals"),
)
def update_dashboard(n):
# ── Fetch new readings ────────────────────────────────────────────────
readings = get_all_readings()
time_store.append(datetime.now().strftime("%H:%M:%S"))

for r in readings:
    data_store[r["sensor"]].append(r["value"])

readings_map = {r["sensor"]: r for r in readings}
times = list(time_store)

# ── KPI Cards ─────────────────────────────────────────────────────────
kpi_labels = {
    "kiln_outlet_temp": "Kiln Outlet Temp",
    "kiln_inlet_temp":  "Kiln Inlet Temp",
    "clinker_output":   "Clinker Output",
    "id_fan_rpm":       "ID Fan RPM",
}
kpi_cards = [
    make_kpi_card(
        label,
        key,
        readings_map[key]["value"],
        readings_map[key]["unit"],
        readings_map[key]["status"],
    )
    for key, label in kpi_labels.items()
]

# ── Temperature Chart ─────────────────────────────────────────────────
def make_chart(sensors, title, colors_list):
    fig = go.Figure()
    for sensor, color in zip(sensors, colors_list):
        fig.add_trace(go.Scatter(
            x=times,
            y=list(data_store[sensor]),
            name=sensor.replace("_", " ").title(),
            line=dict(color=color, width=2),
            mode="lines",
        ))
        # Add threshold bands
        cfg = SENSOR_CONFIG[sensor]
        fig.add_hline(y=cfg["max"], line_dash="dot",
                      line_color=COLORS["red"], line_width=1, opacity=0.5)
        fig.add_hline(y=cfg["min"], line_dash="dot",
                      line_color=COLORS["amber"], line_width=1, opacity=0.5)

    fig.update_layout(
        title=dict(text=title, font=dict(size=13, color=COLORS["muted"])),
        paper_bgcolor=COLORS["card"],
        plot_bgcolor=COLORS["card"],
        font=dict(color=COLORS["text"], size=11),
        margin=dict(l=48, r=16, t=40, b=40),
        legend=dict(orientation="h", y=-0.15),
        xaxis=dict(gridcolor=COLORS["border"], showgrid=True),
        yaxis=dict(gridcolor=COLORS["border"], showgrid=True),
        height=280,
    )
    return fig

temp_fig = make_chart(
    ["kiln_inlet_temp", "kiln_outlet_temp"],
    "Kiln Temperatures (°C)",
    [COLORS["blue"], COLORS["red"]],
)
flow_fig = make_chart(
    ["raw_mill_feed", "clinker_output"],
    "Material Flow (t/h)",
    [COLORS["green"], COLORS["amber"]],
)

# ── Alarm Panel ───────────────────────────────────────────────────────
alarms = [r for r in readings if r["status"] == "ALARM"]
if alarms:
    alarm_items = [
        html.Div([
            html.Span("⚠ ALARM", style={"color": COLORS["red"],
                       "fontWeight": "700", "fontSize": "11px",
                       "marginRight": "10px"}),
            html.Span(f"{r['sensor'].replace('_',' ').upper()} — "
                      f"Value: {r['value']} {r['unit']} "
                      f"(Safe range: {r['min']}–{r['max']})",
                      style={"fontSize": "12px", "color": COLORS["text"]}),
            html.Span(f"  {r['timestamp']}",
                      style={"fontSize": "11px", "color": COLORS["muted"],
                             "marginLeft": "10px"}),
        ], style={"padding": "8px 12px", "marginBottom": "6px",
                  "background": "#1c1118",
                  "border": f"1px solid {COLORS['red']}",
                  "borderRadius": "4px"})
        for r in alarms
    ]
else:
    alarm_items = [html.P("✓ All parameters within normal range.",
                           style={"color": COLORS["green"],
                                  "fontSize": "13px", "margin": 0})]

clock = f"Last updated: {datetime.now().strftime('%H:%M:%S')}"
return kpi_cards, temp_fig, flow_fig, alarm_items, clock
Enter fullscreen mode Exit fullscreen mode

if name == "main":
app.run(debug=True, host="0.0.0.0", port=8050)

Step 5: Run It
bashpython dashboard.py
Open your browser at http://localhost:8050 and watch your plant come alive.

Connecting to Real SCADA Data
The simulator is great for development. In production, swap it with a real data source. Here are three common approaches:
Option A — OPC-UA (most industrial plants)
pythonfrom opcua import Client

client = Client("opc.tcp://192.168.1.100:4840")
client.connect()

node = client.get_node("ns=2;i=1001") # Kiln outlet temp node ID
value = node.get_value()
Option B — Modbus TCP (older PLCs)
pythonfrom pymodbus.client import ModbusTcpClient

client = ModbusTcpClient("192.168.1.50", port=502)
result = client.read_holding_registers(address=100, count=1, slave=1)
kiln_temp = result.registers[0] / 10.0 # Scale factor from PLC config
Option C — CSV/Excel Historian Export
pythonimport pandas as pd

df = pd.read_csv("historian_export_2024.csv", parse_dates=["timestamp"])
df = df.set_index("timestamp").resample("5s").mean()

What I Learned Building This
After 40 years watching analog gauges and DCS screens, here's what surprised me most about building this in Python:

  1. The data is the same — the flexibility is new. A kiln outlet temperature is 1,450°C whether it's on a Honeywell TDC 3000 or a Plotly chart. But now I can slice, correlate, and visualize it in ways the DCS vendor never imagined.
  2. Domain knowledge beats coding skill. I didn't know Python deeply when I started. But I knew exactly what the dashboard needed to show, what the safe ranges were, and which correlations mattered. That domain knowledge is irreplaceable.
  3. The alarm logic is where experience lives. Any developer can draw a red line at a threshold. Only a plant veteran knows that a kiln inlet temp of 920°C is dangerous — but only if the cooler pressure is also dropping. The combinations are what matter.

Next Steps
This dashboard is a foundation. Here's where to take it next:

Add ML predictions — train a model on historical data to predict temperature spikes 10 minutes before they happen
SMS/email alerts — use smtplib or Twilio to push alarm notifications to shift supervisors
Deploy on plant network — run on a Raspberry Pi 4 connected to the DCS network
Historical trend analysis — log all readings to SQLite or InfluxDB for shift reports

Final Thought
The cement industry generates enormous amounts of process data. Most of it is never properly analyzed. It sits in historian archives, reviewed only when something breaks.
Python changes that equation. You don't need a million-dollar analytics platform. You need domain expertise, a laptop, and the curiosity to start building.
I started at 60. You can start today.

Aminuddin M. Khan — The Industrial Commander
40 years in Cement Plant Operations (CCR) | Technical Writer | AI Industrial Imagery Creator.

Tags: #python #industrial #automation #dashboard #plotly #cement #iot #engineering #beginners

Top comments (0)