This notebook serves as supporting material for topics covered in Chapter 10 - Classical Planning and Chapter 11 - Planning and Acting in the Real World from the book Artificial Intelligence: A Modern Approach. This notebook uses implementations from the planning.py module. See the intro notebook for instructions.
We'll start by looking at PlanningProblem
and Action
data types for defining problems and actions.
Then, we will see how to use them by trying to plan a trip from Sibiu to Bucharest across the familiar map of Romania, from search.ipynb
followed by some common planning problems and methods of solving them.
Let's start by importing everything from the planning module.
from planning import *
from notebook import psource
Classical Planning
Planning in the real world
PDDL stands for Planning Domain Definition Language.
The PlanningProblem
class is used to represent planning problems in this module. The following attributes are essential to be able to define a problem:
View the source to see how the Python code tries to realise these.
psource(PlanningProblem)
class PlanningProblem:
"""
Planning Domain Definition Language (PlanningProblem) used to define a search problem.
It stores states in a knowledge base consisting of first order logic statements.
The conjunction of these logical statements completely defines a state.
"""
def __init__(self, init, goals, actions):
self.init = self.convert(init)
self.goals = self.convert(goals)
self.actions = actions
def convert(self, clauses):
"""Converts strings into exprs"""
if not isinstance(clauses, Expr):
if len(clauses) > 0:
clauses = expr(clauses)
else:
clauses = []
try:
clauses = conjuncts(clauses)
except AttributeError:
clauses = clauses
new_clauses = []
for clause in clauses:
if clause.op == '~':
new_clauses.append(expr('Not' + str(clause.args[0])))
else:
new_clauses.append(clause)
return new_clauses
def goal_test(self):
"""Checks if the goals have been reached"""
return all(goal in self.init for goal in self.goals)
def act(self, action):
"""
Performs the action given as argument.
Note that action is an Expr like expr('Remove(Glass, Table)') or expr('Eat(Sandwich)')
"""
action_name = action.op
args = action.args
list_action = first(a for a in self.actions if a.name == action_name)
if list_action is None:
raise Exception("Action '{}' not found".format(action_name))
if not list_action.check_precond(self.init, args):
raise Exception("Action '{}' pre-conditions not satisfied".format(action))
self.init = list_action(self.init, args).clauses
The init
attribute is an expression that forms the initial knowledge base for the problem.
To be able to model a planning problem properly, it is essential to be able to represent an Action. Each action we model requires at least three things:
The module models actions using the Action
class
psource(Action)
class Action:
"""
Defines an action schema using preconditions and effects.
Use this to describe actions in PlanningProblem.
action is an Expr where variables are given as arguments(args).
Precondition and effect are both lists with positive and negative literals.
Negative preconditions and effects are defined by adding a 'Not' before the name of the clause
Example:
precond = [expr("Human(person)"), expr("Hungry(Person)"), expr("NotEaten(food)")]
effect = [expr("Eaten(food)"), expr("Hungry(person)")]
eat = Action(expr("Eat(person, food)"), precond, effect)
"""
def __init__(self, action, precond, effect):
if isinstance(action, str):
action = expr(action)
self.name = action.op
self.args = action.args
self.precond = self.convert(precond)
self.effect = self.convert(effect)
def __call__(self, kb, args):
return self.act(kb, args)
def __repr__(self):
return '{}({})'.format(self.__class__.__name__, Expr(self.name, *self.args))
def convert(self, clauses):
"""Converts strings into Exprs"""
if isinstance(clauses, Expr):
clauses = conjuncts(clauses)
for i in range(len(clauses)):
if clauses[i].op == '~':
clauses[i] = expr('Not' + str(clauses[i].args[0]))
elif isinstance(clauses, str):
clauses = clauses.replace('~', 'Not')
if len(clauses) > 0:
clauses = expr(clauses)
try:
clauses = conjuncts(clauses)
except AttributeError:
pass
return clauses
def substitute(self, e, args):
"""Replaces variables in expression with their respective Propositional symbol"""
new_args = list(e.args)
for num, x in enumerate(e.args):
for i, _ in enumerate(self.args):
if self.args[i] == x:
new_args[num] = args[i]
return Expr(e.op, *new_args)
def check_precond(self, kb, args):
"""Checks if the precondition is satisfied in the current state"""
if isinstance(kb, list):
kb = FolKB(kb)
for clause in self.precond:
if self.substitute(clause, args) not in kb.clauses:
return False
return True
def act(self, kb, args):
"""Executes the action on the state's knowledge base"""
if isinstance(kb, list):
kb = FolKB(kb)
if not self.check_precond(kb, args):
raise Exception('Action pre-conditions not satisfied')
for clause in self.effect:
kb.tell(self.substitute(clause, args))
if clause.op[:3] == 'Not':
new_clause = Expr(clause.op[3:], *clause.args)
if kb.ask(self.substitute(new_clause, args)) is not False:
kb.retract(self.substitute(new_clause, args))
else:
new_clause = Expr('Not' + clause.op, *clause.args)
if kb.ask(self.substitute(new_clause, args)) is not False:
kb.retract(self.substitute(new_clause, args))
return kb
This class represents an action given the expression, the preconditions and its effects.
A list precond
stores the preconditions of the action and a list effect
stores its effects.
Negative preconditions and effects are input using a ~
symbol before the clause, which are internally prefixed with a Not
to make it easier to work with.
For example, the negation of At(obj, loc)
will be input as ~At(obj, loc)
and internally represented as NotAt(obj, loc)
.
This equivalently creates a new clause for each negative literal, removing the hassle of maintaining two separate knowledge bases.
This greatly simplifies algorithms like GraphPlan
as we will see later.
The convert
method takes an input string, parses it, removes conjunctions if any and returns a list of Expr
objects.
The check_precond
method checks if the preconditions for that action are valid, given a kb
.
The act
method carries out the action on the given knowledge base.
Now lets try to define a planning problem using these tools. Since we already know about the map of Romania, lets see if we can plan a trip across a simplified map of Romania.
Here is our simplified map definition:
from utils import *
# this imports the required expr so we can create our knowledge base
knowledge_base = [
expr("Connected(Bucharest,Pitesti)"),
expr("Connected(Pitesti,Rimnicu)"),
expr("Connected(Rimnicu,Sibiu)"),
expr("Connected(Sibiu,Fagaras)"),
expr("Connected(Fagaras,Bucharest)"),
expr("Connected(Pitesti,Craiova)"),
expr("Connected(Craiova,Rimnicu)")
]
Let us add some logic propositions to complete our knowledge about travelling around the map. These are the typical symmetry and transitivity properties of connections on a map. We can now be sure that our knowledge_base
understands what it truly means for two locations to be connected in the sense usually meant by humans when we use the term.
Let's also add our starting location - Sibiu to the map.
knowledge_base.extend([
expr("Connected(x,y) ==> Connected(y,x)"),
expr("Connected(x,y) & Connected(y,z) ==> Connected(x,z)"),
expr("At(Sibiu)")
])
We now have a complete knowledge base, which can be seen like this:
knowledge_base
[Connected(Bucharest, Pitesti), Connected(Pitesti, Rimnicu), Connected(Rimnicu, Sibiu), Connected(Sibiu, Fagaras), Connected(Fagaras, Bucharest), Connected(Pitesti, Craiova), Connected(Craiova, Rimnicu), (Connected(x, y) ==> Connected(y, x)), ((Connected(x, y) & Connected(y, z)) ==> Connected(x, z)), At(Sibiu)]
We now define possible actions to our problem. We know that we can drive between any connected places. But, as is evident from this list of Romanian airports, we can also fly directly between Sibiu, Bucharest, and Craiova.
We can define these flight actions like this:
#Sibiu to Bucharest
precond = 'At(Sibiu)'
effect = 'At(Bucharest) & ~At(Sibiu)'
fly_s_b = Action('Fly(Sibiu, Bucharest)', precond, effect)
#Bucharest to Sibiu
precond = 'At(Bucharest)'
effect = 'At(Sibiu) & ~At(Bucharest)'
fly_b_s = Action('Fly(Bucharest, Sibiu)', precond, effect)
#Sibiu to Craiova
precond = 'At(Sibiu)'
effect = 'At(Craiova) & ~At(Sibiu)'
fly_s_c = Action('Fly(Sibiu, Craiova)', precond, effect)
#Craiova to Sibiu
precond = 'At(Craiova)'
effect = 'At(Sibiu) & ~At(Craiova)'
fly_c_s = Action('Fly(Craiova, Sibiu)', precond, effect)
#Bucharest to Craiova
precond = 'At(Bucharest)'
effect = 'At(Craiova) & ~At(Bucharest)'
fly_b_c = Action('Fly(Bucharest, Craiova)', precond, effect)
#Craiova to Bucharest
precond = 'At(Craiova)'
effect = 'At(Bucharest) & ~At(Craiova)'
fly_c_b = Action('Fly(Craiova, Bucharest)', precond, effect)
And the drive actions like this.
#Drive
precond = 'At(x)'
effect = 'At(y) & ~At(x)'
drive = Action('Drive(x, y)', precond, effect)
Our goal is defined as
goals = 'At(Bucharest)'
Finally, we can define a a function that will tell us when we have reached our destination, Bucharest.
def goal_test(kb):
return kb.ask(expr('At(Bucharest)'))
Thus, with all the components in place, we can define the planning problem.
prob = PlanningProblem(knowledge_base, goals, [fly_s_b, fly_b_s, fly_s_c, fly_c_s, fly_b_c, fly_c_b, drive])
In the Air Cargo problem, we start with cargo at two airports, SFO and JFK. Our goal is to send each cargo to the other airport. We have two airplanes to help us accomplish the task.
The problem can be defined with three actions: Load, Unload and Fly.
Let us look how the air_cargo
problem has been defined in the module.
psource(air_cargo)
def air_cargo():
"""
[Figure 10.1] AIR-CARGO-PROBLEM
An air-cargo shipment problem for delivering cargo to different locations,
given the starting location and airplanes.
Example:
>>> from planning import *
>>> ac = air_cargo()
>>> ac.goal_test()
False
>>> ac.act(expr('Load(C2, P2, JFK)'))
>>> ac.act(expr('Load(C1, P1, SFO)'))
>>> ac.act(expr('Fly(P1, SFO, JFK)'))
>>> ac.act(expr('Fly(P2, JFK, SFO)'))
>>> ac.act(expr('Unload(C2, P2, SFO)'))
>>> ac.goal_test()
False
>>> ac.act(expr('Unload(C1, P1, JFK)'))
>>> ac.goal_test()
True
>>>
"""
return PlanningProblem(init='At(C1, SFO) & At(C2, JFK) & At(P1, SFO) & At(P2, JFK) & Cargo(C1) & Cargo(C2) & Plane(P1) & Plane(P2) & Airport(SFO) & Airport(JFK)',
goals='At(C1, JFK) & At(C2, SFO)',
actions=[Action('Load(c, p, a)',
precond='At(c, a) & At(p, a) & Cargo(c) & Plane(p) & Airport(a)',
effect='In(c, p) & ~At(c, a)'),
Action('Unload(c, p, a)',
precond='In(c, p) & At(p, a) & Cargo(c) & Plane(p) & Airport(a)',
effect='At(c, a) & ~In(c, p)'),
Action('Fly(p, f, to)',
precond='At(p, f) & Plane(p) & Airport(f) & Airport(to)',
effect='At(p, to) & ~At(p, f)')])
At(c, a): The cargo 'c' is at airport 'a'.
~At(c, a): The cargo 'c' is not at airport 'a'.
In(c, p): Cargo 'c' is in plane 'p'.
~In(c, p): Cargo 'c' is not in plane 'p'.
Cargo(c): Declare 'c' as cargo.
Plane(p): Declare 'p' as plane.
Airport(a): Declare 'a' as airport.
In the initial_state
, we have cargo C1, plane P1 at airport SFO and cargo C2, plane P2 at airport JFK.
Our goal state is to have cargo C1 at airport JFK and cargo C2 at airport SFO. We will discuss on how to achieve this. Let us now define an object of the air_cargo
problem:
airCargo = air_cargo()
Before taking any actions, we will check if airCargo
has reached its goal:
print(airCargo.goal_test())
False
It returns False because the goal state is not yet reached. Now, we define the sequence of actions that it should take in order to achieve the goal.
The actions are then carried out on the airCargo
PlanningProblem.
The actions available to us are the following: Load, Unload, Fly
Load(c, p, a): Load cargo 'c' into plane 'p' from airport 'a'.
Fly(p, f, t): Fly the plane 'p' from airport 'f' to airport 't'.
Unload(c, p, a): Unload cargo 'c' from plane 'p' to airport 'a'.
This problem can have multiple valid solutions. One such solution is shown below.
solution = [expr("Load(C1 , P1, SFO)"),
expr("Fly(P1, SFO, JFK)"),
expr("Unload(C1, P1, JFK)"),
expr("Load(C2, P2, JFK)"),
expr("Fly(P2, JFK, SFO)"),
expr("Unload (C2, P2, SFO)")]
for action in solution:
airCargo.act(action)
As the airCargo
has taken all the steps it needed in order to achieve the goal, we can now check if it has acheived its goal:
print(airCargo.goal_test())
True
It has now achieved its goal.
Let's consider the problem of changing a flat tire of a car. The goal is to mount a spare tire onto the car's axle, given that we have a flat tire on the axle and a spare tire in the trunk.
psource(spare_tire)
def spare_tire():
"""[Figure 10.2] SPARE-TIRE-PROBLEM
A problem involving changing the flat tire of a car
with a spare tire from the trunk.
Example:
>>> from planning import *
>>> st = spare_tire()
>>> st.goal_test()
False
>>> st.act(expr('Remove(Spare, Trunk)'))
>>> st.act(expr('Remove(Flat, Axle)'))
>>> st.goal_test()
False
>>> st.act(expr('PutOn(Spare, Axle)'))
>>> st.goal_test()
True
>>>
"""
return PlanningProblem(init='Tire(Flat) & Tire(Spare) & At(Flat, Axle) & At(Spare, Trunk)',
goals='At(Spare, Axle) & At(Flat, Ground)',
actions=[Action('Remove(obj, loc)',
precond='At(obj, loc)',
effect='At(obj, Ground) & ~At(obj, loc)'),
Action('PutOn(t, Axle)',
precond='Tire(t) & At(t, Ground) & ~At(Flat, Axle)',
effect='At(t, Axle) & ~At(t, Ground)'),
Action('LeaveOvernight',
precond='',
effect='~At(Spare, Ground) & ~At(Spare, Axle) & ~At(Spare, Trunk) & \
~At(Flat, Ground) & ~At(Flat, Axle) & ~At(Flat, Trunk)')])
At(obj, loc): object 'obj' is at location 'loc'.
~At(obj, loc): object 'obj' is not at location 'loc'.
Tire(t): Declare a tire of type 't'.
Let us now define an object of spare_tire
problem:
spareTire = spare_tire()
Before taking any actions, we will check if spare_tire
has reached its goal:
print(spareTire.goal_test())
False
As we can see, it hasn't completed the goal.
We now define a possible solution that can help us reach the goal of having a spare tire mounted onto the car's axle.
The actions are then carried out on the spareTire
PlanningProblem.
The actions available to us are the following: Remove, PutOn
Remove(obj, loc): Remove the tire 'obj' from the location 'loc'.
PutOn(t, Axle): Attach the tire 't' on the Axle.
LeaveOvernight(): We live in a particularly bad neighborhood and all tires, flat or not, are stolen if we leave them overnight.
solution = [expr("Remove(Flat, Axle)"),
expr("Remove(Spare, Trunk)"),
expr("PutOn(Spare, Axle)")]
for action in solution:
spareTire.act(action)
print(spareTire.goal_test())
True
This is a valid solution.
spareTire = spare_tire()
solution = [expr('Remove(Spare, Trunk)'),
expr('Remove(Flat, Axle)'),
expr('PutOn(Spare, Axle)')]
for action in solution:
spareTire.act(action)
print(spareTire.goal_test())
True
Notice that both solutions work, which means that the problem can be solved irrespective of the order in which the Remove
actions take place, as long as both Remove
actions take place before the PutOn
action.
We have successfully mounted a spare tire onto the axle.
This problem's domain consists of a set of cube-shaped blocks sitting on a table. The blocks can be stacked, but only one block can fit directly on top of another. A robot arm can pick up a block and move it to another position, either on the table or on top of another block. The arm can pick up only one block at a time, so it cannot pick up a block that has another one on it. The goal will always be to build one or more stacks of blocks. In our case, we consider only three blocks. The particular configuration we will use is called the Sussman anomaly after Prof. Gerry Sussman.
Let's take a look at the definition of three_block_tower()
in the module.
psource(three_block_tower)
def three_block_tower():
"""
[Figure 10.3] THREE-BLOCK-TOWER
A blocks-world problem of stacking three blocks in a certain configuration,
also known as the Sussman Anomaly.
Example:
>>> from planning import *
>>> tbt = three_block_tower()
>>> tbt.goal_test()
False
>>> tbt.act(expr('MoveToTable(C, A)'))
>>> tbt.act(expr('Move(B, Table, C)'))
>>> tbt.goal_test()
False
>>> tbt.act(expr('Move(A, Table, B)'))
>>> tbt.goal_test()
True
>>>
"""
return PlanningProblem(init='On(A, Table) & On(B, Table) & On(C, A) & Block(A) & Block(B) & Block(C) & Clear(B) & Clear(C)',
goals='On(A, B) & On(B, C)',
actions=[Action('Move(b, x, y)',
precond='On(b, x) & Clear(b) & Clear(y) & Block(b) & Block(y)',
effect='On(b, y) & Clear(x) & ~On(b, x) & ~Clear(y)'),
Action('MoveToTable(b, x)',
precond='On(b, x) & Clear(b) & Block(b)',
effect='On(b, Table) & Clear(x) & ~On(b, x)')])
On(b, x): The block 'b' is on 'x'. 'x' can be a table or a block.
~On(b, x): The block 'b' is not on 'x'. 'x' can be a table or a block.
Block(b): Declares 'b' as a block.
Clear(x): To indicate that there is nothing on 'x' and it is free to be moved around.
~Clear(x): To indicate that there is something on 'x' and it cannot be moved.
Let us now define an object of three_block_tower
problem:
threeBlockTower = three_block_tower()
Before taking any actions, we will check if threeBlockTower
has reached its goal:
print(threeBlockTower.goal_test())
False
As we can see, it hasn't completed the goal.
We now define a sequence of actions that can stack three blocks in the required order.
The actions are then carried out on the threeBlockTower
PlanningProblem.
The actions available to us are the following: MoveToTable, Move
MoveToTable(b, x): Move box 'b' stacked on 'x' to the table, given that box 'b' is clear.
Move(b, x, y): Move box 'b' stacked on 'x' to the top of 'y', given that both 'b' and 'y' are clear.
solution = [expr("MoveToTable(C, A)"),
expr("Move(B, Table, C)"),
expr("Move(A, Table, B)")]
for action in solution:
threeBlockTower.act(action)
As the three_block_tower
has taken all the steps it needed in order to achieve the goal, we can now check if it has acheived its goal.
print(threeBlockTower.goal_test())
True
It has now successfully achieved its goal i.e, to build a stack of three blocks in the specified order.
The three_block_tower
problem can also be defined in simpler terms using just two actions ToTable(x, y)
and FromTable(x, y)
.
The underlying problem remains the same however, stacking up three blocks in a certain configuration given a particular starting state.
Let's have a look at the alternative definition.
psource(simple_blocks_world)
def simple_blocks_world():
"""
SIMPLE-BLOCKS-WORLD
A simplified definition of the Sussman Anomaly problem.
Example:
>>> from planning import *
>>> sbw = simple_blocks_world()
>>> sbw.goal_test()
False
>>> sbw.act(expr('ToTable(A, B)'))
>>> sbw.act(expr('FromTable(B, A)'))
>>> sbw.goal_test()
False
>>> sbw.act(expr('FromTable(C, B)'))
>>> sbw.goal_test()
True
>>>
"""
return PlanningProblem(init='On(A, B) & Clear(A) & OnTable(B) & OnTable(C) & Clear(C)',
goals='On(B, A) & On(C, B)',
actions=[Action('ToTable(x, y)',
precond='On(x, y) & Clear(x)',
effect='~On(x, y) & Clear(y) & OnTable(x)'),
Action('FromTable(y, x)',
precond='OnTable(y) & Clear(y) & Clear(x)',
effect='~OnTable(y) & ~Clear(x) & On(y, x)')])
On(x, y): The block 'x' is on 'y'. Both 'x' and 'y' have to be blocks.
~On(x, y): The block 'x' is not on 'y'. Both 'x' and 'y' have to be blocks.
OnTable(x): The block 'x' is on the table.
~OnTable(x): The block 'x' is not on the table.
Clear(x): To indicate that there is nothing on 'x' and it is free to be moved around.
~Clear(x): To indicate that there is something on 'x' and it cannot be moved.
Let's now define a simple_blocks_world
prolem.
simpleBlocksWorld = simple_blocks_world()
Before taking any actions, we will see if simple_bw
has reached its goal.
simpleBlocksWorld.goal_test()
False
As we can see, it hasn't completed the goal.
We now define a sequence of actions that can stack three blocks in the required order.
The actions are then carried out on the simple_bw
PlanningProblem.
The actions available to us are the following: MoveToTable, Move
ToTable(x, y): Move box 'x' stacked on 'y' to the table, given that box 'y' is clear.
FromTable(x, y): Move box 'x' from wherever it is, to the top of 'y', given that both 'x' and 'y' are clear.
solution = [expr('ToTable(A, B)'),
expr('FromTable(B, A)'),
expr('FromTable(C, B)')]
for action in solution:
simpleBlocksWorld.act(action)
As the three_block_tower
has taken all the steps it needed in order to achieve the goal, we can now check if it has acheived its goal.
print(simpleBlocksWorld.goal_test())
True
It has now successfully achieved its goal i.e, to build a stack of three blocks in the specified order.
This problem requires us to acquire a carton of milk, a banana and a drill.
Initially, we start from home and it is known to us that milk and bananas are available in the supermarket and the hardware store sells drills.
Let's take a look at the definition of the shopping_problem
in the module.
psource(shopping_problem)
def shopping_problem():
"""
SHOPPING-PROBLEM
A problem of acquiring some items given their availability at certain stores.
Example:
>>> from planning import *
>>> sp = shopping_problem()
>>> sp.goal_test()
False
>>> sp.act(expr('Go(Home, HW)'))
>>> sp.act(expr('Buy(Drill, HW)'))
>>> sp.act(expr('Go(HW, SM)'))
>>> sp.act(expr('Buy(Banana, SM)'))
>>> sp.goal_test()
False
>>> sp.act(expr('Buy(Milk, SM)'))
>>> sp.goal_test()
True
>>>
"""
return PlanningProblem(init='At(Home) & Sells(SM, Milk) & Sells(SM, Banana) & Sells(HW, Drill)',
goals='Have(Milk) & Have(Banana) & Have(Drill)',
actions=[Action('Buy(x, store)',
precond='At(store) & Sells(store, x)',
effect='Have(x)'),
Action('Go(x, y)',
precond='At(x)',
effect='At(y) & ~At(x)')])
At(x): Indicates that we are currently at 'x' where 'x' can be Home, SM (supermarket) or HW (Hardware store).
~At(x): Indicates that we are currently not at 'x'.
Sells(s, x): Indicates that item 'x' can be bought from store 's'.
Have(x): Indicates that we possess the item 'x'.
shoppingProblem = shopping_problem()
Let's first check whether the goal state Have(Milk), Have(Banana), Have(Drill) is reached or not.
print(shoppingProblem.goal_test())
False
Let's look at the possible actions
Buy(x, store): Buy an item 'x' from a 'store' given that the 'store' sells 'x'.
Go(x, y): Go to destination 'y' starting from source 'x'.
We now define a valid solution that will help us reach the goal.
The sequence of actions will then be carried out onto the shoppingProblem
PlanningProblem.
solution = [expr('Go(Home, SM)'),
expr('Buy(Milk, SM)'),
expr('Buy(Banana, SM)'),
expr('Go(SM, HW)'),
expr('Buy(Drill, HW)')]
for action in solution:
shoppingProblem.act(action)
We have taken the steps required to acquire all the stuff we need. Let's see if we have reached our goal.
shoppingProblem.goal_test()
True
It has now successfully achieved the goal.
This is a simple problem of putting on a pair of socks and shoes. The problem is defined in the module as given below.
psource(socks_and_shoes)
def socks_and_shoes():
"""
SOCKS-AND-SHOES-PROBLEM
A task of wearing socks and shoes on both feet
Example:
>>> from planning import *
>>> ss = socks_and_shoes()
>>> ss.goal_test()
False
>>> ss.act(expr('RightSock'))
>>> ss.act(expr('RightShoe'))
>>> ss.act(expr('LeftSock'))
>>> ss.goal_test()
False
>>> ss.act(expr('LeftShoe'))
>>> ss.goal_test()
True
>>>
"""
return PlanningProblem(init='',
goals='RightShoeOn & LeftShoeOn',
actions=[Action('RightShoe',
precond='RightSockOn',
effect='RightShoeOn'),
Action('RightSock',
precond='',
effect='RightSockOn'),
Action('LeftShoe',
precond='LeftSockOn',
effect='LeftShoeOn'),
Action('LeftSock',
precond='',
effect='LeftSockOn')])
LeftSockOn: Indicates that we have already put on the left sock.
RightSockOn: Indicates that we have already put on the right sock.
LeftShoeOn: Indicates that we have already put on the left shoe.
RightShoeOn: Indicates that we have already put on the right shoe.
socksShoes = socks_and_shoes()
Let's first check whether the goal state is reached or not.
socksShoes.goal_test()
False
As the goal state isn't reached, we will define a sequence of actions that might help us achieve the goal.
These actions will then be acted upon the socksShoes
PlanningProblem to check if the goal state is reached.
solution = [expr('RightSock'),
expr('RightShoe'),
expr('LeftSock'),
expr('LeftShoe')]
for action in solution:
socksShoes.act(action)
socksShoes.goal_test()
True
We have reached our goal.
This problem requires us to reach the state of having a cake and having eaten a cake simlutaneously, given a single cake.
Let's first take a look at the definition of the have_cake_and_eat_cake_too
problem in the module.
psource(have_cake_and_eat_cake_too)
def have_cake_and_eat_cake_too():
"""
[Figure 10.7] CAKE-PROBLEM
A problem where we begin with a cake and want to
reach the state of having a cake and having eaten a cake.
The possible actions include baking a cake and eating a cake.
Example:
>>> from planning import *
>>> cp = have_cake_and_eat_cake_too()
>>> cp.goal_test()
False
>>> cp.act(expr('Eat(Cake)'))
>>> cp.goal_test()
False
>>> cp.act(expr('Bake(Cake)'))
>>> cp.goal_test()
True
>>>
"""
return PlanningProblem(init='Have(Cake)',
goals='Have(Cake) & Eaten(Cake)',
actions=[Action('Eat(Cake)',
precond='Have(Cake)',
effect='Eaten(Cake) & ~Have(Cake)'),
Action('Bake(Cake)',
precond='~Have(Cake)',
effect='Have(Cake)')])
Since this problem doesn't involve variables, states can be considered similar to symbols in propositional logic.
Have(Cake): Declares that we have a 'Cake'.
~Have(Cake): Declares that we don't have a 'Cake'.
cakeProblem = have_cake_and_eat_cake_too()
First let us check whether the goal state 'Have(Cake)' and 'Eaten(Cake)' are reached or not.
print(cakeProblem.goal_test())
False
Let us look at the possible actions.
Bake(x): To bake ' x '.
Eat(x): To eat ' x '.
We now define a valid solution that can help us reach the goal.
The sequence of actions will then be acted upon the cakeProblem
PlanningProblem.
solution = [expr("Eat(Cake)"),
expr("Bake(Cake)")]
for action in solution:
cakeProblem.act(action)
Now we have made actions to bake the cake and eat the cake. Let us check if we have reached the goal.
print(cakeProblem.goal_test())
True
It has now successfully achieved its goal i.e, to have and eat the cake.
One might wonder if the order of the actions matters for this problem. Let's see for ourselves.
cakeProblem = have_cake_and_eat_cake_too()
solution = [expr('Bake(Cake)'),
expr('Eat(Cake)')]
for action in solution:
cakeProblem.act(action)
--------------------------------------------------------------------------- Exception Traceback (most recent call last) <ipython-input-128-b340f831489f> in <module>() 5 6 for action in solution: ----> 7 cakeProblem.act(action) ~/aima-python/planning.py in act(self, action) 58 raise Exception("Action '{}' not found".format(action_name)) 59 if not list_action.check_precond(self.init, args): ---> 60 raise Exception("Action '{}' pre-conditions not satisfied".format(action)) 61 self.init = list_action(self.init, args).clauses 62 Exception: Action 'Bake(Cake)' pre-conditions not satisfied
It raises an exception. Indeed, according to the problem, we cannot bake a cake if we already have one. In planning terms, '~Have(Cake)' is a precondition to the action 'Bake(Cake)'. Hence, this solution is invalid.
The Problem
class is a wrapper for PlanningProblem
with some additional functionality and data-structures to handle real-world planning problems that involve time and resource constraints.
The Problem
class includes everything that the PlanningProblem
class includes.
Additionally, it also includes the following attributes essential to define a real-world planning problem:
jobs
to be doneresources
It also overloads the act
method to call the do_action
method of the HLA
class,
and also includes a new method refinements
that finds refinements or primitive actions for high level actions.
psource(Problem)
class Problem(PlanningProblem):
"""
Define real-world problems by aggregating resources as numerical quantities instead of
named entities.
This class is identical to PDLL, except that it overloads the act function to handle
resource and ordering conditions imposed by HLA as opposed to Action.
"""
def __init__(self, init, goals, actions, jobs=None, resources=None):
super().__init__(init, goals, actions)
self.jobs = jobs
self.resources = resources or {}
def act(self, action):
"""
Performs the HLA given as argument.
Note that this is different from the superclass action - where the parameter was an
Expression. For real world problems, an Expr object isn't enough to capture all the
detail required for executing the action - resources, preconditions, etc need to be
checked for too.
"""
args = action.args
list_action = first(a for a in self.actions if a.name == action.name)
if list_action is None:
raise Exception("Action '{}' not found".format(action.name))
self.init = list_action.do_action(self.jobs, self.resources, self.init, args).clauses
def refinements(hla, state, library): # refinements may be (multiple) HLA themselves ...
"""
state is a Problem, containing the current state kb
library is a dictionary containing details for every possible refinement. eg:
{
'HLA': [
'Go(Home, SFO)',
'Go(Home, SFO)',
'Drive(Home, SFOLongTermParking)',
'Shuttle(SFOLongTermParking, SFO)',
'Taxi(Home, SFO)'
],
'steps': [
['Drive(Home, SFOLongTermParking)', 'Shuttle(SFOLongTermParking, SFO)'],
['Taxi(Home, SFO)'],
[],
[],
[]
],
# empty refinements indicate a primitive action
'precond': [
['At(Home) & Have(Car)'],
['At(Home)'],
['At(Home) & Have(Car)'],
['At(SFOLongTermParking)'],
['At(Home)']
],
'effect': [
['At(SFO) & ~At(Home)'],
['At(SFO) & ~At(Home)'],
['At(SFOLongTermParking) & ~At(Home)'],
['At(SFO) & ~At(SFOLongTermParking)'],
['At(SFO) & ~At(Home)']
]
}
"""
e = Expr(hla.name, hla.args)
indices = [i for i, x in enumerate(library['HLA']) if expr(x).op == hla.name]
for i in indices:
actions = []
for j in range(len(library['steps'][i])):
# find the index of the step [j] of the HLA
index_step = [k for k,x in enumerate(library['HLA']) if x == library['steps'][i][j]][0]
precond = library['precond'][index_step][0] # preconditions of step [j]
effect = library['effect'][index_step][0] # effect of step [j]
actions.append(HLA(library['steps'][i][j], precond, effect))
yield actions
def hierarchical_search(problem, hierarchy):
"""
[Figure 11.5] 'Hierarchical Search, a Breadth First Search implementation of Hierarchical
Forward Planning Search'
The problem is a real-world problem defined by the problem class, and the hierarchy is
a dictionary of HLA - refinements (see refinements generator for details)
"""
act = Node(problem.actions[0])
frontier = deque()
frontier.append(act)
while True:
if not frontier:
return None
plan = frontier.popleft()
print(plan.state.name)
hla = plan.state # first_or_null(plan)
prefix = None
if plan.parent:
prefix = plan.parent.state.action # prefix, suffix = subseq(plan.state, hla)
outcome = Problem.result(problem, prefix)
if hla is None:
if outcome.goal_test():
return plan.path()
else:
print("else")
for sequence in Problem.refinements(hla, outcome, hierarchy):
print("...")
frontier.append(Node(plan.state, plan.parent, sequence))
def result(state, actions):
"""The outcome of applying an action to the current problem"""
for a in actions:
if a.check_precond(state, a.args):
state = a(state, a.args).clauses
return state
def angelic_search(problem, hierarchy, initialPlan):
"""
[Figure 11.8] A hierarchical planning algorithm that uses angelic semantics to identify and
commit to high-level plans that work while avoiding high-level plans that don’t.
The predicate MAKING-PROGRESS checks to make sure that we aren’t stuck in an infinite regression
of refinements.
At top level, call ANGELIC -SEARCH with [Act ] as the initialPlan .
initialPlan contains a sequence of HLA's with angelic semantics
The possible effects of an angelic HLA in initialPlan are :
~ : effect remove
$+: effect possibly add
$-: effect possibly remove
$$: possibly add or remove
"""
frontier = deque(initialPlan)
while True:
if not frontier:
return None
plan = frontier.popleft() # sequence of HLA/Angelic HLA's
opt_reachable_set = Problem.reach_opt(problem.init, plan)
pes_reachable_set = Problem.reach_pes(problem.init, plan)
if problem.intersects_goal(opt_reachable_set):
if Problem.is_primitive( plan, hierarchy ):
return ([x for x in plan.action])
guaranteed = problem.intersects_goal(pes_reachable_set)
if guaranteed and Problem.making_progress(plan, plan):
final_state = guaranteed[0] # any element of guaranteed
#print('decompose')
return Problem.decompose(hierarchy, problem, plan, final_state, pes_reachable_set)
(hla, index) = Problem.find_hla(plan, hierarchy) # there should be at least one HLA/Angelic_HLA, otherwise plan would be primitive.
prefix = plan.action[:index-1]
suffix = plan.action[index+1:]
outcome = Problem(Problem.result(problem.init, prefix), problem.goals , problem.actions )
for sequence in Problem.refinements(hla, outcome, hierarchy): # find refinements
frontier.append(Angelic_Node(outcome.init, plan, prefix + sequence+ suffix, prefix+sequence+suffix))
def intersects_goal(problem, reachable_set):
"""
Find the intersection of the reachable states and the goal
"""
return [y for x in list(reachable_set.keys()) for y in reachable_set[x] if all(goal in y for goal in problem.goals)]
def is_primitive(plan, library):
"""
checks if the hla is primitive action
"""
for hla in plan.action:
indices = [i for i, x in enumerate(library['HLA']) if expr(x).op == hla.name]
for i in indices:
if library["steps"][i]:
return False
return True
def reach_opt(init, plan):
"""
Finds the optimistic reachable set of the sequence of actions in plan
"""
reachable_set = {0: [init]}
optimistic_description = plan.action #list of angelic actions with optimistic description
return Problem.find_reachable_set(reachable_set, optimistic_description)
def reach_pes(init, plan):
"""
Finds the pessimistic reachable set of the sequence of actions in plan
"""
reachable_set = {0: [init]}
pessimistic_description = plan.action_pes # list of angelic actions with pessimistic description
return Problem.find_reachable_set(reachable_set, pessimistic_description)
def find_reachable_set(reachable_set, action_description):
"""
Finds the reachable states of the action_description when applied in each state of reachable set.
"""
for i in range(len(action_description)):
reachable_set[i+1]=[]
if type(action_description[i]) is Angelic_HLA:
possible_actions = action_description[i].angelic_action()
else:
possible_actions = action_description
for action in possible_actions:
for state in reachable_set[i]:
if action.check_precond(state , action.args) :
if action.effect[0] :
new_state = action(state, action.args).clauses
reachable_set[i+1].append(new_state)
else:
reachable_set[i+1].append(state)
return reachable_set
def find_hla(plan, hierarchy):
"""
Finds the the first HLA action in plan.action, which is not primitive
and its corresponding index in plan.action
"""
hla = None
index = len(plan.action)
for i in range(len(plan.action)): # find the first HLA in plan, that is not primitive
if not Problem.is_primitive(Node(plan.state, plan.parent, [plan.action[i]]), hierarchy):
hla = plan.action[i]
index = i
break
return (hla, index)
def making_progress(plan, initialPlan):
"""
Not correct
Normally should from infinite regression of refinements
Only case covered: when plan contains one action (then there is no regression to be done)
"""
if (len(plan.action)==1):
return False
return True
def decompose(hierarchy, s_0, plan, s_f, reachable_set):
solution = []
while plan.action_pes:
action = plan.action_pes.pop()
i = max(reachable_set.keys())
if (i==0):
return solution
s_i = Problem.find_previous_state(s_f, reachable_set,i, action)
problem = Problem(s_i, s_f , plan.action)
j=0
for x in Problem.angelic_search(problem, hierarchy, [Angelic_Node(s_i, Node(None), [action],[action])]):
solution.insert(j,x)
j+=1
s_f = s_i
return solution
def find_previous_state(s_f, reachable_set, i, action):
"""
Given a final state s_f and an action finds a state s_i in reachable_set
such that when action is applied to state s_i returns s_f.
"""
s_i = reachable_set[i-1][0]
for state in reachable_set[i-1]:
if s_f in [x for x in Problem.reach_pes(state, Angelic_Node(state, None, [action],[action]))[1]]:
s_i =state
break
return s_i
To be able to model a real-world planning problem properly, it is essential to be able to represent a high-level action (HLA) that can be hierarchically reduced to primitive actions.
psource(HLA)
class HLA(Action):
"""
Define Actions for the real-world (that may be refined further), and satisfy resource
constraints.
"""
unique_group = 1
def __init__(self, action, precond=None, effect=None, duration=0,
consume=None, use=None):
"""
As opposed to actions, to define HLA, we have added constraints.
duration holds the amount of time required to execute the task
consumes holds a dictionary representing the resources the task consumes
uses holds a dictionary representing the resources the task uses
"""
precond = precond or [None]
effect = effect or [None]
super().__init__(action, precond, effect)
self.duration = duration
self.consumes = consume or {}
self.uses = use or {}
self.completed = False
# self.priority = -1 # must be assigned in relation to other HLAs
# self.job_group = -1 # must be assigned in relation to other HLAs
def do_action(self, job_order, available_resources, kb, args):
"""
An HLA based version of act - along with knowledge base updation, it handles
resource checks, and ensures the actions are executed in the correct order.
"""
# print(self.name)
if not self.has_usable_resource(available_resources):
raise Exception('Not enough usable resources to execute {}'.format(self.name))
if not self.has_consumable_resource(available_resources):
raise Exception('Not enough consumable resources to execute {}'.format(self.name))
if not self.inorder(job_order):
raise Exception("Can't execute {} - execute prerequisite actions first".
format(self.name))
kb = super().act(kb, args) # update knowledge base
for resource in self.consumes: # remove consumed resources
available_resources[resource] -= self.consumes[resource]
self.completed = True # set the task status to complete
return kb
def has_consumable_resource(self, available_resources):
"""
Ensure there are enough consumable resources for this action to execute.
"""
for resource in self.consumes:
if available_resources.get(resource) is None:
return False
if available_resources[resource] < self.consumes[resource]:
return False
return True
def has_usable_resource(self, available_resources):
"""
Ensure there are enough usable resources for this action to execute.
"""
for resource in self.uses:
if available_resources.get(resource) is None:
return False
if available_resources[resource] < self.uses[resource]:
return False
return True
def inorder(self, job_order):
"""
Ensure that all the jobs that had to be executed before the current one have been
successfully executed.
"""
for jobs in job_order:
if self in jobs:
for job in jobs:
if job is self:
return True
if not job.completed:
return False
return True
In addition to preconditions and effects, an object of the HLA
class also stores:
duration
of the HLAcompleted
denoting if the HLA
has been completedThe class also has some useful helper methods:
do_action
: checks if required consumable and reusable resources are available and if so, executes the action.has_consumable_resource
: checks if there exists sufficient quantity of the required consumable resource.has_usable_resource
: checks if reusable resources are available and not already engaged.inorder
: ensures that all the jobs that had to be executed before the current one have been successfully executed.This is a simple problem involving the assembly of two cars simultaneously.
The problem consists of two jobs, each of the form [AddEngine
, AddWheels
, Inspect
] to be performed on two cars with different requirements and availability of resources.
psource(job_shop_problem)
def job_shop_problem():
"""
[Figure 11.1] JOB-SHOP-PROBLEM
A job-shop scheduling problem for assembling two cars,
with resource and ordering constraints.
Example:
>>> from planning import *
>>> p = job_shop_problem()
>>> p.goal_test()
False
>>> p.act(p.jobs[1][0])
>>> p.act(p.jobs[1][1])
>>> p.act(p.jobs[1][2])
>>> p.act(p.jobs[0][0])
>>> p.act(p.jobs[0][1])
>>> p.goal_test()
False
>>> p.act(p.jobs[0][2])
>>> p.goal_test()
True
>>>
"""
resources = {'EngineHoists': 1, 'WheelStations': 2, 'Inspectors': 2, 'LugNuts': 500}
add_engine1 = HLA('AddEngine1', precond='~Has(C1, E1)', effect='Has(C1, E1)', duration=30, use={'EngineHoists': 1})
add_engine2 = HLA('AddEngine2', precond='~Has(C2, E2)', effect='Has(C2, E2)', duration=60, use={'EngineHoists': 1})
add_wheels1 = HLA('AddWheels1', precond='~Has(C1, W1)', effect='Has(C1, W1)', duration=30, use={'WheelStations': 1}, consume={'LugNuts': 20})
add_wheels2 = HLA('AddWheels2', precond='~Has(C2, W2)', effect='Has(C2, W2)', duration=15, use={'WheelStations': 1}, consume={'LugNuts': 20})
inspect1 = HLA('Inspect1', precond='~Inspected(C1)', effect='Inspected(C1)', duration=10, use={'Inspectors': 1})
inspect2 = HLA('Inspect2', precond='~Inspected(C2)', effect='Inspected(C2)', duration=10, use={'Inspectors': 1})
actions = [add_engine1, add_engine2, add_wheels1, add_wheels2, inspect1, inspect2]
job_group1 = [add_engine1, add_wheels1, inspect1]
job_group2 = [add_engine2, add_wheels2, inspect2]
return Problem(init='Car(C1) & Car(C2) & Wheels(W1) & Wheels(W2) & Engine(E2) & Engine(E2) & ~Has(C1, E1) & ~Has(C2, E2) & ~Has(C1, W1) & ~Has(C2, W2) & ~Inspected(C1) & ~Inspected(C2)',
goals='Has(C1, W1) & Has(C1, E1) & Inspected(C1) & Has(C2, W2) & Has(C2, E2) & Inspected(C2)',
actions=actions,
jobs=[job_group1, job_group2],
resources=resources)
The states of this problem are:
~Has(x, y): Car 'x' does not have 'y' where 'y' can be an Engine or a Wheel.
Inspected(c): Car 'c' has been inspected.
~Inspected(c): Car 'c' has not been inspected.
In the initial state, C1
and C2
are cars and neither have an engine or wheels and haven't been inspected.
E1
and E2
are engines.
W1
and W2
are wheels.
jobShopProblem = job_shop_problem()
Before taking any actions, we will check if jobShopProblem
has reached its goal.
print(jobShopProblem.goal_test())
False
We now define a possible solution that can help us reach the goal.
The actions are then carried out on the jobShopProblem
object.
The following actions are available to us:
AddEngine1: Adds an engine to the car C1. Takes 30 minutes to complete and uses an engine hoist.
AddEngine2: Adds an engine to the car C2. Takes 60 minutes to complete and uses an engine hoist.
AddWheels1: Adds wheels to car C1. Takes 30 minutes to complete. Uses a wheel station and consumes 20 lug nuts.
AddWheels2: Adds wheels to car C2. Takes 15 minutes to complete. Uses a wheel station and consumes 20 lug nuts as well.
Inspect1: Gets car C1 inspected. Requires 10 minutes of inspection by one inspector.
Inspect2: Gets car C2 inspected. Requires 10 minutes of inspection by one inspector.
solution = [jobShopProblem.jobs[1][0],
jobShopProblem.jobs[1][1],
jobShopProblem.jobs[1][2],
jobShopProblem.jobs[0][0],
jobShopProblem.jobs[0][1],
jobShopProblem.jobs[0][2]]
for action in solution:
jobShopProblem.act(action)
print(jobShopProblem.goal_test())
True
This is a valid solution and one of many correct ways to solve this problem.
This problem is a simple case of a multiactor planning problem, where two agents act at once and can simultaneously change the current state of the problem. A correct plan is one that, if executed by the actors, achieves the goal. In the true multiagent setting, of course, the agents may not agree to execute any particular plan, but atleast they will know what plans would work if they did agree to execute them.
psource(double_tennis_problem)
def double_tennis_problem():
"""
[Figure 11.10] DOUBLE-TENNIS-PROBLEM
A multiagent planning problem involving two partner tennis players
trying to return an approaching ball and repositioning around in the court.
Example:
>>> from planning import *
>>> dtp = double_tennis_problem()
>>> goal_test(dtp.goals, dtp.init)
False
>>> dtp.act(expr('Go(A, RightBaseLine, LeftBaseLine)'))
>>> dtp.act(expr('Hit(A, Ball, RightBaseLine)'))
>>> goal_test(dtp.goals, dtp.init)
False
>>> dtp.act(expr('Go(A, LeftNet, RightBaseLine)'))
>>> goal_test(dtp.goals, dtp.init)
True
>>>
"""
return PlanningProblem(init='At(A, LeftBaseLine) & At(B, RightNet) & Approaching(Ball, RightBaseLine) & Partner(A, B) & Partner(B, A)',
goals='Returned(Ball) & At(a, LeftNet) & At(a, RightNet)',
actions=[Action('Hit(actor, Ball, loc)',
precond='Approaching(Ball, loc) & At(actor, loc)',
effect='Returned(Ball)'),
Action('Go(actor, to, loc)',
precond='At(actor, loc)',
effect='At(actor, to) & ~At(actor, loc)')])
The states of this problem are:
Approaching(Ball, loc): The Ball
is approaching the location loc
.
Returned(Ball): One of the actors successfully hit the approaching ball from the correct location which caused it to return to the other side.
At(actor, loc): actor
is at location loc
.
~At(actor, loc): actor
is not at location loc
.
Let's now define an object of double_tennis_problem
.
doubleTennisProblem = double_tennis_problem()
Before taking any actions, we will check if doubleTennisProblem
has reached the goal.
print(doubleTennisProblem.goal_test())
False
As we can see, the goal hasn't been reached.
We now define a possible solution that can help us reach the goal of having the ball returned.
The actions will then be carried out on the doubleTennisProblem
object.
The actions available to us are the following:
Hit(actor, ball, loc): returns an approaching ball if actor
is present at the loc
that the ball is approaching.
Go(actor, to, loc): moves an actor
from location loc
to location to
.
We notice something different in this problem though,
which is quite unlike any other problem we have seen so far.
The goal state of the problem contains a variable a
.
This happens sometimes in multiagent planning problems
and it means that it doesn't matter which actor is at the LeftNet
or the RightNet
, as long as there is atleast one actor at either LeftNet
or RightNet
.
solution = [expr('Go(A, RightBaseLine, LeftBaseLine)'),
expr('Hit(A, Ball, RightBaseLine)'),
expr('Go(A, LeftNet, RightBaseLine)')]
for action in solution:
doubleTennisProblem.act(action)
doubleTennisProblem.goal_test()
False
It has now successfully reached its goal, ie, to return the approaching ball.