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 aKeyErrorwhen a PLC omits a field. - βΈ
A
TypedDict+@dataclassbased 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.
SETUP CELL
Experiment 1A β Understanding the naive dict approach
Explain the brittle pattern of accessing PLC state using plain dictionaries with direct key indexing.
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.
EXPERIMENT CELL
Experiment 1A β Running the naive implementation
Execute the naive dict approach with simulated PLC state to see KeyError handling in action.
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.
SETUP CELL
Experiment 1B β Defining a TypedDict schema for PLC snapshots
Use TypedDict to explicitly declare which fields exist in a PLC snapshot and their types.
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.
SETUP CELL
Experiment 1B β Building a DiagnosticAgent with safe field access
Wrap PLC lookup logic inside a dataclass-based agent that uses .get() to avoid KeyErrors.
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.
CORE CELL
Experiment 1B β Running the TypedDict + dataclass implementation
Execute the typed implementation with simulated PLC state to see safe field access in action.
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.
CHECKPOINT CELL
Checkpoint β State as a first-class design decision
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.
SETUP CELL
Experiment 2A β Understanding blocking I/O with time.sleep
Show how a simple PLC read function blocks the entire program while waiting for I/O.
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.
SETUP CELL
Experiment 2A β Sequential polling logic with list comprehensions
Build a polling loop that reads all lines one-by-one and uses a list comprehension to extract faulted lines.
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.
EXPERIMENT CELL
Experiment 2A β Running the sequential polling implementation
Execute the blocking sequential polling loop to measure total latency.
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.
SETUP CELL
Experiment 2B β Setting up async I/O with logging and jitter simulation
Configure logging and create a helper function that simulates realistic PLC response time variations.
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.
SETUP CELL
Experiment 2B β Building a non-blocking async PLC reader
Create an async function that uses await asyncio.sleep instead of blocking time.sleep.
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.
SETUP CELL
Experiment 2B β Understanding the decorator pattern for cross-cutting concerns
Show how decorators centralize logging and error handling without cluttering core logic.
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.
CORE CELL
Experiment 2B β Applying the decorator to create a production-safe reader
Combine the raw async reader with the logging decorator to create a resilient PLC reader.
# 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.
SETUP CELL
Experiment 2C β Using asyncio.gather for concurrent polling
Build a concurrent polling function that starts all PLC reads simultaneously using asyncio.gather.
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.
EXPERIMENT CELL
Experiment 2C β Running the concurrent polling implementation
Execute the async concurrent polling to measure the speedup vs sequential polling.
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.
CHECKPOINT CELL
Checkpoint β From scripts to reusable agent primitives
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
-
Python asyncio β Asynchronous I/O
Official asyncio guide covering async/await, gather, and event loops.
-
Python typing β Type hints
Complete reference for TypedDict, dataclasses, and type annotations.
-
Python dataclasses
Decorator-based classes with automatic init and type checking.
Industrial Patterns
-
Real Python β Async IO in Python
Comprehensive guide to async patterns with real-world examples.
-
FastAPI async patterns
Production async patterns from a modern web framework (applicable to agents).
-
PEP 484 β Type Hints
The original specification for Python type hints and gradual typing.
β 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.