Spatial-Gym 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.
@@ -0,0 +1,1314 @@
1
+ from enum import Enum
2
+ import json
3
+ from collections import Counter, deque
4
+ import gymnasium as gym
5
+ from gymnasium import spaces
6
+ import numpy as np
7
+ import yaml
8
+ from dataclasses import dataclass
9
+ from .render import HumanRenderer, LLMRenderer
10
+ from datasets import load_dataset
11
+
12
+ class Actions(Enum):
13
+ """
14
+ Enum class representing the possible actions the agent can take in the environment.
15
+
16
+ Actions:
17
+ right (int): Move the agent one step to the right.
18
+ up (int): Move the agent one step upward.
19
+ left (int): Move the agent one step to the left.
20
+ down (int): Move the agent one step downward.
21
+ """
22
+ right = 0
23
+ up = 1
24
+ left = 2
25
+ down = 3
26
+
27
+
28
+ @dataclass
29
+ class RegionData:
30
+ id: int
31
+ cells: list
32
+ area: int
33
+ symbols: dict
34
+ colors: dict
35
+
36
+ def to_summary(self):
37
+ return {
38
+ "id": self.id,
39
+ "area": self.area,
40
+ "symbol_counts": {k: len(v) for k, v in self.symbols.items()},
41
+ "colors": self.colors
42
+ }
43
+
44
+ class Spatial_Gym(gym.Env):
45
+ metadata = {"render_modes": ["human", "llm"], "render_fps": 30}
46
+ def __init__(self, df_name='lkaesberg/SPaRC', df_split='all', df_set='test', render_mode=None, observation='new', traceback=False, max_steps=2000):
47
+ """
48
+ Initialize the Spatial_Gym environment.
49
+
50
+ Loads the puzzle dataset, sets up rendering modes, and prepares the environment for agent interaction.
51
+
52
+ Parameters:
53
+ df_name (str): Name of the HuggingFace dataset to load (default: 'lkaesberg/SPaRC').
54
+ df_split (str): Dataset split to use (default: 'all').
55
+ df_set (str): Subset of the dataset for testing (default: 'test').
56
+ render_mode (str or None): Rendering mode, either 'human', 'llm', or None.
57
+ observation (str): Observation format, either 'new' (multi-array) or 'SPaRC' (JSON/text).
58
+ traceback (bool): If True, enables traceback.
59
+ max_steps (int): Maximum steps per episode before truncation.
60
+
61
+ Loads the puzzles, initializes renderers, sets up observation and action spaces, and loads the first puzzle.
62
+ """
63
+ self.render_mode = render_mode
64
+ self.observation = observation
65
+ self.traceback = traceback
66
+ self.max_steps = max_steps
67
+
68
+ # Initialize renderers
69
+ self.human_renderer = None
70
+ self.llm_renderer = None
71
+ if render_mode == "human":
72
+ self.human_renderer = HumanRenderer(scale_factor=3.0)
73
+ elif render_mode == "llm":
74
+ self.llm_renderer = LLMRenderer()
75
+
76
+ # Load the puzzles
77
+ ds = load_dataset(df_name, df_split, split=df_set)
78
+ df = ds.to_pandas()
79
+ self.puzzles = df
80
+ self.current_puzzle_index = 0
81
+ self.current_step = 0
82
+
83
+ self.rule_status = {}
84
+
85
+ # Process the puzzles to extract relevant information
86
+ if self.puzzles is None or isinstance(self.puzzles, Exception):
87
+ raise ValueError("No valid dataframe provided")
88
+ self.puzzles = self._process_puzzles(self.puzzles)
89
+ # Load the first puzzle
90
+ self._load_puzzle(self.current_puzzle_index)
91
+
92
+
93
+ # ---------- Puzzle Processing/Loading Functions ----------
94
+
95
+ def _load_puzzle(self, index):
96
+ '''
97
+ Function to load a puzzle from the dataset
98
+ and initialize the environment variables
99
+ aswell as the observation and action spaces
100
+
101
+ Parameters:
102
+ index : int
103
+ The index of the puzzle to load from the dataset.
104
+
105
+ --------------
106
+ puzzle variables:
107
+
108
+ difficulty : int
109
+ The difficulty of the puzzle
110
+
111
+ polyshapes : dict of 2d arrays
112
+ The polyshapes of the puzzle
113
+
114
+ x_size : int
115
+ The x size of the puzzle
116
+
117
+ y_size : int
118
+ The y size of the puzzle
119
+
120
+ obs_array : dict; Dictionary of 2D arrays
121
+ The observation array of the puzzle
122
+ or if observation == 'SPaRC':
123
+ The observation array in SPaRC format
124
+
125
+ unique_properties : int
126
+ The number of unique properties(star,polyshape, Co.) in the puzzle
127
+
128
+ start_location : tuple
129
+ The starting location of the agent in the puzzle
130
+
131
+ target_location : tuple
132
+ The target location of the agent in the puzzle
133
+
134
+ solution_paths : list
135
+ The solution paths of the puzzle
136
+
137
+ solution_count : int
138
+ The number of solution paths of the puzzle
139
+
140
+ '''
141
+ puzzle = self.puzzles[index]
142
+
143
+ self.difficulty = puzzle['difficulty']
144
+ self.polyshapes = puzzle['polyshapes']
145
+
146
+ self.x_size = puzzle['x_size']
147
+ self.y_size = puzzle['y_size']
148
+
149
+ self.obs_array = puzzle['obs_array']
150
+ self.color_array = puzzle['color_array']
151
+ self.additional_info = puzzle['additional_info']
152
+
153
+ if self.observation == 'SPaRC':
154
+ raw = puzzle['observ']
155
+ if isinstance(raw, np.ndarray) and raw.dtype == object and raw.ndim == 1:
156
+ grid_rows = [r.astype(str).tolist() for r in raw]
157
+ elif isinstance(raw, np.ndarray) and raw.ndim == 2:
158
+ grid_rows = raw.astype(str).tolist()
159
+ else:
160
+ grid_rows = [[str(c) for c in row] for row in raw]
161
+ w = len(grid_rows[0])
162
+ if any(len(r) != w for r in grid_rows):
163
+ raise ValueError("Non-rectangular SPaRC grid")
164
+ self.observ = grid_rows
165
+
166
+ self.start_location = puzzle['start_location']
167
+ self.target_location = puzzle['target_location']
168
+
169
+ self.solution_paths = puzzle['solution_paths']
170
+ self.solution_count = puzzle['solution_count']
171
+
172
+ # Initialize the agent's path with the starting location
173
+ self.path = [[self.start_location[0], self.start_location[1]]]
174
+ self.normal_reward = 0
175
+ self.outcome_reward = 0
176
+
177
+ self.rule_status = {}
178
+
179
+ self._agent_location = np.array([self.start_location[0], self.start_location[1]], dtype=np.int32)
180
+ self._target_location = np.array([self.target_location[0], self.target_location[1]], dtype=np.int32)
181
+
182
+ self._validate_rules(terminated=False, truncated=False)
183
+
184
+ # Mark the starting location as visited and set the agent's and target's positions in the observation array
185
+ self.obs_array['visited'][self._agent_location[0], self._agent_location[1]] = 1
186
+ self.obs_array['agent_location'][self._agent_location[0], self._agent_location[1]] = 1
187
+ self.obs_array['target_location'][self._target_location[0], self._target_location[1]] = 1
188
+
189
+ # Define the observation space for the environment
190
+ if self.observation == 'new':
191
+ keys = list(self.obs_array.keys())
192
+ self.observation_space = spaces.Dict({
193
+ 'base': spaces.Dict({key: spaces.Box(low=0, high=1, shape=(self.x_size, self.y_size), dtype=np.int32) for key in keys}),
194
+ 'color': spaces.Box(low=0, high=8, shape=(self.x_size, self.y_size), dtype=np.int32),
195
+ 'additional_info': spaces.Box(low=0, high=143632, shape=(self.x_size, self.y_size), dtype=np.int64)
196
+ })
197
+
198
+ elif self.observation == 'SPaRC':
199
+ init_json = self._build_json_obs()
200
+ overlay_chars = set("LV.")
201
+ charset = "".join(sorted(set(init_json) | overlay_chars))
202
+ max_length = int(len(init_json) * 2)
203
+ self._json_charset = charset
204
+ self.observation_space = spaces.Text(max_length=max_length, charset=charset)
205
+
206
+ else:
207
+ raise ValueError("Invalid observation type. Choose 'new' or 'SPaRC'.")
208
+
209
+ # Define the action space (4 discrete actions: right, up, left, down)
210
+ self.action_space = gym.spaces.Discrete(4)
211
+ # Map actions to directions
212
+ self._action_to_direction = {
213
+ Actions.right.value: np.array([1, 0]),
214
+ Actions.up.value: np.array([0, -1]),
215
+ Actions.left.value: np.array([-1, 0]),
216
+ Actions.down.value: np.array([0, 1]),
217
+ }
218
+
219
+ def _process_puzzles(self, df):
220
+ """
221
+ Processes a DataFrame of puzzles and returns a list of puzzle dictionaries.
222
+
223
+ Parameters:
224
+ df (pd.DataFrame): The DataFrame containing puzzle data.
225
+
226
+ --------
227
+
228
+ Returns:
229
+ list: A list of dictionaries, each representing a processed puzzle.
230
+ """
231
+
232
+ puzzles = []
233
+
234
+ for i in range(len(df)):
235
+
236
+ puzzle = {}
237
+
238
+ # Extract difficulty
239
+ difficulty = df['difficulty_level'][i]
240
+ puzzle.update({'difficulty': difficulty})
241
+
242
+ # Extract grid size
243
+ grid_size = df['grid_size'][i]
244
+ x_size = grid_size['width']
245
+ y_size = grid_size['height']
246
+ x_size = x_size + x_size + 1
247
+ y_size = y_size + y_size + 1
248
+ puzzle.update({'x_size': x_size, 'y_size': y_size})
249
+
250
+ # Extract solution paths
251
+ solution_count = df['solution_count'][i]
252
+ solutions = df['solutions'][i]
253
+ solution_paths = []
254
+ for item in solutions:
255
+ path = [[point["x"], point["y"]] for point in item["path"]]
256
+ solution_paths.append(path)
257
+ puzzle.update({'solution_count': solution_count, 'solution_paths': solution_paths})
258
+
259
+ # Extract the polyshapes (eg. an L shape)
260
+ polyshapes = df['polyshapes'][i]
261
+ polyshapes_yaml = yaml.safe_load(polyshapes)
262
+ puzzle.update({'polyshapes': polyshapes_yaml})
263
+
264
+ # Extract start and target locations
265
+ text_visualization = df['text_visualization'][i]
266
+ text_yaml = yaml.safe_load(text_visualization)
267
+ start_location = (text_yaml["puzzle"]["start"]["x"], text_yaml["puzzle"]["start"]["y"])
268
+ target_location = (text_yaml["puzzle"]["end"]["x"], text_yaml["puzzle"]["end"]["y"])
269
+ puzzle.update({'start_location': start_location, 'target_location': target_location})
270
+
271
+ # Initialize observation arrays
272
+ obs_array = {
273
+ 'visited': np.zeros((x_size, y_size), dtype=np.int32),
274
+ 'gaps': np.zeros((x_size, y_size), dtype=np.int32),
275
+ 'agent_location': np.zeros((x_size, y_size), dtype=np.int32),
276
+ 'target_location': np.zeros((x_size, y_size), dtype=np.int32)
277
+ }
278
+
279
+ color_array = np.zeros((x_size, y_size), dtype=np.int32)
280
+ additional_info = np.zeros((x_size, y_size), dtype=np.int64)
281
+
282
+ # Extract symbols, colors and additional info
283
+ for cell in text_yaml["puzzle"]["cells"]:
284
+ properties = cell.get("properties", {})
285
+ count = None
286
+ shape = None
287
+ color = None
288
+ for key, value in properties.items():
289
+ if key == 'type':
290
+ if value == 'star' or value == 'square':
291
+ symbol = f"{value}"
292
+ color = properties.get('color', '')
293
+ elif value == 'triangle':
294
+ symbol = f"{value}"
295
+ color = properties.get('color', '')
296
+ count = properties.get('count', '')
297
+ else:
298
+ symbol = f"{value}"
299
+ color = properties.get('color', '')
300
+ shape = properties.get('polyshape', '')
301
+
302
+ elif key == 'dot':
303
+ symbol = 'dot'
304
+ # Add new property to obs_array if not already present
305
+ if symbol not in obs_array:
306
+ obs_array.update({symbol: np.zeros((x_size, y_size), dtype=np.int32)})
307
+
308
+ # Update the colors
309
+ if color:
310
+ color_to_number = {"red": 1, "blue": 2, "yellow": 3, "green": 4, "black": 5, "purple": 6, "orange": 7, "white": 8}
311
+ position = cell.get("position", {})
312
+ x, y = position.get("x"), position.get("y")
313
+ for color_ in color_to_number:
314
+ if color_ == color:
315
+ color_array[x][y] = color_to_number[color_]
316
+
317
+ # update additional information
318
+ if count:
319
+ position = cell.get("position", {})
320
+ x, y = position.get("x"), position.get("y")
321
+ additional_info[x][y] = count
322
+ elif shape:
323
+ position = cell.get("position", {})
324
+ x, y = position.get("x"), position.get("y")
325
+ additional_info[x][y] = shape
326
+
327
+
328
+ # Populate observation arrays
329
+ for cell in text_yaml["puzzle"]["cells"]:
330
+ position = cell.get("position", {})
331
+ properties = cell.get("properties", {})
332
+ x, y = position.get("x"), position.get("y")
333
+
334
+ for key, value in properties.items():
335
+ if key == 'type':
336
+ symbol = f"{value}"
337
+ elif key == 'dot':
338
+ symbol = 'dot'
339
+ elif key == 'gap':
340
+ symbol = 'gaps'
341
+ # Update the corresponding observation array
342
+ if symbol in obs_array:
343
+ obs_array[symbol][x, y] = 1
344
+
345
+ x_size = x_size - 1
346
+ y_size = y_size - 1
347
+ # Mark all the green cells as gaps
348
+ for k in range(x_size):
349
+ for j in range(y_size):
350
+ if k % 2 == 1 and j % 2 == 1:
351
+ obs_array['gaps'][k, j] = 1
352
+
353
+ puzzle.update({'obs_array': obs_array})
354
+ puzzle.update({'color_array': color_array})
355
+ puzzle.update({'additional_info': additional_info})
356
+
357
+ # If using the SPaRC observation format
358
+ if self.observation == 'SPaRC':
359
+ observ = df['puzzle_array'][i]
360
+ puzzle.update({'observ': observ})
361
+
362
+ id = df['id'][i]
363
+ puzzle.update({'id': id})
364
+
365
+ # Add the processed puzzle to the list
366
+ puzzles.append(puzzle)
367
+
368
+ return puzzles
369
+
370
+ # ---------- End Puzzle Processing/Loading Functions ----------
371
+
372
+ # ---------- Compute Regions Functions ----------
373
+
374
+ def _cell_index_grid(self):
375
+ '''
376
+ Helper Function for _compute_regions
377
+ Generate a mask for Rule cells.
378
+ '''
379
+
380
+ visited = self.obs_array['visited']
381
+ h, w = visited.shape
382
+ mask = np.zeros((h, w), dtype=bool)
383
+ for x in range(h):
384
+ for y in range(w):
385
+ if x % 2 == 1 and y % 2 == 1:
386
+ mask[x, y] = True
387
+
388
+ return mask
389
+
390
+ def _cell_index_grid2(self):
391
+ '''
392
+ Helper Function for _compute_regions
393
+ Generate a mask for visited cells and gaps.
394
+ '''
395
+
396
+ visited = self.obs_array['visited']
397
+ gaps = self.obs_array['gaps']
398
+ h, w = visited.shape
399
+ mask = np.zeros((h, w), dtype=bool)
400
+ path_nodes = {tuple(p) for p in self.path}
401
+ for x in range(h):
402
+ for y in range(w):
403
+ if gaps[x, y] == 1:
404
+ mask[x, y] = True
405
+
406
+ for x, y in path_nodes:
407
+ mask[x, y] = True
408
+
409
+ return mask
410
+
411
+ def _neighbours_cell(self, x, y, h, w):
412
+ """
413
+ Helper Function for _compute_regions
414
+ Generate neighboring cell coordinates (1 unit away) in the grid.
415
+ """
416
+ for dx, dy in ((0, 1), (0, -1), (1, 0), (-1, 0)):
417
+ nx, ny = x + dx, y + dy
418
+ if 0 <= nx < h and 0 <= ny < w:
419
+ yield nx, ny
420
+
421
+
422
+ def _compute_regions(self):
423
+ """
424
+ Compute regions in the grid based on the observation layers.
425
+ """
426
+ mask = self._cell_index_grid()
427
+ mask2 = self._cell_index_grid2()
428
+ h, w = mask.shape
429
+ region_map = -1 * np.ones((h, w), dtype=np.int32)
430
+ regions = []
431
+ rid = 0
432
+
433
+ for x in range(h):
434
+ for y in range(w):
435
+ if mask[x, y] and region_map[x, y] == -1:
436
+ enqueued_non_cells = np.zeros((h, w), dtype=bool)
437
+ q = deque([(x, y)])
438
+ region_map[x, y] = rid
439
+ cells = []
440
+ while q:
441
+ cx, cy = q.popleft()
442
+ if mask[cx, cy]:
443
+ cells.append((cx, cy))
444
+ for nx, ny in self._neighbours_cell(cx, cy, h, w):
445
+ if mask[nx, ny] and region_map[nx, ny] == -1:
446
+ region_map[nx, ny] = rid
447
+ q.append((nx, ny))
448
+ if not mask2[nx, ny] and not enqueued_non_cells[nx, ny]:
449
+ enqueued_non_cells[nx, ny] = True
450
+ q.append((nx, ny))
451
+ regions.append(RegionData(id=rid, cells=cells, area=len(cells), symbols={}, colors={}))
452
+ rid += 1
453
+
454
+ return regions, region_map
455
+
456
+ def _collect_region_symbols(self, regions, region_map):
457
+ """
458
+ Collect symbols for each region based on the observation layers.
459
+ """
460
+
461
+ if not regions:
462
+ return
463
+
464
+ # Build fast lookup
465
+ regions_by_id = {r.id: r for r in regions}
466
+
467
+ skip_layers = {'visited', 'gaps', 'agent_location', 'target_location'}
468
+ for layer, arr in self.obs_array.items():
469
+ if layer in skip_layers:
470
+ continue
471
+ # Collect all coordinates where symbol appears
472
+ xs, ys = np.where(arr == 1)
473
+ for x, y in zip(xs, ys):
474
+ rid = region_map[x, y]
475
+ if rid == -1:
476
+ continue
477
+ reg = regions_by_id[rid]
478
+ reg.symbols.setdefault(layer, []).append((x, y))
479
+ # Color info (if available)
480
+ color_val = self.color_array[x, y]
481
+ if color_val:
482
+ reg.colors[color_val] = reg.colors.get(color_val, 0) + 1
483
+
484
+ # ---------- End Compute Regions Functions ----------
485
+
486
+ # ---------- Rule Check Functions ----------
487
+
488
+ def _rule_reached_target(self):
489
+ '''
490
+ Checks:
491
+ The agent must reach the target location.
492
+ '''
493
+ return bool(np.array_equal(self._agent_location, self._target_location)), {
494
+ "agent_loc": self._agent_location.tolist(),
495
+ "target_loc": self._target_location.tolist()
496
+ }
497
+
498
+ def _rule_path_not_crossing(self):
499
+ '''
500
+ Checks:
501
+ No path nodes should be crossed more than once.
502
+ '''
503
+ path_nodes = [tuple(p) for p in self.path]
504
+ counts = Counter(path_nodes)
505
+ dup = {k: v for k, v in counts.items() if v > 1}
506
+ return len(dup) == 0, {"duplicates": dup}
507
+
508
+ def _rule_no_gap_violations(self):
509
+ '''
510
+ Checks:
511
+ No gaps should be crossed by the agent's path.
512
+ '''
513
+ gaps = self.obs_array['gaps']
514
+ violations = []
515
+ for (x, y) in self.path:
516
+ gx, gy = x, y
517
+ if gaps[gx, gy] == 1:
518
+ violations.append((gx, gy))
519
+ return len(violations) == 0, {"violations": violations}
520
+
521
+ def _rule_all_dots_collected(self):
522
+ """
523
+ Checks:
524
+ All dots must be collected (visited) by the agent.
525
+ """
526
+ if 'dot' not in self.obs_array:
527
+ return True, {"total": 0, "collected": 0}
528
+ dot_mask = self.obs_array['dot'] == 1
529
+ visited = self.obs_array['visited'] == 1
530
+ total = int(dot_mask.sum())
531
+ collected = int((dot_mask & visited).sum())
532
+ return (total == 0) or (collected == total), {"total": total, "collected": collected}
533
+
534
+ def _rule_color_square_separation(self, regions):
535
+ """
536
+ Checks:
537
+ All squares inside any single region must have same color;
538
+ different colors must be separated by path -> no region with >1 square color.
539
+ """
540
+ if 'square' not in self.obs_array:
541
+ return True, {"regions": []}
542
+ bad = []
543
+ details = []
544
+ for r in regions:
545
+ squares = r.symbols.get('square', [])
546
+ if not squares:
547
+ continue
548
+ colors = set(self.color_array[x, y] for (x, y) in squares if self.color_array[x, y] != 0)
549
+ if len(colors) > 1:
550
+ bad.append(r.id)
551
+ details.append({"region": r.id, "square_count": len(squares), "colors": list(colors)})
552
+ return len(bad) == 0, {"violating_regions": bad, "region_square_details": details}
553
+
554
+ def _rule_star_pairing_exact(self, regions):
555
+ """
556
+ Checks:
557
+ Each star must share region with exactly one other same-color symbol.
558
+ => For each region: for each star color, count must be 0 or 2 (not 1, not >2).
559
+ """
560
+ if 'star' not in self.obs_array:
561
+ return True, {"regions": []}
562
+ violations = []
563
+ per_region = []
564
+ for r in regions:
565
+ star_coords = r.symbols.get('star', [])
566
+ if not star_coords:
567
+ continue
568
+
569
+ color_counts_all = {}
570
+ for layer, coords in r.symbols.items():
571
+ for (x, y) in coords:
572
+ c = self.color_array[x, y]
573
+ if c == 0:
574
+ continue
575
+ color_counts_all[c] = color_counts_all.get(c, 0) + 1
576
+
577
+ star_colors = {}
578
+ for (x, y) in star_coords:
579
+ c = self.color_array[x, y]
580
+ if c == 0:
581
+ violations.append({"region": r.id, "color": 0, "found_total": 1})
582
+ continue
583
+ star_colors[c] = star_colors.get(c, 0) + 1
584
+
585
+ region_ok = True
586
+ region_star_details = []
587
+ for c, star_count in star_colors.items():
588
+ total_c = color_counts_all.get(c, 0)
589
+ ok = (total_c == 2)
590
+ if not ok:
591
+ region_ok = False
592
+ violations.append({
593
+ "region": r.id,
594
+ "color": c,
595
+ "found_total": total_c,
596
+ "star_cells": star_count
597
+ })
598
+ region_star_details.append({
599
+ "color": c,
600
+ "total_symbols_of_color": total_c,
601
+ "star_cells": star_count,
602
+ "ok": ok
603
+ })
604
+
605
+ per_region.append({
606
+ "region": r.id,
607
+ "details": region_star_details,
608
+ "all_ok": region_ok
609
+ })
610
+
611
+ return len(violations) == 0, {
612
+ "violations": violations,
613
+ "per_region": per_region
614
+ }
615
+
616
+
617
+ def _rule_triangles_edges(self):
618
+ """
619
+ Checks:
620
+ For each triangle cell: required count == number of touched edges.
621
+ """
622
+ if 'triangle' not in self.obs_array:
623
+ return True, {"mismatches": []}
624
+ tri = self.obs_array['triangle']
625
+ h, w = tri.shape
626
+ mismatches = []
627
+ for x in range(1, h-1):
628
+ for y in range(1, w-1):
629
+ if tri[x, y] == 1:
630
+ required = int(self.additional_info[x, y])
631
+ if required <= 0:
632
+ continue
633
+ touches = self._triangle_touches(x, y)
634
+ if touches != required:
635
+ mismatches.append({"x": x, "y": y, "required": required, "touches": touches})
636
+ return len(mismatches) == 0, {"mismatches": mismatches}
637
+
638
+ def _triangle_touches(self, tri_x, tri_y):
639
+ path_nodes = {(p[0], p[1]) for p in self.path}
640
+ neighbor_nodes = [
641
+ (tri_x + 1, tri_y),
642
+ (tri_x - 1, tri_y),
643
+ (tri_x, tri_y - 1),
644
+ (tri_x, tri_y + 1)
645
+ ]
646
+ return sum(1 for n in neighbor_nodes if n in path_nodes)
647
+
648
+ def _rule_poly_ylop_balance(self, regions):
649
+ """
650
+ Combined check:
651
+ - area_ok: sum(poly) - sum(ylop) == region.area
652
+ - exact_ok: shapes can be placed exactly (no rotation/mirror) inside region
653
+ Rule passes only if both are True for all regions that contain any poly/ylop.
654
+ """
655
+ instances = self._extract_poly_instances()
656
+ if not instances:
657
+ return True, {"regions": []}
658
+
659
+ # Map instances to regions
660
+ _, region_map = self._compute_regions()
661
+ by_region = {}
662
+ for inst in instances:
663
+ x, y = inst["x"], inst["y"]
664
+ if 0 <= x < region_map.shape[0] and 0 <= y < region_map.shape[1]:
665
+ rid = region_map[x, y]
666
+ if rid != -1:
667
+ by_region.setdefault(rid, []).append(inst)
668
+
669
+ regions_by_id = {r.id: r for r in regions}
670
+ violations = []
671
+ region_details = []
672
+
673
+ for rid, lst in by_region.items():
674
+ region = regions_by_id.get(rid)
675
+ if region is None:
676
+ continue
677
+
678
+ # area check
679
+ poly_area = sum(inst["area"] for inst in lst if inst["kind"] == "poly")
680
+ ylop_area = sum(inst["area"] for inst in lst if inst["kind"] == "ylop")
681
+ net = poly_area - ylop_area
682
+ area_ok = (region.area == net)
683
+
684
+ detail = {
685
+ "region": rid,
686
+ "area_check": {
687
+ "region_area": region.area,
688
+ "poly_area": poly_area,
689
+ "ylop_area": ylop_area,
690
+ "net": net,
691
+ "ok": area_ok
692
+ }
693
+ }
694
+
695
+ # exact-fit (only attempt if area looks right)
696
+ if area_ok:
697
+ exact_ok, exact_det = self._polyfit_region_exact(region, lst)
698
+ else:
699
+ exact_ok, exact_det = False, {"skipped": True}
700
+
701
+ detail["exact_fit"] = {"ok": exact_ok, **exact_det}
702
+ detail["ok"] = area_ok and exact_ok
703
+ region_details.append(detail)
704
+
705
+ violations = [d["region"] for d in region_details if not d["ok"]]
706
+
707
+ return len(violations) == 0, {
708
+ "violations": violations,
709
+ "region_details": region_details
710
+ }
711
+
712
+ # ---------- End Rule Check Functions ----------
713
+
714
+ # ---------- Poly/Ylop helpers ----------
715
+
716
+ def _extract_poly_instances(self):
717
+ """
718
+ Helper Function for _rule_poly_ylop_balance
719
+ Extracts instances of polyshapes and ylop shapes from the additional_info array.
720
+ """
721
+ instances = []
722
+ if not isinstance(self.polyshapes, dict):
723
+ return instances
724
+ h, w = self.additional_info.shape
725
+ for x in range(h):
726
+ for y in range(w):
727
+ val = self.additional_info[x, y]
728
+ if val != 0:
729
+ name = f'{val}'
730
+ if name not in self.polyshapes:
731
+ continue
732
+ shape_arr = np.array(self.polyshapes[name])
733
+ area = int(shape_arr.sum())
734
+ kind = 'poly' if (self.obs_array['poly'][x, y] == 1) else 'ylop'
735
+ instances.append({"name": name, "x": x, "y": y, "area": area, "kind": kind})
736
+ return instances
737
+
738
+ def _polyfit_region_exact(self, region, instances):
739
+ '''
740
+ Helper function to check exact fit of polygons in a region.
741
+ '''
742
+ H, W = self.x_size, self.y_size
743
+
744
+ region_center_mask = np.zeros((H, W), dtype=bool)
745
+ for (rx, ry) in region.cells:
746
+ region_center_mask[rx, ry] = True
747
+ region_size = int(region_center_mask[1::2, 1::2].sum())
748
+
749
+ polys, ylops = [], []
750
+ poly_area = 0
751
+ ylop_area = 0
752
+ for inst in instances:
753
+ name = inst["name"]
754
+ arr = np.array(self.polyshapes[name], dtype=np.int32)
755
+ area = int(arr.sum())
756
+ if inst["kind"] == "poly":
757
+ polys.append({"name": name, "array": arr})
758
+ poly_area += area
759
+ else:
760
+ ylops.append({"name": name, "array": arr})
761
+ ylop_area += area
762
+
763
+ net = poly_area - ylop_area
764
+
765
+ # If ylops cancel polys exactly, geometry poses no constraint
766
+ if net == 0:
767
+ poly_names = Counter(p["name"] for p in polys)
768
+ ylop_names = Counter(y["name"] for y in ylops)
769
+ if poly_names == ylop_names:
770
+ return True, {
771
+ "region_id": region.id,
772
+ "region_area": region_size,
773
+ "poly_area": poly_area,
774
+ "ylop_area": ylop_area,
775
+ "net": net
776
+ }
777
+
778
+ grid = np.zeros((H, W), dtype=np.int32)
779
+ if net > 0:
780
+ grid[region_center_mask] = -1
781
+
782
+ anchors_all = [(x, y) for x in range(1, H, 2) for y in range(1, W, 2)]
783
+
784
+ ok = self._polyfit_place_ylops(ylops, 0, polys, grid, anchors_all)
785
+
786
+ return ok, {
787
+ "region_id": region.id,
788
+ "region_area": region_size,
789
+ "poly_area": poly_area,
790
+ "ylop_area": ylop_area,
791
+ "net": net
792
+ }
793
+
794
+ def _polyfit_place_ylops(self, ylops, idx, polys, grid, anchors):
795
+ '''
796
+ Helper function to place ylop shapes in the grid.
797
+ '''
798
+
799
+ if idx == len(ylops):
800
+ # hand over to poly placement (to be implemented next)
801
+ return self._polyfit_place_polys(polys, grid)
802
+
803
+ ylop = ylops[idx]
804
+ arr_o = ylop["array"]
805
+
806
+ offsets = self._get_offsets(arr_o)
807
+ for (ax, ay) in anchors:
808
+ if not self._try_place_polys(grid, offsets, ax, ay, sign=-1):
809
+ continue
810
+
811
+ if self._polyfit_place_ylops(ylops, idx + 1, polys, grid, anchors):
812
+ return True
813
+
814
+ self._unplace_offsets(grid, offsets, ax, ay, sign=-1)
815
+
816
+ return False
817
+
818
+ def _polyfit_place_polys(self, polys, grid):
819
+ '''
820
+ Helper function to place poly shapes in the grid.
821
+ '''
822
+ if np.any(grid > 0):
823
+ return False
824
+
825
+ if not polys:
826
+ return not np.any(grid < 0)
827
+
828
+ negs = np.argwhere((grid < 0))
829
+ if negs.size == 0:
830
+ return True
831
+ nx, ny = negs[np.lexsort((negs[:,1], negs[:,0]))][0]
832
+ target = [(int(nx), int(ny))]
833
+
834
+ for (ax, ay) in target:
835
+ tried_names = set()
836
+ for i, poly in enumerate(polys):
837
+ name = poly["name"]
838
+ if name in tried_names:
839
+ continue
840
+ tried_names.add(name)
841
+
842
+ arr_o = poly["array"]
843
+ offsets = self._get_offsets(arr_o)
844
+ if not self._try_place_polys(grid, offsets, ax, ay, sign=+1):
845
+ continue
846
+
847
+ rem = polys[:i] + polys[i+1:]
848
+ if self._polyfit_place_polys(rem, grid):
849
+ return True
850
+
851
+ self._unplace_offsets(grid, offsets, ax, ay, sign=+1)
852
+
853
+ return False
854
+
855
+
856
+ def _get_offsets(self, shape_arr):
857
+ '''
858
+ Helper function to get the offsets of a shape array.
859
+ '''
860
+ shape = np.array(shape_arr, dtype=np.int32)
861
+ xs, ys = np.where(shape == 1)
862
+ if len(xs) == 0:
863
+ return []
864
+ ax = xs.min()
865
+ ay = ys[np.where(xs == ax)[0]].min()
866
+ offsets = []
867
+ for x, y in zip(xs, ys):
868
+ cx = 2 * (x - ax)
869
+ cy = 2 * (y - ay)
870
+ offsets.append((cx, cy))
871
+ return offsets
872
+
873
+
874
+ def _try_place_polys(self, grid, offsets, anchor_x, anchor_y, sign):
875
+ '''
876
+ Helper function to try placing polygons in the grid.
877
+ '''
878
+ H, W = grid.shape
879
+ targets = []
880
+ for dx, dy in offsets:
881
+ tx, ty = anchor_x + dx, anchor_y + dy
882
+ if tx < 0 or tx >= H or ty < 0 or ty >= W:
883
+ return False
884
+ targets.append((tx, ty))
885
+ for tx, ty in targets:
886
+ grid[tx, ty] += sign
887
+ return True
888
+
889
+ def _unplace_offsets(self, grid, offsets, anchor_x, anchor_y, sign):
890
+ '''
891
+ Helper function to unplace shapes from the grid.
892
+ '''
893
+ for dx, dy in offsets:
894
+ tx, ty = anchor_x + dx, anchor_y + dy
895
+ grid[tx, ty] -= sign
896
+
897
+ # ---------- End Poly/Ylop helpers ----------
898
+
899
+ # ---------- Validate Rules Functions ----------
900
+
901
+ def _run_rule_validators(self, regions, terminated, truncated):
902
+ '''
903
+ Run all rule validators on the given regions.
904
+ '''
905
+
906
+ rule_results = {}
907
+
908
+ def add(name, passed, detail):
909
+ rule_results[name] = {"passed": passed, "detail": detail}
910
+
911
+ # Global / path related
912
+ p, d = self._rule_reached_target()
913
+ add("reached_target", p, d)
914
+ p, d = self._rule_path_not_crossing()
915
+ add("path_not_crossing", p, d)
916
+ p, d = self._rule_no_gap_violations()
917
+ add("no_gap_violations", p, d)
918
+ p, d = self._rule_all_dots_collected()
919
+ add("all_dots_collected", p, d)
920
+
921
+ # Region & symbol based
922
+ p, d = self._rule_color_square_separation(regions)
923
+ add("square_color_separation", p, d)
924
+ p, d = self._rule_star_pairing_exact(regions)
925
+ add("star_pairing_exact", p, d)
926
+ p, d = self._rule_triangles_edges()
927
+ add("triangles_edge_count", p, d)
928
+ p, d = self._rule_poly_ylop_balance(regions)
929
+ add("poly_ylop_area", p, d)
930
+
931
+ # Aggregate success (only core puzzle rules, not termination flags)
932
+ core = [k for k in rule_results.keys()
933
+ if k not in ("_terminated", "_truncated", "all_rules_satisfied")]
934
+ all_pass = all(rule_results[k]["passed"] for k in core)
935
+ add("all_rules_satisfied", all_pass, {"rules_checked": core})
936
+
937
+ rule_results["_terminated"] = {"passed": True, "detail": terminated}
938
+ rule_results["_truncated"] = {"passed": True, "detail": truncated}
939
+ return rule_results
940
+
941
+ def _validate_rules(self, terminated=False, truncated=False):
942
+ '''
943
+ Validate the rules for the current puzzle state.
944
+ '''
945
+ regions, region_map = self._compute_regions()
946
+ self._collect_region_symbols(regions, region_map)
947
+ self.rule_status = self._run_rule_validators(regions, terminated, truncated)
948
+ # Attach region summaries
949
+ self.rule_status["_regions"] = {r.id: r.to_summary() for r in regions}
950
+ return self.rule_status
951
+
952
+ # ---------- End Validate Rules Functions ----------
953
+
954
+ # ---------- Get Observation/Info Functions ----------
955
+
956
+ def _get_obs(self):
957
+ '''
958
+ Function to return the current observation of the puzzle
959
+ if observation == 'new':
960
+
961
+ Returns: dictionary of:
962
+ dictionary of 2D arrays,
963
+ 2D array,
964
+ 2D array
965
+ ----------
966
+ {
967
+ obs : dict; dictionary of 2D arrays
968
+ A dictionary containing the current locations of the properties of the puzzle
969
+ color_array : 2D array
970
+ A 2D array containing the colors of the properties in the puzzle
971
+ additional_info : 2D array
972
+ A 2D array containing additional information about the properties in the puzzle
973
+ }
974
+
975
+ if observation == 'SPaRC':
976
+ Returns a json string representation of the puzzle in the SPaRC format
977
+ '''
978
+ if self.observation == 'new':
979
+ return {'base': self.obs_array, 'color': self.color_array, 'additional_info': self.additional_info}
980
+
981
+ elif self.observation == 'SPaRC':
982
+ observ = self._build_json_obs()
983
+ return observ
984
+
985
+ else:
986
+ raise ValueError("Invalid observation type. Choose 'new' or 'SPaRC'.")
987
+
988
+ def _build_json_obs(self):
989
+ '''
990
+ Helper Function to turn the SPaRC observation into JSON format
991
+ '''
992
+ return json.dumps(self.observ, separators=(',', ':'))
993
+
994
+ def _get_info(self):
995
+ '''
996
+ Function to return extra information of the current puzzle
997
+
998
+ Returns:
999
+ info : dict
1000
+ A dictionary containing the following information:
1001
+ - solution_count: The number of solutions for the current puzzle
1002
+ - difficulty: The difficulty of the current puzzle
1003
+ - grid_y_size: The y size of the current puzzle
1004
+ - grid_x_size: The x size of the current puzzle
1005
+ - legal_actions: The legal actions for the current state of the agent
1006
+ - current_step: The current step of the agent
1007
+ - agent_location: The current location of the agent
1008
+ - rule_status: The current status of the rules
1009
+ - Rewards: The current rewards
1010
+ '''
1011
+ self._validate_rules(terminated=False, truncated=False)
1012
+ info = {"solution_count": self.solution_count,
1013
+ "difficulty": self.difficulty,
1014
+ "grid_x_size": self.x_size,
1015
+ "grid_y_size": self.y_size,
1016
+ "legal_actions": self._get_legal_actions(),
1017
+ "current_step": self.current_step,
1018
+ "agent_location": self._agent_location,
1019
+ "rule_status": self.rule_status,
1020
+ "Rewards": {"normal_reward": self.normal_reward, "outcome_reward": self.outcome_reward}
1021
+ }
1022
+ return info
1023
+
1024
+ def _get_legal_actions(self):
1025
+ '''
1026
+ Function to get the legal actions for the current state of the agent
1027
+
1028
+ Returns:
1029
+ legal : list
1030
+ A list of legal actions for the current state of the agent
1031
+ '''
1032
+ legal = []
1033
+
1034
+ for action, direction in self._action_to_direction.items():
1035
+ next_loc = self._agent_location + direction
1036
+ # np.clip to make sure we don't go out of bounds
1037
+ agent_location_temp = np.clip(next_loc, [0, 0], [self.x_size - 1, self.y_size - 1])
1038
+ # Check if the next location is not a gap
1039
+ if self.obs_array['gaps'][agent_location_temp[0], agent_location_temp[1]] == 0:
1040
+ if self.obs_array['visited'][agent_location_temp[0], agent_location_temp[1]] == 1:
1041
+ if self.traceback:
1042
+ if len(self.path) >= 2:
1043
+ last_loc = np.array([self.path[-2][0], self.path[-2][1]], dtype=np.int32)
1044
+ if np.array_equal(last_loc, agent_location_temp):
1045
+ if np.array_equal(next_loc, agent_location_temp):
1046
+ legal.append(action)
1047
+ else:
1048
+ if np.array_equal(next_loc, agent_location_temp):
1049
+ legal.append(action)
1050
+
1051
+ return legal
1052
+
1053
+ # ---------- End Get Observation/Info Functions ----------
1054
+
1055
+ # ---------- Core Gym Env Functions ----------
1056
+
1057
+ def reset(self, seed=None, options=None):
1058
+ '''
1059
+ Function to reset the environment and load the next puzzle
1060
+ Parameters:
1061
+ seed : int
1062
+ The seed for the random number generator
1063
+ options : dict
1064
+ Additional options for resetting the environment
1065
+ Only used option is 'puzzle_id' to load a specific puzzle by its ID
1066
+ ----------
1067
+ Returns:
1068
+ obs : depends on obs type given in gym.make()
1069
+ The Observation of the current puzzle state
1070
+ info : dict
1071
+ A dictionary containing the extra information of the current puzzle
1072
+ '''
1073
+ super().reset(seed=seed)
1074
+
1075
+ puzzle_id = None
1076
+ if options is not None:
1077
+ # If a specific puzzle ID is given, load that puzzle
1078
+ puzzle_id = options.get('puzzle_id', None)
1079
+ for idx, puzzle in enumerate(self.puzzles):
1080
+ if puzzle['id'] == puzzle_id:
1081
+ self.current_puzzle_index = idx
1082
+ break
1083
+ else:
1084
+ if seed is not None:
1085
+ self.current_puzzle_index = int(self.np_random.integers(len(self.puzzles)))
1086
+ else:
1087
+ self.current_puzzle_index = (self.current_puzzle_index + 1) % len(self.puzzles)
1088
+
1089
+ # Also possible to randomly select a puzzle
1090
+ # self.current_puzzle_index = np.random.randint(0, len(self.puzzles))
1091
+
1092
+ self.current_step = 0
1093
+
1094
+ # Load the next puzzle
1095
+ self._load_puzzle(self.current_puzzle_index)
1096
+
1097
+ # Visualize the initial state of the environment
1098
+ if self.render_mode == "human":
1099
+ if self.human_renderer is None:
1100
+ self.human_renderer = HumanRenderer(scale_factor=3.0)
1101
+ self.render()
1102
+ elif self.render_mode == "llm":
1103
+ if self.llm_renderer is None:
1104
+ self.llm_renderer = LLMRenderer()
1105
+ self.render()
1106
+
1107
+ # Return the initial observation for the new puzzle
1108
+ return self._get_obs(), self._get_info()
1109
+
1110
+
1111
+ def step(self, action):
1112
+ '''
1113
+ Function to take a step in the environment
1114
+ Parameters:
1115
+ action : int
1116
+ The action to take in the environment
1117
+ ----------
1118
+ Returns:
1119
+ obs : depends on obs type given in gym.make()
1120
+ The Observation of the current puzzle state
1121
+ reward : int
1122
+ The reward for taking the action
1123
+ terminated : bool
1124
+ Whether the episode has terminated
1125
+ truncated : bool
1126
+ Whether the episode has been truncated
1127
+ info : dict
1128
+ A dictionary containing the extra information of the current puzzle
1129
+ '''
1130
+
1131
+ orig_loc = self._agent_location.copy()
1132
+ self.current_step += 1
1133
+ self.normal_reward = 0
1134
+ truncated = self.current_step >= self.max_steps
1135
+
1136
+ # If the action is not in the legal actions, we do not move
1137
+ if action in self._get_legal_actions():
1138
+ direction = self._action_to_direction[action]
1139
+ agent_location_temp = self._agent_location + direction
1140
+
1141
+ if self.obs_array['visited'][agent_location_temp[0], agent_location_temp[1]] == 1:
1142
+ if self.traceback:
1143
+ last_loc = np.array([self.path[-2][0], self.path[-2][1]], dtype=np.int32)
1144
+ if np.array_equal(last_loc, agent_location_temp):
1145
+ # If the next location is already visited and is the last location, we are allowed to move back
1146
+ self.obs_array['agent_location'][self._agent_location[0]][self._agent_location[1]] = 0
1147
+ self.obs_array['visited'][self._agent_location[0]][self._agent_location[1]] = 0
1148
+
1149
+ # Update the SPaRC observation if it is active
1150
+ if self.observation == 'SPaRC':
1151
+ r, c = self._agent_location[0], self._agent_location[1]
1152
+ self.observ[c][r] = '.' if self.obs_array['gaps'][r, c] == 1 else '+'
1153
+
1154
+ self._agent_location = agent_location_temp
1155
+
1156
+ # Update the agent's location in the observation
1157
+ self.obs_array['visited'][self._agent_location[0]][self._agent_location[1]] = 1
1158
+ self.obs_array['agent_location'][self._agent_location[0]][self._agent_location[1]] = 1
1159
+
1160
+ # Also update the SPaRC observation if it is active
1161
+ if self.observation == 'SPaRC':
1162
+ r, c = self._agent_location[0], self._agent_location[1]
1163
+ self.observ[c][r] = 'L'
1164
+
1165
+ # Update the path
1166
+ del self.path[-1]
1167
+ else:
1168
+ self.obs_array['agent_location'][self._agent_location[0]][self._agent_location[1]] = 0
1169
+
1170
+ # Update the SPaRC observation if it is active
1171
+ if self.observation == 'SPaRC':
1172
+ r, c = self._agent_location[0], self._agent_location[1]
1173
+ self.observ[c][r] = 'V'
1174
+
1175
+ self._agent_location = agent_location_temp
1176
+
1177
+ # Update the agent's location in the observation
1178
+ self.obs_array['visited'][self._agent_location[0]][self._agent_location[1]] = 1
1179
+ self.obs_array['agent_location'][self._agent_location[0]][self._agent_location[1]] = 1
1180
+
1181
+ # Also update the SPaRC observation if it is active
1182
+ if self.observation == 'SPaRC':
1183
+ r, c = self._agent_location[0], self._agent_location[1]
1184
+ self.observ[c][r] = 'L'
1185
+
1186
+ # Update the path
1187
+ path = [self._agent_location[0], self._agent_location[1]]
1188
+ self.path.append(path)
1189
+
1190
+
1191
+ # An episode is done if the agent has reached the target, does not mean success
1192
+ terminated = np.array_equal(self._agent_location, self._target_location)
1193
+
1194
+ # If there are no legal actions left (for the next step), the episode is truncated
1195
+ if self._get_legal_actions() == []:
1196
+ truncated = True
1197
+
1198
+ if terminated:
1199
+ truncated = False # If terminated, not truncated
1200
+
1201
+ # Reward logic:
1202
+ # Have an outcome reward and a normal reward
1203
+ # The normal reward is updated during the episode, the outcome reward is only updated at the end of the episode
1204
+ if terminated or truncated:
1205
+ for i in range(self.solution_count):
1206
+ if np.array_equal(self.path, self.solution_paths[i]):
1207
+ self.outcome_reward = 1
1208
+ self.normal_reward = 1
1209
+ break
1210
+
1211
+ if self.outcome_reward != 1:
1212
+ self.outcome_reward = -1
1213
+ self.normal_reward = -1
1214
+ else:
1215
+ self.outcome_reward = 0
1216
+ if not np.array_equal(orig_loc, self._agent_location):
1217
+ for i in range(self.solution_count):
1218
+ current_solution_path = self.solution_paths[i]
1219
+ if self._is_on_solution_path(self.path, current_solution_path):
1220
+ self.normal_reward = 0.01
1221
+ break
1222
+ else:
1223
+ self.normal_reward = -0.01
1224
+
1225
+
1226
+ # Update the observation
1227
+ self._validate_rules(terminated=terminated, truncated=truncated)
1228
+ observation = self._get_obs()
1229
+ info = self._get_info()
1230
+
1231
+ # Unfortunately, I have to return the normal reward here, since gymnasium expects a reward to be a scalar value, not a dictionary of scalar values
1232
+ reward = self.normal_reward
1233
+
1234
+ # Visualize the current state of the environment
1235
+ if self.render_mode == "human" or self.render_mode == "llm":
1236
+ self.render()
1237
+
1238
+ return observation, reward, terminated, truncated, info
1239
+
1240
+
1241
+ # ---------- End Core Gym Env Functions ----------
1242
+
1243
+
1244
+ def _is_on_solution_path(self, current_path, solution_path):
1245
+ """
1246
+ Helper function for step function.
1247
+ Checks if the current path is still on the solution path.
1248
+
1249
+ Args:
1250
+ current_path (list): The path taken so far.
1251
+ solution_path (list): The correct solution path.
1252
+
1253
+ Returns:
1254
+ bool: True if the current path is on the solution path, False otherwise.
1255
+ """
1256
+ # If the current path is longer than the solution path, return False
1257
+ if len(current_path) > len(solution_path):
1258
+ return False
1259
+
1260
+ # Compare each step in the current path with the solution path
1261
+ for i in range(len(current_path)):
1262
+ if current_path[i] != solution_path[i]:
1263
+ return False
1264
+
1265
+ return True
1266
+
1267
+ # ---------- Visualization Functions ----------
1268
+
1269
+ def render(self):
1270
+ """
1271
+ Renders the environment using the appropriate renderer based on render_mode.
1272
+
1273
+ Returns:
1274
+ str or None: For LLM mode, returns a text representation. For human mode, returns None.
1275
+ """
1276
+ if self.render_mode == "human" and self.human_renderer is not None:
1277
+ return self.human_renderer.render(
1278
+ self.obs_array,
1279
+ self.color_array,
1280
+ self.additional_info,
1281
+ self.polyshapes,
1282
+ self.x_size,
1283
+ self.y_size,
1284
+ self.start_location,
1285
+ self.target_location,
1286
+ self.path
1287
+ )
1288
+ elif self.render_mode == "llm" and self.llm_renderer is not None:
1289
+ return self.llm_renderer.render(
1290
+ self.obs_array,
1291
+ self.color_array,
1292
+ self.additional_info,
1293
+ self.polyshapes,
1294
+ self.x_size,
1295
+ self.y_size
1296
+ )
1297
+ else:
1298
+ # No rendering or unsupported mode
1299
+ return None
1300
+
1301
+ def close(self):
1302
+ """
1303
+ Close the environment and cleanup any resources.
1304
+ """
1305
+ if self.human_renderer is not None:
1306
+ self.human_renderer.close()
1307
+ self.human_renderer = None
1308
+
1309
+ if self.llm_renderer is not None:
1310
+ self.llm_renderer.close()
1311
+ self.llm_renderer = None
1312
+
1313
+
1314
+ # ---------- End Visualization Functions ----------