solver-support 0.1.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.
- solver_support/__init__.py +132 -0
- solver_support/command_builders.py +334 -0
- solver_support/file_management.py +243 -0
- solver_support/global_state.py +62 -0
- solver_support/horizon_utils.py +350 -0
- solver_support/model_registry.py +792 -0
- solver_support/objective.py +92 -0
- solver_support/run_summary.py +338 -0
- solver_support/solver_base.py +140 -0
- solver_support/solver_enhsp.py +101 -0
- solver_support/solver_fd.py +261 -0
- solver_support/solver_horizon.py +634 -0
- solver_support/solver_runner.py +702 -0
- solver_support/solver_stats.py +724 -0
- solver_support/solver_upfplan.py +168 -0
- solver_support/utilities.py +146 -0
- solver_support-0.1.1.dist-info/METADATA +120 -0
- solver_support-0.1.1.dist-info/RECORD +21 -0
- solver_support-0.1.1.dist-info/WHEEL +5 -0
- solver_support-0.1.1.dist-info/licenses/LICENSE +29 -0
- solver_support-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fast Downward solver implementation.
|
|
3
|
+
|
|
4
|
+
This module provides the FastDownwardSolver class for executing the Fast Downward
|
|
5
|
+
PDDL planner with support for both standard and anytime (portfolio) modes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from .solver_base import Solver
|
|
13
|
+
from .solver_runner import SolverResult
|
|
14
|
+
from .file_management import SolutionFileNamer
|
|
15
|
+
from .utilities import relativize_path
|
|
16
|
+
from .global_state import global_state
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FastDownwardSolver(Solver):
|
|
20
|
+
"""Solver implementation for Fast Downward PDDL planner."""
|
|
21
|
+
|
|
22
|
+
def solve(self, param_file: Path, **kwargs) -> Optional[Path]:
|
|
23
|
+
"""
|
|
24
|
+
Execute Fast Downward solver for PDDL models.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
param_file: Path to PDDL problem file
|
|
28
|
+
**kwargs: Additional arguments including:
|
|
29
|
+
- model_path: Path to PDDL domain file (required)
|
|
30
|
+
- anytime: Whether to run the anytime portfolio (default: False).
|
|
31
|
+
The runner decides this via registry.is_anytime_tune (the
|
|
32
|
+
active tune's regime), not the model name.
|
|
33
|
+
- instance_file: Original problem instance file
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Path to solution file if successful, None otherwise
|
|
37
|
+
"""
|
|
38
|
+
# Extract required kwargs
|
|
39
|
+
model_path = kwargs.get('model_path')
|
|
40
|
+
if not model_path:
|
|
41
|
+
raise ValueError("model_path is required for Fast Downward solver")
|
|
42
|
+
|
|
43
|
+
anytime = kwargs.get('anytime', False)
|
|
44
|
+
instance_file = kwargs.get('instance_file')
|
|
45
|
+
|
|
46
|
+
# The resolved solver name selects the fd-type config. SymK is a Fast
|
|
47
|
+
# Downward fork, so it flows through this same solver; its config just
|
|
48
|
+
# supplies command:symk and the symbolic search_args.
|
|
49
|
+
solver_name = self.runner.summary.solver or 'fd'
|
|
50
|
+
|
|
51
|
+
# Optional --tune preset from the CLI, held in global_state like the
|
|
52
|
+
# savilerow extra-option flags. A single flag covers both engines;
|
|
53
|
+
# build_fd_command resolves it against the active solver's tune presets
|
|
54
|
+
# (fd: blind/hmax/ff/lmcut/verbose; symk: fw/bw/bd) and falls back to the
|
|
55
|
+
# solver's default_tune if the name is not valid for this solver. With no
|
|
56
|
+
# --tune, honour a model-pinned tune (models.yaml `tune:`) - the same
|
|
57
|
+
# resolution the upf/horizon paths use - so a model can select a working
|
|
58
|
+
# non-default config as its default.
|
|
59
|
+
tune = self._effective_tune()
|
|
60
|
+
|
|
61
|
+
# Build command using global model registry. Raw --options are appended
|
|
62
|
+
# unchanged (the runner does not interpret them).
|
|
63
|
+
cmd = global_state.model_registry.build_fd_command(
|
|
64
|
+
model_path=model_path,
|
|
65
|
+
param_path=str(param_file),
|
|
66
|
+
anytime=anytime,
|
|
67
|
+
timeout=self.runner.timeout,
|
|
68
|
+
tune=tune,
|
|
69
|
+
extra_options=global_state.options,
|
|
70
|
+
solver_name=solver_name
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Execute solver
|
|
74
|
+
expected_solution = Path("sas_plan") # FD writes to current directory
|
|
75
|
+
|
|
76
|
+
# Clean up any existing solution files
|
|
77
|
+
for existing in Path.cwd().glob("sas_plan*"):
|
|
78
|
+
try:
|
|
79
|
+
existing.unlink()
|
|
80
|
+
except Exception as e:
|
|
81
|
+
self.logger.warning(f"Failed to remove existing solution file {existing}: {e}")
|
|
82
|
+
|
|
83
|
+
self.logger.info("Running Fast Downward solver")
|
|
84
|
+
result = self.runner.run_phase("solving", cmd, expected_outputs=[str(expected_solution)])
|
|
85
|
+
|
|
86
|
+
if result.interrupted:
|
|
87
|
+
self.logger.warning("Fast Downward solving was interrupted")
|
|
88
|
+
# Check if partial solution exists
|
|
89
|
+
if anytime:
|
|
90
|
+
return self._handle_anytime_solutions(result)
|
|
91
|
+
elif expected_solution.exists():
|
|
92
|
+
return self._handle_single_solution(expected_solution)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
if result.timed_out:
|
|
96
|
+
self.logger.info(f"Fast Downward solver timed out after {result.duration:.1f}s")
|
|
97
|
+
if anytime:
|
|
98
|
+
# Anytime portfolio: check if any plans were banked before the budget ran out
|
|
99
|
+
return self._handle_anytime_solutions(result)
|
|
100
|
+
else:
|
|
101
|
+
# For regular PDDL, timeout with no solution is a timeout error
|
|
102
|
+
raise TimeoutError(f"Fast Downward solver timed out after {result.duration:.1f}s")
|
|
103
|
+
|
|
104
|
+
if result.success:
|
|
105
|
+
if anytime:
|
|
106
|
+
return self._handle_anytime_solutions(result)
|
|
107
|
+
else:
|
|
108
|
+
return self._handle_single_solution(expected_solution)
|
|
109
|
+
else:
|
|
110
|
+
# Anytime portfolio tolerance: exit code 34 is expected when some
|
|
111
|
+
# portfolio configurations don't support axioms (as in original run
|
|
112
|
+
# script). This tolerance is specific to the seq-sat-fdss portfolio,
|
|
113
|
+
# currently the only anytime fd tune.
|
|
114
|
+
if (anytime and ("Error: Unexpected exit codes: [34]" in result.stderr or
|
|
115
|
+
"Error: Unexpected exit codes: [34]" in result.stdout)):
|
|
116
|
+
if global_state.verbose:
|
|
117
|
+
self.logger.info("Anytime portfolio encountered expected exit code 34 (unsupported features), continuing with available solutions")
|
|
118
|
+
|
|
119
|
+
solution_file = self._handle_anytime_solutions(result)
|
|
120
|
+
if solution_file is None:
|
|
121
|
+
# No solutions found in anytime mode - this typically happens when the
|
|
122
|
+
# portfolio runs out of time (components get shorter time allocations)
|
|
123
|
+
# Treat this as a timeout case, not a failure (same pattern as UPF solver)
|
|
124
|
+
if global_state.verbose:
|
|
125
|
+
self.logger.info("Anytime portfolio found no solutions within timeout - treating as timeout")
|
|
126
|
+
raise TimeoutError(f"Anytime portfolio timed out after {result.duration:.1f}s with no solutions found")
|
|
127
|
+
|
|
128
|
+
return solution_file
|
|
129
|
+
else:
|
|
130
|
+
self.logger.error(f"Fast Downward solver failed: {result.stderr}")
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def _handle_single_solution(self, expected_solution: Path) -> Optional[Path]:
|
|
134
|
+
"""Handle single PDDL solution (standard mode)."""
|
|
135
|
+
if expected_solution.exists():
|
|
136
|
+
solution_file = self._generate_solution_file_path(suffix=".1.solution")
|
|
137
|
+
|
|
138
|
+
if self._copy_solution_file(expected_solution, solution_file, move=True):
|
|
139
|
+
# Update the phase output files to reflect the moved file
|
|
140
|
+
solving_phase = self.runner.summary.get_phase("solving")
|
|
141
|
+
if solving_phase:
|
|
142
|
+
expected_solution_rel = str(expected_solution)
|
|
143
|
+
solution_file_rel = str(solution_file)
|
|
144
|
+
|
|
145
|
+
# Replace the expected path with actual path in output_files
|
|
146
|
+
if expected_solution_rel in solving_phase.output_files:
|
|
147
|
+
solving_phase.output_files.remove(expected_solution_rel)
|
|
148
|
+
solving_phase.output_files.append(solution_file_rel)
|
|
149
|
+
|
|
150
|
+
return solution_file
|
|
151
|
+
else:
|
|
152
|
+
return None
|
|
153
|
+
else:
|
|
154
|
+
self.logger.info("No solution found")
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
def _handle_anytime_solutions(self, result: SolverResult) -> Optional[Path]:
|
|
158
|
+
"""Handle multiple PDDL solutions (anytime portfolio mode)."""
|
|
159
|
+
# For interrupted runs, use the best plan tracked during streaming
|
|
160
|
+
if result.interrupted and hasattr(result, 'best_plan_number') and result.best_plan_number > 0:
|
|
161
|
+
plancount = result.best_plan_number
|
|
162
|
+
self.logger.info(f"Anytime portfolio was interrupted, using best plan found so far: {plancount}")
|
|
163
|
+
else:
|
|
164
|
+
# Extract plan count from output by counting "plan manager: found new plan" lines
|
|
165
|
+
plancount = 0
|
|
166
|
+
for line in result.stdout.splitlines():
|
|
167
|
+
if "plan manager: found new plan with cost" in line:
|
|
168
|
+
plancount += 1
|
|
169
|
+
|
|
170
|
+
self.logger.info(f"Anytime portfolio found {plancount} plans")
|
|
171
|
+
|
|
172
|
+
if plancount > 0:
|
|
173
|
+
solution_files = []
|
|
174
|
+
solving_phase = self.runner.summary.get_phase("solving")
|
|
175
|
+
|
|
176
|
+
# For interrupted runs with saved solutions, use those instead of original files
|
|
177
|
+
if (result.interrupted and hasattr(result, 'saved_solutions') and
|
|
178
|
+
result.saved_solutions and result.best_plan_number in result.saved_solutions):
|
|
179
|
+
|
|
180
|
+
self.logger.info(f"Using saved solutions from interrupted run")
|
|
181
|
+
# Copy the best saved solution to final location
|
|
182
|
+
self._bank_solution(
|
|
183
|
+
result.saved_solutions[result.best_plan_number],
|
|
184
|
+
result.best_plan_number, solving_phase, solution_files)
|
|
185
|
+
else:
|
|
186
|
+
# Original logic: move all numbered solution files
|
|
187
|
+
for n in range(1, plancount + 1):
|
|
188
|
+
# First check if we have a saved solution for this plan
|
|
189
|
+
if (hasattr(result, 'saved_solutions') and
|
|
190
|
+
result.saved_solutions and n in result.saved_solutions):
|
|
191
|
+
|
|
192
|
+
self._bank_solution(result.saved_solutions[n], n,
|
|
193
|
+
solving_phase, solution_files)
|
|
194
|
+
else:
|
|
195
|
+
# Original file-based logic for non-interrupted runs
|
|
196
|
+
current_solution = Path(f"sas_plan.{n}")
|
|
197
|
+
|
|
198
|
+
if current_solution.exists():
|
|
199
|
+
self._bank_solution(current_solution, n,
|
|
200
|
+
solving_phase, solution_files,
|
|
201
|
+
move=True)
|
|
202
|
+
|
|
203
|
+
if solution_files:
|
|
204
|
+
self.logger.info(f"Processed {len(solution_files)} solution files")
|
|
205
|
+
# Return the best solution (highest numbered, like original line 630)
|
|
206
|
+
return solution_files[-1]
|
|
207
|
+
else:
|
|
208
|
+
self.logger.error("No solution files could be processed")
|
|
209
|
+
return None
|
|
210
|
+
else:
|
|
211
|
+
self.logger.info("No solutions found")
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
def _bank_solution(self, source: Path, plan_num: int, solving_phase,
|
|
215
|
+
solution_files: list, move: bool = False) -> None:
|
|
216
|
+
"""Bank one anytime plan file under the run's numbered solution name.
|
|
217
|
+
|
|
218
|
+
Generates the ``.{plan_num}.solution`` path, then either moves
|
|
219
|
+
(``move=True``, for the planner's own ``sas_plan.N`` files) or copies
|
|
220
|
+
(``move=False``, for in-memory saved temps, which are unlinked once
|
|
221
|
+
banked) ``source`` onto it, recording the result in ``solution_files``
|
|
222
|
+
and the solving phase's ``output_files``. Failures are logged and
|
|
223
|
+
swallowed, matching the per-plan best-effort banking of the portfolio
|
|
224
|
+
loop; the copy-vs-move split also selects the log/error wording.
|
|
225
|
+
"""
|
|
226
|
+
solution_file = SolutionFileNamer.generate_solution_path(
|
|
227
|
+
self.runner.summary.instance,
|
|
228
|
+
self.runner.summary.solver,
|
|
229
|
+
self.runner.summary.model,
|
|
230
|
+
self.runner.summary.timestamp,
|
|
231
|
+
tune=self.runner.summary.tune_slot,
|
|
232
|
+
suffix=f".{plan_num}.solution"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
if move:
|
|
237
|
+
os.rename(str(source), str(solution_file))
|
|
238
|
+
else:
|
|
239
|
+
import shutil
|
|
240
|
+
shutil.copy2(str(source), str(solution_file))
|
|
241
|
+
solution_files.append(solution_file)
|
|
242
|
+
|
|
243
|
+
# Update phase output files
|
|
244
|
+
if solving_phase:
|
|
245
|
+
solving_phase.output_files.append(relativize_path(str(solution_file), self.runner.output_dir))
|
|
246
|
+
|
|
247
|
+
if move:
|
|
248
|
+
self.logger.info(f"Moved solution {plan_num} to: {solution_file}")
|
|
249
|
+
else:
|
|
250
|
+
# Clean up temporary file
|
|
251
|
+
try:
|
|
252
|
+
source.unlink()
|
|
253
|
+
except:
|
|
254
|
+
pass # Ignore cleanup errors
|
|
255
|
+
self.logger.info(f"Used saved solution for plan {plan_num}")
|
|
256
|
+
|
|
257
|
+
except Exception as e:
|
|
258
|
+
if move:
|
|
259
|
+
self.logger.error(f"Failed to move solution file {plan_num}: {e}")
|
|
260
|
+
else:
|
|
261
|
+
self.logger.error(f"Failed to copy saved solution file {plan_num}: {e}")
|