Back to Lecture 9

Python Programming for Automated Planning

From PDDL representation to automated reasoning with Python

Bridging Theory and Practice

From PDDL to Python

Now that you understand planning theory, let's see how to implement it in Python!

We'll use PDDL for representation (domain & problem files) and Python for automated reasoning (finding plans programmatically).

PDDL Files

Declarative problem specification

Python Code

Automated reasoning & search

Solution Plans

Executable action sequences

What You'll Learn
  • How to write PDDL domain and problem files
  • Using Python libraries (pddlpy) for planning
  • Implementing automated reasoning
  • Building a simple Python planner from scratch
  • State representation in Python
  • Forward search implementation
Two Complementary Approaches

We'll explore two ways to implement planning in Python:

  1. Using PDDL + pddlpy library: Leverage existing tools for automated reasoning
  2. Pure Python simulation: Build your own planner to understand the mechanics

PDDL Syntax Reference

Quick PDDL Command Reference

This is a comprehensive reference of PDDL syntax and commands. Use this as a quick guide when writing your own domain and problem files!

Domain File Structure
Domain Template PDDL
(define (domain domain-name)
  (:requirements :strips :typing :negative-preconditions)
  
  (:types 
    type1 type2 - object
  )
  
  (:predicates
    (predicate-name ?param1 - type1 ?param2 - type2)
  )
  
  (:action action-name
    :parameters (?var1 - type1 ?var2 - type2)
    :precondition (and
      (predicate1 ?var1)
      (predicate2 ?var1 ?var2)
    )
    :effect (and
      (new-predicate ?var1)
      (not (old-predicate ?var2))
    )
  )
)
Problem File Structure
Problem Template PDDL
(define (problem problem-name)
  (:domain domain-name)
  
  (:objects
    obj1 obj2 - type1
    obj3 obj4 - type2
  )
  
  (:init
    (predicate1 obj1)
    (predicate2 obj1 obj2)
  )
  
  (:goal (and
    (goal-predicate1 obj3)
    (goal-predicate2 obj3 obj4)
  ))
)
Core PDDL Commands
Command Purpose Example
define Start domain or problem definition (define (domain robots))
:requirements Specify PDDL features needed :strips :typing
:types Define object types robot location - object
:predicates Define state predicates (at ?r - robot ?l - location)
:action Define an action schema (:action move ...)
:parameters Action parameters (variables) (?r - robot ?from ?to - location)
:precondition Conditions that must be true (at ?r ?from)
:effect Changes to state (and (at ?r ?to) (not (at ?r ?from)))
:objects Define problem objects robot1 - robot kitchen - location
:init Initial state facts (at robot1 kitchen)
:goal Goal condition to achieve (at robot1 bedroom)
Logical Operators
Operator Meaning Example
and Logical AND (all must be true) (and (p) (q))
or Logical OR (at least one true) (or (p) (q))
not Logical NOT (negation) (not (p))
imply Logical implication (imply (p) (q))
Usage Notes:
  • and: Most commonly used in preconditions and effects
  • or: Requires :disjunctive-preconditions
  • not: In effects, represents delete list
  • Variables: Start with ? (e.g., ?robot)
  • Constants: No special prefix (e.g., kitchen)
Common PDDL Requirements
Requirement Description
:strips Basic STRIPS-style planning (preconditions, add/delete effects)
:typing Allows typed objects and parameters
:negative-preconditions Allows (not ...) in preconditions
:disjunctive-preconditions Allows (or ...) in preconditions
:equality Allows (= ?x ?y) for testing equality
:conditional-effects Effects can be conditional: (when (p) (q))
:fluents Numeric fluents and functions
:adl Action Description Language (includes many features)
Action Schema Components (Detailed)
Complete Action Example
(:action move
  ; Parameters: variables that will be instantiated
  :parameters (?robot - robot 
               ?from - location 
               ?to - location)
  
  ; Preconditions: must ALL be true for action to execute
  :precondition (and
    (at ?robot ?from)           ; robot is at 'from' location
    (connected ?from ?to)       ; locations are connected
    (not (blocked ?to))         ; destination is not blocked
  )
  
  ; Effects: changes to the state
  :effect (and
    (at ?robot ?to)             ; ADD: robot is now at 'to'
    (not (at ?robot ?from))     ; DELETE: robot no longer at 'from'
    (visited ?to)               ; ADD: mark location as visited
  )
)
šŸ“‹ Parameters
  • Variables prefixed with ?
  • Typed with - typename
  • Will be grounded to objects
āœ… Preconditions
  • Must all be satisfied
  • Can use and, or, not
  • Reference parameters
⚔ Effects
  • Add: positive predicates
  • Delete: (not ...)
  • Applied after action executes
Common PDDL Patterns
Pattern 1: Pick and Place
; Pick up object
(:action pick
  :parameters (?obj - object)
  :precondition (and
    (at-hand empty)
    (at-obj ?obj table)
  )
  :effect (and
    (holding ?obj)
    (not (at-hand empty))
    (not (at-obj ?obj table))
  )
)

; Place object
(:action place
  :parameters (?obj - object)
  :precondition (holding ?obj)
  :effect (and
    (at-obj ?obj table)
    (at-hand empty)
    (not (holding ?obj))
  )
)
Pattern 2: Move with Precondition
; Move between locations
(:action drive
  :parameters (?v - vehicle
               ?from ?to - location)
  :precondition (and
    (at ?v ?from)
    (road ?from ?to)
    (has-fuel ?v)
  )
  :effect (and
    (at ?v ?to)
    (not (at ?v ?from))
  )
)

; Refuel vehicle
(:action refuel
  :parameters (?v - vehicle
               ?loc - location)
  :precondition (and
    (at ?v ?loc)
    (has-pump ?loc)
  )
  :effect (has-fuel ?v)
)
PDDL Best Practices
āœ… Do:
  • Use meaningful predicate and action names
  • Keep actions simple and focused
  • Use typing to reduce grounding
  • Test with simple problems first
  • Add comments for clarity
āŒ Don't:
  • Forget parentheses (very common error!)
  • Use variables in problem files
  • Mix constants and variables
  • Make overly complex predicates
  • Forget to declare all types
Quick Checklist

Before running your PDDL files, verify:

  1. All parentheses are balanced
  2. Variables use ? prefix
  3. All types are declared in :types
  4. Problem objects match domain types
  5. Initial state uses concrete objects (no variables)
  6. Goal condition references declared predicates

Python PDDL API Reference (pddlpy)

Python API for Working with PDDL

This reference shows how to use Python to load, manipulate, and reason about PDDL planning problems using the pddlpy library.

Loading PDDL Files
Basic Loading pddlpy
from pddlpy import DomainProblem

# Load domain and problem files
domain_file = "domain.pddl"
problem_file = "problem.pddl"

# Create DomainProblem object
dp = DomainProblem(domain_file, problem_file)

# The dp object now contains parsed PDDL information
print(f"Domain: {dp.domain_name}")
print(f"Problem: {dp.problem_name}")
Core pddlpy Methods
Method Purpose Example
dp.ground_operator() Get all ground actions (instantiated) actions = list(dp.ground_operator())
dp.is_applicable(action) Check if action can be executed in current state if dp.is_applicable(action):
dp.apply_operator(action) Execute action and update state dp.apply_operator(('move', 'A', 'B'))
dp.check_goal() Check if current state satisfies goal if dp.check_goal(): print("Goal!")
dp.copy() Create deep copy of current state new_dp = dp.copy()
dp.state Access current state (set of fluents) current_fluents = dp.state
dp.initial_state Access initial state init = dp.initial_state
dp.goals Access goal conditions goal_fluents = dp.goals
dp.operators() Get action schemas (uninstantiated) schemas = dp.operators()
dp.worldobjects() Get all objects in the problem objects = dp.worldobjects()
State Manipulation Examples
Working with States
from pddlpy import DomainProblem

dp = DomainProblem('domain.pddl', 'problem.pddl')

# ===== Accessing State =====
# Get current state (set of fluents)
current_state = dp.state
print("Current state:", current_state)

# Get initial state
initial = dp.initial_state
print("Initial state:", initial)

# Get goal conditions
goals = dp.goals
print("Goals:", goals)

# ===== Checking Predicates =====
# Check if a specific fluent is in current state
if ('at', 'robot1', 'kitchen') in dp.state:
    print("Robot is in kitchen!")

# ===== Working with Actions =====
# Get all possible ground actions
all_actions = list(dp.ground_operator())
print(f"Total actions: {len(all_actions)}")

# Find applicable actions in current state
applicable = [a for a in all_actions if dp.is_applicable(a)]
print(f"Applicable actions: {len(applicable)}")

# ===== Applying Actions =====
# Apply an action (modifies state in-place)
action = ('move', 'robot1', 'kitchen', 'bedroom')
if dp.is_applicable(action):
    dp.apply_operator(action)
    print(f"Applied action: {action}")
    print(f"New state: {dp.state}")

# ===== Goal Checking =====
# Check if goal is satisfied
if dp.check_goal():
    print("āœ… Goal achieved!")
else:
    print("āŒ Goal not yet achieved")

# ===== Copying State =====
# Create a copy to explore without modifying original
dp_copy = dp.copy()
dp_copy.apply_operator(some_action)  # Won't affect dp
Action Structure in Python
Action Representation:

Actions are represented as tuples:

# Structure: (action_name, param1, param2, ...)

# Examples:
action1 = ('move', 'robot1', 'kitchen', 'bedroom')
action2 = ('pick-up', 'blockA')
action3 = ('stack', 'blockA', 'blockB')
action4 = ('load', 'cargo1', 'plane1', 'airport1')

# Accessing components:
action_name = action1[0]  # 'move'
parameters = action1[1:]  # ('robot1', 'kitchen', 'bedroom')
Fluent Representation:

Fluents are also tuples:

# Structure: (predicate_name, obj1, obj2, ...)

# Examples:
fluent1 = ('at', 'robot1', 'kitchen')
fluent2 = ('ontable', 'blockA')
fluent3 = ('on', 'blockA', 'blockB')
fluent4 = ('clear', 'blockC')
fluent5 = ('handempty',)  # Note the comma!

# Checking fluents:
if fluent1 in dp.state:
    print("Fluent is true!")
Common Python Patterns
Pattern 1: Finding Applicable Actions
def get_applicable_actions(dp):
    """Get all actions that can be executed in current state"""
    all_actions = dp.ground_operator()
    applicable = []
    
    for action in all_actions:
        if dp.is_applicable(action):
            applicable.append(action)
    
    return applicable

# Usage
applicable = get_applicable_actions(dp)
print(f"Can execute {len(applicable)} actions")
for action in applicable:
    print(f"  - {action[0]}({', '.join(action[1:])})")
Pattern 2: Simulating Action Execution
def simulate_plan(dp, plan):
    """Simulate execution of a plan and show state after each action"""
    print("Initial state:")
    print(f"  {dp.state}\n")
    
    for i, action in enumerate(plan, 1):
        if not dp.is_applicable(action):
            print(f"āŒ Action {i} is not applicable: {action}")
            return False
        
        print(f"Step {i}: {action[0]}({', '.join(action[1:])})")
        dp.apply_operator(action)
        print(f"  State: {dp.state}\n")
    
    if dp.check_goal():
        print("āœ… Goal achieved!")
        return True
    else:
        print("āŒ Goal not achieved")
        return False

# Usage
plan = [
    ('pick-up', 'blockA'),
    ('stack', 'blockA', 'blockB')
]
simulate_plan(dp, plan)
Pattern 3: Checking Specific Conditions
def check_condition(dp, condition):
    """Check if a condition (fluent) is true in current state"""
    return condition in dp.state

def check_all_conditions(dp, conditions):
    """Check if all conditions are true (AND)"""
    return all(cond in dp.state for cond in conditions)

def check_any_condition(dp, conditions):
    """Check if any condition is true (OR)"""
    return any(cond in dp.state for cond in conditions)

# Usage
if check_condition(dp, ('at', 'robot1', 'kitchen')):
    print("Robot is in kitchen")

conditions = [('clear', 'blockA'), ('ontable', 'blockB')]
if check_all_conditions(dp, conditions):
    print("Both blocks are ready")
Pattern 4: Iterating Over State
def print_state_pretty(dp):
    """Pretty print the current state grouped by predicate"""
    from collections import defaultdict
    
    grouped = defaultdict(list)
    for fluent in dp.state:
        predicate = fluent[0]
        args = fluent[1:]
        grouped[predicate].append(args)
    
    print("Current State:")
    for predicate, args_list in sorted(grouped.items()):
        print(f"  {predicate}:")
        for args in args_list:
            if args:
                print(f"    - {', '.join(args)}")
            else:
                print(f"    - (no arguments)")

# Usage
print_state_pretty(dp)

# Output example:
# Current State:
#   at:
#     - robot1, kitchen
#     - blockA, table
#   clear:
#     - blockA
#     - blockB
#   handempty:
#     - (no arguments)
Integrating with Search Algorithms
Simple BFS Planner
from collections import deque
from pddlpy import DomainProblem

def bfs_planner(domain_file, problem_file, max_depth=10):
    """Simple breadth-first search planner using pddlpy"""
    
    # Load problem
    dp = DomainProblem(domain_file, problem_file)
    
    # Initialize search
    queue = deque([(dp, [])])  # (state, plan)
    visited = {frozenset(dp.state)}
    nodes_expanded = 0
    
    while queue:
        current_dp, plan = queue.popleft()
        nodes_expanded += 1
        
        # Goal test
        if current_dp.check_goal():
            print(f"āœ… Solution found!")
            print(f"šŸ“Š Nodes expanded: {nodes_expanded}")
            print(f"šŸ“ Plan length: {len(plan)}")
            return plan
        
        # Depth limit
        if len(plan) >= max_depth:
            continue
        
        # Expand node
        for action in current_dp.ground_operator():
            if current_dp.is_applicable(action):
                # Create successor
                new_dp = current_dp.copy()
                new_dp.apply_operator(action)
                
                # Check if visited
                state_sig = frozenset(new_dp.state)
                if state_sig not in visited:
                    visited.add(state_sig)
                    new_plan = plan + [action]
                    queue.append((new_dp, new_plan))
    
    print("āŒ No solution found")
    return None

# Usage
plan = bfs_planner('blocks-domain.pddl', 'blocks-problem.pddl')

if plan:
    print("\nšŸ“‹ Plan:")
    for i, action in enumerate(plan, 1):
        print(f"  {i}. {action[0]}({', '.join(action[1:])})")
Error Handling Tips
Common Errors:
  • File not found: Check file paths are correct
  • Parse errors: Verify PDDL syntax (parentheses!)
  • Action not applicable: Check preconditions
  • Type errors: Ensure objects match types
Best Practices:
  • Always check is_applicable() before apply_operator()
  • Use copy() when exploring branches
  • Convert state to frozenset for hashing
  • Handle empty action lists gracefully
Quick Python-PDDL Cheat Sheet
Loading & Setup:
# Load
from pddlpy import DomainProblem
dp = DomainProblem('d.pddl', 'p.pddl')

# Access info
dp.state           # Current state
dp.initial_state   # Initial state
dp.goals           # Goal conditions
Core Operations:
# Actions
actions = list(dp.ground_operator())
if dp.is_applicable(action):
    dp.apply_operator(action)

# Goals
if dp.check_goal():
    print("Done!")

Approach 1: PDDL + Python (pddlpy)

The Blocks World Problem

We'll use the classic Blocks World domain where a robot manipulates blocks.

Goal: Stack block A on top of block B

1
Create the Domain File

File: blocks-domain.pddl

PDDL Domain STRIPS
(define (domain blocks)
  (:requirements :strips)
  
  (:predicates
    (on ?x ?y)        ; block x is on block y
    (ontable ?x)      ; block x is on the table
    (clear ?x)        ; nothing is on top of block x
    (holding ?x)      ; robot is holding block x
    (handempty)       ; robot's hand is empty
  )
  
  (:action pick-up
    :parameters (?x)
    :precondition (and (ontable ?x) (clear ?x) (handempty))
    :effect (and (holding ?x)
                 (not (ontable ?x))
                 (not (clear ?x))
                 (not (handempty)))
  )
  
  (:action put-down
    :parameters (?x)
    :precondition (holding ?x)
    :effect (and (ontable ?x)
                 (clear ?x)
                 (handempty)
                 (not (holding ?x)))
  )
  
  (:action stack
    :parameters (?x ?y)
    :precondition (and (holding ?x) (clear ?y))
    :effect (and (on ?x ?y)
                 (clear ?x)
                 (handempty)
                 (not (holding ?x))
                 (not (clear ?y)))
  )
  
  (:action unstack
    :parameters (?x ?y)
    :precondition (and (on ?x ?y) (clear ?x) (handempty))
    :effect (and (holding ?x)
                 (clear ?y)
                 (not (on ?x ?y))
                 (not (clear ?x))
                 (not (handempty)))
  )
)
Key Components:
  • Predicates: 5 predicates to represent the state
  • Actions: 4 actions (pick-up, put-down, stack, unstack)
  • Each action has: parameters, preconditions, and effects
2
Create the Problem File

File: blocks-problem.pddl

PDDL Problem
(define (problem stack-blocks)
  (:domain blocks)
  
  (:objects A B)
  
  (:init
    (ontable A)    ; A is on the table
    (ontable B)    ; B is on the table
    (clear A)      ; A is clear
    (clear B)      ; B is clear
    (handempty)    ; robot's hand is empty
  )
  
  (:goal (on A B))  ; Goal: A is on top of B
)
Initial State:
A B
Table
Goal State:
A
B
Table
3
Install the pddlpy Library

Install pddlpy using pip:

$pip install pddlpy
Note: The pddlpy library provides Python bindings to parse and reason about PDDL files, but doesn't include a full planner. We'll add simple search logic!
4
Write Python Code for Automated Reasoning

File: planner.py

Python Planner pddlpy
from pddlpy import DomainProblem

# Load the domain and problem files
dp = DomainProblem('blocks-domain.pddl', 'blocks-problem.pddl')

print("šŸ” Initial State Reasoning:")
print("=" * 50)

# Get all ground operators (actions with specific parameters)
all_actions = list(dp.ground_operator())
print(f"Total possible actions: {len(all_actions)}")

# Find applicable actions in initial state
applicable = [a for a in all_actions if dp.is_applicable(a)]
print(f"\nāœ… Applicable actions in initial state:")
for act in applicable:
    print(f"   • {act}")

# Simple forward search to find a plan
def search_plan(dp, max_depth=5):
    """Simple breadth-first search for a plan"""
    from collections import deque
    
    # Queue stores: (domain_problem_state, plan_so_far)
    queue = deque([(dp, [])])
    visited = set()
    
    while queue:
        current_dp, plan = queue.popleft()
        
        # Check if goal is reached
        if current_dp.check_goal():
            return plan
        
        # Get state signature for cycle detection
        state_sig = frozenset(current_dp.state)
        if state_sig in visited or len(plan) >= max_depth:
            continue
        visited.add(state_sig)
        
        # Try all applicable actions
        for action in current_dp.ground_operator():
            if current_dp.is_applicable(action):
                # Create new state by applying action
                new_dp = current_dp.copy()
                new_dp.apply_operator(action)
                new_plan = plan + [action]
                queue.append((new_dp, new_plan))
    
    return None  # No plan found

print("\n🧠 Searching for a plan...")
print("=" * 50)

plan = search_plan(dp)

if plan:
    print(f"\nšŸŽ‰ Plan found with {len(plan)} actions:")
    for i, action in enumerate(plan, 1):
        print(f"   Step {i}: {action[0]}({', '.join(action[1:])})")
else:
    print("\nāŒ No plan found!")

# Verify the plan by applying it
print("\nšŸ”¬ Verifying plan execution:")
print("=" * 50)
for i, action in enumerate(plan, 1):
    print(f"Step {i}: Applying {action}")
    dp.apply_operator(action)

if dp.check_goal():
    print("\nāœ… Goal achieved successfully!")
else:
    print("\nāŒ Goal not achieved!")
5
Run and See the Output

Execute the planner:

$python planner.py

Expected Output:

šŸ” Initial State Reasoning:
==================================================
Total possible actions: 12
āœ… Applicable actions in initial state:
• ('pick-up', 'A')
• ('pick-up', 'B')
🧠 Searching for a plan...
==================================================
šŸŽ‰ Plan found with 2 actions:
Step 1: pick-up(A)
Step 2: stack(A, B)
šŸ”¬ Verifying plan execution:
==================================================
Step 1: Applying ('pick-up', 'A')
Step 2: Applying ('stack', 'A', 'B')
āœ… Goal achieved successfully!
What Just Happened?
  1. Python loaded and parsed the PDDL files
  2. It identified which actions are applicable in the initial state
  3. A breadth-first search found a valid plan
  4. The plan was verified by simulating execution
  5. All done automatically with automated reasoning!

Approach 2: Pure Python Simulation

Building a Planner from Scratch

Now let's implement the same problem using pure Python without any external libraries. This helps understand the underlying mechanics of automated planning!

Complete Python Implementation

File: python_planner.py

Pure Python Planner No Dependencies
from collections import deque
from copy import deepcopy

# ========== State Representation ==========
class BlocksWorld:
    """Represents the state of the blocks world"""
    
    def __init__(self):
        self.ontable = set()      # blocks on the table
        self.clear = set()        # blocks with nothing on top
        self.on = {}              # on[x] = y means x is on y
        self.holding = None       # block currently held (or None)
    
    def copy(self):
        """Create a deep copy of the state"""
        new_state = BlocksWorld()
        new_state.ontable = self.ontable.copy()
        new_state.clear = self.clear.copy()
        new_state.on = self.on.copy()
        new_state.holding = self.holding
        return new_state
    
    def __str__(self):
        """String representation of the state"""
        lines = []
        lines.append(f"On table: {sorted(self.ontable)}")
        lines.append(f"Clear: {sorted(self.clear)}")
        lines.append(f"On relations: {self.on}")
        lines.append(f"Holding: {self.holding}")
        return "\n".join(lines)
    
    def signature(self):
        """Get a hashable signature for state comparison"""
        return (
            frozenset(self.ontable),
            frozenset(self.clear),
            frozenset(self.on.items()),
            self.holding
        )

# ========== Action Definitions ==========
class BlocksActions:
    """Defines all possible actions in blocks world"""
    
    @staticmethod
    def can_pickup(state, x):
        """Check if we can pick up block x"""
        return (x in state.ontable and 
                x in state.clear and 
                state.holding is None)
    
    @staticmethod
    def pickup(state, x):
        """Pick up block x from the table"""
        new_state = state.copy()
        new_state.ontable.remove(x)
        new_state.clear.remove(x)
        new_state.holding = x
        return new_state
    
    @staticmethod
    def can_putdown(state, x):
        """Check if we can put down block x"""
        return state.holding == x
    
    @staticmethod
    def putdown(state, x):
        """Put down block x on the table"""
        new_state = state.copy()
        new_state.ontable.add(x)
        new_state.clear.add(x)
        new_state.holding = None
        return new_state
    
    @staticmethod
    def can_stack(state, x, y):
        """Check if we can stack block x on block y"""
        return state.holding == x and y in state.clear
    
    @staticmethod
    def stack(state, x, y):
        """Stack block x on block y"""
        new_state = state.copy()
        new_state.on[x] = y
        new_state.clear.add(x)
        new_state.clear.remove(y)
        new_state.holding = None
        return new_state
    
    @staticmethod
    def can_unstack(state, x, y):
        """Check if we can unstack block x from block y"""
        return (x in state.on and 
                state.on[x] == y and 
                x in state.clear and 
                state.holding is None)
    
    @staticmethod
    def unstack(state, x, y):
        """Unstack block x from block y"""
        new_state = state.copy()
        del new_state.on[x]
        new_state.clear.add(y)
        new_state.clear.remove(x)
        new_state.holding = x
        return new_state

# ========== Planner ==========
class Planner:
    """Simple forward search planner"""
    
    def __init__(self, initial_state, goal_test, actions_class):
        self.initial_state = initial_state
        self.goal_test = goal_test
        self.actions = actions_class
    
    def get_applicable_actions(self, state, blocks):
        """Get all applicable actions in current state"""
        applicable = []
        
        for x in blocks:
            if self.actions.can_pickup(state, x):
                applicable.append(('pickup', x))
            if self.actions.can_putdown(state, x):
                applicable.append(('putdown', x))
            
            for y in blocks:
                if x != y:
                    if self.actions.can_stack(state, x, y):
                        applicable.append(('stack', x, y))
                    if self.actions.can_unstack(state, x, y):
                        applicable.append(('unstack', x, y))
        
        return applicable
    
    def apply_action(self, state, action):
        """Apply an action to a state"""
        action_name = action[0]
        
        if action_name == 'pickup':
            return self.actions.pickup(state, action[1])
        elif action_name == 'putdown':
            return self.actions.putdown(state, action[1])
        elif action_name == 'stack':
            return self.actions.stack(state, action[1], action[2])
        elif action_name == 'unstack':
            return self.actions.unstack(state, action[1], action[2])
        
        return None
    
    def search(self, blocks, max_depth=10):
        """Breadth-first search for a plan"""
        queue = deque([(self.initial_state, [])])
        visited = {self.initial_state.signature()}
        nodes_expanded = 0
        
        while queue:
            current_state, plan = queue.popleft()
            nodes_expanded += 1
            
            # Check if goal is reached
            if self.goal_test(current_state):
                print(f"\nšŸ“Š Search Statistics:")
                print(f"   Nodes expanded: {nodes_expanded}")
                print(f"   Plan length: {len(plan)}")
                return plan
            
            # Don't search beyond max depth
            if len(plan) >= max_depth:
                continue
            
            # Try all applicable actions
            for action in self.get_applicable_actions(current_state, blocks):
                new_state = self.apply_action(current_state, action)
                state_sig = new_state.signature()
                
                if state_sig not in visited:
                    visited.add(state_sig)
                    new_plan = plan + [action]
                    queue.append((new_state, new_plan))
        
        return None  # No plan found

# ========== Main Program ==========
def main():
    print("🧱 Blocks World Planner")
    print("=" * 60)
    
    # Define initial state
    initial = BlocksWorld()
    initial.ontable = {'A', 'B'}
    initial.clear = {'A', 'B'}
    initial.on = {}
    initial.holding = None
    
    print("\nšŸ“¦ Initial State:")
    print(initial)
    print("\nšŸŽÆ Goal: Stack A on B (i.e., A is on B)")
    
    # Define goal test
    def goal_test(state):
        return 'A' in state.on and state.on['A'] == 'B'
    
    # Create planner
    planner = Planner(initial, goal_test, BlocksActions)
    
    # Search for a plan
    print("\nšŸ” Searching for a plan...")
    plan = planner.search(['A', 'B'])
    
    if plan:
        print(f"\nāœ… Plan found with {len(plan)} actions:")
        for i, action in enumerate(plan, 1):
            action_str = f"{action[0]}({', '.join(action[1:])})"
            print(f"   Step {i}: {action_str}")
        
        # Simulate execution
        print("\nšŸŽ¬ Simulating plan execution:")
        print("=" * 60)
        state = initial
        
        for i, action in enumerate(plan, 1):
            print(f"\nStep {i}: {action[0]}({', '.join(action[1:])})")
            state = planner.apply_action(state, action)
            print(state)
        
        if goal_test(state):
            print("\nšŸŽ‰ Goal achieved successfully!")
        else:
            print("\nāŒ Goal not achieved!")
    else:
        print("\nāŒ No plan found!")

if __name__ == "__main__":
    main()
Running the Pure Python Planner
$python python_planner.py

Output:

🧱 Blocks World Planner
============================================================
šŸ“¦ Initial State:
On table: ['A', 'B']
Clear: ['A', 'B']
On relations: {}
Holding: None
šŸŽÆ Goal: Stack A on B (i.e., A is on B)
šŸ” Searching for a plan...
šŸ“Š Search Statistics:
Nodes expanded: 3
Plan length: 2
āœ… Plan found with 2 actions:
Step 1: pickup(A)
Step 2: stack(A, B)
šŸŽ¬ Simulating plan execution:
============================================================
Step 1: pickup(A)
On table: ['B']
Clear: ['B']
On relations: {}
Holding: A
Step 2: stack(A, B)
On table: ['B']
Clear: ['A']
On relations: {'A': 'B'}
Holding: None
šŸŽ‰ Goal achieved successfully!
Key Components Explained
1. State Representation:
  • Python sets and dictionaries
  • Easy to copy and compare
  • Signature for cycle detection
2. Action Definitions:
  • Precondition checking functions
  • State transformation functions
  • Returns new states (immutable)
3. Search Algorithm:
  • Breadth-first search
  • Visited set for cycle detection
  • Max depth limit
4. Goal Test:
  • Custom function
  • Checks state properties
  • Returns True/False

Comparing the Two Approaches

PDDL + pddlpy

Advantages:
  • āœ… Standard representation (PDDL)
  • āœ… Declarative and clean
  • āœ… Can use with external planners
  • āœ… Domain-independent
Disadvantages:
  • āš ļø Requires external library
  • āš ļø Less control over search
  • āš ļø Learning curve for PDDL syntax
Pure Python

Advantages:
  • āœ… No dependencies
  • āœ… Full control over everything
  • āœ… Easy to understand and debug
  • āœ… Can optimize for specific domains
Disadvantages:
  • āš ļø More code to write
  • āš ļø Domain-specific
  • āš ļø Harder to reuse
Feature Comparison
Feature PDDL + pddlpy Pure Python
Dependencies pddlpy library None
Code Lines ~70 (Python) + PDDL files ~200 (all Python)
Flexibility Medium High
Standard Format āœ… Yes (PDDL) āŒ No
Learning Curve Medium (PDDL syntax) Low (just Python)
Reusability High (PDDL files) Medium (Python code)
Best For Production systems Learning & prototyping

Key Takeaways

āœ… What You Learned
  • How to write PDDL domain and problem files
  • Using Python libraries for automated planning
  • Implementing search algorithms from scratch
  • State representation in code
  • Action preconditions and effects
šŸš€ Next Steps
  • Try more complex domains (8-puzzle, robot navigation)
  • Implement heuristics (A* search)
  • Use real planners (Fast Downward, pyperplan)
  • Optimize your Python planner
  • Build domain-specific planners
Summary
PDDL Representation

Standard, declarative format

Python Implementation

Flexible, programmable reasoning

Automated Planning

Let computers find solutions!

šŸŽ“ Congratulations! You've now bridged the gap between planning theory and practical implementation. You can build real planning systems!