UNPKG

agentscape

Version:

Agentscape is a library for creating agent-based simulations. It provides a simple API for defining agents and their behavior, and for defining the environment in which the agents interact. Agentscape is designed to be flexible and extensible, allowing

378 lines (314 loc) 11.9 kB
import { CellGrid } from '../structures' import Agent from './Agent' import { Angle, Color, RandomGenerator, Vector2 } from '../numbers' /** * Derive a value from a function of the cell's current state * instead of storing it as a constant. */ export function Dynamic<T extends Cell, V>(f: (cell: T) => V) { return function(target: T, propertyKey: string) { Object.defineProperty(target, propertyKey, { get: function() { return f(this as T) }, set: function(value) { return value } }) } } export type CellConstructor = { /** * The [x,y] location of the cell in the grid beginning at [0,0]. */ location: [number, number], color?: Color, strokeColor?: Color, energy?: number, } /** * A cell in a grid. The basic unit of space for a model. * May be used as-is or extended to include additional properties. * * The default cell has a `location`, `color`, and `energy`. Only the `location` is required. * The default color is white and the default energy is 0. * * Additionally, a cell has a set of `tenantAgents` which are agents that occupy the cell * at the current time step. */ export default class Cell { public location: [number, number] public tenantAgents = new Set<Agent>() public color: Color public strokeColor: Color public energy: number constructor(opts: CellConstructor) { this.location = opts.location this.energy = opts.energy ?? 0 this.color = opts.color ?? Color.fromName('white') this.strokeColor = opts.strokeColor ?? undefined } /** * Gives the distance between this Cell and another Cell or Agent. */ public distanceTo<T extends Cell | Agent>( world: CellGrid<Cell>, other: T, options?: { metric?: 'euclidean' | 'manhattan' } ): number { const { metric = 'euclidean' } = options ?? {} const otherPosition = other instanceof Agent ? other.position : new Vector2(other.location) if (world.boundaryCondition === 'periodic') { const width = world.cells.length const dx = Math.abs(this.location[0] - otherPosition.x) const dy = Math.abs(this.location[1] - otherPosition.y) const toroidalDx = Math.min(dx, width - dx) const toroidalDy = Math.min(dy, width - dy) if (metric === 'euclidean') { return Math.sqrt(toroidalDx ** 2 + toroidalDy ** 2) } else { return toroidalDx + toroidalDy } } else { if (metric === 'euclidean') { return Math.sqrt((this.location[0] - otherPosition.x) ** 2 + (this.location[1] - otherPosition.y) ** 2) } else { return Math.abs(this.location[0] - otherPosition.x) + Math.abs(this.location[1] - otherPosition.y) } } } /** * Gets all Cells within a given square radius. */ public getNeighborsInRadius<T extends Cell>( world: CellGrid<T>, options?: { includeSelf?: boolean, radius?: number } ): Array<T> { const cells = world.cells const neighbors = new Set<T>() const x = this.location[0] const y = this.location[1] const { radius = 1, includeSelf = false } = options ?? {} const width = cells.length // assert degree is a positive integer if (!Number.isInteger(radius) || radius < 1) { throw new Error('getNeighborsInRadius radius option must be a positive integer.') } for (let i = -radius; i <= radius; i++) { for (let j = -radius; j <= radius; j++) { const newX = x + i const newY = y + j if (newX >= 0 && newX < width && newY >= 0 && newY < width) { neighbors.add(cells[newX][newY]) } else if (world.boundaryCondition === 'periodic') { neighbors.add(cells[(newX + width) % width][(newY + width) % width]) } } } if (!includeSelf) { neighbors.delete(this as unknown as T) } return Array.from(neighbors) } /** * Diffuses a property to neighboring cells. * The property function should return a number for each cell. * The cell diffuses equal shares of `rate` percentage of the property to each neighbor. */ public diffuse<T extends this>( world: CellGrid<T>, property: (cell: T) => number, setter: (cell: T, value: number) => void, rate: number, range: number = 1 ): void { // Get the property value for this cell const value = property(this as unknown as T) const neighbors = this.getNeighborsInRadius(world, { radius: range }) // Calculate the total amount to diffuse const totalDiffusionAmount = value * rate // Calculate the amount to diffuse to each neighbor const amountPerNeighbor = totalDiffusionAmount / neighbors.length // Deplete the property on this cell by the total amount diffused setter(this as unknown as T, value - totalDiffusionAmount) // Diffuse the property to each neighbor for (const neighbor of neighbors) { const neighborValue = property(neighbor) setter(neighbor, neighborValue + amountPerNeighbor) } } /** * Performs a 2D convolution on the cell's neighbors using the given kernel. * The kernel is a 3x3 matrix of numbers. * The property function should return a number for each cell. * */ public getNeighborConvolution<T extends Cell>(world: CellGrid<T>, kernel: number[][], property: (cell: T) => number): number { const neighbors = [] const x = this.location[0] const y = this.location[1] const width = world.cells.length // TODO: confirm this works with this.getNeighbors // gets the values of the neighbors in row-major order // with finite boundary conditions if (world.boundaryCondition === 'finite') { for (let i = -1; i <= 1; i++) { for (let j = -1; j <= 1; j++) { const newX = x + i const newY = y + j if (newX >= 0 && newX < width && newY >= 0 && newY < width) { const cell = world.cells[newX][newY] neighbors.push(property(cell)) } else { neighbors.push(0) } } } } else { // gets the values of the neighbors in row-major order // with toroidal boundary conditions for (let i = -1; i <= 1; i++) { for (let j = -1; j <= 1; j++) { const newX = (x + i + width) % width const newY = (y + j + width) % width const cell = world.cells[newX][newY] neighbors.push(property(cell)) } } } // perform the convolution let result = 0 for (let i = 0; i < kernel.length; i++) { for (let j = 0; j < kernel[i].length; j++) { result += kernel[i][j] * neighbors[i * 3 + j] } } return result } /** * Gets the four neighbors of the cell. * Optionally includes the four diagonal neighbors and this cell. */ public getNeighbors<T extends Cell>( world: CellGrid<T>, options?: { includeDiagonals?: boolean, includeSelf?: boolean, } ): Array<T> { const boundaryCondition = world.boundaryCondition const { includeDiagonals = false, includeSelf = false, } = options ?? {} const cells = world.cells const neighbors = new Set<T>() const wrap = (coord, max) => (coord + max) % max const gridWidth = world.width const gridHeight = world.height const x = this.location[0] const y = this.location[1] if (boundaryCondition === 'periodic') { // left neighbors.add(cells[wrap(x - 1, gridWidth)][wrap(y, gridHeight)]) // right neighbors.add(cells[wrap(x + 1, gridWidth)][wrap(y, gridHeight)]) // top neighbors.add(cells[wrap(x, gridHeight)][wrap(y - 1, gridHeight)]) // bottom neighbors.add(cells[wrap(x, gridHeight)][wrap(y + 1, gridHeight)]) if (includeDiagonals) { // top left neighbors.add(cells[wrap(x - 1, gridHeight)][wrap(y - 1, gridHeight)]) // top right neighbors.add(cells[wrap(x + 1, gridHeight)][wrap(y - 1, gridHeight)]) // bottom left neighbors.add(cells[wrap(x - 1, gridHeight)][wrap(y + 1, gridHeight)]) // bottom right neighbors.add(cells[wrap(x + 1, gridHeight)][wrap(y + 1, gridHeight)]) } } else if (boundaryCondition === 'finite') { // left if (x > 0) { neighbors.add(cells[x - 1][y]) } // right if (x < gridWidth - 1) { neighbors.add(cells[x + 1][y]) } // up if (y > 0) { neighbors.add(cells[x][y - 1]) } // down if (y < gridHeight - 1) { neighbors.add(cells[x][y + 1]) } if (includeDiagonals) { if (x > 0 && y > 0) { // top-left neighbors.add(cells[x - 1][y - 1]) } if (x > 0 && y < gridHeight - 1) { // bottom-left neighbors.add(cells[x - 1][y + 1]) } if (x < gridWidth - 1 && y > 0) { // top-right neighbors.add(cells[x + 1][y - 1]) } if (x < gridWidth - 1 && y < gridHeight - 1) { neighbors.add(cells[x + 1][y + 1]) } } } else { throw new Error(`Unknown boundary condition: ${boundaryCondition}`) } if (includeSelf) { neighbors.add(this as unknown as T) } return Array.from(neighbors) } public toJSON(): object { const propNames = Object.getOwnPropertyNames(this) propNames.forEach((key) => { if (typeof this[key] === 'function') { delete this[key] } }) const json = {} propNames.forEach((key) => { if (this[key] instanceof Set) { json[key] = Array.from(this[key]) return } if (this[key] instanceof Vector2) { json[key] = this[key].components return } if (this[key] instanceof RandomGenerator) { return } if (this[key] instanceof Map) { json[key] = Array.from(this[key].entries()) return } if (this[key] instanceof Angle) { json[key] = `${this[key].asDegrees()} deg` return } if (this[key] instanceof Color) { json[key] = this[key].toRGB() return } json[key] = this[key] }) return json } }