multi-puzzle-solver 0.9.12__py3-none-any.whl → 0.9.14__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.
Potentially problematic release.
This version of multi-puzzle-solver might be problematic. Click here for more details.
- {multi_puzzle_solver-0.9.12.dist-info → multi_puzzle_solver-0.9.14.dist-info}/METADATA +128 -17
- {multi_puzzle_solver-0.9.12.dist-info → multi_puzzle_solver-0.9.14.dist-info}/RECORD +17 -15
- puzzle_solver/__init__.py +3 -2
- puzzle_solver/core/utils.py +228 -127
- puzzle_solver/core/utils_ortools.py +235 -172
- puzzle_solver/puzzles/battleships/battleships.py +1 -0
- puzzle_solver/puzzles/black_box/black_box.py +313 -0
- puzzle_solver/puzzles/filling/filling.py +117 -192
- puzzle_solver/puzzles/inertia/tsp.py +4 -1
- puzzle_solver/puzzles/lits/lits.py +162 -0
- puzzle_solver/puzzles/pearl/pearl.py +12 -44
- puzzle_solver/puzzles/range/range.py +2 -51
- puzzle_solver/puzzles/singles/singles.py +9 -50
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +212 -212
- puzzle_solver/puzzles/tracks/tracks.py +12 -41
- {multi_puzzle_solver-0.9.12.dist-info → multi_puzzle_solver-0.9.14.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-0.9.12.dist-info → multi_puzzle_solver-0.9.14.dist-info}/top_level.txt +0 -0
|
@@ -1,172 +1,235 @@
|
|
|
1
|
-
import time
|
|
2
|
-
import json
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
from typing import Optional, Callable, Any, Union
|
|
5
|
-
|
|
6
|
-
from ortools.sat.python import cp_model
|
|
7
|
-
from ortools.sat.python.cp_model import CpSolverSolutionCallback
|
|
8
|
-
|
|
9
|
-
from puzzle_solver.core.utils import Pos
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@dataclass(frozen=True)
|
|
13
|
-
class SingleSolution:
|
|
14
|
-
assignment: dict[Pos, Union[str, int]]
|
|
15
|
-
|
|
16
|
-
def get_hashable_solution(self) -> str:
|
|
17
|
-
result = []
|
|
18
|
-
for pos, v in self.assignment.items():
|
|
19
|
-
result.append((pos.x, pos.y, v))
|
|
20
|
-
return json.dumps(result, sort_keys=True)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def and_constraint(model: cp_model.CpModel, target: cp_model.IntVar, cs: list[cp_model.IntVar]):
|
|
24
|
-
for c in cs:
|
|
25
|
-
model.Add(target <= c)
|
|
26
|
-
model.Add(target >= sum(cs) - len(cs) + 1)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def or_constraint(model: cp_model.CpModel, target: cp_model.IntVar, cs: list[cp_model.IntVar]):
|
|
30
|
-
for c in cs:
|
|
31
|
-
model.Add(target >= c)
|
|
32
|
-
model.Add(target <= sum(cs))
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class AllSolutionsCollector(CpSolverSolutionCallback):
|
|
37
|
-
def __init__(self,
|
|
38
|
-
board: Any,
|
|
39
|
-
board_to_solution: Callable[Any, SingleSolution],
|
|
40
|
-
max_solutions: Optional[int] = None,
|
|
41
|
-
callback: Optional[Callable[SingleSolution, None]] = None
|
|
42
|
-
):
|
|
43
|
-
super().__init__()
|
|
44
|
-
self.board = board
|
|
45
|
-
self.board_to_solution = board_to_solution
|
|
46
|
-
self.max_solutions = max_solutions
|
|
47
|
-
self.callback = callback
|
|
48
|
-
self.solutions = []
|
|
49
|
-
self.unique_solutions = set()
|
|
50
|
-
|
|
51
|
-
def on_solution_callback(self):
|
|
52
|
-
try:
|
|
53
|
-
result = self.board_to_solution(self.board, self)
|
|
54
|
-
result_json = result.get_hashable_solution()
|
|
55
|
-
if result_json in self.unique_solutions:
|
|
56
|
-
return
|
|
57
|
-
self.unique_solutions.add(result_json)
|
|
58
|
-
self.solutions.append(result)
|
|
59
|
-
if self.callback is not None:
|
|
60
|
-
self.callback(result)
|
|
61
|
-
if self.max_solutions is not None and len(self.solutions) >= self.max_solutions:
|
|
62
|
-
self.StopSearch()
|
|
63
|
-
except Exception as e:
|
|
64
|
-
print(e)
|
|
65
|
-
raise e
|
|
66
|
-
|
|
67
|
-
def generic_solve_all(board: Any, board_to_solution: Callable[Any, SingleSolution], max_solutions: Optional[int] = None, callback: Optional[Callable[[SingleSolution], None]] = None, verbose: bool = True) -> list[SingleSolution]:
|
|
68
|
-
try:
|
|
69
|
-
solver = cp_model.CpSolver()
|
|
70
|
-
solver.parameters.enumerate_all_solutions = True
|
|
71
|
-
collector = AllSolutionsCollector(board, board_to_solution, max_solutions=max_solutions, callback=callback)
|
|
72
|
-
tic = time.time()
|
|
73
|
-
solver.solve(board.model, collector)
|
|
74
|
-
if verbose:
|
|
75
|
-
print("Solutions found:", len(collector.solutions))
|
|
76
|
-
print("status:", solver.StatusName())
|
|
77
|
-
toc = time.time()
|
|
78
|
-
print(f"Time taken: {toc - tic:.2f} seconds")
|
|
79
|
-
return collector.solutions
|
|
80
|
-
except Exception as e:
|
|
81
|
-
print(e)
|
|
82
|
-
raise e
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def manhattan_distance(p1: Pos, p2: Pos) -> int:
|
|
86
|
-
return abs(p1.x - p2.x) + abs(p1.y - p2.y)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def force_connected_component(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.IntVar], is_neighbor: Callable[[Any, Any], bool] = None):
|
|
90
|
-
"""
|
|
91
|
-
Forces a single connected component of the given variables and any abstract function that defines adjacency.
|
|
92
|
-
Returns a dictionary of new variables that can be used to enforce the connected component constraint.
|
|
93
|
-
Total new variables: =
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# =V
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
# for
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
1
|
+
import time
|
|
2
|
+
import json
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional, Callable, Any, Union
|
|
5
|
+
|
|
6
|
+
from ortools.sat.python import cp_model
|
|
7
|
+
from ortools.sat.python.cp_model import CpSolverSolutionCallback
|
|
8
|
+
|
|
9
|
+
from puzzle_solver.core.utils import Pos
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class SingleSolution:
|
|
14
|
+
assignment: dict[Pos, Union[str, int]]
|
|
15
|
+
|
|
16
|
+
def get_hashable_solution(self) -> str:
|
|
17
|
+
result = []
|
|
18
|
+
for pos, v in self.assignment.items():
|
|
19
|
+
result.append((pos.x, pos.y, v))
|
|
20
|
+
return json.dumps(result, sort_keys=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def and_constraint(model: cp_model.CpModel, target: cp_model.IntVar, cs: list[cp_model.IntVar]):
|
|
24
|
+
for c in cs:
|
|
25
|
+
model.Add(target <= c)
|
|
26
|
+
model.Add(target >= sum(cs) - len(cs) + 1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def or_constraint(model: cp_model.CpModel, target: cp_model.IntVar, cs: list[cp_model.IntVar]):
|
|
30
|
+
for c in cs:
|
|
31
|
+
model.Add(target >= c)
|
|
32
|
+
model.Add(target <= sum(cs))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AllSolutionsCollector(CpSolverSolutionCallback):
|
|
37
|
+
def __init__(self,
|
|
38
|
+
board: Any,
|
|
39
|
+
board_to_solution: Callable[Any, SingleSolution],
|
|
40
|
+
max_solutions: Optional[int] = None,
|
|
41
|
+
callback: Optional[Callable[SingleSolution, None]] = None
|
|
42
|
+
):
|
|
43
|
+
super().__init__()
|
|
44
|
+
self.board = board
|
|
45
|
+
self.board_to_solution = board_to_solution
|
|
46
|
+
self.max_solutions = max_solutions
|
|
47
|
+
self.callback = callback
|
|
48
|
+
self.solutions = []
|
|
49
|
+
self.unique_solutions = set()
|
|
50
|
+
|
|
51
|
+
def on_solution_callback(self):
|
|
52
|
+
try:
|
|
53
|
+
result = self.board_to_solution(self.board, self)
|
|
54
|
+
result_json = result.get_hashable_solution()
|
|
55
|
+
if result_json in self.unique_solutions:
|
|
56
|
+
return
|
|
57
|
+
self.unique_solutions.add(result_json)
|
|
58
|
+
self.solutions.append(result)
|
|
59
|
+
if self.callback is not None:
|
|
60
|
+
self.callback(result)
|
|
61
|
+
if self.max_solutions is not None and len(self.solutions) >= self.max_solutions:
|
|
62
|
+
self.StopSearch()
|
|
63
|
+
except Exception as e:
|
|
64
|
+
print(e)
|
|
65
|
+
raise e
|
|
66
|
+
|
|
67
|
+
def generic_solve_all(board: Any, board_to_solution: Callable[Any, SingleSolution], max_solutions: Optional[int] = None, callback: Optional[Callable[[SingleSolution], None]] = None, verbose: bool = True) -> list[SingleSolution]:
|
|
68
|
+
try:
|
|
69
|
+
solver = cp_model.CpSolver()
|
|
70
|
+
solver.parameters.enumerate_all_solutions = True
|
|
71
|
+
collector = AllSolutionsCollector(board, board_to_solution, max_solutions=max_solutions, callback=callback)
|
|
72
|
+
tic = time.time()
|
|
73
|
+
solver.solve(board.model, collector)
|
|
74
|
+
if verbose:
|
|
75
|
+
print("Solutions found:", len(collector.solutions))
|
|
76
|
+
print("status:", solver.StatusName())
|
|
77
|
+
toc = time.time()
|
|
78
|
+
print(f"Time taken: {toc - tic:.2f} seconds")
|
|
79
|
+
return collector.solutions
|
|
80
|
+
except Exception as e:
|
|
81
|
+
print(e)
|
|
82
|
+
raise e
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def manhattan_distance(p1: Pos, p2: Pos) -> int:
|
|
86
|
+
return abs(p1.x - p2.x) + abs(p1.y - p2.y)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def force_connected_component(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.IntVar], is_neighbor: Callable[[Any, Any], bool] = None):
|
|
90
|
+
"""
|
|
91
|
+
Forces a single connected component of the given variables and any abstract function that defines adjacency.
|
|
92
|
+
Returns a dictionary of new variables that can be used to enforce the connected component constraint.
|
|
93
|
+
Total new variables: =4V [for N by M 2D grid total is 4NM]
|
|
94
|
+
"""
|
|
95
|
+
if is_neighbor is None:
|
|
96
|
+
is_neighbor = lambda p1, p2: manhattan_distance(p1, p2) <= 1
|
|
97
|
+
|
|
98
|
+
vs = vars_to_force
|
|
99
|
+
v_count = len(vs)
|
|
100
|
+
# =V model variables, one for each variable
|
|
101
|
+
is_root: dict[Pos, cp_model.IntVar] = {} # =V, defines the unique root
|
|
102
|
+
prefix_zero: dict[Pos, cp_model.IntVar] = {} # =V, used for picking the unique root
|
|
103
|
+
node_height: dict[Pos, cp_model.IntVar] = {} # =V, trickles down from the root
|
|
104
|
+
max_neighbor_height: dict[Pos, cp_model.IntVar] = {} # =V, the height of the tallest neighbor
|
|
105
|
+
prefix_name = "connected_component_"
|
|
106
|
+
# total = 4V [for N by M 2D grid total is 4NM]
|
|
107
|
+
|
|
108
|
+
keys_in_order = list(vs.keys()) # must enforce some ordering
|
|
109
|
+
|
|
110
|
+
for p in keys_in_order:
|
|
111
|
+
is_root[p] = model.NewBoolVar(f"{prefix_name}is_root[{p}]")
|
|
112
|
+
node_height[p] = model.NewIntVar(0, v_count, f"{prefix_name}node_height[{p}]")
|
|
113
|
+
max_neighbor_height[p] = model.NewIntVar(0, v_count, f"{prefix_name}max_neighbor_height[{p}]")
|
|
114
|
+
# Unique root: the smallest index i with x[i] = 1
|
|
115
|
+
# prefix_zero[i] = AND_{k < i} (not x[k])
|
|
116
|
+
prev_p = None
|
|
117
|
+
for p in keys_in_order:
|
|
118
|
+
b = model.NewBoolVar(f"{prefix_name}prefix_zero[{p}]")
|
|
119
|
+
prefix_zero[p] = b
|
|
120
|
+
if prev_p is None: # No earlier cells -> True
|
|
121
|
+
model.Add(b == 1)
|
|
122
|
+
else:
|
|
123
|
+
# b <-> (prefix_zero[i-1] & ~x[i-1])
|
|
124
|
+
and_constraint(model, b, [prefix_zero[prev_p], vs[prev_p].Not()])
|
|
125
|
+
prev_p = p
|
|
126
|
+
|
|
127
|
+
# x[i] & prefix_zero[i] -> root[i]
|
|
128
|
+
for p in keys_in_order:
|
|
129
|
+
and_constraint(model, is_root[p], [vs[p], prefix_zero[p]])
|
|
130
|
+
# Exactly one root:
|
|
131
|
+
model.Add(sum(is_root.values()) == 1)
|
|
132
|
+
|
|
133
|
+
# For each node i, consider only neighbors
|
|
134
|
+
for i, pi in enumerate(keys_in_order):
|
|
135
|
+
# ps is list of neighbor heights
|
|
136
|
+
ps = [node_height[pj] for j, pj in enumerate(keys_in_order) if i != j and is_neighbor(pi, pj)]
|
|
137
|
+
model.AddMaxEquality(max_neighbor_height[pi], ps)
|
|
138
|
+
# if a node is active and its not root, its height is the height of the tallest neighbor - 1
|
|
139
|
+
model.Add(node_height[pi] == max_neighbor_height[pi] - 1).OnlyEnforceIf([vs[pi], is_root[pi].Not()])
|
|
140
|
+
model.Add(node_height[pi] == v_count).OnlyEnforceIf(is_root[pi])
|
|
141
|
+
model.Add(node_height[pi] == 0).OnlyEnforceIf(vs[pi].Not())
|
|
142
|
+
|
|
143
|
+
# final check: all active nodes have height > 0
|
|
144
|
+
for p in keys_in_order:
|
|
145
|
+
model.Add(node_height[p] > 0).OnlyEnforceIf(vs[p])
|
|
146
|
+
|
|
147
|
+
all_new_vars: dict[str, cp_model.IntVar] = {}
|
|
148
|
+
for k, v in is_root.items():
|
|
149
|
+
all_new_vars[f"{prefix_name}is_root[{k}]"] = v
|
|
150
|
+
for k, v in prefix_zero.items():
|
|
151
|
+
all_new_vars[f"{prefix_name}prefix_zero[{k}]"] = v
|
|
152
|
+
for k, v in node_height.items():
|
|
153
|
+
all_new_vars[f"{prefix_name}node_height[{k}]"] = v
|
|
154
|
+
for k, v in max_neighbor_height.items():
|
|
155
|
+
all_new_vars[f"{prefix_name}max_neighbor_height[{k}]"] = v
|
|
156
|
+
|
|
157
|
+
return all_new_vars
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def force_no_loops(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.IntVar], is_neighbor: Callable[[Any, Any], bool] = None):
|
|
161
|
+
"""
|
|
162
|
+
Forces no loops in the given variables and any abstract function that defines adjacency.
|
|
163
|
+
Returns a dictionary of new variables that can be used to enforce the no component constraint.
|
|
164
|
+
"""
|
|
165
|
+
if is_neighbor is None:
|
|
166
|
+
is_neighbor = lambda p1, p2: manhattan_distance(p1, p2) <= 1
|
|
167
|
+
|
|
168
|
+
vs = vars_to_force
|
|
169
|
+
v_count = len(vs)
|
|
170
|
+
is_root: dict[Pos, cp_model.IntVar] = {}
|
|
171
|
+
block_root: dict[Pos, cp_model.IntVar] = {}
|
|
172
|
+
node_height: dict[Pos, cp_model.IntVar] = {}
|
|
173
|
+
tree_edge: dict[tuple[Pos, Pos], cp_model.IntVar] = {} # tree_edge[p, q] means p is parent of q
|
|
174
|
+
prefix_name = "no_loops_"
|
|
175
|
+
|
|
176
|
+
def parent_of(p: Pos) -> list[Pos]:
|
|
177
|
+
return [q for q in keys_in_order if (q, p) in tree_edge]
|
|
178
|
+
def children_of(p: Pos) -> list[Pos]:
|
|
179
|
+
return [q for q in keys_in_order if (p, q) in tree_edge]
|
|
180
|
+
|
|
181
|
+
keys_in_order = list(vs.keys()) # must enforce some ordering
|
|
182
|
+
node_to_idx: dict[Pos, int] = {p: i+1 for i, p in enumerate(keys_in_order)}
|
|
183
|
+
for p in keys_in_order:
|
|
184
|
+
# capacity = node_to_idx[p] + 1
|
|
185
|
+
# if p == Pos(0, 0):
|
|
186
|
+
# capacity = 10
|
|
187
|
+
# print(f'capacity for {p} = {capacity}')
|
|
188
|
+
node_height[p] = model.NewIntVar(0, v_count, f"{prefix_name}node_height[{p}]")
|
|
189
|
+
block_root[p] = model.NewIntVar(0, node_to_idx[p], f"{prefix_name}block_root[{p}]")
|
|
190
|
+
is_root[p] = model.NewBoolVar(f"{prefix_name}is_root[{p}]")
|
|
191
|
+
model.Add(is_root[p] == 0).OnlyEnforceIf([vs[p].Not()])
|
|
192
|
+
model.Add(node_height[p] == 0).OnlyEnforceIf([vs[p].Not()])
|
|
193
|
+
model.Add(node_height[p] == 1).OnlyEnforceIf([is_root[p]])
|
|
194
|
+
model.Add(block_root[p] == 0).OnlyEnforceIf([vs[p].Not()])
|
|
195
|
+
model.Add(block_root[p] == node_to_idx[p]).OnlyEnforceIf([is_root[p]])
|
|
196
|
+
|
|
197
|
+
for p in keys_in_order:
|
|
198
|
+
for q in keys_in_order:
|
|
199
|
+
if p == q:
|
|
200
|
+
continue
|
|
201
|
+
if is_neighbor(p, q):
|
|
202
|
+
tree_edge[(p, q)] = model.NewBoolVar(f"{prefix_name}tree_edge[{p} is parent of {q}]")
|
|
203
|
+
model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([vs[p].Not()])
|
|
204
|
+
model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([vs[q].Not()])
|
|
205
|
+
# a tree_edge[p, q] means p is parent of q thus h[q] = h[p] + 1
|
|
206
|
+
model.Add(node_height[q] == node_height[p] + 1).OnlyEnforceIf([tree_edge[(p, q)]])
|
|
207
|
+
model.Add(block_root[q] == block_root[p]).OnlyEnforceIf([tree_edge[(p, q)]])
|
|
208
|
+
|
|
209
|
+
for (p, q) in tree_edge:
|
|
210
|
+
if (q, p) in tree_edge:
|
|
211
|
+
model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([tree_edge[(q, p)]])
|
|
212
|
+
model.Add(tree_edge[(p, q)] == 1).OnlyEnforceIf([tree_edge[(q, p)].Not(), vs[p], vs[q]])
|
|
213
|
+
|
|
214
|
+
for p in keys_in_order:
|
|
215
|
+
for p_child in children_of(p):
|
|
216
|
+
# i am root thus I point to all my children
|
|
217
|
+
model.Add(tree_edge[(p, p_child)] == 1).OnlyEnforceIf([is_root[p], vs[p_child]])
|
|
218
|
+
for p_parent in parent_of(p):
|
|
219
|
+
# i am root thus I have no parent
|
|
220
|
+
model.Add(tree_edge[(p_parent, p)] == 0).OnlyEnforceIf([is_root[p]])
|
|
221
|
+
# every active node has exactly 1 parent except root has none
|
|
222
|
+
model.AddExactlyOne([tree_edge[(p_parent, p)] for p_parent in parent_of(p)] + [vs[p].Not(), is_root[p]])
|
|
223
|
+
|
|
224
|
+
# now each subgraph has directions where each non-root points to a single parent (and its value is parent+1).
|
|
225
|
+
# to break cycles, every non-root active node must be > all neighbors that arent children
|
|
226
|
+
|
|
227
|
+
all_new_vars: dict[str, cp_model.IntVar] = {}
|
|
228
|
+
for k, v in is_root.items():
|
|
229
|
+
all_new_vars[f"{prefix_name}is_root[{k}]"] = v
|
|
230
|
+
for k, v in tree_edge.items():
|
|
231
|
+
all_new_vars[f"{prefix_name}tree_edge[{k[0]} is parent of {k[1]}]"] = v
|
|
232
|
+
for k, v in node_height.items():
|
|
233
|
+
all_new_vars[f"{prefix_name}node_height[{k}]"] = v
|
|
234
|
+
|
|
235
|
+
return all_new_vars
|