planning-machine 0.1__py3-none-any.whl

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.
@@ -0,0 +1,4 @@
1
+ from .yaml_parser import YAML2PDDL
2
+ from .report import PlanReport
3
+ from .lib import PlanningMachine
4
+ from .pddl_parser import PDDL2YAML
@@ -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,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: planning-machine
3
+ Version: 0.1
4
+ License-File: LICENSE
5
+ Requires-Dist: unified_planning>=1.3.0
6
+ Requires-Dist: PyYAML>=6.0.3
7
+ Requires-Dist: docker>=7.1.0
8
+ Dynamic: license-file
9
+ Dynamic: requires-dist
@@ -0,0 +1,11 @@
1
+ planning_machine/__init__.py,sha256=n7Xj5dKv3ZhVZyTpls_i5IIHZCoM5vnLk5lstBCdrPg,133
2
+ planning_machine/lib.py,sha256=BwKMTOk7adNNPYE-hH7JryNXZ3rdQBL4bnWXisoxEww,5852
3
+ planning_machine/mcp_server.py,sha256=k9i8X0doP929R_hZBfnsKBw0eqD56NT9wBPxXffCq7w,2126
4
+ planning_machine/pddl_parser.py,sha256=xbYrHVnbfVfDv-FibJa7MVr0UE8u2WxdaF6OhPcX8Vc,5202
5
+ planning_machine/report.py,sha256=Z7PLYnGnuN_F3h8j66Y2NmWyBKipA_oXUuHwRncNfSI,4471
6
+ planning_machine/yaml_parser.py,sha256=B04DfFdeEFAwdQXnOvwwr0k7C3blh1zRUG7CibRw59A,14194
7
+ planning_machine-0.1.dist-info/licenses/LICENSE,sha256=TFXVt8crZDKEALiRIL8zP8QYQ3omIcxpRggpVGIRz4U,1082
8
+ planning_machine-0.1.dist-info/METADATA,sha256=EJyKpcEsTTIY5VJ74USmhMK3FEtPjVC951Ndsz4Kxmg,231
9
+ planning_machine-0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
+ planning_machine-0.1.dist-info/top_level.txt,sha256=nO3MD2rvLz14hEqPJgf56yYuNct7jOfvQTN09Rab9Rg,17
11
+ planning_machine-0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ planning_machine