🟣 Developer Track
Tutorial 1 of 16
🟣 DEVELOPER TRACK β€’ FOUNDATIONS β€’ INTERMEDIATE

Developer Track #1 (Foundations): Python Foundations for Industrial AI

Turn plain Python into a reliable substrate for industrial agents.

βœ… CORE MISSION OF THIS TUTORIAL

By the end of this tutorial, the reader will be able to:

  • βœ… Design small Python classes that map directly to industrial agent roles (diagnostics, schedulers, planners).
  • βœ… Use type hints and TypedDict to define explicit state contracts for LangGraph-style StateGraphs.
  • βœ… Apply async/await and asyncio to talk to multiple PLCs in parallel without blocking the rest of the system.
  • βœ… Wrap critical operations with decorators, structured error handling, and logging suitable for industrial audits.
  • βœ… Use list comprehensions and functional patterns to transform PLC snapshots and alarm streams efficiently.

This tutorial builds the Python substrate that all later Developer Track agents depend on β€” so you are debugging logic, not language quirks.

🌍 VENDOR-AGNOSTIC ENGINEERING NOTE

This tutorial uses:

  • β–Έ Generic IEC 61131-3 Structured Text (ST)
  • β–Έ TwinCAT, Siemens TIA Portal, CODESYS
  • β–Έ Allen-Bradley ST
  • β–Έ Any IEC-based runtime

Examples use simulated PLC data only. You'll adapt the patterns to your own fieldbus/driver stack.

1️⃣ CONCEPT OVERVIEW β€” PYTHON AS THE CONTROL-ROOM GLUE

In industrial plants, Python usually sits between PLC programs, historians, and AI agents. It glues together alarm logs, OPC UA clients, LangGraph state machines, and sometimes half a dozen internal APIs.

When this glue layer is just a pile of untyped dictionaries and ad-hoc helper functions, small bugs can cascade into expensive incidents. A single unchecked KeyError can silently skip a critical fault and leave an operator blind while a line is down.

Key Principle: treat Python like a control system, not a script.
Classes, type hints, async patterns, and logging are your IEC 61131-3 equivalents on the agent side.

  • β–Έ Classes mirror industrial roles (DiagnosticAgent, SchedulerAgent, PlannerAgent).
  • β–Έ TypedDict and type hints become your StateGraph schema for agent state and PLC snapshots.
  • β–Έ Async I/O lets you poll multiple lines simultaneously without blocking everything on a slow device.
  • β–Έ Decorators, logging, and robust error handling give you post-incident traces that make sense to engineers.

Imagine an agent that polls four packaging lines. A naive synchronous loop with poor error handling can easily add 1–2 seconds of latency per scan and silently swallow timeouts. At $1,200/hour of production, losing just 90 seconds per shift to avoidable retries can add up to tens of dollars a day β€” with no clear root cause in the logs.

2️⃣ PYTHON TYPES & CLASSES β€” SHAPING AGENT STATE

Before you wire in LangGraph, you need clean Python shapes for state: which fields describe a PLC snapshot, which describe an agent's internal state, and which get persisted between steps.

graph LR
    A[Raw PLC read dict]:::cyan --> B[TypedDict PlcSnapshot]:::blue
    B --> C[DiagnosticAgent class]:::purple
    C --> D[LangGraph State node]:::green
    D --> E[Logged decision + next action]:::purple

    classDef cyan fill:#04d9ff,stroke:#04d9ff,color:#000
    classDef blue fill:#1f75ff,stroke:#1f75ff,color:#fff
    classDef purple fill:#6366f1,stroke:#6366f1,color:#fff
    classDef green fill:#00ff7f,stroke:#00ff7f,color:#000

The goal is to make your agent code boring to debug. If a PLC field is missing, you want the type checker and runtime checks to complain loudly, not fail silently in the middle of a night shift.

In the experiments below you'll build:

  • β–Έ A naive dict-only implementation that throws a KeyError when a PLC omits a field.
  • β–Έ A TypedDict + @dataclass based agent that degrades gracefully and logs what's missing.
  • β–Έ An async polling loop wrapped in a decorator that logs PLC read failures and still returns a safe fallback snapshot.

4️⃣ EXPERIMENTS β€” PYTHON PATTERNS FOR INDUSTRIAL AGENTS

You'll start with a fragile, untyped implementation, then progressively harden it with TypedDict, dataclasses, async/await, decorators, and logging. All experiments use simulated PLC data, so cost is $0.00 in API calls and only a few CPU seconds per run.

Experiment 1 β€” Typed vs untyped PLC snapshots

Compare a brittle dict-only approach with a TypedDict + dataclass agent that can safely handle missing data.

1

SETUP CELL

Experiment 1A β€” Understanding the naive dict approach

setup

Explain the brittle pattern of accessing PLC state using plain dictionaries with direct key indexing.

Python
from typing import Dict, Any, Optional

# Minimal example state so the function has a defined input source.
plc_state: Dict[str, Dict[str, Any]] = {
    "Line1": {"motor_running": True, "pallets_in_buffer": 3},
}

def naive_get_fault(line_id: str) -> Optional[str]:
    """
    Attempt to read fault_code from a global PLC state dictionary.
    WARNING: This assumes fault_code always exists in the snapshot.
    """
    state = plc_state.get(line_id, {})
    # Direct indexing: raises KeyError if fault_code is missing
    try:
        return state["fault_code"]
    except KeyError as exc:
        print(f"[RUNTIME ERROR] Missing key for {line_id}: {exc}")
        return None

Explanation

  • - This snippet now declares a minimal plc_state up front, because otherwise the first real failure would be NameError: plc_state is not defined.
  • - This function retrieves a snapshot using plc_state.get(line_id, {}), which safely returns an empty dict if the line does not exist.
  • - However, state["fault_code"] assumes fault_code is always present β€” if a PLC omits this field, Python raises a KeyError.
  • - The try/except block catches the error, but this pattern is easy to forget in larger codebases with dozens of similar functions.

Takeaway

Direct dictionary indexing (state['key']) requires defensive error handling in every function that touches PLC data.

2

EXPERIMENT CELL

Experiment 1A β€” Running the naive implementation

experiment

Execute the naive dict approach with simulated PLC state to see KeyError handling in action.

Python
from typing import Dict, Any, Optional

# Simulated PLC state from two lines (notice Line1 has no fault_code)
plc_state: Dict[str, Dict[str, Any]] = {
    "Line1": {"motor_running": True, "pallets_in_buffer": 3},
    "Line2": {"motor_running": False, "pallets_in_buffer": 0, "fault_code": "E1001"},
}

def naive_get_fault(line_id: str) -> Optional[str]:
    state = plc_state.get(line_id, {})
    try:
        return state["fault_code"]  # KeyError if missing
    except KeyError as exc:
        print(f"[RUNTIME ERROR] Missing key for {line_id}: {exc}")
        return None

# Test with both lines
for line in ["Line1", "Line2"]:
    print(line, "fault:", naive_get_fault(line))
Expected output
[RUNTIME ERROR] Missing key for Line1: 'fault_code'
Line1 fault: None
Line2 fault: E1001

Explanation

  • - Line1 is missing fault_code, so the direct index raises a KeyError that gets caught and logged.
  • - Line2 has fault_code="E1001", so the function returns it successfully.
  • - If this error were not caught, it could crash an agent mid-diagnosis with no useful context for the operator.
  • - Cost: $0.00 in API calls, but potentially minutes of lost diagnosis time when this happens in production.

Common mistake

Indexing into PLC snapshots with state['fault_code'] instead of using .get(...) or TypedDict constraints.

Takeaway

Untyped dictionaries make it far too easy for missing PLC fields to explode as runtime errors in the middle of a shift.

3

SETUP CELL

Experiment 1B β€” Defining a TypedDict schema for PLC snapshots

setup

Use TypedDict to explicitly declare which fields exist in a PLC snapshot and their types.

Python
from typing import TypedDict, Optional

class PlcSnapshot(TypedDict, total=False):
    """
    Schema for PLC line snapshots.
    total=False means all fields are optional (can be missing).
    """
    line_id: str
    motor_running: bool
    pallets_in_buffer: int
    fault_code: Optional[str]  # Can be None or missing entirely

Explanation

  • - TypedDict defines the allowed keys and their types, similar to a struct in PLC languages like Structured Text.
  • - The total=False parameter means all fields are optional β€” PLCs can omit any field without breaking the schema.
  • - This schema will later plug directly into LangGraph StateGraph definitions as typed state nodes.
  • - Static type checkers (mypy, pyright) can now flag typos like snapshot["falut_code"] before you deploy.

Takeaway

TypedDict gives you compile-time guarantees about field names and runtime clarity about what data to expect.

4

SETUP CELL

Experiment 1B β€” Building a DiagnosticAgent with safe field access

setup

Wrap PLC lookup logic inside a dataclass-based agent that uses .get() to avoid KeyErrors.

Python
from dataclasses import dataclass
from typing import Optional, TypedDict

class PlcSnapshot(TypedDict, total=False):
    line_id: str
    motor_running: bool
    pallets_in_buffer: int
    fault_code: Optional[str]

# Minimal typed state so the agent has a defined input source.
plc_typed_state: dict[str, PlcSnapshot] = {
    "Line1": {"line_id": "Line1", "motor_running": True, "pallets_in_buffer": 3},
}

@dataclass
class DiagnosticAgent:
    """
    Agent that reads fault codes from PLC snapshots.
    Uses .get() for safe field access instead of direct indexing.
    """
    name: str

    def get_fault(self, line_id: str) -> Optional[str]:
        snapshot = plc_typed_state.get(line_id)
        if snapshot is None:
            print(f"[WARN] No snapshot for {line_id}")
            return None
        # Safe access: returns None if fault_code is missing
        return snapshot.get("fault_code")

Explanation

  • - This snippet declares a minimal plc_typed_state so the example does not implicitly depend on a previous cell.
  • - The @dataclass decorator automatically generates __init__, __repr__, and other boilerplate methods.
  • - The get_fault method uses .get(line_id) to safely retrieve snapshots without raising KeyError.
  • - Using snapshot.get("fault_code") returns None if the field is missing, avoiding exceptions.
  • - This class structure gives you a natural home for agent behavior (methods) instead of scattered helper functions.

Takeaway

Dataclasses make agents easy to instantiate, test, and evolve as you add new behaviors.

5

CORE CELL

Experiment 1B β€” Running the TypedDict + dataclass implementation

core

Execute the typed implementation with simulated PLC state to see safe field access in action.

Python
from dataclasses import dataclass
from typing import TypedDict, Optional

class PlcSnapshot(TypedDict, total=False):
    line_id: str
    motor_running: bool
    pallets_in_buffer: int
    fault_code: Optional[str]

# Simulated PLC state using the typed schema
plc_typed_state: dict[str, PlcSnapshot] = {
    "Line1": {"line_id": "Line1", "motor_running": True, "pallets_in_buffer": 3},
    "Line2": {
        "line_id": "Line2",
        "motor_running": False,
        "pallets_in_buffer": 0,
        "fault_code": "E1001",
    },
}

@dataclass
class DiagnosticAgent:
    name: str

    def get_fault(self, line_id: str) -> Optional[str]:
        snapshot = plc_typed_state.get(line_id)
        if snapshot is None:
            print(f"[WARN] No snapshot for {line_id}")
            return None
        return snapshot.get("fault_code")

# Create and test the agent
agent = DiagnosticAgent(name="D1-baseline")

for line in ["Line1", "Line2", "Line3"]:
    print(line, "fault:", agent.get_fault(line))
Expected output
Line1 fault: None
Line2 fault: E1001
[WARN] No snapshot for Line3
Line3 fault: None

Explanation

  • - Line1 has no fault_code, so .get("fault_code") returns None without raising an exception.
  • - Line2 has fault_code="E1001", which is returned successfully.
  • - Line3 does not exist in plc_typed_state, so the snapshot lookup returns None and logs a warning.
  • - Cost: $0.00, with the benefit that static type checkers can now flag bad field names before deployment.

Why this matters

Strongly-typed state makes it easier to evolve your StateGraph and agent behaviors without subtle runtime regressions.

Takeaway

TypedDict + dataclasses give your agents clear boundaries: what state they accept, and how they behave when it is missing.

6

CHECKPOINT CELL

Checkpoint β€” State as a first-class design decision

checkpoint

Summarize the transition from fragile dicts to explicit agent state models.

Explanation

  • - You saw how a simple missing field can trigger noisy KeyErrors in naive dict-only code.
  • - TypedDict-based snapshots and small agent classes centralize behavior and make missing data an explicit, logged event.
  • - These exact state shapes will later plug into LangGraph nodes as strongly-typed StateGraph definitions.

Takeaway

Before you add LLMs or LangGraph, get your Python state modeling under control β€” it pays off in every later tutorial.

Experiment 2 β€” Async polling, decorators, and logging

Compare a blocking, sequential PLC polling loop with an async version wrapped in a logging decorator. Both use list comprehensions to track faulted lines.

7

SETUP CELL

Experiment 2A β€” Understanding blocking I/O with time.sleep

setup

Show how a simple PLC read function blocks the entire program while waiting for I/O.

Python
import time
import random
from typing import Dict

def read_plc_line(line_id: str) -> Dict[str, object]:
    """
    Simulate a blocking PLC read (~300ms).
    While time.sleep() runs, nothing else can execute.
    """
    time.sleep(0.3)  # Blocks for 300ms
    return {
        "line_id": line_id,
        "motor_running": random.choice([True, False]),
        "pallets_in_buffer": random.randint(0, 5),
        "fault_code": random.choice([None, "E1001", "E2002"]),
    }

Explanation

  • - time.sleep(0.3) completely blocks the Python interpreter for 300 milliseconds β€” nothing else can run.
  • - This simulates a blocking PLC read over Ethernet/IP or OPC UA where you wait for the device response.
  • - Random values simulate typical PLC state: motor status, buffer levels, and occasional fault codes.
  • - Cost: $0.00 β€” this is pure Python simulation with no external dependencies.

Takeaway

Blocking I/O (time.sleep) is simple but prevents your program from doing any other work while waiting.

8

SETUP CELL

Experiment 2A β€” Sequential polling logic with list comprehensions

setup

Build a polling loop that reads all lines one-by-one and uses a list comprehension to extract faulted lines.

Python
from typing import List

def poll_all_lines_sequential(line_ids: List[str]) -> List[Dict[str, object]]:
    """
    Poll all lines sequentially (one at a time).
    Total time = number of lines Γ— 0.3s per line.
    """
    start = time.perf_counter()

    # List comprehension: call read_plc_line for each line_id
    snapshots = [read_plc_line(line_id) for line_id in line_ids]

    duration = time.perf_counter() - start
    print(f"[SEQUENTIAL] Read {len(line_ids)} lines in {duration:.2f}s")

    # Extract all lines with any fault_code (including "E1001", "E2002", etc.)
    fault_lines = [s["line_id"] for s in snapshots if s["fault_code"]]
    print("[SEQUENTIAL] Faulty lines:", fault_lines)

    return snapshots

Explanation

  • - The list comprehension [read_plc_line(line_id) for line_id in line_ids] calls read_plc_line sequentially for each line.
  • - time.perf_counter() measures wall-clock time to show the cumulative blocking delay.
  • - The second list comprehension extracts fault_lines in one concise expression instead of a multi-line loop.
  • - This pattern is easy to understand but scales poorly: 10 lines Γ— 0.3s = 3.0 seconds of blocking I/O.
  • - Cost: $0.00, but sequential scans can add hundreds of milliseconds of latency at plant scale.

Takeaway

List comprehensions keep state transformations readable, but sequential loops still suffer from cumulative blocking delays.

9

EXPERIMENT CELL

Experiment 2A β€” Running the sequential polling implementation

experiment

Execute the blocking sequential polling loop to measure total latency.

Python
import time
import random
from typing import Dict, List

def read_plc_line(line_id: str) -> Dict[str, object]:
    """Simulate a blocking PLC read (~300ms)."""
    time.sleep(0.3)
    return {
        "line_id": line_id,
        "motor_running": random.choice([True, False]),
        "pallets_in_buffer": random.randint(0, 5),
        "fault_code": random.choice([None, "E1001", "E2002"]),
    }

def poll_all_lines_sequential(line_ids: List[str]) -> List[Dict[str, object]]:
    start = time.perf_counter()
    snapshots = [read_plc_line(line_id) for line_id in line_ids]
    duration = time.perf_counter() - start
    print(f"[SEQUENTIAL] Read {len(line_ids)} lines in {duration:.2f}s")

    fault_lines = [s["line_id"] for s in snapshots if s["fault_code"]]
    print("[SEQUENTIAL] Faulty lines:", fault_lines)
    return snapshots

# Poll 4 lines sequentially
poll_all_lines_sequential(["Line1", "Line2", "Line3", "Line4"])
Expected output
[SEQUENTIAL] Read 4 lines in 1.20s
[SEQUENTIAL] Faulty lines: ['Line1', 'Line3']

Explanation

  • - Each PLC read sleeps for ~0.3s, so four lines take about 1.2s end to end (4 Γ— 0.3s).
  • - The program is completely blocked during each read β€” no other work can happen.
  • - The list comprehension successfully extracts fault_lines, but the total scan time is still linear.
  • - Cost: $0.00 in API calls, but at plant scale, synchronous scans can easily add hundreds of milliseconds of latency.

Common mistake

Treating PLC and agent I/O as purely sequential even when each operation is I/O-bound and could be parallelized.

Takeaway

Sequential polling is simple but quickly becomes a bottleneck when you monitor several lines or devices at once.

10

SETUP CELL

Experiment 2B β€” Setting up async I/O with logging and jitter simulation

setup

Configure logging and create a helper function that simulates realistic PLC response time variations.

Python
import asyncio
import logging
import random
from typing import Any, Dict

# Configure logging for production-like observability
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")

def plc_io_delay() -> float:
    """
    Simulate realistic PLC response time with jitter (150–350ms).
    Industrial networks have variable latency depending on load and switches.
    """
    return random.uniform(0.15, 0.35)

Explanation

  • - logging.basicConfig sets up structured logs with timestamps and severity levels (INFO, ERROR, etc.).
  • - The plc_io_delay() function returns a random delay between 150-350ms to model real Ethernet/IP or OPC UA latency.
  • - Real industrial networks have variable latency due to switch hops, device load, and network congestion.
  • - This jitter simulation makes the async version more realistic when we test it in later cells.
  • - Cost: $0.00 β€” pure Python setup with no external dependencies.

Takeaway

Realistic jitter simulation helps you test agent resilience under production-like network conditions.

11

SETUP CELL

Experiment 2B β€” Building a non-blocking async PLC reader

setup

Create an async function that uses await asyncio.sleep instead of blocking time.sleep.

Python
import asyncio
import random
from typing import Any, Dict

def plc_io_delay() -> float:
    return random.uniform(0.15, 0.35)

async def async_read_plc_line_raw(line_id: str) -> Dict[str, Any]:
    """
    Async PLC read that can fail with ~10% probability.
    Uses await asyncio.sleep() instead of time.sleep(), allowing other tasks to run.
    """
    # Non-blocking sleep: other tasks can run while this awaits
    await asyncio.sleep(plc_io_delay())

    # Simulate occasional transient failure (network timeout, switch reset, etc.)
    if random.random() < 0.1:
        raise ConnectionError(f"Timeout talking to {line_id}")

    return {
        "line_id": line_id,
        "motor_running": random.choice([True, False]),
        "pallets_in_buffer": random.randint(0, 5),
        "fault_code": random.choice([None, "E1001", "E2002"]),
    }

Explanation

  • - The snippet now includes its required imports and plc_io_delay helper so the first runtime issue is not a missing name.
  • - The async def keyword makes this function a coroutine that can be suspended and resumed without blocking.
  • - await asyncio.sleep(plc_io_delay()) yields control back to the event loop while waiting, allowing other tasks to run.
  • - The 10% failure rate simulates real industrial networks: occasional packet loss, switch resets, or device reboots.
  • - This raw reader will be wrapped in a decorator (next cells) to add logging and fallback behavior.
  • - Cost: $0.00 β€” this is pure async Python with no external API calls.

Takeaway

Async functions with realistic failure injection let you test agent resilience before touching real PLCs.

12

SETUP CELL

Experiment 2B β€” Understanding the decorator pattern for cross-cutting concerns

setup

Show how decorators centralize logging and error handling without cluttering core logic.

Python
import logging
from typing import Any, Dict

def log_and_swallow_errors(fn):
    """
    Decorator that wraps async_read_plc_line_raw to add:
    1. Logging (INFO for success, ERROR for failure)
    2. Fallback snapshots when exceptions occur
    """
    async def wrapper(line_id: str) -> Dict[str, Any]:
        try:
            logging.info("Reading %s", line_id)
            snapshot = await fn(line_id)  # Call the original function
            logging.info("OK: %s", line_id)
            return snapshot
        except Exception as exc:
            logging.error("PLC read failed for %s: %s", line_id, exc)
            # Return a safe fallback instead of crashing
            return {
                "line_id": line_id,
                "motor_running": False,
                "pallets_in_buffer": 0,
                "fault_code": "E-CONNECTION",  # Explicit error code
            }
    return wrapper

Explanation

  • - The decorator pattern centralizes cross-cutting concerns: every PLC read now gets the same logging and error handling.
  • - The wrapper function calls the original fn (async_read_plc_line_raw) and catches any exceptions.
  • - When an exception occurs, the wrapper logs it with ERROR severity and returns a predictable fallback snapshot.
  • - The fallback uses fault_code="E-CONNECTION" so downstream logic can distinguish network failures from actual PLC faults.
  • - This pattern is reusable: you can apply the same decorator to database reads, API calls, or historian queries.

Takeaway

Decorators let you enforce industrial-grade error handling without cluttering your core agent logic with try/except blocks.

13

CORE CELL

Experiment 2B β€” Applying the decorator to create a production-safe reader

core

Combine the raw async reader with the logging decorator to create a resilient PLC reader.

Python
# Continuing from previous cells (imports, plc_io_delay, async_read_plc_line_raw, log_and_swallow_errors)

# Apply the decorator to create a production-safe reader
async_read_plc_line = log_and_swallow_errors(async_read_plc_line_raw)

# Now async_read_plc_line has:
# - All the PLC read logic from async_read_plc_line_raw
# - Automatic logging for every read attempt
# - Safe fallback snapshots when network errors occur
# - No changes needed in the calling code

Explanation

  • - The line async_read_plc_line = log_and_swallow_errors(async_read_plc_line_raw) applies the decorator.
  • - Now every call to async_read_plc_line() automatically gets logging and error handling.
  • - The original async_read_plc_line_raw remains unchanged β€” decorators are composable and non-invasive.
  • - In production, you would extend this to include retry logic, circuit breakers, and metrics for observability.
  • - Cost: $0.00 for the decorator logic itself; logging overhead is typically <1ms per call.

Why this matters

Decorators let you add production-grade features (logging, retries, metrics) without modifying core business logic.

Takeaway

A well-designed decorator turns unreliable I/O into predictable, loggable operations that agents can safely depend on.

14

SETUP CELL

Experiment 2C β€” Using asyncio.gather for concurrent polling

setup

Build a concurrent polling function that starts all PLC reads simultaneously using asyncio.gather.

Python
import asyncio
import logging
import time
from typing import Any, Dict, List

async def async_read_plc_line(line_id: str) -> Dict[str, Any]:
    return {
        "line_id": line_id,
        "motor_running": True,
        "pallets_in_buffer": 2,
        "fault_code": None,
    }

async def poll_all_lines_concurrent(line_ids: List[str]) -> List[Dict[str, Any]]:
    """
    Poll all lines concurrently using asyncio.gather.
    Total time β‰ˆ max(individual read times), not the sum.
    """
    start = time.perf_counter()

    # asyncio.gather starts all reads simultaneously and waits for all to complete
    # The * unpacks the generator expression into individual arguments
    snapshots = await asyncio.gather(*(async_read_plc_line(line_id) for line_id in line_ids))

    duration = time.perf_counter() - start
    logging.info("[ASYNC] Read %d lines in %.2fs", len(line_ids), duration)

    # List comprehension to extract faulted lines
    fault_lines = [s["line_id"] for s in snapshots if s["fault_code"]]
    logging.info("[ASYNC] Faulty lines: %s", fault_lines)

    return snapshots

Explanation

  • - A tiny async_read_plc_line stub is included so the concurrency example focuses on gather itself instead of failing on a missing function.
  • - asyncio.gather starts all PLC reads concurrently, so the total time is determined by the slowest line (~0.35s), not the sum (~1.2s).
  • - The * operator unpacks the generator expression into individual async tasks for gather to coordinate.
  • - Each task runs independently: while one awaits network I/O, others can make progress.
  • - The list comprehension concisely extracts fault_lines for dashboard summaries or agent decision-making.
  • - This represents a ~3.4x speedup compared to the sequential version (1.2s β†’ 0.35s) for the same four lines.

Takeaway

asyncio.gather is the key to parallel I/O in Python: start all tasks, wait for all results.

15

EXPERIMENT CELL

Experiment 2C β€” Running the concurrent polling implementation

experiment

Execute the async concurrent polling to measure the speedup vs sequential polling.

Python
import asyncio
import logging
import random
import time
from typing import Any, Dict, List

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")

def plc_io_delay() -> float:
    return random.uniform(0.15, 0.35)

async def async_read_plc_line_raw(line_id: str) -> Dict[str, Any]:
    await asyncio.sleep(plc_io_delay())
    if random.random() < 0.1:
        raise ConnectionError(f"Timeout talking to {line_id}")
    return {
        "line_id": line_id,
        "motor_running": random.choice([True, False]),
        "pallets_in_buffer": random.randint(0, 5),
        "fault_code": random.choice([None, "E1001", "E2002"]),
    }

def log_and_swallow_errors(fn):
    async def wrapper(line_id: str) -> Dict[str, Any]:
        try:
            logging.info("Reading %s", line_id)
            snapshot = await fn(line_id)
            logging.info("OK: %s", line_id)
            return snapshot
        except Exception as exc:
            logging.error("PLC read failed for %s: %s", line_id, exc)
            return {
                "line_id": line_id,
                "motor_running": False,
                "pallets_in_buffer": 0,
                "fault_code": "E-CONNECTION",
            }
    return wrapper

async_read_plc_line = log_and_swallow_errors(async_read_plc_line_raw)

async def poll_all_lines_concurrent(line_ids: List[str]) -> List[Dict[str, Any]]:
    start = time.perf_counter()
    snapshots = await asyncio.gather(*(async_read_plc_line(line_id) for line_id in line_ids))
    duration = time.perf_counter() - start
    logging.info("[ASYNC] Read %d lines in %.2fs", len(line_ids), duration)
    fault_lines = [s["line_id"] for s in snapshots if s["fault_code"]]
    logging.info("[ASYNC] Faulty lines: %s", fault_lines)
    return snapshots

# Example usage (in Jupyter or script with asyncio.run):
# asyncio.run(poll_all_lines_concurrent(["Line1", "Line2", "Line3", "Line4"]))
Expected output
2024-01-01 12:00:00 [INFO] Reading Line1
2024-01-01 12:00:00 [INFO] Reading Line2
2024-01-01 12:00:00 [INFO] Reading Line3
2024-01-01 12:00:00 [INFO] Reading Line4
2024-01-01 12:00:00 [INFO] OK: Line1
2024-01-01 12:00:00 [INFO] OK: Line3
2024-01-01 12:00:00 [INFO] OK: Line4
2024-01-01 12:00:00 [ERROR] PLC read failed for Line2: Timeout talking to Line2
2024-01-01 12:00:00 [INFO] [ASYNC] Read 4 lines in 0.35s
2024-01-01 12:00:00 [INFO] [ASYNC] Faulty lines: ['Line2', 'Line4']

Explanation

  • - All four reads start simultaneously at 12:00:00, showing true concurrent execution.
  • - When Line2 fails with ConnectionError, the decorator catches it and returns a fallback snapshot with fault_code="E-CONNECTION".
  • - Total time is ~0.35s (the slowest individual read), compared to 1.2s for sequential polling.
  • - This represents a ~3.4x speedup for just 4 lines; the benefit scales linearly with more devices.
  • - In real plants, this speedup keeps scan times under control: 10 lines at 300ms each is 3.0s sequential vs ~0.3s concurrent.
  • - Cost: $0.00 in API calls; typical production overhead is <10ms for asyncio event loop management.

Why this matters

Industrial agents often monitor dozens of lines or devices; concurrent polling keeps scan times under control as you scale.

Takeaway

Combining asyncio.gather with a logging decorator gives you fast, observable, and resilient multi-device polling.

16

CHECKPOINT CELL

Checkpoint β€” From scripts to reusable agent primitives

checkpoint

Connect async, decorators, and list comprehensions back to industrial agent design.

Explanation

  • - Sequential loops are easy to write, but they bake latency into your entire control layer as you add more PLCs.
  • - Async polling with a logging decorator creates a reusable primitive for future tutorials: a resilient, observable PLC reader.
  • - List comprehensions stayed useful in both versions, turning raw snapshots into concise fault summaries for dashboards or agents.
  • - The patterns from cells 7-15 (blocking I/O β†’ async reader β†’ decorator β†’ concurrent executor) form the foundation for production agent architectures.

Takeaway

When you combine state typing, async I/O, decorators, and logging, Python stops being glue code and becomes a reliable part of your industrial control architecture.

Further Reading

Official Documentation

Industrial Patterns

βœ… KEY TAKEAWAYS

  • βœ… Python classes and TypedDicts give your agents clear, testable contracts for PLC and internal state.
  • βœ… Naive dict-only code works in demos but often fails in production with hard-to-debug KeyErrors and silent omissions.
  • βœ… Async/await is a natural fit for polling multiple PLCs or services in parallel, especially when each call is I/O-bound.
  • βœ… Decorators let you standardize logging, error handling, and fallback behavior around every PLC interaction.
  • βœ… List comprehensions and small functional helpers keep your state transformations concise and readable for other engineers.
  • βœ… All patterns here are API-cost free β€” but they dramatically reduce the risk of hidden latency and flaky behavior when you later add LLM calls.

πŸ”œ NEXT TUTORIAL

D2 β€” Async Patterns & Error Handling for Agents

Deepen your async, retry, and backoff strategies to make agent workers safe and predictable under real plant conditions.