3. Python analytics environment setup

An industrial data platform rarely stores its process history in a single convenient CSV file. Historians such as OSIsoft PI, AVEVA Wonderware, and Honeywell PHD expose their own query interfaces; relational stores such as SQL Server, Oracle, PostgreSQL, and MySQL hold batch records, lab results, and asset metadata; and time-series engines such as InfluxDB or TimescaleDB handle high-frequency sensor streams. A Python analytics environment is therefore not just "pandas installed on a laptop" — it is a layered stack of drivers, connection abstractions, and dataframe adapters that lets one script reach all of these sources without rewriting the transport logic each time.

This chapter builds that stack from the bottom up: the database driver layer (ODBC/JDBC and native Python drivers), the connection-management layer (SQLAlchemy), and the analytics layer (pandas). The goal is a reproducible environment where a single read_sql call can pull a tag history, a batch summary, and an asset register into one analysis.

The three-layer connection stack

Every industrial Python-to-database path passes through the same conceptual layers, even though the specific libraries differ by vendor.

  • Driver layer. The lowest layer speaks the database's wire protocol. For ODBC-accessible systems this is an ODBC driver (for example, ODBC Driver 18 for SQL Server or the PI OLEDB / PI SQL ODBC driver). For JDBC-only systems, a JDBC driver plus a Python JDBC bridge (such as JayDeBeApi) is used. Many modern databases also ship a native Python driver (psycopg2 for PostgreSQL, oracledb for Oracle, pyodbc for anything ODBC).
  • Connection layer. SQLAlchemy wraps the driver in a uniform Engine object, manages pooling, transactions, and dialect differences, and exposes a single connection string format.
  • Analytics layer. pandas consumes a SQLAlchemy engine (or a raw DBAPI connection) through read_sql / to_sql, returning and writing DataFrames.

The practical consequence: you configure the driver once, express the connection as a URL once, and then all downstream code is database-agnostic.

Python Analytics Connection Stack for Industrial Data Analytics Layer — pandas pd.read_sql(query, engine) → DataFrame df.to_sql("batch_summary", engine, if_exists="append") Connection Layer — SQLAlchemy Engine create_engine("mssql+pyodbc://...") · pooling · transactions dialect translation · unified URL format Driver Layer — protocol-specific ODBC pyodbc · PI SQL ODBC JDBC JayDeBeApi · vendor jar Native Python psycopg2 · oracledb SQL Server · Oracle · PostgreSQL · PI Historian · InfluxDB · TimescaleDB

Installing and verifying the driver layer

Start by pinning the driver that matches the target system. For a SQL Server historian or a plant MES database, the ODBC route is the most common. On Windows the driver is installed as a system component; on Linux it comes from the Microsoft package repository. Verify it is visible to the OS before touching Python:

# Windows
odbcad32.exe          # check the Drivers tab for "ODBC Driver 18 for SQL Server"

# Linux
odbcinst -q -d        # lists registered ODBC drivers

Then install the Python side. A minimal, reproducible environment uses a virtual environment and a pinned requirements file:

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "pyodbc>=5.0" "SQLAlchemy>=2.0" "pandas>=2.1" python-dotenv

For JDBC-only historians, add JayDeBeApi and the vendor JAR, and point the bridge at it via the CLASSPATH environment variable. For PostgreSQL or Oracle, install psycopg2-binary or oracledb instead of pyodbc.

Keep credentials out of source code. Store the connection URL in a .env file and load it with python-dotenv; commit only a .env.example with placeholder values.

Building the SQLAlchemy engine

SQLAlchemy 2.x expresses every connection as a URL of the form dialect+driver://user:password@host:port/database. The dialect selects the SQL flavor; the driver selects the transport. Three representative industrial URLs:

# SQL Server via ODBC (e.g. plant MES / historian SQL bridge)
mssql+pyodbc://svc_analytics:Pwd@10.20.0.15:1433/PlantOps?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes

# PostgreSQL (asset register / lab results)
postgresql+psycopg2://analyst:secret@pg-plant.internal:5432/assetdb

# Oracle (batch execution system)
oracle+oracledb://batch_ro:secret@ora-scan:1521/?service_name=BATCHP

Create the engine once and reuse it. The engine is a factory for connections and owns the pool, so creating it per query defeats pooling and slows down repeated reads:

import os
from dotenv import load_dotenv
from sqlalchemy import create_engine, text

load_dotenv()
engine = create_engine(
    os.environ["PLANT_DB_URL"],
    pool_size=5,
    max_overflow=10,
    pool_pre_ping=True,      # detects dropped connections before use
    future=True,
)

with engine.connect() as conn:
    version = conn.execute(text("SELECT @@VERSION")).scalar()
    print(version)

pool_pre_ping=True is especially valuable on plant networks where VPN or firewall idle timeouts silently kill connections overnight. Without it, the first query after a long idle period raises an OperationalError; with it, SQLAlchemy transparently recycles the dead connection.

Reading industrial data into pandas

With the engine in place, pandas becomes the analysis surface. The two functions that matter are read_sql and to_sql. Always pass parameters rather than f-strings — this prevents SQL injection and lets the driver cache the execution plan:

import pandas as pd

query = text("""
    SELECT tag_name, ts, value, quality
    FROM   tag_history
    WHERE  ts BETWEEN :start AND :end
      AND  tag_name IN :tags
""")

df = pd.read_sql(
    query,
    engine,
    params={"start": "2024-03-01 00:00", "end": "2024-03-02 00:00",
            "tags": ("PT101", "TT205", "FT310")},
    parse_dates=["ts"],
)
print(df.dtypes)
print(df.head())

Two details matter for industrial data. First, parse_dates converts the timestamp column to datetime64[ns] so resampling and rolling windows work correctly. Second, historian quality codes (often 0 = good, 1 = uncertain, 2 = bad) should be filtered explicitly — a naive mean() over a column containing bad-quality values produces a plausible but wrong result:

good = df[df["quality"] == 0]
hourly = (good.set_index("ts")
              .groupby("tag_name")["value"]
              .resample("1h").mean())

Writing results back

Analytics output often needs to return to the platform — a batch summary table, a KPI snapshot, or a model score. to_sql handles this, but choose the write mode deliberately:

if_existsBehaviorTypical industrial use
"fail"Raises if table existsFirst-time creation of a new result table
"replace"Drops and recreatesFull refresh of a daily KPI snapshot
"append"Inserts rowsIncremental batch summaries, event logs
hourly.reset_index().to_sql(
    "kpi_hourly",
    engine,
    if_exists="append",
    index=False,
    chunksize=5000,
    method="multi",
)

chunksize and method="multi" reduce round-trips and are important when writing tens of thousands of rows over a plant network.

Common failure symptoms and fixes

  • Data source name not found — the ODBC driver name in the URL does not match the installed driver. Run odbcinst -q -d and copy the exact string.
  • Login timeout expired — network or firewall issue, not credentials. Test with telnet host 1433 before debugging Python.
  • Invalid column name on a query that works in the vendor tool — the SQLAlchemy dialect may be quoting identifiers differently. Use text() with explicit quoting or check the dialect's identifier_preparer.
  • Silent wrong aggregates — bad-quality historian values included in the calculation. Always filter on the quality column.
  • Memory blow-up on large tag pulls — reading years of 1-second data into one DataFrame. Push aggregation into SQL (GROUP BY, AVG) and return only the reduced result.
Never embed credentials directly in a connection URL that is committed to version control. Use environment variables or a secrets manager, and grant the analytics account read-only access to production historians wherever possible.

With the driver, engine, and pandas adapters in place, the environment is ready to query real plant data. The next step is shaping that data — filtering, resampling, and joining across sources — which is where the analytics work proper begins.