From a3c3a3651bcfef87f161e372bc57242228bc37da Mon Sep 17 00:00:00 2001 From: Gabriele Roeger Date: Thu, 26 Oct 2023 19:24:29 +0200 Subject: [PATCH 01/11] replace goal with an action to achieve an artificial goal --- up_fast_downward/fast_downward.py | 83 ++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/up_fast_downward/fast_downward.py b/up_fast_downward/fast_downward.py index 80d9851..715ebe9 100644 --- a/up_fast_downward/fast_downward.py +++ b/up_fast_downward/fast_downward.py @@ -1,12 +1,14 @@ +from itertools import count import pkg_resources import sys import unified_planning as up from typing import Callable, Iterator, IO, List, Optional, Tuple, Union -from unified_planning.model import ProblemKind +from unified_planning.model import ProblemKind, InstantaneousAction from unified_planning.engines import OptimalityGuarantee from unified_planning.engines import PlanGenerationResultStatus as ResultStatus from unified_planning.engines import PDDLAnytimePlanner, PDDLPlanner from unified_planning.engines import OperationMode, Credits +from unified_planning.shortcuts import BoolType, MinimizeActionCosts from unified_planning.engines.results import LogLevel, LogMessage, PlanGenerationResult credits = { @@ -257,3 +259,82 @@ def supports(problem_kind: "ProblemKind") -> bool: @staticmethod def satisfies(optimality_guarantee: OptimalityGuarantee) -> bool: return True + + # To avoid the introduction of axioms with complicated goals, we introduce + # a separate goal action (later to be removed from the plan) + def _solve( + self, + problem: "up.model.AbstractProblem", + heuristic: Optional[Callable[["up.model.state.State"], Optional[float]]] = None, + timeout: Optional[float] = None, + output_stream: Optional[Union[Tuple[IO[str], IO[str]], IO[str]]] = None, + ) -> "up.engines.results.PlanGenerationResult": + def get_new_name(problem, prefix): + for num in count(): + candidate = f"{prefix}{num}" + if not problem.has_name(candidate): + return candidate + + assert isinstance(problem, up.model.Problem) + + modified_problem = problem.clone() + # add a new goal atom (initially false) plus an action that has the + # original goal as precondition and sets the new goal atom + goal_fluent_name = get_new_name(modified_problem, "goal") + goal_fluent = modified_problem.add_fluent( + goal_fluent_name, BoolType(), default_initial_value=False + ) + goal_action_name = get_new_name(modified_problem, "reach_goal") + goal_action = InstantaneousAction(goal_action_name) + for goal in modified_problem.goals: + goal_action.add_precondition(goal) + goal_action.add_effect(goal_fluent, True) + modified_problem.add_action(goal_action) + modified_problem.clear_goals() + modified_problem.add_goal(goal_fluent) + if modified_problem.quality_metrics and isinstance( + modified_problem.quality_metrics[0], MinimizeActionCosts + ): + m = modified_problem.quality_metrics[0] + action_costs = m.costs + action_costs[goal_action] = 1 + metric = MinimizeActionCosts(action_costs, m.default, m.environment) + modified_problem.clear_quality_metrics() + modified_problem.add_quality_metric(metric) + + return super()._solve(modified_problem, heuristic, timeout, output_stream) + + # overwrite plan extraction to remove the newly introduced goal action from + # the end of the plan + def _plan_from_file( + self, + problem: "up.model.Problem", + plan_filename: str, + get_item_named: Callable[ + [str], + Union[ + "up.model.Type", + "up.model.Action", + "up.model.Fluent", + "up.model.Object", + "up.model.Parameter", + "up.model.Variable", + "up.model.multi_agent.Agent", + ], + ], + ) -> "up.plans.Plan": + """ + Takes a problem, a filename and a map of renaming and returns the plan parsed from the file. + :param problem: The up.model.problem.Problem instance for which the plan is generated. + :param plan_filename: The path of the file in which the plan is written. + :param get_item_named: A function that takes a name and returns the original up.model element instance + linked to that renaming. + :return: The up.plans.Plan corresponding to the parsed plan from the file + """ + with open(plan_filename) as plan: + plan_string = plan.read() + # We remove the last two lines (goal action plus a comment with the + # cost) from the plan string + plan_string = plan_string.split("\n")[:-3] + plan_string = "\n".join(plan_string) + return self._plan_from_str(problem, plan_string, get_item_named) From e88241dbb80670ab9cf013cbfd90f15e4120a18d Mon Sep 17 00:00:00 2001 From: Gabriele Roeger Date: Mon, 6 Nov 2023 19:03:18 +0100 Subject: [PATCH 02/11] make removal of last action more robust --- up_fast_downward/fast_downward.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/up_fast_downward/fast_downward.py b/up_fast_downward/fast_downward.py index 715ebe9..7f8e723 100644 --- a/up_fast_downward/fast_downward.py +++ b/up_fast_downward/fast_downward.py @@ -333,8 +333,11 @@ def _plan_from_file( """ with open(plan_filename) as plan: plan_string = plan.read() - # We remove the last two lines (goal action plus a comment with the - # cost) from the plan string - plan_string = plan_string.split("\n")[:-3] + # Remove all comments from the plan string. + plan_string = [line for line in plan_string.split("\n") + if not line.strip().startswith(";")] + # Remove the last line (= goal action) from the plan string. + plan_string = plan_string[:-2] plan_string = "\n".join(plan_string) + print(plan_string) return self._plan_from_str(problem, plan_string, get_item_named) From 165180d14b4de9d42bdbc0cfefc5f3e1cd8c764c Mon Sep 17 00:00:00 2001 From: Framba Luca <82944800+Framba-Luca@users.noreply.github.com> Date: Mon, 6 Nov 2023 21:29:40 +0100 Subject: [PATCH 03/11] Use UP problem kind version 2 --------- Co-authored-by: Gabriele Roeger --- CHANGES.md | 3 +++ misc/tox.ini | 2 +- setup.py | 2 +- up_fast_downward/fast_downward.py | 6 ++++-- up_fast_downward/fast_downward_grounder.py | 14 ++++++++------ 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 942490b..e38c449 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,7 @@ # Release notes +UP Fast Downward 0.3.2 +- support UP problem kind version 2 + UP Fast Downward 0.3.1 - fix windows wheel diff --git a/misc/tox.ini b/misc/tox.ini index 94a3ae7..a1c9d16 100644 --- a/misc/tox.ini +++ b/misc/tox.ini @@ -10,6 +10,6 @@ changedir = {toxinidir}/tests/ deps = pytest up-fast-downward - unified-planning + unified-planning >= 1.0.0.168.dev1 commands = pytest test-simple.py diff --git a/setup.py b/setup.py index 23bd4cc..fbe1acb 100644 --- a/setup.py +++ b/setup.py @@ -74,7 +74,7 @@ def run(self): long_description = "This package makes the [Fast Downward](https://www.fast-downward.org/) planning system available in the [unified_planning library](https://github.com/aiplan4eu/unified-planning) by the [AIPlan4EU project](https://www.aiplan4eu-project.eu/)." setup(name='up_fast_downward', - version='0.3.1', + version='0.3.2', description='Unified Planning Integration of the Fast Downward planning system', long_description=long_description, long_description_content_type="text/markdown", diff --git a/up_fast_downward/fast_downward.py b/up_fast_downward/fast_downward.py index 7f8e723..129a58a 100644 --- a/up_fast_downward/fast_downward.py +++ b/up_fast_downward/fast_downward.py @@ -183,7 +183,7 @@ def satisfies(optimality_guarantee: "OptimalityGuarantee") -> bool: @staticmethod def supported_kind() -> "ProblemKind": - supported_kind = ProblemKind() + supported_kind = ProblemKind(version=2) supported_kind.set_problem_class("ACTION_BASED") supported_kind.set_typing("FLAT_TYPING") supported_kind.set_typing("HIERARCHICAL_TYPING") @@ -198,6 +198,7 @@ def supported_kind() -> "ProblemKind": supported_kind.set_effects_kind("FORALL_EFFECTS") supported_kind.set_quality_metrics("ACTIONS_COST") supported_kind.set_actions_cost_kind("STATIC_FLUENTS_IN_ACTIONS_COST") + supported_kind.set_actions_cost_kind("INT_NUMBERS_IN_ACTIONS_COST") supported_kind.set_quality_metrics("PLAN_LENGTH") return supported_kind @@ -238,7 +239,7 @@ def get_credits(**kwargs) -> Optional["Credits"]: @staticmethod def supported_kind() -> "ProblemKind": - supported_kind = ProblemKind() + supported_kind = ProblemKind(version=2) supported_kind.set_problem_class("ACTION_BASED") supported_kind.set_typing("FLAT_TYPING") supported_kind.set_typing("HIERARCHICAL_TYPING") @@ -249,6 +250,7 @@ def supported_kind() -> "ProblemKind": supported_kind.set_conditions_kind("EQUALITIES") supported_kind.set_quality_metrics("ACTIONS_COST") supported_kind.set_actions_cost_kind("STATIC_FLUENTS_IN_ACTIONS_COST") + supported_kind.set_actions_cost_kind("INT_NUMBERS_IN_ACTIONS_COST") supported_kind.set_quality_metrics("PLAN_LENGTH") return supported_kind diff --git a/up_fast_downward/fast_downward_grounder.py b/up_fast_downward/fast_downward_grounder.py index 0601c27..62b126e 100644 --- a/up_fast_downward/fast_downward_grounder.py +++ b/up_fast_downward/fast_downward_grounder.py @@ -51,7 +51,7 @@ def get_credits(**kwargs) -> Optional["Credits"]: @staticmethod def supported_kind() -> ProblemKind: - supported_kind = ProblemKind() + supported_kind = ProblemKind(version=2) supported_kind.set_problem_class("ACTION_BASED") supported_kind.set_typing("FLAT_TYPING") supported_kind.set_typing("HIERARCHICAL_TYPING") @@ -64,6 +64,7 @@ def supported_kind() -> ProblemKind: supported_kind.set_effects_kind("FLUENTS_IN_BOOLEAN_ASSIGNMENTS") supported_kind.set_quality_metrics("ACTIONS_COST") supported_kind.set_actions_cost_kind("STATIC_FLUENTS_IN_ACTIONS_COST") + supported_kind.set_actions_cost_kind("INT_NUMBERS_IN_ACTIONS_COST") supported_kind.set_quality_metrics("PLAN_LENGTH") supported_kind.set_conditions_kind("UNIVERSAL_CONDITIONS") supported_kind.set_conditions_kind("EXISTENTIAL_CONDITIONS") @@ -81,7 +82,7 @@ def supports_compilation(compilation_kind: CompilationKind) -> bool: def resulting_problem_kind( problem_kind: ProblemKind, compilation_kind: Optional[CompilationKind] = None ) -> ProblemKind: - return ProblemKind(problem_kind.features) + return problem_kind.clone() def _compile( self, problem: "up.model.AbstractProblem", compilation_kind: "CompilationKind" @@ -163,7 +164,7 @@ def get_credits(**kwargs) -> Optional["Credits"]: @staticmethod def supported_kind() -> ProblemKind: - supported_kind = ProblemKind() + supported_kind = ProblemKind(version=2) supported_kind.set_problem_class("ACTION_BASED") supported_kind.set_typing("FLAT_TYPING") supported_kind.set_typing("HIERARCHICAL_TYPING") @@ -175,6 +176,7 @@ def supported_kind() -> ProblemKind: supported_kind.set_effects_kind("FLUENTS_IN_BOOLEAN_ASSIGNMENTS") supported_kind.set_quality_metrics("ACTIONS_COST") supported_kind.set_actions_cost_kind("STATIC_FLUENTS_IN_ACTIONS_COST") + supported_kind.set_actions_cost_kind("INT_NUMBERS_IN_ACTIONS_COST") supported_kind.set_quality_metrics("PLAN_LENGTH") # We don't support allquantified conditions because they can lead @@ -204,9 +206,9 @@ def supports_compilation(compilation_kind: CompilationKind) -> bool: def resulting_problem_kind( problem_kind: ProblemKind, compilation_kind: Optional[CompilationKind] = None ) -> ProblemKind: - orig_features = problem_kind.features - features = set.difference(orig_features, {"DISJUNCTIVE_CONDITIONS"}) - return ProblemKind(features) + resulting_problem_kind = problem_kind.clone() + resulting_problem_kind.unset_conditions_kind("DISJUNCTIVE_CONDITIONS") + return resulting_problem_kind def _get_fnode( self, From 46ce127761d6ae45878674b7df5763a6db428759 Mon Sep 17 00:00:00 2001 From: Gabi Roeger Date: Tue, 7 Nov 2023 10:48:49 +0100 Subject: [PATCH 04/11] Fix grounders * fix bug in Fast Downward reachability grounder * fix simplifier creation in Fast Downward grounder * silence internal Fast Downward output in grounders --------- Co-authored-by: Luca Framba --- CHANGES.md | 4 ++++ up_fast_downward/fast_downward_grounder.py | 11 +++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index e38c449..6fb1fdb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,8 @@ # Release notes +UP Fast Downward 0.3.3 +- fix bug in fast-downward-reachability grounder +- silence output in grounders + UP Fast Downward 0.3.2 - support UP problem kind version 2 diff --git a/up_fast_downward/fast_downward_grounder.py b/up_fast_downward/fast_downward_grounder.py index 62b126e..1934ca8 100644 --- a/up_fast_downward/fast_downward_grounder.py +++ b/up_fast_downward/fast_downward_grounder.py @@ -1,4 +1,5 @@ from collections import defaultdict +from io import StringIO import os.path import sys import unified_planning as up @@ -104,6 +105,8 @@ def _compile( pddl_domain = writer.get_domain().split("\n") orig_path = list(sys.path) + orig_stdout = sys.stdout + sys.stdout = StringIO() path = os.path.join( os.path.dirname(__file__), "downward/builds/release/bin/translate" ) @@ -122,6 +125,7 @@ def _compile( fast_downward_normalize.normalize(task) prog = prolog_program(task) model = compute_model(prog) + sys.stdout = orig_stdout grounding_action_map = defaultdict(list) exp_manager = problem.environment.expression_manager for atom in model: @@ -130,7 +134,7 @@ def _compile( schematic_up_action = writer.get_item_named(action.name) params = ( writer.get_item_named(p) - for p in atom.args[: len(action.parameters)] + for p in atom.args[:action.num_external_parameters] ) up_params = tuple(exp_manager.ObjectExp(p) for p in params) grounding_action_map[schematic_up_action].append(up_params) @@ -289,6 +293,8 @@ def _compile( pddl_domain = writer.get_domain().split("\n") orig_path = list(sys.path) + orig_stdout = sys.stdout + sys.stdout = StringIO() path = os.path.join( os.path.dirname(__file__), "downward/builds/release/bin/translate" ) @@ -305,6 +311,7 @@ def _compile( fast_downward_normalize.normalize(task) _, _, actions, goals, axioms, _ = fd_instantiate.explore(task) + sys.stdout = orig_stdout if axioms: raise UPUnsupportedProblemTypeError(axioms_msg) @@ -335,7 +342,7 @@ def _compile( new_problem.clear_quality_metrics() for qm in problem.quality_metrics: if isinstance(qm, MinimizeActionCosts): - simplifier = Simplifier(new_problem) + simplifier = Simplifier(new_problem.environment, new_problem) ground_minimize_action_costs_metric(qm, trace_back_map, simplifier) else: new_problem.add_quality_metric(qm) From 989004b42367eeefd9ed5cbd0fc1ecd12060d081 Mon Sep 17 00:00:00 2001 From: Gabriele Roeger Date: Tue, 7 Nov 2023 11:20:15 +0100 Subject: [PATCH 05/11] remove leftover print statement --- up_fast_downward/fast_downward.py | 1 - 1 file changed, 1 deletion(-) diff --git a/up_fast_downward/fast_downward.py b/up_fast_downward/fast_downward.py index 129a58a..5c11c8e 100644 --- a/up_fast_downward/fast_downward.py +++ b/up_fast_downward/fast_downward.py @@ -341,5 +341,4 @@ def _plan_from_file( # Remove the last line (= goal action) from the plan string. plan_string = plan_string[:-2] plan_string = "\n".join(plan_string) - print(plan_string) return self._plan_from_str(problem, plan_string, get_item_named) From e87f1b5571a3e13c6a5b272568e0028b1163a2bd Mon Sep 17 00:00:00 2001 From: Gabriele Roeger Date: Tue, 7 Nov 2023 14:52:42 +0100 Subject: [PATCH 06/11] move problem transformation to separate module --- setup.py | 1 + up_fast_downward/fast_downward.py | 32 ++---------------- up_fast_downward/utils.py | 56 +++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 30 deletions(-) create mode 100644 up_fast_downward/utils.py diff --git a/setup.py b/setup.py index fbe1acb..5001c3c 100644 --- a/setup.py +++ b/setup.py @@ -90,6 +90,7 @@ def run(self): package_data={ "": ['fast_downward.py', 'fast_downward_grounder.py', + 'utils.py', 'downward/fast-downward.py', 'downward/README.md', 'downward/LICENSE.md', 'downward/builds/release/bin/*', diff --git a/up_fast_downward/fast_downward.py b/up_fast_downward/fast_downward.py index f49de06..653da01 100644 --- a/up_fast_downward/fast_downward.py +++ b/up_fast_downward/fast_downward.py @@ -1,4 +1,3 @@ -from itertools import count import pkg_resources import sys import unified_planning as up @@ -10,6 +9,7 @@ from unified_planning.engines import OperationMode, Credits from unified_planning.shortcuts import BoolType, MinimizeActionCosts from unified_planning.engines.results import LogLevel, LogMessage, PlanGenerationResult +from up_fast_downward import utils credits = { "name": "Fast Downward", @@ -277,39 +277,11 @@ def _solve( timeout: Optional[float] = None, output_stream: Optional[Union[Tuple[IO[str], IO[str]], IO[str]]] = None, ) -> "up.engines.results.PlanGenerationResult": - def get_new_name(problem, prefix): - for num in count(): - candidate = f"{prefix}{num}" - if not problem.has_name(candidate): - return candidate - assert isinstance(problem, up.model.Problem) - modified_problem = problem.clone() # add a new goal atom (initially false) plus an action that has the # original goal as precondition and sets the new goal atom - goal_fluent_name = get_new_name(modified_problem, "goal") - goal_fluent = modified_problem.add_fluent( - goal_fluent_name, BoolType(), default_initial_value=False - ) - goal_action_name = get_new_name(modified_problem, "reach_goal") - goal_action = InstantaneousAction(goal_action_name) - for goal in modified_problem.goals: - goal_action.add_precondition(goal) - goal_action.add_effect(goal_fluent, True) - modified_problem.add_action(goal_action) - modified_problem.clear_goals() - modified_problem.add_goal(goal_fluent) - if modified_problem.quality_metrics and isinstance( - modified_problem.quality_metrics[0], MinimizeActionCosts - ): - m = modified_problem.quality_metrics[0] - action_costs = m.costs - action_costs[goal_action] = 1 - metric = MinimizeActionCosts(action_costs, m.default, m.environment) - modified_problem.clear_quality_metrics() - modified_problem.add_quality_metric(metric) - + modified_problem, _, _ = utils.introduce_artificial_goal_action(problem) return super()._solve(modified_problem, heuristic, timeout, output_stream) # overwrite plan extraction to remove the newly introduced goal action from diff --git a/up_fast_downward/utils.py b/up_fast_downward/utils.py new file mode 100644 index 0000000..bb7cf47 --- /dev/null +++ b/up_fast_downward/utils.py @@ -0,0 +1,56 @@ +from itertools import count +from unified_planning.shortcuts import BoolType, MinimizeActionCosts +from unified_planning.model import InstantaneousAction + +# This function clones the task and adds an artificial goal atom and an +# additional action that achieves it when the original goal is satisfied. +# Parameter other_actions_destroy_goal indicates whether all original actions +# should destroy the artificial goal atom. This is not necessary if we run our +# planner on the transformed problem (because search terminates once it finds +# a plan). If we use this in grounding, we do not know what others do with +# the task. Thus we need this option to ensure that for every plan of the +# transformed task, omitting all occurrences of the artificial goal action +# gives a plan for the original task. + +def introduce_artificial_goal_action(problem: "up.model.AbstractProblem", + other_actions_destroy_goal=False) -> "up.model.AbstractProblem": + + def get_new_name(problem, prefix): + for num in count(): + candidate = f"{prefix}{num}" + if not problem.has_name(candidate): + return candidate + + modified_problem = problem.clone() + # add a new goal atom (initially false) plus an action that has the + # original goal as precondition and sets the new goal atom + goal_fluent_name = get_new_name(modified_problem, "goal") + goal_fluent = modified_problem.add_fluent( + goal_fluent_name, BoolType(), default_initial_value=False + ) + + if other_actions_destroy_goal: + for action in modified_problem.actions: + action.add_effect(goal_fluent, False) + modified_to_orig_action = dict(elem for elem in zip(modified_problem.actions, + problem.actions)) + + goal_action_name = get_new_name(modified_problem, "reach_goal") + goal_action = InstantaneousAction(goal_action_name) + for goal in modified_problem.goals: + goal_action.add_precondition(goal) + goal_action.add_effect(goal_fluent, True) + modified_problem.add_action(goal_action) + modified_problem.clear_goals() + modified_problem.add_goal(goal_fluent) + if modified_problem.quality_metrics and isinstance( + modified_problem.quality_metrics[0], MinimizeActionCosts + ): + m = modified_problem.quality_metrics[0] + action_costs = m.costs + action_costs[goal_action] = 1 + metric = MinimizeActionCosts(action_costs, m.default, m.environment) + modified_problem.clear_quality_metrics() + modified_problem.add_quality_metric(metric) + + return modified_problem, goal_action, modified_to_orig_action From 5f953a78b6d8a283ea2beaa5e8cf306f60ff9616 Mon Sep 17 00:00:00 2001 From: Gabriele Roeger Date: Tue, 7 Nov 2023 14:55:01 +0100 Subject: [PATCH 07/11] reformat with black --- up_fast_downward/fast_downward.py | 7 +++++-- up_fast_downward/utils.py | 20 +++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/up_fast_downward/fast_downward.py b/up_fast_downward/fast_downward.py index 653da01..3bc9a85 100644 --- a/up_fast_downward/fast_downward.py +++ b/up_fast_downward/fast_downward.py @@ -314,8 +314,11 @@ def _plan_from_file( with open(plan_filename) as plan: plan_string = plan.read() # Remove all comments from the plan string. - plan_string = [line for line in plan_string.split("\n") - if not line.strip().startswith(";")] + plan_string = [ + line + for line in plan_string.split("\n") + if not line.strip().startswith(";") + ] # Remove the last line (= goal action) from the plan string. plan_string = plan_string[:-2] plan_string = "\n".join(plan_string) diff --git a/up_fast_downward/utils.py b/up_fast_downward/utils.py index bb7cf47..48f3576 100644 --- a/up_fast_downward/utils.py +++ b/up_fast_downward/utils.py @@ -12,9 +12,10 @@ # transformed task, omitting all occurrences of the artificial goal action # gives a plan for the original task. -def introduce_artificial_goal_action(problem: "up.model.AbstractProblem", - other_actions_destroy_goal=False) -> "up.model.AbstractProblem": - + +def introduce_artificial_goal_action( + problem: "up.model.AbstractProblem", other_actions_destroy_goal=False +) -> "up.model.AbstractProblem": def get_new_name(problem, prefix): for num in count(): candidate = f"{prefix}{num}" @@ -26,14 +27,15 @@ def get_new_name(problem, prefix): # original goal as precondition and sets the new goal atom goal_fluent_name = get_new_name(modified_problem, "goal") goal_fluent = modified_problem.add_fluent( - goal_fluent_name, BoolType(), default_initial_value=False - ) + goal_fluent_name, BoolType(), default_initial_value=False + ) if other_actions_destroy_goal: for action in modified_problem.actions: action.add_effect(goal_fluent, False) - modified_to_orig_action = dict(elem for elem in zip(modified_problem.actions, - problem.actions)) + modified_to_orig_action = dict( + elem for elem in zip(modified_problem.actions, problem.actions) + ) goal_action_name = get_new_name(modified_problem, "reach_goal") goal_action = InstantaneousAction(goal_action_name) @@ -44,8 +46,8 @@ def get_new_name(problem, prefix): modified_problem.clear_goals() modified_problem.add_goal(goal_fluent) if modified_problem.quality_metrics and isinstance( - modified_problem.quality_metrics[0], MinimizeActionCosts - ): + modified_problem.quality_metrics[0], MinimizeActionCosts + ): m = modified_problem.quality_metrics[0] action_costs = m.costs action_costs[goal_action] = 1 From 475a615e458979debd47a072e7f216899a0f8a31 Mon Sep 17 00:00:00 2001 From: Gabriele Roeger Date: Tue, 7 Nov 2023 15:02:20 +0100 Subject: [PATCH 08/11] Transform task to avoid axioms for goals in Grounder If the goal is not a conjunction of literals, Fast Downward introduces axioms to handle it. To avoid this, we instead introduce an artificial goal action (removed when mapping back). There can be several ground representatives of the artificial goal action in the compiled task (e.g. from disjunctive preconditions). We now give each instance an individual name. --- up_fast_downward/fast_downward_grounder.py | 93 ++++++++++++++++++---- 1 file changed, 79 insertions(+), 14 deletions(-) diff --git a/up_fast_downward/fast_downward_grounder.py b/up_fast_downward/fast_downward_grounder.py index 1934ca8..51b464b 100644 --- a/up_fast_downward/fast_downward_grounder.py +++ b/up_fast_downward/fast_downward_grounder.py @@ -1,22 +1,28 @@ from collections import defaultdict from io import StringIO +from itertools import count import os.path import sys import unified_planning as up from functools import partial -from typing import Callable, Optional, Union +from typing import Callable, Optional, Union, Set from unified_planning.model import FNode, Problem, ProblemKind, MinimizeActionCosts from unified_planning.model.walkers import Simplifier from unified_planning.model.action import InstantaneousAction +from unified_planning.model.operators import OperatorKind from unified_planning.engines.compilers.utils import lift_action_instance -from unified_planning.engines.compilers.grounder import Grounder, ground_minimize_action_costs_metric +from unified_planning.engines.compilers.grounder import ( + Grounder, + ground_minimize_action_costs_metric, +) from unified_planning.engines.engine import Engine from unified_planning.engines import Credits from unified_planning.engines.mixins.compiler import CompilationKind from unified_planning.engines.mixins.compiler import CompilerMixin from unified_planning.engines.results import CompilerResult from unified_planning.exceptions import UPUnsupportedProblemTypeError +from up_fast_downward import utils credits = Credits( @@ -134,7 +140,7 @@ def _compile( schematic_up_action = writer.get_item_named(action.name) params = ( writer.get_item_named(p) - for p in atom.args[:action.num_external_parameters] + for p in atom.args[: action.num_external_parameters] ) up_params = tuple(exp_manager.ObjectExp(p) for p in params) grounding_action_map[schematic_up_action].append(up_params) @@ -145,9 +151,7 @@ def _compile( new_problem = up_res.problem new_problem.name = f"{self.name}_{problem.name}" - return CompilerResult( - new_problem, up_res.map_back_action_instance, self.name - ) + return CompilerResult(new_problem, up_res.map_back_action_instance, self.name) def destroy(self): pass @@ -254,15 +258,25 @@ def _transform_action( "up.model.Variable", ], ], + used_action_names: Set[str], ) -> InstantaneousAction: def fnode(fact): return self._get_fnode(fact, problem, get_item_named) + exp_manager = problem.environment.expression_manager name_and_args = fd_action.name[1:-1].split() name = get_item_named(name_and_args[0]).name name_and_args[0] = name - action = InstantaneousAction("_".join(name_and_args)) + full_name = "_".join(name_and_args) + if full_name in used_action_names: + for num in count(): + candidate = f"{full_name}_{num}" + if candidate not in used_action_names: + full_name = candidate + break + used_action_names.add(full_name) + action = InstantaneousAction(full_name) for fact in fd_action.precondition: action.add_precondition(fnode(fact)) for cond, fact in fd_action.add_effects: @@ -273,6 +287,37 @@ def fnode(fact): action.add_effect(fnode(fact), False, c) return action + def _add_goal_action_for_complicated_goal( + self, problem: "up.model.AbstractProblem" + ) -> bool: + COMPLICATED_KINDS = ( + OperatorKind.OR, + OperatorKind.IMPLIES, + OperatorKind.IFF, + OperatorKind.EXISTS, + OperatorKind.FORALL, + ) + + def is_complicated_goal(fnode): + if fnode.node_type in COMPLICATED_KINDS: + return True + if fnode.node_type == OperatorKind.NOT: + assert len(fnode.args) == 1 + if fnode.args[0].node_type != OperatorKind.FLUENT_EXP: + return True + return any(is_complicated_goal(arg) for arg in fnode.args) + + def has_complicated_goal(problem): + return any(is_complicated_goal(g) for g in problem.goals) + + if not has_complicated_goal(problem): + return problem, None, None + else: + # To avoid the introduction of axioms with complicated goals, we + # introduce a separate goal action (later to be removed by + # map_back) + return utils.introduce_artificial_goal_action(problem, True) + def _compile( self, problem: "up.model.AbstractProblem", compilation_kind: "CompilationKind" ) -> CompilerResult: @@ -287,6 +332,11 @@ def _compile( :return: The resulting `CompilerResult` data structure. """ assert isinstance(problem, Problem) + ( + problem, + artificial_goal_action, + modified_to_orig_action, + ) = self._add_goal_action_for_complicated_goal(problem) writer = up.io.PDDLWriter(problem) pddl_problem = writer.get_problem().split("\n") @@ -323,15 +373,23 @@ def _compile( trace_back_map = dict() - exp_manager = problem.environment.expression_manager + used_action_names = set() + exp_manager = problem.environment.expression_manager for a in actions: - inst_action = self._transform_action(a, new_problem, writer.get_item_named) + inst_action = self._transform_action( + a, new_problem, writer.get_item_named, used_action_names + ) name_and_args = a.name[1:-1].split() - schematic_up_action = writer.get_item_named(name_and_args[0]) - params = (writer.get_item_named(p) for p in name_and_args[1:]) - up_params = tuple(exp_manager.ObjectExp(p) for p in params) - trace_back_map[inst_action] = (schematic_up_action, up_params) + schematic_up_act = writer.get_item_named(name_and_args[0]) + if schematic_up_act == artificial_goal_action: + trace_back_map[inst_action] = None + else: + if modified_to_orig_action is not None: + schematic_up_act = modified_to_orig_action[schematic_up_act] + params = (writer.get_item_named(p) for p in name_and_args[1:]) + up_params = tuple(exp_manager.ObjectExp(p) for p in params) + trace_back_map[inst_action] = (schematic_up_act, up_params) new_problem.add_action(inst_action) for g in goals: @@ -347,9 +405,16 @@ def _compile( else: new_problem.add_quality_metric(qm) + mbai = lambda x: ( + None + if trace_back_map[x.action] is None + else partial(lift_action_instance, map=trace_back_map)(x) + ) + return CompilerResult( new_problem, - partial(lift_action_instance, map=trace_back_map), + mbai, + # partial(lift_action_instance, map=trace_back_map), self.name, ) From 83c5e4c3f1ef35485f47a967a9ae11c343e6774a Mon Sep 17 00:00:00 2001 From: Gabriele Roeger Date: Tue, 7 Nov 2023 15:31:47 +0100 Subject: [PATCH 09/11] clean up reachability grounder --- up_fast_downward/fast_downward_grounder.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/up_fast_downward/fast_downward_grounder.py b/up_fast_downward/fast_downward_grounder.py index 51b464b..41f82c8 100644 --- a/up_fast_downward/fast_downward_grounder.py +++ b/up_fast_downward/fast_downward_grounder.py @@ -110,6 +110,9 @@ def _compile( pddl_problem = writer.get_problem().split("\n") pddl_domain = writer.get_domain().split("\n") + + # perform Fast Downward translation until (and including) + # the reachability analysis orig_path = list(sys.path) orig_stdout = sys.stdout sys.stdout = StringIO() @@ -132,6 +135,14 @@ def _compile( prog = prolog_program(task) model = compute_model(prog) sys.stdout = orig_stdout + sys.path = orig_path + + + # The model contains an overapproximation of the reachable components + # of the task, in particular also of the reachable ground actions. + # We retreive the parameters from these actions and hand them over to + # the Grounder from the UP, which performs the instantiation on the + # side of the UP. grounding_action_map = defaultdict(list) exp_manager = problem.environment.expression_manager for atom in model: @@ -144,7 +155,6 @@ def _compile( ) up_params = tuple(exp_manager.ObjectExp(p) for p in params) grounding_action_map[schematic_up_action].append(up_params) - sys.path = orig_path up_grounder = Grounder(grounding_actions_map=grounding_action_map) up_res = up_grounder.compile(problem, compilation_kind) @@ -153,9 +163,6 @@ def _compile( return CompilerResult(new_problem, up_res.map_back_action_instance, self.name) - def destroy(self): - pass - class FastDownwardGrounder(Engine, CompilerMixin): def __init__(self): From bce013d32ad84e7bd8af485e0c77251da8ebda23 Mon Sep 17 00:00:00 2001 From: Gabriele Roeger Date: Tue, 7 Nov 2023 16:15:35 +0100 Subject: [PATCH 10/11] cleanup FastDownwardGrounder a bit --- up_fast_downward/fast_downward_grounder.py | 114 ++++++++++++--------- up_fast_downward/utils.py | 29 ++++-- 2 files changed, 84 insertions(+), 59 deletions(-) diff --git a/up_fast_downward/fast_downward_grounder.py b/up_fast_downward/fast_downward_grounder.py index 41f82c8..9c8b380 100644 --- a/up_fast_downward/fast_downward_grounder.py +++ b/up_fast_downward/fast_downward_grounder.py @@ -6,7 +6,7 @@ import unified_planning as up from functools import partial -from typing import Callable, Optional, Union, Set +from typing import Callable, Mapping, Optional, Union, Set, Tuple from unified_planning.model import FNode, Problem, ProblemKind, MinimizeActionCosts from unified_planning.model.walkers import Simplifier from unified_planning.model.action import InstantaneousAction @@ -110,7 +110,6 @@ def _compile( pddl_problem = writer.get_problem().split("\n") pddl_domain = writer.get_domain().split("\n") - # perform Fast Downward translation until (and including) # the reachability analysis orig_path = list(sys.path) @@ -137,7 +136,6 @@ def _compile( sys.stdout = orig_stdout sys.path = orig_path - # The model contains an overapproximation of the reachable components # of the task, in particular also of the reachable ground actions. # We retreive the parameters from these actions and hand them over to @@ -223,6 +221,7 @@ def resulting_problem_kind( ) -> ProblemKind: resulting_problem_kind = problem_kind.clone() resulting_problem_kind.unset_conditions_kind("DISJUNCTIVE_CONDITIONS") + resulting_problem_kind.unset_conditions_kind("EXISTENTIAL_CONDITIONS") return resulting_problem_kind def _get_fnode( @@ -241,6 +240,7 @@ def _get_fnode( ], ], ) -> FNode: + """Translates a Fast Downward fact back into a FNode.""" exp_manager = problem.environment.expression_manager fluent = get_item_named(fact.predicate) args = [problem.object(o) for o in fact.args] @@ -267,6 +267,9 @@ def _transform_action( ], used_action_names: Set[str], ) -> InstantaneousAction: + """Takes a Fast Downward ground actions and builds it with the + vobabulary of the UP.""" + def fnode(fact): return self._get_fnode(fact, problem, get_item_named) @@ -294,30 +297,43 @@ def fnode(fact): action.add_effect(fnode(fact), False, c) return action - def _add_goal_action_for_complicated_goal( + def _add_goal_action_if_complicated_goal( self, problem: "up.model.AbstractProblem" - ) -> bool: + ) -> Tuple[ + "up.model.AbstractProblem", + Optional["up.model.InstantaneousAction"], + Optional[ + Mapping["up.model.InstantaneousAction", "up.model.InstantaneousAction"] + ], + ]: + """ + Tests whether the given problem has a complicated goal (not just + a conjunction of positive and negative fluents). If yes, it returns + a transformed problem with an artificial goal action and a single goal + fluent, where the existing actions are modified to delete the goal fluent. + The second return value is the new goal action. The third return value + maps the actions of the modified problem to the actions of the original + problem. If the goal was not complicated, it returns the original + problem, None, and None. + """ COMPLICATED_KINDS = ( - OperatorKind.OR, - OperatorKind.IMPLIES, - OperatorKind.IFF, OperatorKind.EXISTS, OperatorKind.FORALL, + OperatorKind.IFF, + OperatorKind.IMPLIES, + OperatorKind.OR, ) def is_complicated_goal(fnode): - if fnode.node_type in COMPLICATED_KINDS: + if fnode.node_type in COMPLICATED_KINDS or ( + fnode.node_type == OperatorKind.NOT + and fnode.args[0].node_type != OperatorKind.FLUENT_EXP + ): return True - if fnode.node_type == OperatorKind.NOT: - assert len(fnode.args) == 1 - if fnode.args[0].node_type != OperatorKind.FLUENT_EXP: - return True return any(is_complicated_goal(arg) for arg in fnode.args) - def has_complicated_goal(problem): - return any(is_complicated_goal(g) for g in problem.goals) - - if not has_complicated_goal(problem): + if not any(is_complicated_goal(g) for g in problem.goals): + # no complicated goal return problem, None, None else: # To avoid the introduction of axioms with complicated goals, we @@ -325,30 +341,7 @@ def has_complicated_goal(problem): # map_back) return utils.introduce_artificial_goal_action(problem, True) - def _compile( - self, problem: "up.model.AbstractProblem", compilation_kind: "CompilationKind" - ) -> CompilerResult: - """ - Takes an instance of a :class:`~up.model.Problem` and the `GROUNDING` - :class:`~up.engines.CompilationKind` and returns a `CompilerResult` - where the problem does not have actions with parameters; so every - action is grounded. - :param problem: The instance of the `Problem` that must be grounded. - :param compilation_kind: The `CompilationKind` that must be applied on - the given problem; only `GROUNDING` is supported by this compiler - :return: The resulting `CompilerResult` data structure. - """ - assert isinstance(problem, Problem) - ( - problem, - artificial_goal_action, - modified_to_orig_action, - ) = self._add_goal_action_for_complicated_goal(problem) - - writer = up.io.PDDLWriter(problem) - pddl_problem = writer.get_problem().split("\n") - pddl_domain = writer.get_domain().split("\n") - + def _instantiate_with_fast_downward(self, pddl_problem, pddl_domain): orig_path = list(sys.path) orig_stdout = sys.stdout sys.stdout = StringIO() @@ -369,20 +362,45 @@ def _compile( _, _, actions, goals, axioms, _ = fd_instantiate.explore(task) sys.stdout = orig_stdout + sys.path = orig_path + return actions, goals, axioms + + def _compile( + self, problem: "up.model.AbstractProblem", compilation_kind: "CompilationKind" + ) -> CompilerResult: + assert isinstance(problem, Problem) + # If necessary, perform goal transformation to avoid the introduction + # of axioms. + ( + problem, + artificial_goal_action, + modified_to_orig_action, + ) = self._add_goal_action_if_complicated_goal(problem) + + # Ground the problem with Fast Downward + writer = up.io.PDDLWriter(problem) + pddl_problem = writer.get_problem().split("\n") + pddl_domain = writer.get_domain().split("\n") + + actions, goals, axioms = self._instantiate_with_fast_downward( + pddl_problem, pddl_domain + ) if axioms: raise UPUnsupportedProblemTypeError(axioms_msg) + # Rebuild the ground problem from Fast Downward in the UP new_problem = problem.clone() new_problem.name = f"{self.name}_{problem.name}" new_problem.clear_actions() new_problem.clear_goals() trace_back_map = dict() - used_action_names = set() - exp_manager = problem.environment.expression_manager + + # Construct Fast Downward ground actions in the UP and remember the + # mapping from the ground actions to the original actions. for a in actions: inst_action = self._transform_action( a, new_problem, writer.get_item_named, used_action_names @@ -399,10 +417,10 @@ def _compile( trace_back_map[inst_action] = (schematic_up_act, up_params) new_problem.add_action(inst_action) + # Construct Fast Downward goals in the UP for g in goals: fnode = self._get_fnode(g, new_problem, writer.get_item_named) new_problem.add_goal(fnode) - sys.path = orig_path new_problem.clear_quality_metrics() for qm in problem.quality_metrics: @@ -412,6 +430,10 @@ def _compile( else: new_problem.add_quality_metric(qm) + # We need to use a more complicated function for mapping back the + # actions because "partial(lift_action_instance, map=trace_back_map)" + # cannot map an action to None (we need this for the artificial goal + # action). mbai = lambda x: ( None if trace_back_map[x.action] is None @@ -421,9 +443,5 @@ def _compile( return CompilerResult( new_problem, mbai, - # partial(lift_action_instance, map=trace_back_map), self.name, ) - - def destroy(self): - pass diff --git a/up_fast_downward/utils.py b/up_fast_downward/utils.py index 48f3576..05188e7 100644 --- a/up_fast_downward/utils.py +++ b/up_fast_downward/utils.py @@ -1,21 +1,28 @@ from itertools import count from unified_planning.shortcuts import BoolType, MinimizeActionCosts from unified_planning.model import InstantaneousAction - -# This function clones the task and adds an artificial goal atom and an -# additional action that achieves it when the original goal is satisfied. -# Parameter other_actions_destroy_goal indicates whether all original actions -# should destroy the artificial goal atom. This is not necessary if we run our -# planner on the transformed problem (because search terminates once it finds -# a plan). If we use this in grounding, we do not know what others do with -# the task. Thus we need this option to ensure that for every plan of the -# transformed task, omitting all occurrences of the artificial goal action -# gives a plan for the original task. +from typing import Mapping, Optional, Tuple def introduce_artificial_goal_action( problem: "up.model.AbstractProblem", other_actions_destroy_goal=False -) -> "up.model.AbstractProblem": +) -> Tuple[ + "up.model.AbstractProblem", + Optional["up.model.InstantaneousAction"], + Optional[Mapping["up.model.InstantaneousAction", "up.model.InstantaneousAction"]], +]: + """ + Clones the task and adds an artificial goal atom and an additional action + that achieves it when the original goal is satisfied. Parameter + other_actions_destroy_goal indicates whether all original actions should + destroy the artificial goal atom. This is not necessary if we run our + planner on the transformed problem (because search terminates once it finds + a plan). If we use this in grounding, we do not know what others do with + the task. Thus we need this option to ensure that for every plan of the + transformed task, omitting all occurrences of the artificial goal action + gives a plan for the original task. + """ + def get_new_name(problem, prefix): for num in count(): candidate = f"{prefix}{num}" From 50047500176bf216f160d43bb0350665f9179922 Mon Sep 17 00:00:00 2001 From: Gabriele Roeger Date: Thu, 9 Nov 2023 09:59:11 +0100 Subject: [PATCH 11/11] update changelog --- CHANGES.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index a58da8f..86df990 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,8 +1,15 @@ # Release notes UP Fast Downward 0.3.3 -- fix bug in fast-downward-reachability grounder +- fix bug in fast-downward-reachability grounder (number of parameters) +- Transform task to avoid axioms for goals in Grounder and optimal solver: + If the goal is not a conjunction of literals, Fast Downward introduces + axioms to handle it. To avoid this, we instead introduce an artificial + goal action (removed from plans or when mapping back in grounders). +- With fast-downward-grounder, there can be several ground representatives of + the artificial goal action in the compiled task (e.g. from disjunctive + preconditions). We now give each of them an individual name. - silence output in grounders -- support result status MEMOUT, TIMEOUT and UNSUPPORTED_PROBLEM +- support result status MEMOUT, TIMEOUT and UNSUPPORTED_PROBLEM in solvers UP Fast Downward 0.3.2 - support UP problem kind version 2