"""Physical encounter, sensors, interactions, and scoring for grasping."""
from dataclasses import dataclass, field
from random import Random
from ..robots import BallPrey, GraspingRobot
from ..robots.config import BALL_PREY_CONFIG
from .config import WORLD_CONFIG
# Sensor interfaces
def proximity_interface(
distance: float,
near: float = WORLD_CONFIG.near_distance,
far: float = WORLD_CONFIG.far_distance,
) -> float:
"""Map continuous distance to a normalized nearby-object signal.
:param distance: Object distance in centimetres.
:param near: Distance producing a full response.
:param far: Distance producing a zero response.
:returns: Normalized proximity in the interval ``[0, 1]``.
:raises ValueError: If ``far`` is not greater than ``near``.
"""
if far <= near:
raise ValueError("far must be greater than near")
return min(1.0, max(0.0, (far - distance) / (far - near)))
def touch_interface(pressed: bool) -> float:
"""Map the internal touch switch to the empty-gripper signal.
:param pressed: Whether caught prey presses the switch.
:returns: Zero when pressed and one while the gripper is empty.
"""
return 0.0 if pressed else 1.0
# Arena geometry
@dataclass(frozen=True)
class GraspingArena:
"""Define the rectangular checkerboard arena.
:param width: Arena width in display units.
:param height: Arena height in display units.
:param cell_size: Width and height of one checkerboard cell.
"""
width: float = WORLD_CONFIG.arena_width
height: float = WORLD_CONFIG.arena_height
cell_size: float = WORLD_CONFIG.arena_cell_size
@property
def bounds(self) -> tuple[float, float]:
"""Return the arena dimensions.
:returns: Arena ``(width, height)`` in display units.
"""
return self.width, self.height
# Encounter state and dynamics
[docs]
@dataclass
class GraspingWorld:
"""Represent one stationary gripper and one wandering ball prey.
:param arena: Visible checkerboard dimensions.
:param robot: Physical gripper body controlled by an actuator.
:param ball: Ball prey moving in front of the gripper.
:param elapsed: Simulated time in seconds.
:param touch_pressed: Whether caught prey presses the touch sensor.
:param readings: Latest normalized proximity and touch readings.
:param correct_grips: Closing transitions that caught prey.
:param missed_grips: Grippable visits that ended uncaught.
:param empty_grips: Closing transitions made without grippable prey.
"""
arena: GraspingArena
robot: GraspingRobot
ball: BallPrey
elapsed: float = 0.0
touch_pressed: bool = False
readings: dict[str, float] = field(default_factory=dict)
correct_grips: int = 0
missed_grips: int = 0
empty_grips: int = 0
_inside_visit: bool = False
_visit_gripped: bool = False
_caught_at: float | None = None
_rng: Random = field(default_factory=Random, repr=False)
# Construction and simulation API
[docs]
@classmethod
def demo(
cls,
robot: GraspingRobot | None = None,
seed: int | None = None,
) -> GraspingWorld:
"""Create the configured arena, robot, and randomly placed ball prey.
:param robot: Existing robot to place in the world. A brainless robot is
created when omitted.
:param seed: Optional seed for reproducible prey movement.
:returns: Initialized world with its first sensor snapshot.
"""
rng = Random(seed)
robot = robot or GraspingRobot(connect_brain=False)
ball = BallPrey(
0.0,
robot.y,
rng.uniform(*BALL_PREY_CONFIG.initial_distance_range),
rng.uniform(*BALL_PREY_CONFIG.initial_velocity_range),
)
world = cls(GraspingArena(), robot, ball, _rng=rng)
ball.schedule_motion_change(world.elapsed, rng)
world._update_ball_position()
world.readings = world.sensor_readings()
return world
[docs]
def step(self, gripper_activation: float, dt: float) -> None:
"""Advance the encounter and apply the latest actuator output.
:param gripper_activation: Normalized command applied to the jaws.
:param dt: Positive interval in simulated seconds.
:raises ValueError: If ``dt`` is not positive.
"""
if dt <= 0:
raise ValueError("dt must be positive")
was_closed = self.robot.gripper_closed
ball_was_inside = self._ball_is_grippable()
self.robot.apply_activation(gripper_activation)
self.elapsed += dt
self._handle_gripper_transition(was_closed, ball_was_inside)
self._advance_ball(dt)
self._update_visit_counter()
self.touch_pressed = self.ball.caught
self._update_ball_position()
self.readings = self.sensor_readings()
[docs]
def sensor_readings(self) -> dict[str, float]:
"""Calculate the two normalized robot sensor values.
:returns: Proximity and touch readings keyed by sensor name.
"""
return {
"proximity": proximity_interface(self.ball.distance),
"touch": touch_interface(self.touch_pressed),
}
# Grip, digestion, and scoring internals
def _handle_gripper_transition(self, was_closed: bool, ball_was_inside: bool) -> None:
"""Score and apply a newly issued closing command."""
if self.robot.gripper_closed and not was_closed:
if ball_was_inside:
self._catch_ball()
else:
self.empty_grips += 1
def _catch_ball(self) -> None:
"""Mark the ball caught and start the digestion interval."""
self.correct_grips += 1
self.ball.caught = True
self._caught_at = self.elapsed
self._inside_visit = True
self._visit_gripped = True
def _advance_ball(self, dt: float) -> None:
"""Hold caught prey or advance a free ball."""
if self.ball.caught:
self._hold_or_digest_ball()
return
closed_barrier = WORLD_CONFIG.grippable_distance if self.robot.gripper_closed else None
self.ball.step(
self.elapsed,
dt,
WORLD_CONFIG.minimum_distance,
closed_barrier,
self._rng,
)
def _hold_or_digest_ball(self) -> None:
"""Hold caught prey still and respawn it after digestion."""
if self._caught_at is None:
raise RuntimeError("caught ball has no capture time")
if self.elapsed - self._caught_at >= WORLD_CONFIG.digestion_time:
self._respawn_ball()
else:
self.robot.gripper_closed = True
self.ball.velocity = 0.0
def _respawn_ball(self) -> None:
"""Replace eaten prey at a random far position."""
self.ball.caught = False
self._caught_at = None
self.ball.distance = self._rng.uniform(*BALL_PREY_CONFIG.respawn_distance_range)
self.ball.velocity = self._rng.uniform(*BALL_PREY_CONFIG.initial_velocity_range)
self._inside_visit = False
self._visit_gripped = False
self.ball.schedule_motion_change(self.elapsed, self._rng)
def _update_visit_counter(self) -> None:
"""Count an uncaught visit when prey leaves the grippable zone."""
is_inside = self._ball_is_grippable()
if is_inside and not self._inside_visit:
self._inside_visit = True
self._visit_gripped = False
elif not is_inside and self._inside_visit:
if not self._visit_gripped:
self.missed_grips += 1
self._inside_visit = False
self._visit_gripped = False
def _ball_is_grippable(self) -> bool:
"""Return whether prey lies inside the configured jaw interval."""
return (
WORLD_CONFIG.minimum_distance <= self.ball.distance <= WORLD_CONFIG.grippable_distance
)
def _update_ball_position(self) -> None:
"""Convert sensor distance to the ball's horizontal display position."""
sensor_origin_x = self.robot.x + WORLD_CONFIG.sensor_offset_x
self.ball.x = sensor_origin_x + self.ball.distance * WORLD_CONFIG.ball_distance_scale
self.ball.y = self.robot.y