co2114 2025.2.2__py3-none-any.whl → 2026.0.2__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.
@@ -1,10 +1,11 @@
1
1
  """ENVIRONMENT.PY
2
- This contains code for agent environments
2
+ This contains code for agent environments and simulations
3
3
  """
4
4
  import warnings
5
5
  import math
6
6
  import random
7
- from collections.abc import *
7
+ from collections.abc import Collection, Iterable
8
+ from typing import Union, TypeVar, override
8
9
 
9
10
  with warnings.catch_warnings():
10
11
  warnings.simplefilter("ignore")
@@ -13,163 +14,276 @@ with warnings.catch_warnings():
13
14
  from ..engine import App
14
15
  from ..util.colours import COLOR_BLACK, COLOR_WHITE
15
16
  from ..util.fonts import _get_symbolic_font_unsafe
16
- from .things import Thing, Agent, Obstacle
17
+ from .things import Thing, Agent, Obstacle, Action
17
18
 
18
19
  SYMBOL_FONT_NAME = _get_symbolic_font_unsafe()
19
20
 
21
+ T = TypeVar("T")
22
+ GenericLocation = T | tuple[T] | None
23
+ XYLocation = tuple[int, int]
24
+
20
25
  class BaseEnvironment:
21
26
  """ Base Environment
22
27
  Adapted from AIMI code
23
28
  """
24
- def __repr__(self):
29
+ def __repr__(self) -> str:
30
+ """ Base string representation """
25
31
  return self.__class__.__name__
32
+
26
33
  @property
27
- def is_done(self):
28
- NotImplemented
29
- def step(self):
30
- NotImplemented
31
- def run(self, steps=100, pause_for_user=True):
34
+ def is_done(self) -> bool:
35
+ """ Property indicating completion of simulation, needs overriding
36
+ """
37
+ raise NotImplementedError
38
+
39
+ def step(self) -> None:
40
+ """ Executes incremental step in discrete environment, needs overriding
41
+ """
42
+ raise NotImplementedError
43
+
44
+ def run(self, steps:int = 100, pause_for_user:bool = True) -> None:
45
+ """ Executes the simulation until either environment is done, or total
46
+ steps exceeds limit
47
+
48
+ :param steps: max. # of iterations in simulation, default 100
49
+ :param pause_for_user: require user input before beginning, default True
50
+ """
32
51
  if pause_for_user:
33
52
  input("Press enter to start simulation")
53
+
34
54
  print(f"{self}: Running for {steps} iterations.")
55
+
35
56
  for i in range(steps):
36
- if self.is_done:
57
+ if self.is_done: # print terination message and exit
37
58
  print(f"{self}: Simulation complete after {i} of {steps} iterations.")
38
59
  return
39
- self.step()
40
- print(f"{self}: Simulation complete after {steps} of {steps} iterations.")
60
+ self.step() # else iterate one step
61
+ print( # if loop completes, print max steps reached
62
+ f"{self}: Simulation complete after {steps} of {steps} iterations.")
63
+
41
64
 
42
65
  class Environment(BaseEnvironment):
43
- """ Environment
44
- An Environment is a BaseEnvironment that has Things and Agents
66
+ """ An Environment is a BaseEnvironment that has Things and Agents
45
67
  """
46
68
  def __init__(self):
47
- self.things = set()
48
- self.agents = set()
69
+ """ Initialises set of things and agents """
70
+ self.things = set() # all things in environment
71
+ self.agents = set() # all agents in environment
72
+
49
73
  @property
50
- def is_done(self):
74
+ def is_done(self) -> bool:
75
+ """ Is considered done if there are no agents in environment """
51
76
  return len(self.agents) == 0 # cannot simulate with no agents
52
-
53
- def step(self):
77
+
78
+
79
+ def step(self) -> None:
80
+ """ Executes percept, program and action step for each agent. """
54
81
  if not self.is_done:
55
- actions = {agent:agent.program(self.percept(agent))
56
- for agent in self.agents} # dictionary of actions by agents
82
+ actions = {
83
+ agent: agent.program(self.percept(agent))
84
+ for agent in self.agents} # dictionary of actions by agents
57
85
 
58
86
  for agent, action in actions.items():
59
- self.execute_action(agent, action)
87
+ # iterate through each action and allow agent to execute it
88
+ self.execute_action(agent, action)
89
+
60
90
  if self.is_done:
61
91
  print(f"{self}: Task environment complete. No further actions.")
62
92
 
63
- def percept(self, agent:Agent):
64
- NotImplemented
65
- def execute_action(self, agent:Agent, action):
66
- NotImplemented
67
93
 
68
- def add_thing(self, thing:Thing, location=None):
69
- if isinstance(thing, type):
94
+ def percept(self, agent:Agent) -> Collection[Thing]:
95
+ """ Returns the collection, e.g. set or list, of Things that an Agent
96
+ can perceive in the current state.
97
+
98
+ :param agent: the agent that is percieving
99
+ """
100
+ raise NotImplementedError
101
+
102
+
103
+ def execute_action(self, agent:Agent, action:Action) -> None:
104
+ """ For a given agent and action, performs execution
105
+
106
+ :param agent: the agent that is executing an action
107
+ :param action: the action statement to be executed by the agent
108
+ """
109
+ raise NotImplementedError
110
+
111
+
112
+ def add_thing(self, thing:Thing, location:GenericLocation = None) -> None:
113
+ """ Adds a thing to the environment at a given location (if applicable).
114
+
115
+ :param thing: the Thing (or Thing subclass) to be added
116
+ :param location: the location to add the Thing at (if applicable)
117
+ """
118
+ if isinstance(thing, type): # if a class is given, instantiate it
70
119
  if issubclass(thing, Thing):
71
120
  thing = thing()
72
- else:
121
+ else: # not a Thing
73
122
  print(f"{self}: Tried to add {thing} but its not a Thing.")
74
123
  return
75
124
 
76
- if not isinstance(thing, Thing):
125
+ if not isinstance(thing, Thing): # not a Thing
77
126
  print(f"{self}: Tried to add {thing} but its not a Thing.")
78
127
  return
79
128
 
80
- if thing in self.things:
129
+ if thing in self.things: # prevent duplicate addition
81
130
  print(f"{self}: Tried and failed to add duplicate {thing}.")
82
131
  return
83
132
 
84
- if location:
85
- thing.location = location
133
+ if location: # if a location is specified, set it
134
+ thing.location = location # set location of thing
86
135
  print(f"{self}: Adding {thing} at {location}")
87
- self.things.add(thing)
136
+ self.things.add(thing) # add thing to environment
88
137
 
89
- if isinstance(thing, Agent):
138
+ if isinstance(thing, Agent): # if thing is an agent, add to agents list
90
139
  print(f"{self}: Adding {thing} to list of agents.")
91
140
  self.agents.add(thing)
92
141
 
93
- def add_agent(self, agent:Agent, location=None):
94
- if not isinstance(agent, Agent):
142
+
143
+ def add_agent(self, agent:Agent, location:GenericLocation=None) -> None:
144
+ """ Adds an agent to the environment at a given location
145
+ (if applicable).
146
+
147
+ Wrapper for add_thing, specifically for agents.
148
+
149
+ :param agent: the Agent (or Agent subclass) to be added
150
+ :param location: the location to add the Agent at (if applicable)
151
+ """
152
+ if not isinstance(agent, Agent): # not an Agent
95
153
  print(f"{self}: {agent} is not an Agent. Adding as Thing instead.")
96
- self.add_thing(agent, location)
154
+ self.add_thing(agent, location) # delegate to add_thing
155
+
156
+
157
+ def delete_thing(self, thing:Thing) -> None:
158
+ """ Removes a thing from the environment.
159
+
160
+ :param thing: the Thing (or Thing subclass) to be removed
161
+ """
162
+ if thing not in self.things: return # thing not in environment
163
+
164
+ self.things.remove(thing) # remove thing from environment
165
+ if isinstance(thing, Agent): # if thing is an agent,
166
+ self.agents.remove(thing) # remove from agents list too
97
167
 
98
- def delete_thing(self, thing:Thing):
99
- if thing not in self.things:
100
- return
101
- self.things.remove(thing)
102
- if isinstance(thing, Agent):
103
- self.agents.remove(thing)
104
168
 
105
- def things_at(self, location):
169
+ def things_at(self, location:GenericLocation) -> list[Thing]:
170
+ """ Returns list of things at a given location.
171
+
172
+ :param location: the location to get Things at
173
+ """
106
174
  return [thing for thing in self.things if thing.location == location]
107
- def __call__(self, location):
175
+
176
+
177
+ def __call__(self, location:GenericLocation) -> list[Thing]:
178
+ """ Allows environment to be called as function to get things at location.
179
+
180
+ Wrapper for things_at.
181
+
182
+ :param location: the location to get Things at
183
+ """
108
184
  return self.things_at(location)
109
185
 
186
+
110
187
  class XYEnvironment(Environment):
111
- DEFAULT_BG = COLOR_BLACK
188
+ """ A 2D grid environment for agents and things """
189
+ DEFAULT_BG = COLOR_BLACK # default background color
112
190
 
113
- def __init__(self, width=10, height=10):
191
+ def __init__(self, width:int = 10, height:int = 10) -> None:
192
+ """ Initialises environment of given width and height
193
+
194
+ :param width: width of environment grid
195
+ :param height: height of environment grid
196
+ """
114
197
  if not isinstance(width, int) or not isinstance(height, int):
115
198
  raise TypeError(f"{self}: dimensions must be integers")
199
+
116
200
  if width < 1 or height < 1:
117
201
  raise ValueError(f"{self}: dimensions must be greater than zero")
118
- super().__init__()
119
- if not hasattr(self, 'color'):
202
+
203
+ super().__init__() # initialise base environment
204
+
205
+ if not hasattr(self, 'color'): # set default color if not overridden
120
206
  self.color = self.DEFAULT_BG
121
- self.width, self.height = width, height
122
- self.observers = []
123
- self.bumped = set()
124
- self.x_start_offset = self.x_end_offset = 0
125
- self.y_start_offset = self.y_end_offset = 0
207
+
208
+ self.width, self.height = width, height # set dimensions
209
+ self.observers = [] # observers of environment changes
210
+ self.bumped = set() # agents that have bumped into obstacles
211
+ self.x_start_offset = self.x_end_offset = 0 # offsets for walls
212
+ self.y_start_offset = self.y_end_offset = 0 # offsets for walls
126
213
 
214
+
127
215
  @property
128
- def x_start(self):
216
+ def x_start(self) -> int:
217
+ """ Starting x coordinate considering walls """
129
218
  return 0 + self.x_start_offset
219
+
220
+
130
221
  @property
131
- def x_end(self):
222
+ def x_end(self) -> int:
223
+ """ Ending x coordinate considering walls """
132
224
  return self.width - self.x_end_offset
225
+
226
+
133
227
  @property
134
- def y_start(self):
228
+ def y_start(self) -> int:
229
+ """ Starting y coordinate considering walls """
135
230
  return 0 + self.y_start_offset
231
+
232
+
136
233
  @property
137
- def y_end(self):
234
+ def y_end(self) -> int:
235
+ """ Ending y coordinate considering walls """
138
236
  return self.height - self.y_end_offset
139
237
 
140
238
 
141
- def things_near(self, location, radius=1):
142
- # TODO
239
+ def things_near(self, location:XYLocation, radius:int|float = 1) -> Collection[Thing]:
240
+ """ Returns list of things near a given location within a certain radius.
241
+
242
+ :param location: the location to get Things near
243
+ :param radius: the radius around location to search
244
+ """
143
245
  raise NotImplementedError
144
246
 
145
- def _add_wall(self, location):
146
- class Wall(Obstacle):
147
- pass
148
- if all(not isinstance(obj, Obstacle)
247
+
248
+ def _add_wall(self, location:XYLocation) -> None:
249
+ """ Adds a wall at the given location if no obstacle is present.
250
+
251
+ :param location: the location to add wall at
252
+ """
253
+ class Wall(Obstacle): pass # simple wall class
254
+
255
+ if all(not isinstance(obj, Obstacle) # only add wall if no obstacle
149
256
  for obj in self.things_at(location)):
150
- self.add_thing(Wall(), location)
257
+ self.add_thing(Wall(), location) # add wall to environment
151
258
 
152
- def add_walls(self):
153
- if self.width > 2:
259
+
260
+ def add_walls(self) -> None:
261
+ """ Adds walls around the perimeter of the environment """
262
+ if self.width > 2: # add left and right walls
154
263
  for y in range(self.height):
155
264
  self._add_wall((0, y))
156
- if self.width > 1:
157
- self._add_wall((self.width-1, y))
158
- if self.height > 2:
265
+ if self.width > 1: self._add_wall((self.width-1, y))
266
+
267
+ if self.height > 2: # add top and bottom walls
159
268
  for x in range(self.width):
160
269
  self._add_wall((x, 0))
161
- if self.height > 1:
162
- self._add_wall((x, self.height - 1))
163
- self.y_start_offset += 1
164
- self.y_end_offset += 1
270
+ if self.height > 1: self._add_wall((x, self.height - 1))
271
+ self.y_start_offset += 1 # account for walls
272
+ self.y_end_offset += 1 # account for walls
273
+
165
274
  if self.width > 2:
166
- self.x_start_offset += 1
167
- self.x_end_offset += 1
275
+ self.x_start_offset += 1 # account for walls
276
+ self.x_end_offset += 1 # account for walls
168
277
 
169
- def is_valid(self, location):
170
- if not isinstance(location, Iterable): return False
171
- if len(location) != 2: return False
172
- if any(map(lambda x: not isinstance(x,int), location)): return False
278
+
279
+ def is_valid(self, location:XYLocation) -> bool:
280
+ """ Checks if a location is valid in the environment
281
+
282
+ :param location: the location to validate
283
+ """
284
+ if not isinstance(location, Iterable): return False # must be iterable
285
+ if len(location) != 2: return False # must be length 2
286
+ if any(map(lambda x: not isinstance(x,int), location)): return False # must be ints
173
287
  return True
174
288
 
175
289
  def is_inbounds(self, location):
@@ -179,123 +293,175 @@ class XYEnvironment(Environment):
179
293
  if not (y >= self.y_start and y < self.y_end): return False
180
294
  return True
181
295
 
182
- def add_thing(self, thing:Thing, location):
183
- if location is None:
296
+ @override
297
+ def add_thing(self, thing:Thing, location:XYLocation) -> None:
298
+ if location is None: # default to starting location
184
299
  location = (self.x_start, self.y_start)
185
- elif self.is_inbounds(location):
300
+ elif self.is_inbounds(location): # check location validity
186
301
  location = tuple(location) # force tuple to make hashable
187
- else:
302
+ else: # invalid location
188
303
  print(f"Tried and failed to add {thing} to environment")
189
304
  return
190
305
 
191
- if isinstance(self, Agent):
306
+ if isinstance(self, Agent): # if thing is an agent
192
307
  thing.bump = False # give capacity to be bumped
193
- super().add_thing(thing, location)
308
+ super().add_thing(thing, location) # delegate to base method
309
+
310
+ assert thing in self.things_at(location) # sanity check
194
311
 
195
- assert thing in self.things_at(location)
196
312
 
197
313
  def add_thing_randomly(self, thing:Thing):
198
- x = random.randint(self.x_start, self.x_end-1)
199
- y = random.randint(self.y_start, self.y_end-1)
200
- self.add_thing(thing, (x,y))
314
+ """ Adds a thing to a random location in the environment
315
+
316
+ :param thing: the Thing (or Thing subclass) to be added
317
+ """
318
+ x = random.randint(self.x_start, self.x_end-1) # random x coord
319
+ y = random.randint(self.y_start, self.y_end-1) # random y coord
320
+ self.add_thing(thing, (x,y)) # delegate to add_thing
321
+
201
322
 
202
323
  class GraphicEnvironment(XYEnvironment):
203
- def run(self, graphical=True, steps=100, **kwargs):
204
- if graphical:
324
+ """ A graphical 2D grid environment for agents and things """
325
+ @override
326
+ def run(self, graphical=True, steps=100, **kwargs) -> None:
327
+ """ Executes the simulation in graphical or base mode.
328
+
329
+ :param graphical: whether to run graphical version
330
+ :param steps: max. # of iterations in simulation, default 100
331
+ :param **kwargs: additional arguments for environment base class
332
+ """
333
+ if graphical: # run graphical version
205
334
  EnvironmentApp(self, steps=steps, name=f"{self}", **kwargs).run()
206
- else:
335
+ else: # run base version
207
336
  super().run(steps=steps, **kwargs)
208
337
 
338
+
209
339
  class EnvironmentApp(App):
210
340
  """EnvironmentApp
211
- Graphical version of
341
+ Graphical version of an XYEnvironment
212
342
  """
213
343
  # size = width, height = 600, 400 # uncomment to override
214
- def __init__(self, environment:XYEnvironment=None, steps=100, **kwargs):
215
- if environment is None:
344
+ def __init__(self,
345
+ environment:XYEnvironment|None = None,
346
+ steps:int = 100,
347
+ **kwargs) -> None:
348
+ """ Initialises graphical environment app
349
+
350
+ :param environment: the XYEnvironment to visualise
351
+ :param steps: max. # of iterations in simulation, default 100
352
+ :param **kwargs: additional arguments for App base class
353
+ """
354
+ if environment is None: # default to 12x8 XYEnvironment
216
355
  print(f"{self}: No environment specified, using default")
217
356
  environment = XYEnvironment(12, 8)
218
- if not isinstance(environment, XYEnvironment):
357
+ if not isinstance(environment, XYEnvironment): # check type
219
358
  raise TypeError(f"{self}: environment must be XYEnvironment")
220
359
 
221
360
  self.environment = environment
222
- self._fit_to_environment()
223
- super().__init__(**kwargs)
361
+ self._fit_to_environment() # fit window aspect to environment
362
+ super().__init__(**kwargs) # initialise base App class
224
363
 
364
+ # font for rendering things
225
365
  self.thing_font = pygame.font.SysFont(SYMBOL_FONT_NAME, 28)
226
- self.counter = -1
227
- self.steps = steps
228
- self._flag = True
366
+
367
+ self.counter = -1 # start at -1 to allow initial render
368
+ self.steps = steps # max. steps in simulation
369
+ self._flag = True # flag for completion message
229
370
 
230
- def _fit_to_environment(self):
371
+
372
+ def _fit_to_environment(self) -> None:
231
373
  """ Fit width and height ratios to match environment """
232
- _flag = self.environment.width > self.environment.height
233
- if _flag:
374
+ # determine whether environment is wider than tall
375
+ _wider_than_tall = self.environment.width > self.environment.height
376
+
377
+ if _wider_than_tall:
234
378
  xy_ratio = self.environment.width/self.environment.height
235
379
  a, b = self.height, self.width
236
380
  else:
237
381
  xy_ratio = self.environment.height/self.environment.width
238
382
  b, a = self.height, self.width
239
383
 
240
- a = b // xy_ratio
384
+ a = b // xy_ratio # adjust dimensions
241
385
  b = int(a*xy_ratio)
242
386
 
243
- self.height, self.width = (a,b) if _flag else (b,a)
244
- self.size = self.width, self.height
387
+ self.height, self.width = (a,b) if _wider_than_tall else (b,a)
388
+ self.size = self.width, self.height # set size attribute
389
+
245
390
 
246
- def update(self):
247
- if self.counter < 0:
391
+ @override
392
+ def update(self) -> None:
393
+ """ Main process loop
394
+ Steps the environment simulation
395
+ """
396
+ if self.counter < 0: # initial render only
248
397
  self.counter += 1
249
398
  return
250
399
  if self.counter < self.steps and not self.environment.is_done:
251
400
  self.environment.step()
252
401
  self.counter += 1
253
- elif self._flag:
402
+ elif self._flag: # print completion message once
254
403
  print(f"{self.environment}: Simulation complete after {self.counter} of {self.steps} iterations.")
255
404
  self._flag = False
256
-
257
- def render(self):
258
- self.screen.fill(self.environment.color)
259
- self.render_grid()
260
- self.render_things()
261
405
 
262
- def render_grid(self):
263
- """ render tiles """
406
+
407
+ @override
408
+ def render(self) -> None:
409
+ """ Main render loop
410
+ Renders the environment grid and things
411
+ """
412
+ self.screen.fill(self.environment.color) # write background color
413
+ self.render_grid() # render grid
414
+ self.render_things() # render things
415
+
416
+
417
+ def render_grid(self) -> None:
418
+ """ Render environment grid """
264
419
  nx,ny = self.environment.width, self.environment.height
265
- self.tile_size = self.width // nx
420
+ self.tile_size = self.width // nx # size of each tile
266
421
  tile_origin = (0, 0)
267
- tiles = []
268
- for i in range(ny):
269
- row = []
270
- for j in range(nx):
271
- tileRect = pygame.Rect(
422
+
423
+ tiles:list[list[pygame.Rect]] = [] # 2D list of tile rects
424
+
425
+ for i in range(ny): # for each row
426
+ row = []
427
+ for j in range(nx): # for each column
428
+ tileRect = pygame.Rect( # define tile rectangle
272
429
  tile_origin[0] + j * self.tile_size,
273
430
  tile_origin[1] + i * self.tile_size,
274
431
  self.tile_size, self.tile_size)
432
+ # draw tile border
275
433
  pygame.draw.rect(self.screen, COLOR_WHITE, tileRect, 1)
276
-
434
+ # add tile render object to row
277
435
  row.append(tileRect)
278
- tiles.append(row)
279
- self.tiles = tiles
436
+ tiles.append(row) # add row to tiles
280
437
 
281
- def render_things(self):
282
- """ render things """
283
- locations = {}
438
+ self.tiles = tiles # store tiles for later use
439
+
440
+
441
+ def render_things(self) -> None:
442
+ """ Render things in environment """
443
+ locations:dict = {} # map of locations to things
444
+
445
+ # build location map
284
446
  for thing in self.environment.things:
285
447
  if thing.location in locations:
286
448
  locations[thing.location].append(thing)
287
449
  else:
288
450
  locations[thing.location] = [thing]
451
+
452
+ # render things at each location
289
453
  for location, things in locations.items():
290
454
  n_things = len(things)
291
- if n_things > 1:
455
+ if n_things > 1: # multiple things, pick one at random per render
292
456
  thing = things[random.randint(0,n_things-1)]
293
- elif n_things == 1:
457
+ elif n_things == 1: # single thing
294
458
  thing = things[0]
295
- else:
459
+ else: # no things to render at this location
296
460
  continue
461
+ # determine render location
297
462
  renderLoc = self.tiles[location[1]][location[0]].center
298
463
  thingRender = self.thing_font.render(str(thing), True, COLOR_WHITE)
299
464
  thingRect = thingRender.get_rect()
300
465
  thingRect.center = renderLoc
466
+ # blit thing to screen
301
467
  self.screen.blit(thingRender, thingRect)