planning-machine 0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- planning_machine-0.1/LICENSE +21 -0
- planning_machine-0.1/PKG-INFO +9 -0
- planning_machine-0.1/README.md +37 -0
- planning_machine-0.1/planning_machine/__init__.py +4 -0
- planning_machine-0.1/planning_machine/lib.py +148 -0
- planning_machine-0.1/planning_machine/mcp_server.py +68 -0
- planning_machine-0.1/planning_machine/pddl_parser.py +129 -0
- planning_machine-0.1/planning_machine/report.py +94 -0
- planning_machine-0.1/planning_machine/yaml_parser.py +315 -0
- planning_machine-0.1/planning_machine.egg-info/PKG-INFO +9 -0
- planning_machine-0.1/planning_machine.egg-info/SOURCES.txt +14 -0
- planning_machine-0.1/planning_machine.egg-info/dependency_links.txt +1 -0
- planning_machine-0.1/planning_machine.egg-info/requires.txt +3 -0
- planning_machine-0.1/planning_machine.egg-info/top_level.txt +1 -0
- planning_machine-0.1/setup.cfg +4 -0
- planning_machine-0.1/setup.py +12 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Learning Machines Pty Ltd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Introduction
|
|
2
|
+
PlanningMachine is an automated planning library that parse YAML-based instructions into PDDL, solves them using a planner, and returns a structured plan with step effects. It can be run as a standalone service via Docker Compose or integrated directly into Python projects as a library. The goal of this project is to make the technology in automated planning more accessible to software engineers without exposing them to the internals of how they work.
|
|
3
|
+
|
|
4
|
+
# Setup and Installation
|
|
5
|
+
The Python library can be installed either by building the wheel files from source or via pip. We recommend the latter.
|
|
6
|
+
|
|
7
|
+
## from Source
|
|
8
|
+
1. In the root directory, execute `python setup.py sdist bdist_wheel` to create a `.whl` file in a newly created `dist` folder.
|
|
9
|
+
2. Execute `pip install /dist/filename.whl`, where `filename.whl` is a placeholder name for the created `.whl` file.
|
|
10
|
+
|
|
11
|
+
## from PyPi
|
|
12
|
+
Execute `pip install planning_machine`.
|
|
13
|
+
|
|
14
|
+
# Usage
|
|
15
|
+
PlanningMachine assumes Docker is installed (and running) on your computer.
|
|
16
|
+
|
|
17
|
+
## Docker Compose
|
|
18
|
+
1. Create a directory named `workdir` in the root folder.
|
|
19
|
+
2. Place two files in `workdir`: `domain.yaml` and `problem.yaml`.
|
|
20
|
+
3. Execute `docker compose up` in the root folder. This parses the files in the `workdir` to PDDL, calls the planner to solve them, and extract the effects of each plan step. The solution is placed in `workdir/plan.yaml`.
|
|
21
|
+
|
|
22
|
+
## Python
|
|
23
|
+
|
|
24
|
+
Import and use the `PlanningMachine` class as follows:
|
|
25
|
+
```python
|
|
26
|
+
from planning_machine import PlanningMachine
|
|
27
|
+
|
|
28
|
+
pm = PlanningMachine()
|
|
29
|
+
solution = pm.solve(yaml_domain_path, yaml_problem_path)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Provide the paths to your YAML domain and problem files, and `.solve()` will return the solution to your planning problem.
|
|
33
|
+
|
|
34
|
+
A demo Jupyter notebook, `demo.ipynb`, is also included. It walks through a real-world logistics problem end-to-end, covering setup, solving, analytics, and visualization.
|
|
35
|
+
|
|
36
|
+
## Contact
|
|
37
|
+
Please contact <a href="mailto:opensource@learningmachines.au">opensource@learningmachines.au</a> for questions, comments, or feedback about the PlanningMachine library.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import docker
|
|
2
|
+
import tempfile
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from planning_machine import YAML2PDDL
|
|
6
|
+
from planning_machine import PlanReport
|
|
7
|
+
|
|
8
|
+
class PlanningMachine:
|
|
9
|
+
def __init__(self, planner: str = 'LAPKT'):
|
|
10
|
+
"""Connect to the local Docker daemon.
|
|
11
|
+
|
|
12
|
+
Uses ``docker.from_env()`` to pick up connection details from
|
|
13
|
+
environment variables (e.g. ``DOCKER_HOST``), then sends a ping
|
|
14
|
+
to verify that the daemon is responsive.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
planner: the planner docker image to be used in subsequent steps
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
RuntimeError: If the Docker daemon cannot be reached or the
|
|
21
|
+
ping fails.
|
|
22
|
+
"""
|
|
23
|
+
self.planner = planner
|
|
24
|
+
try:
|
|
25
|
+
self.docker_client = docker.from_env()
|
|
26
|
+
self.docker_client.ping()
|
|
27
|
+
except Exception as e:
|
|
28
|
+
raise RuntimeError("[ERROR] Docker is not running") from e
|
|
29
|
+
def solve(self, domain_yaml_path: str, problem_yaml_path: str):
|
|
30
|
+
"""Run the full planning pipeline and return a structured plan.
|
|
31
|
+
|
|
32
|
+
The method proceeds through five stages:
|
|
33
|
+
|
|
34
|
+
1. **Conversion to PDDL** --
|
|
35
|
+
A `YAML2PDDL` instance parses the two YAML files and returns
|
|
36
|
+
domain and problem PDDL strings.
|
|
37
|
+
|
|
38
|
+
2. **Temporary file setup** --
|
|
39
|
+
Both PDDL strings are written to a temporary directory that
|
|
40
|
+
will be bind-mounted into the Docker containers.
|
|
41
|
+
|
|
42
|
+
3. **Planning** --
|
|
43
|
+
Depending on the choice of planner in "self.planner", the
|
|
44
|
+
corresponding Docker image is used to solve the problem.
|
|
45
|
+
This produces a plan.pddl file in the temporary folder.
|
|
46
|
+
|
|
47
|
+
4. **Validation (VAL)** --
|
|
48
|
+
The ``claudiusk/val`` image is run against the domain,
|
|
49
|
+
problem, and plan files. It produces a LaTeX report
|
|
50
|
+
(``report.tex``) that documents each plan step and its
|
|
51
|
+
effects. The container is also removed after execution.
|
|
52
|
+
|
|
53
|
+
5. **Report extraction** --
|
|
54
|
+
A `PlanReport` instance parses ``report.tex`` and returns a
|
|
55
|
+
dictionary containing the plan length and a list of steps,
|
|
56
|
+
each annotated with its add and delete effects.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
domain_yaml_path: Path to the YAML domain file.
|
|
60
|
+
problem_yaml_path: Path to the YAML problem file.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
A dictionary produced by `PlanReport.report`, containing:
|
|
64
|
+
|
|
65
|
+
* ``plan_length`` (*int*) -- total number of plan steps.
|
|
66
|
+
* ``plan`` (*list*) -- one dict per step with keys ``step``,
|
|
67
|
+
``action``, and ``effects`` (itself holding ``add`` and
|
|
68
|
+
``del`` lists).
|
|
69
|
+
|
|
70
|
+
Returns ``None`` if `PlanReport.report` returns ``None``
|
|
71
|
+
(e.g. when the report file was not generated).
|
|
72
|
+
"""
|
|
73
|
+
parser = YAML2PDDL()
|
|
74
|
+
domain_pddl, problem_pddl = parser.parse(domain_yaml_path, problem_yaml_path)
|
|
75
|
+
with tempfile.TemporaryDirectory() as tmpdirname:
|
|
76
|
+
# write PDDL files to a temporary location
|
|
77
|
+
domain_pddl_path = os.path.join(tmpdirname, "domain.pddl")
|
|
78
|
+
problem_pddl_path = os.path.join(tmpdirname, "problem.pddl")
|
|
79
|
+
with open(domain_pddl_path, 'w+') as domain:
|
|
80
|
+
domain.write(domain_pddl)
|
|
81
|
+
with open(problem_pddl_path, 'w+') as problem:
|
|
82
|
+
problem.write(problem_pddl)
|
|
83
|
+
# Use Fast Downward to find a plan
|
|
84
|
+
if self.planner == "FD":
|
|
85
|
+
self.docker_client.containers.run(
|
|
86
|
+
image="aibasel/downward",
|
|
87
|
+
command=[
|
|
88
|
+
"--plan-file", "/work/plan.pddl",
|
|
89
|
+
"/work/domain.pddl",
|
|
90
|
+
"/work/problem.pddl",
|
|
91
|
+
"--search", "astar(blind())"
|
|
92
|
+
],
|
|
93
|
+
volumes={
|
|
94
|
+
str(tmpdirname): {
|
|
95
|
+
"bind": "/work",
|
|
96
|
+
"mode": "rw"
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
working_dir="/work",
|
|
100
|
+
remove=True,
|
|
101
|
+
)
|
|
102
|
+
# use LAPKT to find a plan
|
|
103
|
+
elif self.planner == "LAPKT":
|
|
104
|
+
self.docker_client.containers.run(
|
|
105
|
+
image="lapkt/lapkt-public",
|
|
106
|
+
command=[
|
|
107
|
+
"bfs_f",
|
|
108
|
+
"--domain", "/work/domain.pddl",
|
|
109
|
+
"--problem", "/work/problem.pddl",
|
|
110
|
+
"--output", "/work/plan.pddl"
|
|
111
|
+
],
|
|
112
|
+
volumes={
|
|
113
|
+
str(tmpdirname): {
|
|
114
|
+
"bind": "/work",
|
|
115
|
+
"mode": "rw"
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
working_dir="/work",
|
|
119
|
+
remove=True,
|
|
120
|
+
)
|
|
121
|
+
# use VAL image to extract plan justifications
|
|
122
|
+
report = self.docker_client.containers.run(
|
|
123
|
+
image="claudiusk/val",
|
|
124
|
+
command=[
|
|
125
|
+
"-f",
|
|
126
|
+
"/work/report",
|
|
127
|
+
"/work/domain.pddl",
|
|
128
|
+
"/work/problem.pddl",
|
|
129
|
+
"/work/plan.pddl"
|
|
130
|
+
],
|
|
131
|
+
volumes={
|
|
132
|
+
str(tmpdirname): {
|
|
133
|
+
"bind": "/work",
|
|
134
|
+
"mode": "rw"
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
working_dir="/work",
|
|
138
|
+
remove=True,
|
|
139
|
+
)
|
|
140
|
+
# post process the report into YAML
|
|
141
|
+
reporter = PlanReport()
|
|
142
|
+
report = reporter.report(os.path.join(tmpdirname, "report.tex"))
|
|
143
|
+
return report
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
planner = PlanningMachine(planner='LAPKT')
|
|
147
|
+
plan = planner.solve(r"example\satellite\domain.yaml", r"example\satellite\problem.yaml")
|
|
148
|
+
print(plan)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import yaml
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from mcp.server import FastMCP
|
|
7
|
+
|
|
8
|
+
from planning_machine import PlanningMachine
|
|
9
|
+
|
|
10
|
+
# setting the master prompt
|
|
11
|
+
script_dir = Path(__file__).parent
|
|
12
|
+
instructions = (script_dir / "prompt.txt").read_text()
|
|
13
|
+
|
|
14
|
+
mcp = FastMCP(
|
|
15
|
+
name="LogisticsMachine",
|
|
16
|
+
instructions=instructions,
|
|
17
|
+
host="0.0.0.0",
|
|
18
|
+
port=8000,
|
|
19
|
+
stateless_http=True
|
|
20
|
+
)
|
|
21
|
+
WORKSPACE = Path.home() / "workspace"
|
|
22
|
+
|
|
23
|
+
@mcp.tool()
|
|
24
|
+
def write_yaml_file(filename: str, content: str) -> str:
|
|
25
|
+
"""Write a YAML planning file (domain or problem) to the workspace.
|
|
26
|
+
|
|
27
|
+
Creates or overwrites a file in the configured workspace directory
|
|
28
|
+
with the provided content. The workspace directory is created
|
|
29
|
+
automatically if it doesn't already exist.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
filename: Name of the file to create (e.g. "domain.yaml").
|
|
33
|
+
content: The full YAML content to write to the file.
|
|
34
|
+
"""
|
|
35
|
+
os.makedirs(WORKSPACE, exist_ok=True)
|
|
36
|
+
path = os.path.join(WORKSPACE, filename)
|
|
37
|
+
with open(path, "w") as f:
|
|
38
|
+
f.write(content)
|
|
39
|
+
return f"[SUCCESS] Written to {path}"
|
|
40
|
+
|
|
41
|
+
@mcp.tool()
|
|
42
|
+
def solve_workspace(domain_path: str, problem_path: str) -> str:
|
|
43
|
+
"""Run the planner on a domain/problem pair from the workspace.
|
|
44
|
+
|
|
45
|
+
Loads the specified domain and problem YAML files from the workspace
|
|
46
|
+
directory, invokes the planning engine, and returns the resulting
|
|
47
|
+
plan serialized as a YAML string.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
domain_path: Filename of the domain definition relative to the
|
|
51
|
+
workspace (e.g. "domain.yaml").
|
|
52
|
+
problem_path: Filename of the problem definition relative to the
|
|
53
|
+
workspace (e.g. "problem.yaml").
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The solution plan formatted as a YAML string, or an empty YAML
|
|
57
|
+
document if no plan was found.
|
|
58
|
+
"""
|
|
59
|
+
planner = PlanningMachine()
|
|
60
|
+
domain = os.path.join(WORKSPACE, domain_path)
|
|
61
|
+
problem = os.path.join(WORKSPACE, problem_path)
|
|
62
|
+
return yaml.dump(planner.solve(domain, problem))
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
if "--http" in sys.argv:
|
|
66
|
+
mcp.run(transport="streamable-http")
|
|
67
|
+
else:
|
|
68
|
+
mcp.run(transport="stdio")
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from unified_planning.io import PDDLReader
|
|
2
|
+
import yaml
|
|
3
|
+
|
|
4
|
+
class PDDL2YAML:
|
|
5
|
+
DOMAIN_NAME = "DomainName"
|
|
6
|
+
def __init__(self):
|
|
7
|
+
pass
|
|
8
|
+
def _parse_domain(self, problem_pddl):
|
|
9
|
+
domain = {
|
|
10
|
+
"domain": PDDL2YAML.DOMAIN_NAME,
|
|
11
|
+
"types": {},
|
|
12
|
+
"predicates": {},
|
|
13
|
+
"actions": {},
|
|
14
|
+
}
|
|
15
|
+
for t in problem_pddl.user_types:
|
|
16
|
+
if t.father != None:
|
|
17
|
+
domain["types"][str(t.name)] = str(t.father)
|
|
18
|
+
else:
|
|
19
|
+
domain["types"][str(t.name)] = "object"
|
|
20
|
+
for fluent in problem_pddl.fluents:
|
|
21
|
+
fluent_args = []
|
|
22
|
+
for param in fluent.signature:
|
|
23
|
+
fluent_args.append({param.name: param.type.name})
|
|
24
|
+
domain["predicates"][fluent.name] = fluent_args
|
|
25
|
+
for action in problem_pddl.actions:
|
|
26
|
+
result = {}
|
|
27
|
+
# Parameters
|
|
28
|
+
params = []
|
|
29
|
+
for param in action.parameters:
|
|
30
|
+
params.append({
|
|
31
|
+
param.name: param.type.name
|
|
32
|
+
})
|
|
33
|
+
result["parameters"] = params
|
|
34
|
+
# Preconditions
|
|
35
|
+
prec = {"and": []}
|
|
36
|
+
for precondition in action.preconditions:
|
|
37
|
+
if precondition.is_and():
|
|
38
|
+
for fluent in precondition.args:
|
|
39
|
+
prec["and"].append({
|
|
40
|
+
fluent.fluent().name: [str(arg) for arg in fluent.args]
|
|
41
|
+
})
|
|
42
|
+
elif hasattr(precondition, 'fluent'):
|
|
43
|
+
# Single atomic precondition (not wrapped in 'and')
|
|
44
|
+
prec["and"].append({
|
|
45
|
+
precondition.fluent().name: [str(arg) for arg in precondition.args]
|
|
46
|
+
})
|
|
47
|
+
result["precondition"] = prec
|
|
48
|
+
# Effects
|
|
49
|
+
effs = {"and": []}
|
|
50
|
+
for effect in action.effects:
|
|
51
|
+
if effect.condition.is_true():
|
|
52
|
+
# Unconditional effect
|
|
53
|
+
if effect.value.is_true():
|
|
54
|
+
effs["and"].append({
|
|
55
|
+
effect.fluent.fluent().name: [str(arg) for arg in effect.fluent.args]
|
|
56
|
+
})
|
|
57
|
+
else:
|
|
58
|
+
effs["and"].append({
|
|
59
|
+
'not': {
|
|
60
|
+
effect.fluent.fluent().name: [str(arg) for arg in effect.fluent.args]
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
else:
|
|
64
|
+
raise ValueError("Conditional effects are not supported.")
|
|
65
|
+
result["effect"] = effs
|
|
66
|
+
domain['actions'][action.name] = result
|
|
67
|
+
return domain
|
|
68
|
+
def _parse_problem(self, problem_pddl):
|
|
69
|
+
problem = {
|
|
70
|
+
"domain": PDDL2YAML.DOMAIN_NAME,
|
|
71
|
+
"problem": problem_pddl.name,
|
|
72
|
+
"objects": {},
|
|
73
|
+
"init": {},
|
|
74
|
+
"goal": {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Goal
|
|
78
|
+
goal = {}
|
|
79
|
+
for g in problem_pddl.goals:
|
|
80
|
+
if g.is_and():
|
|
81
|
+
for fluent in g.args:
|
|
82
|
+
if fluent.fluent().name in goal:
|
|
83
|
+
goal[fluent.fluent().name].append([str(arg) for arg in fluent.args])
|
|
84
|
+
else:
|
|
85
|
+
goal[fluent.fluent().name] = [[str(arg) for arg in fluent.args]]
|
|
86
|
+
elif hasattr(g, 'fluent'):
|
|
87
|
+
# Single atomic goal (not wrapped in 'and')
|
|
88
|
+
goal[g.fluent().name] = [[str(arg) for arg in g.args]]
|
|
89
|
+
else:
|
|
90
|
+
raise ValueError(f"{g} is not supported.")
|
|
91
|
+
problem["goal"] = goal
|
|
92
|
+
|
|
93
|
+
# Init: group atom args by predicate name
|
|
94
|
+
init: dict[str, list[list[str]]] = {}
|
|
95
|
+
for atom in problem_pddl.initial_values:
|
|
96
|
+
if problem_pddl.initial_values[atom].is_true():
|
|
97
|
+
pred = atom.fluent().name
|
|
98
|
+
args = [str(a) for a in atom.args]
|
|
99
|
+
init.setdefault(pred, []).append(args)
|
|
100
|
+
problem["init"] = init
|
|
101
|
+
|
|
102
|
+
# Objects: grouped by type name
|
|
103
|
+
objects: dict[str, list[str]] = {}
|
|
104
|
+
for obj in problem_pddl.all_objects:
|
|
105
|
+
type_name = str(obj.type.name)
|
|
106
|
+
objects.setdefault(type_name, []).append(str(obj.name))
|
|
107
|
+
problem["objects"] = objects
|
|
108
|
+
return problem
|
|
109
|
+
def parse(self, domain_path: str, problem_path: str):
|
|
110
|
+
'''
|
|
111
|
+
Args:
|
|
112
|
+
domain_path: Path to the PDDL domain file.
|
|
113
|
+
problem_path: Path to the PDDL problem file.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
A 2-tuple ``(domain_yaml, problem_yaml)`` where each element
|
|
117
|
+
is a dictionary corresponding to the YAML output.
|
|
118
|
+
'''
|
|
119
|
+
reader = PDDLReader()
|
|
120
|
+
problem_pddl = reader.parse_problem(domain_path, problem_path)
|
|
121
|
+
return (self._parse_domain(problem_pddl), self._parse_problem(problem_pddl))
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
parser = PDDL2YAML()
|
|
125
|
+
(domain_yaml, problem_yaml) = parser.parse("workdir/domain.pddl", "workdir/problem.pddl")
|
|
126
|
+
with open("from_pddl_domain.yaml", 'w+') as f:
|
|
127
|
+
yaml.safe_dump(domain_yaml, f, sort_keys=False)
|
|
128
|
+
with open("from_pddl_problem.yaml", 'w+') as f:
|
|
129
|
+
yaml.safe_dump(problem_yaml, f, sort_keys=False)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
class PlanReport:
|
|
7
|
+
def __init__(self) -> None:
|
|
8
|
+
pass
|
|
9
|
+
def report(self, tex_path: str):
|
|
10
|
+
"""Parse a VAL LaTeX report and return a structured plan dictionary.
|
|
11
|
+
|
|
12
|
+
The method performs the following steps:
|
|
13
|
+
|
|
14
|
+
1. **Open** the ``.tex`` file and read its full content.
|
|
15
|
+
2. **Locate** the ``\\subsection{Plan}`` block and extract every
|
|
16
|
+
``\\action{...}`` command to obtain the ordered list of plan
|
|
17
|
+
actions.
|
|
18
|
+
3. **Locate** the ``\\subsection{Plan Validation}`` block and
|
|
19
|
+
extract every ``\\atime{n}`` block. Within each block,
|
|
20
|
+
``\\adding{...}`` and ``\\deleting{...}`` commands are
|
|
21
|
+
collected as the step's positive and negative effects.
|
|
22
|
+
4. **Build** the result dictionary, replacing LaTeX-escaped
|
|
23
|
+
underscores (``\\_``) with plain underscores in all action
|
|
24
|
+
and effect strings.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
tex_path: Filesystem path to the VAL LaTeX report (``.tex``).
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
A dictionary with two keys:
|
|
31
|
+
|
|
32
|
+
* ``plan_length`` (*int*) -- the total number of plan steps.
|
|
33
|
+
* ``plan`` (*List[Dict]*) -- one entry per step, each
|
|
34
|
+
containing ``step`` (int), ``action`` (str), and
|
|
35
|
+
``effects`` (dict with ``add`` and ``del`` string lists).
|
|
36
|
+
|
|
37
|
+
Returns ``None`` implicitly if *tex_path* does not point to
|
|
38
|
+
an existing file.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
FileNotFoundError: if `tex_path` does not point to a valid address.
|
|
42
|
+
RuntimeError: If the *Plan* subsection is missing.
|
|
43
|
+
RuntimeError: If the *Plan Validation* subsection is missing.
|
|
44
|
+
RuntimeError: If the number of extracted actions does not
|
|
45
|
+
match the number of effect blocks.
|
|
46
|
+
"""
|
|
47
|
+
if os.path.isfile(tex_path):
|
|
48
|
+
with open(tex_path, 'r') as report_file:
|
|
49
|
+
# open the report file
|
|
50
|
+
content = report_file.read()
|
|
51
|
+
# extract content of subsection "Plan"
|
|
52
|
+
actions = re.search(r'\\subsection\{Plan\}(.*?)(?=\\subsection|\\section)', content, re.DOTALL)
|
|
53
|
+
# extract content of subsection "Plan Validation"
|
|
54
|
+
effects = re.search(r'\\subsection\{Plan Validation\}(.*?)(?=\\subsection|\\section)', content, re.DOTALL)
|
|
55
|
+
if not actions:
|
|
56
|
+
raise RuntimeError("Could not find Plan section")
|
|
57
|
+
if not effects:
|
|
58
|
+
raise RuntimeError("Could not find Plan Validation section")
|
|
59
|
+
else:
|
|
60
|
+
# extract plan steps
|
|
61
|
+
actions = re.findall(r'\\action\{([^}]+(?:\{[^}]*\}[^}]*)*)\}', actions.group(1))
|
|
62
|
+
# extract step effects
|
|
63
|
+
effects = re.findall(r'\\atime\{(\d+)\}(.*?)(?=\\atime|\Z)', effects.group(1), re.DOTALL)
|
|
64
|
+
if len(actions) != len(effects):
|
|
65
|
+
raise RuntimeError("Plan Actions and Effects does not have the same cardinality.")
|
|
66
|
+
# Create the output
|
|
67
|
+
plan = list()
|
|
68
|
+
for action, (step, block) in zip(actions, effects):
|
|
69
|
+
del_effs = re.findall(r'\\deleting\{([^}]+)\}', block)
|
|
70
|
+
add_effs = re.findall(r'\\adding\{([^}]+)\}', block)
|
|
71
|
+
plan.append({
|
|
72
|
+
"step": int(step),
|
|
73
|
+
"action": action.replace('\\_', '_'),
|
|
74
|
+
"effects": {
|
|
75
|
+
"add": [add_eff.replace('\\_', '_') for add_eff in add_effs],
|
|
76
|
+
"del": [del_eff.replace('\\_', '_') for del_eff in del_effs],
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
result = {
|
|
81
|
+
"plan_length": int(len(plan)),
|
|
82
|
+
"plan": plan
|
|
83
|
+
}
|
|
84
|
+
return result
|
|
85
|
+
else:
|
|
86
|
+
raise FileNotFoundError
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
parser = argparse.ArgumentParser("Extract justification graph from VAL latex report.")
|
|
89
|
+
parser.add_argument("tex", help="Path to the report file.")
|
|
90
|
+
args = parser.parse_args()
|
|
91
|
+
report_generator = PlanReport()
|
|
92
|
+
report = report_generator.report(args.tex)
|
|
93
|
+
with open("plan.yaml", 'w+') as f:
|
|
94
|
+
yaml.dump(report, f, indent=4, sort_keys=False)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
from collections import OrderedDict
|
|
2
|
+
import yaml
|
|
3
|
+
import argparse
|
|
4
|
+
from unified_planning.model import *
|
|
5
|
+
from unified_planning.shortcuts import *
|
|
6
|
+
from unified_planning.io import PDDLWriter
|
|
7
|
+
|
|
8
|
+
class YAML2PDDL:
|
|
9
|
+
"""Convert a YAML-encoded planning domain and problem into PDDL."""
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.domain_name = None
|
|
12
|
+
self.types = None
|
|
13
|
+
self.predicates = None
|
|
14
|
+
self.actions = None
|
|
15
|
+
def _parse_domain(self, path: str) -> None:
|
|
16
|
+
"""Read and parse a YAML domain file.
|
|
17
|
+
|
|
18
|
+
This is the top-level domain entry-point. It opens the file at
|
|
19
|
+
*path*, deserialises the YAML content, and delegates to the
|
|
20
|
+
specialised helpers below:
|
|
21
|
+
|
|
22
|
+
* `_construct_type_hierarchy` -- optional; skipped if the YAML has
|
|
23
|
+
no ``types`` key.
|
|
24
|
+
* `_construct_predicates` -- required.
|
|
25
|
+
* `_construct_actions` -- required.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
path: Filesystem path to the YAML domain file.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
SyntaxError: If the ``domain`` key is missing, or if
|
|
32
|
+
predicates / actions are not defined properly.
|
|
33
|
+
"""
|
|
34
|
+
print(f"Opening domain {path}")
|
|
35
|
+
with open(path, 'r', encoding='utf-8') as domain:
|
|
36
|
+
domain = yaml.safe_load(domain)
|
|
37
|
+
try:
|
|
38
|
+
self.domain_name = domain["domain"]
|
|
39
|
+
except Exception as e:
|
|
40
|
+
raise SyntaxError("Expected keyword `domain`.") from e
|
|
41
|
+
# the domain can be untyped
|
|
42
|
+
if "types" in domain:
|
|
43
|
+
print("Extracting the type hierarchy...")
|
|
44
|
+
self._construct_type_hierarchy(domain["types"])
|
|
45
|
+
try:
|
|
46
|
+
print("Extracting predicates...")
|
|
47
|
+
self._construct_predicates(domain["predicates"])
|
|
48
|
+
except Exception as e:
|
|
49
|
+
raise SyntaxError("Predicates are not defined properly.") from e
|
|
50
|
+
try:
|
|
51
|
+
print("Extracting actions...")
|
|
52
|
+
self._construct_actions(domain["actions"])
|
|
53
|
+
except Exception as e:
|
|
54
|
+
raise SyntaxError("Actions are not defined properly.") from e
|
|
55
|
+
def _construct_type_hierarchy(self, type_dict: Dict[str, str]) -> None:
|
|
56
|
+
"""Build the type hierarchy from the YAML ``types`` mapping.
|
|
57
|
+
|
|
58
|
+
Each key in *type_dict* is a child type name and its value is the
|
|
59
|
+
parent type name. Parent types are created on-the-fly if they
|
|
60
|
+
have not been seen yet, ensuring the hierarchy is built
|
|
61
|
+
bottom-up regardless of declaration order.
|
|
62
|
+
|
|
63
|
+
The resulting mapping is stored in ``self.types``.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
type_dict: A dict whose keys are type names and values are
|
|
67
|
+
their parent type names.
|
|
68
|
+
Example: ``{"truck": "vehicle", "vehicle": "object"}``
|
|
69
|
+
"""
|
|
70
|
+
types = {}
|
|
71
|
+
for t, f in type_dict.items():
|
|
72
|
+
if f not in types:
|
|
73
|
+
types[f] = UserType(f)
|
|
74
|
+
types[t] = up.shortcuts.UserType(t, types[f])
|
|
75
|
+
self.types = types
|
|
76
|
+
# parses the predicates in the domain
|
|
77
|
+
def _construct_predicates(self, predicate_dict: Dict[str, List[Dict[str, str]]]) -> None:
|
|
78
|
+
"""Build typed predicates from the YAML definition.
|
|
79
|
+
|
|
80
|
+
Each predicate is expected as a mapping from the predicate name to
|
|
81
|
+
a list of argument specs. Every argument spec is itself a
|
|
82
|
+
single-entry dict ``{arg_name: arg_type}``.
|
|
83
|
+
|
|
84
|
+
The arguments are converted to ``Parameter`` objects (using types
|
|
85
|
+
from ``self.types``), and a ``Fluent`` with ``BoolType`` return
|
|
86
|
+
type is created for each predicate.
|
|
87
|
+
|
|
88
|
+
The resulting mapping is stored in ``self.predicates``.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
predicate_dict: Mapping of predicate names to their argument
|
|
92
|
+
lists.
|
|
93
|
+
Example::
|
|
94
|
+
{
|
|
95
|
+
"at": [{"obj": "package"}, {"loc": "location"}],
|
|
96
|
+
"connected": [{"from": "location"}, {"to": "location"}]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
Note:
|
|
100
|
+
Untyped predicates are not yet supported (see TODO in source).
|
|
101
|
+
"""
|
|
102
|
+
predicates = {}
|
|
103
|
+
for predicate in predicate_dict.items():
|
|
104
|
+
name = predicate[0]
|
|
105
|
+
args = predicate[1]
|
|
106
|
+
for i, arg in enumerate(args):
|
|
107
|
+
arg_name, arg_type = next(iter(arg.items()))
|
|
108
|
+
args[i] = Parameter(arg_name, self.types[arg_type])
|
|
109
|
+
fluent = Fluent(name, BoolType(), args)
|
|
110
|
+
predicates[name] = fluent
|
|
111
|
+
self.predicates = predicates
|
|
112
|
+
# TODO: construct untyped predicates
|
|
113
|
+
def _construct_actions(self, actions_dict: Dict[str, Dict[str, Any]]) -> None:
|
|
114
|
+
"""Build actions from the YAML ``actions`` mapping.
|
|
115
|
+
|
|
116
|
+
For every action the method:
|
|
117
|
+
|
|
118
|
+
1. Extracts **parameters** -- an ordered mapping of parameter names
|
|
119
|
+
to their types.
|
|
120
|
+
2. Extracts **preconditions** -- currently only a top-level ``and``
|
|
121
|
+
conjunction is supported. Each conjunct is a predicate applied
|
|
122
|
+
to the action's parameters.
|
|
123
|
+
3. Extracts **effects** -- supports two top-level forms:
|
|
124
|
+
- ``and``: a conjunction of positive or negated predicate
|
|
125
|
+
assignments.
|
|
126
|
+
- ``when``: a conditional effect with a ``condition`` /
|
|
127
|
+
``then`` structure.
|
|
128
|
+
|
|
129
|
+
The resulting mapping is stored in ``self.actions``.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
actions_dict: Mapping of action names to their bodies. Each
|
|
133
|
+
body is a dict with keys ``parameters``, and optionally
|
|
134
|
+
``precondition`` and ``effect``.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
AssertionError: If the precondition or effect block does not
|
|
138
|
+
have exactly one root key (e.g. ``and`` or ``when``).
|
|
139
|
+
"""
|
|
140
|
+
actions = {}
|
|
141
|
+
for action_name, action_body in actions_dict.items():
|
|
142
|
+
params = OrderedDict()
|
|
143
|
+
for param in action_body["parameters"]:
|
|
144
|
+
param = next(iter(param.items()))
|
|
145
|
+
params[param[0]] = self.types[param[1]]
|
|
146
|
+
action = InstantaneousAction(action_name, params)
|
|
147
|
+
if "precondition" in action_body:
|
|
148
|
+
precs = action_body["precondition"]
|
|
149
|
+
# assert a unique root
|
|
150
|
+
assert 1 == len(precs)
|
|
151
|
+
|
|
152
|
+
precs = precs["and"]
|
|
153
|
+
for pre in precs:
|
|
154
|
+
predicate = next(iter(pre.items()))
|
|
155
|
+
predicate_name = self.predicates[predicate[0]]
|
|
156
|
+
predicate_args = [action.parameter(x) for x in predicate[1]]
|
|
157
|
+
action.add_precondition(predicate_name(*predicate_args))
|
|
158
|
+
if "effect" in action_body:
|
|
159
|
+
effs = action_body["effect"]
|
|
160
|
+
# assert a unique root
|
|
161
|
+
assert 1 == len(effs)
|
|
162
|
+
|
|
163
|
+
match next(iter(effs.keys())):
|
|
164
|
+
case "and":
|
|
165
|
+
for k in effs["and"]:
|
|
166
|
+
string = next(iter(k.items()))
|
|
167
|
+
if string[0] in self.predicates:
|
|
168
|
+
predicate_name = self.predicates[string[0]]
|
|
169
|
+
predicate_args = [action.parameter(x) for x in string[1]]
|
|
170
|
+
action.add_effect(predicate_name(*predicate_args), True)
|
|
171
|
+
elif string[0] == "not":
|
|
172
|
+
predicate = next(iter(k["not"].items()))
|
|
173
|
+
predicate_name = self.predicates[predicate[0]]
|
|
174
|
+
predicate_args = [action.parameter(x) for x in predicate[1]]
|
|
175
|
+
action.add_effect(predicate_name(*predicate_args), False)
|
|
176
|
+
else:
|
|
177
|
+
pass
|
|
178
|
+
case "when":
|
|
179
|
+
conditions = [
|
|
180
|
+
self.predicates[name](*[action.parameter(arg) for arg in args])
|
|
181
|
+
for cond in effs["when"]["condition"]
|
|
182
|
+
for name, args in cond.items()
|
|
183
|
+
]
|
|
184
|
+
cond_effs = effs["when"]["then"]
|
|
185
|
+
for cond_eff in cond_effs:
|
|
186
|
+
string = next(iter(cond_eff.items()))
|
|
187
|
+
if string[0] in self.predicates:
|
|
188
|
+
predicate_name = self.predicates[string[0]]
|
|
189
|
+
predicate_args = [action.parameter(x) for x in string[1]]
|
|
190
|
+
action.add_effect(predicate_name(*predicate_args), True, And(*conditions))
|
|
191
|
+
elif string[0] == "not":
|
|
192
|
+
predicate = next(iter(string[1].items()))
|
|
193
|
+
predicate_name = self.predicates[predicate[0]]
|
|
194
|
+
predicate_args = [action.parameter(x) for x in predicate[1]]
|
|
195
|
+
action.add_effect(predicate_name(*predicate_args), False, And(*conditions))
|
|
196
|
+
else:
|
|
197
|
+
pass
|
|
198
|
+
print(f"\textracted {action_name}")
|
|
199
|
+
actions[action_name] = action
|
|
200
|
+
self.actions = actions
|
|
201
|
+
def reset(self) -> None:
|
|
202
|
+
"""Reset all internal state so the instance can be reused.
|
|
203
|
+
|
|
204
|
+
After calling this every attribute is set back to ``None``,
|
|
205
|
+
exactly as if a fresh ``YAML2PDDL()`` had been constructed.
|
|
206
|
+
"""
|
|
207
|
+
self.domain_name = None
|
|
208
|
+
self.types = None
|
|
209
|
+
self.predicates = None
|
|
210
|
+
self.actions = None
|
|
211
|
+
def _parse_problem(self, path: str) -> Problem:
|
|
212
|
+
"""Read and parse a YAML problem file into a ``Problem`` instance.
|
|
213
|
+
|
|
214
|
+
The method expects that ``_parse_domain`` has already been called,
|
|
215
|
+
because it relies on ``self.domain_name``, ``self.types``,
|
|
216
|
+
``self.predicates``, and ``self.actions``.
|
|
217
|
+
|
|
218
|
+
Parsing proceeds in four stages:
|
|
219
|
+
|
|
220
|
+
1. **Validation** -- checks that the ``domain`` key in the YAML problem
|
|
221
|
+
matches ``self.domain_name``.
|
|
222
|
+
2. **Objects** -- typed objects are created and added to the
|
|
223
|
+
problem. Stored in ``self.objects`` for later reference.
|
|
224
|
+
3. **Initial state** -- each predicate grounding listed under
|
|
225
|
+
``init`` is set to ``True``.
|
|
226
|
+
4. **Goal** -- each predicate grounding listed under ``goal`` is
|
|
227
|
+
added as a goal condition.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
path: Path to the YAML problem file.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
A fully constructed ``Problem`` ready for PDDL serialisation.
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
AssertionError: If the YAML does not contain a ``domain`` key
|
|
237
|
+
or if the domain name does not match the previously parsed
|
|
238
|
+
domain.
|
|
239
|
+
"""
|
|
240
|
+
print(f"Opening problem {path}")
|
|
241
|
+
with open(path, 'r', encoding='utf-8') as problem_path:
|
|
242
|
+
problem_dict = yaml.safe_load(problem_path)
|
|
243
|
+
# assert the domain and problem match
|
|
244
|
+
assert "domain" in problem_dict
|
|
245
|
+
assert problem_dict["domain"] == self.domain_name
|
|
246
|
+
# Construct the UP problem
|
|
247
|
+
problem = Problem(problem_dict["problem"])
|
|
248
|
+
for fluent in self.predicates.values():
|
|
249
|
+
problem.add_fluent(fluent, default_initial_value=False)
|
|
250
|
+
problem.add_actions(self.actions.values())
|
|
251
|
+
|
|
252
|
+
# Extract objects from the problem
|
|
253
|
+
print("Extracting objects...")
|
|
254
|
+
if "objects" in problem_dict:
|
|
255
|
+
result = {}
|
|
256
|
+
for (type, objects) in problem_dict["objects"].items():
|
|
257
|
+
for object in objects:
|
|
258
|
+
result[object] = Object(object, self.types[type])
|
|
259
|
+
problem.add_object(result[object])
|
|
260
|
+
self.objects = result
|
|
261
|
+
# extract the initial values
|
|
262
|
+
print("Extracting initial values...")
|
|
263
|
+
if "init" in problem_dict:
|
|
264
|
+
result = []
|
|
265
|
+
for (predicate, values) in problem_dict["init"].items():
|
|
266
|
+
for value in values:
|
|
267
|
+
object_values = [self.objects[o] for o in value]
|
|
268
|
+
problem.set_initial_value(self.predicates[predicate](*object_values), True)
|
|
269
|
+
# extract the goal condition
|
|
270
|
+
print("Extracting the goal condition...")
|
|
271
|
+
if "goal" in problem_dict:
|
|
272
|
+
for (predicate, values) in problem_dict["goal"].items():
|
|
273
|
+
for value in values:
|
|
274
|
+
object_values = [self.objects[o] for o in value]
|
|
275
|
+
problem.add_goal(self.predicates[predicate](*object_values))
|
|
276
|
+
return problem
|
|
277
|
+
def parse(self, domain_path: str, problem_path: str):
|
|
278
|
+
"""Parse a YAML domain and problem, returning the corresponding PDDL strings.
|
|
279
|
+
|
|
280
|
+
This is the main public entry-point. It:
|
|
281
|
+
|
|
282
|
+
1. Resets all internal state via `reset`.
|
|
283
|
+
2. Parses the domain file.
|
|
284
|
+
3. Parses the problem file (building a ``Problem`` model).
|
|
285
|
+
4. Uses ``PDDLWriter`` to serialise the model into two PDDL
|
|
286
|
+
strings.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
domain_path: Path to the YAML domain file.
|
|
290
|
+
problem_path: Path to the YAML problem file.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
A 2-tuple ``(domain_pddl, problem_pddl)`` where each element
|
|
294
|
+
is a string containing the corresponding PDDL output.
|
|
295
|
+
"""
|
|
296
|
+
self.reset()
|
|
297
|
+
self._parse_domain(domain_path)
|
|
298
|
+
model = self._parse_problem(problem_path)
|
|
299
|
+
writer = PDDLWriter(model)
|
|
300
|
+
domainPDDL = writer.get_domain()
|
|
301
|
+
problemPDDL = writer.get_problem()
|
|
302
|
+
return (domainPDDL, problemPDDL)
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
argparser = argparse.ArgumentParser("YAML2PDDL")
|
|
306
|
+
argparser.add_argument("domain", help="Path to the domain file in YAML")
|
|
307
|
+
argparser.add_argument("problem", help="Path to the problem file in YAML")
|
|
308
|
+
args = argparser.parse_args()
|
|
309
|
+
yaml_parser = YAML2PDDL()
|
|
310
|
+
(domain, problem) = yaml_parser.parse(args.domain, args.problem)
|
|
311
|
+
with open('domain.pddl', 'w+') as d_file:
|
|
312
|
+
d_file.write(domain)
|
|
313
|
+
with open('problem.pddl', 'w+') as p_file:
|
|
314
|
+
p_file.write(problem)
|
|
315
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
setup.py
|
|
4
|
+
planning_machine/__init__.py
|
|
5
|
+
planning_machine/lib.py
|
|
6
|
+
planning_machine/mcp_server.py
|
|
7
|
+
planning_machine/pddl_parser.py
|
|
8
|
+
planning_machine/report.py
|
|
9
|
+
planning_machine/yaml_parser.py
|
|
10
|
+
planning_machine.egg-info/PKG-INFO
|
|
11
|
+
planning_machine.egg-info/SOURCES.txt
|
|
12
|
+
planning_machine.egg-info/dependency_links.txt
|
|
13
|
+
planning_machine.egg-info/requires.txt
|
|
14
|
+
planning_machine.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
planning_machine
|