Repository navigation
Architecture
The program separates graphical interaction from simulation state and numerical rules. Pygame handles the window, input and drawing; the simulation modules do not import Pygame.
The conceptual division described in Central Controller remains visible in the implementation: CentralController routes assignments and collects capture reports, ControlledArm manages one arm's activity, and ReachController schedules the local movement rules. Shared scheduling does not mean that the central controller calculates joint angles. Hazard response, reporting and conflict arbitration are separate layers within that schedule.
Application source lives in src/octopus_controller. The table maps modules and classes to the features explained in the principles pages.
| Module | Class or principal functions | Responsibility and corresponding feature |
|---|---|---|
app.py |
Application, main
|
Window lifecycle, keyboard/mouse input, selection, initial scene and fixed-step timing |
organism.py |
CentralController |
Eight-arm layout, assignment routing, shared simulation time, exclusive capture claims and reports |
organism.py |
ControlledArm |
One arm's active/idle assignment, contact observations, grasp dwell, holding, carrying and retraction |
organism.py |
ArmState, CaptureReport
|
Displayed activity states and immutable records of completed captures |
model.py |
ArmParameters, Arm
|
Validated dimensions and movement limits, fixed attachment, relative joint angles and connected geometry |
model.py |
ReachController |
Target storage, previous neighbour requests, trial joint movements, avoidance status and movement acceptance |
segment_control.py |
SegmentRequest, request_start, turn_segment, wrap_angle
|
Local request construction, neighbour bend constraints, gradual turning and shortest-angle calculations |
geometry.py |
Point, validate_point, closest_point, pose_points
|
Common coordinates, validation, finite-segment projection and forward reconstruction of joint positions |
sensing.py |
ObjectKind, WorldObject, Contact
|
Object types, immutable circular scene objects and immutable local observations |
sensing.py |
detect_contacts |
Segment sensing strips tested against food, obstacle and hazard circles |
sensing.py |
Environment |
Editable object collection, stable identities, immutable snapshots and pointer selection |
avoidance.py |
steer_around_obstacles, safe_motion
|
Local circular-boundary steering and conservative swept arm-clearance checks |
grasping.py |
grasp_candidate, Grip
|
Adjacent same-food contact qualification, rigid attachment transforms and swept payload checks |
view.py |
draw_scene, draw_sidebar, draw_arm, draw_card, draw_keycap
|
World rendering, contact/ownership indicators, status cards, selected settings and shortcut reference |
hazards.py |
HazardResponse |
Per-segment hazard memory, recoil timer, reassessment, exposure totals and local endpoint steering |
escalation.py |
EscalationPolicy, HazardSignaller, HazardSignal
|
Validated thresholds and per-arm/per-hazard reporting episodes |
escalation.py |
PriorityReceiver, HazardReport, PriorityMessage
|
Report-only threat knowledge, broad withdrawal requests and bounded diagnostics |
conflicts.py |
ConflictPolicy, ArmIntent, ConflictDecision, ConflictResolver
|
Immutable intentions/decisions, priority arbitration and resource incumbency |
activity.py |
ActivityHistory, ActivityEvent, state_summary
|
Presentation-only significant-event groups, repetition counts and arm-state summary |
scenario.py |
Scenario, parse_scenario, load_scenario, save_scenario
|
Hazard geometry, policy validation and named-food identity persistence |
assets/octopus.png |
Packaged image asset | Window icon and the icon beside the main title and subtitle |
flowchart TD
Entry["Entry point"] --> App["app.py: Application"]
App --> Central["organism.py: CentralController"]
App --> Scene["sensing.py: Environment"]
App --> View["view.py: rendering"]
Central --> Arm["organism.py: ControlledArm x 8"]
Arm --> Reach["model.py: ReachController and Arm"]
Arm --> Sensing["sensing.py: local contacts"]
Arm --> Grip["grasping.py: capture and payload"]
Arm --> Signals["escalation.py: HazardSignaller"]
Signals --> Priority["escalation.py: PriorityReceiver"]
Priority --> Conflicts["conflicts.py: ConflictResolver"]
Arm -->|"ArmIntent"| Conflicts
Conflicts -->|"ConflictDecision"| Arm
Reach --> Hazards["hazards.py: HazardResponse"]
App --> Activity["activity.py: significant-event history"]
Activity --> View
Reach --> Local["segment_control.py: neighbour rules"]
Reach --> Avoid["avoidance.py: steering and movement guard"]
Reach --> Geometry["geometry.py: shared geometry"]
Sensing --> Geometry
Avoid --> Geometry
Grip --> Geometry
Scene -.->|"Snapshots"| Arm
Central -.->|"Read-only display access"| View
Scene -.->|"Objects"| View
This is a responsibility and data-flow diagram rather than a complete import graph. All eight arms currently run in one process and are visited sequentially. Their independence comes from separate state and local rules, not separate threads or network connections.
main initialises Pygame, loads the packaged icon, creates fonts and the window, and then runs the application loop. Cleanup occurs in a finally block so the graphical resources are released even if an error interrupts the loop.
Application owns the CentralController and Environment. It also owns presentation interaction state: selected arm, placement tool, selected scene object, drag flags, pause flag and accumulated frame time.
The frame loop performs three tasks:
- Handle pending input events.
- Advance as many complete simulation ticks as the accumulated time allows.
- Draw the current model state and present the frame.
- Simulation ticks are
1 / 120second - The display clock aims for at most 60 frames per second
-
Application.advancecaps each frame at 0.1 wall-clock second before applying the selected 1×, 2×, 4× or 8× playback multiplier - Accumulated time is consumed in unchanged 1/120-second ticks, preserving the solver sequence and all simulation-time timers at higher playback rates
- That cap deliberately discards excess wall time: simulation time is not guaranteed to match wall time after a stall
- Pause prevents normal advancement
- Single-step calls the same full simulation path for exactly one tick
- Scene edits refresh sensing while paused, but do not advance contact dwell timers or move arms
- A blocked/avoiding movement state is recalculated when simulation movement next advances
The application uses CentralController.step(elapsed_seconds, environment) for the complete lifecycle.
flowchart TD
Start["Validate duration"] --> Time["Advance shared simulation time"]
Time --> Arbitration["Gather reports and arbitrate arm intentions"]
Arbitration --> Owners["Collect remaining grip owners"]
Owners --> Next["Visit next arm in stable order"]
Next --> Local["ControlledArm.update_grasp"]
Local --> Capture{"New capture?"}
Capture -->|"Yes"| Report["Reserve object and record report"]
Capture -->|"No"| More{"More arms?"}
Report --> More
More -->|"Yes"| Next
More -->|"No"| Refresh["Refresh all sensing from final scene"]
Each arm takes a current scene snapshot when it is visited. A carried object moved by an earlier arm is therefore visible to a later arm in that same step. The final refresh makes all displayed contact readings correspond to the final scene.
ControlledArm.update_grasp first reconciles existing attachments and refreshes local contact. Its main branches are:
-
Already holding: delegate to
_carry, which either holds still or attempts movement. -
Eligible food contact: keep the pose still, accumulate dwell time, and create a
Gripwhen the threshold is met. -
No eligible candidate: clear incomplete grasp progress and perform ordinary reaching/sensing through
update.
The arm returns a newly captured object ID once. The central controller then reserves that object for later arms in the step, increments capture_count, and appends a CaptureReport. The report deque retains at most 32 entries; the total count is retained independently.
Before this movement loop, Phase 3 gathers local hazard reports and arm intentions, then ConflictResolver chooses withdrawal, avoidance, grip, reach or yield. Shared resources use ownership, eligible incumbency and arm number as tie-breaks. The final capture reservation order prevents double ownership. Within an arm, grasp_candidate uses stable object identity after filtering incidental food that conflicts with a named task.
ReachController.hazard owns a HazardResponse. Its _local dictionary associates segment indices with last-sensed hazard records. _seen tracks encountered identities, _remaining holds recoil time, and _quiet tracks the local clear interval. Encounter counts and accumulated exposure survive local episode resets until the controller is rebuilt. Remembered geometry is replaced by fresh local observations; it is not continuously updated from unseen hazard positions.
ControlledArm.signaller owns a separate HazardSignaller. Its _episodes dictionary is keyed by hazard identity because continuous exposure and report clearance belong to a particular arm/hazard pair. Each episode stores continuous exposure, quiet time and the last emitted immutable HazardSignal. This state is deliberately separate from the movement response: signalling still runs for idle or holding arms, and a report can remain outstanding after local movement has left the region.
PriorityReceiver.known is keyed by (arm_index, object_id). Values are immutable HazardReport snapshots containing the arm, timestamp and signal. Clearing one arm's observation cannot erase another arm's outstanding report of the same object. The 64-entry reports and messages deques are diagnostic histories, not the source of active threat knowledge; eviction does not clear threats.
ConflictResolver owns the current decisions, resource incumbents, a 64-entry changed-outcome history and cumulative counters. ConflictPolicy configures critical severity and target matching; the action-priority hierarchy is fixed. Neither the resolver nor PriorityReceiver takes an environment map as input.
CentralController.step increments simulation time, then calls _signal_priorities before collecting grip owners or advancing grasp/movement. The pre-movement phase is ordered as follows:
- Each arm refreshes contact observations and runs its signaller against the supplied object snapshot.
- New signals are passed to
PriorityReceiver.receive. Reporting is deduplicated; higher priority and critical-severity upgrades are emitted, while clearance removes the matching active report. - The controller derives the all-arm withdrawal flag from outstanding priority-100 reports at or above
critical_severity. - Each arm supplies an
ArmIntent: activity, target, held object, named-food goal, capture candidate, incidental discovery and avoidance state. The arm constructs this local summary; the resolver does not inspect scene geometry. -
ConflictResolver.resolveselects oneConflictDecisionper arm from those intentions, the set of individually withdrawing arms and the broadcast flag. -
ControlledArm.apply_decisionapplies the command. Withdrawal releases any grip first; yielding clears incomplete grasp dwell and suspends ordinary task movement. Actual releases and abandoned active carries update separate counters.
The remaining owners are then collected and update_grasp runs in stable arm order. This ordering ensures that safety can override an existing grip before carrying moves its payload, and that the final capture reservation check still prevents double ownership.
ConflictDecision records action, priority, conflict label, reason, optional winner and local/central scope. It contains no joint angles. request_withdrawal supplies a temporary goal near the arm's own attachment without replacing an ordinary assigned reach. A released carry loses its attachment and assignment; it is not resumed as if the payload were still held.
Resource matching uses food identity when both intentions identify an object; otherwise it compares destinations using target_tolerance. Coordinate groups use their first member as an anchor, avoiding transitive chains of increasingly distant targets. Ownership outranks an eligible incumbent, which outranks arm number. _incumbents retains the winning intention so a prior winner only keeps precedence while still claiming the same resource. Retreating arms relinquish claims, idle non-owners do not reserve old destinations, and local safety steering is not replaced by Yield.
Named-food assignments are represented by ControlledArm.goal_object. Filtering capture candidates enforces the assignment against incidental discoveries. The scenario loader resolves external food names to runtime identities; saving uses focus_food where necessary to preserve identity while retaining a fixed coordinate destination. Reservations, reports and active recoil episodes are runtime state, not a saved playback checkpoint.
HazardResponse.sense detects circles using finite-segment projection. Detection includes the sensing strip and detection range, while exposure requires strict penetration of the actual hazard radius. steer replaces a segment's endpoint request during recoil or mild-hazard penetration; otherwise it reuses boundary steering with buffered copies of sensed hazard circles. Those copies never enter the solid-obstacle collision guard.
The movement controller preserves joint-speed and bend bounds and tests swept obstacle clearance. If the combined proposal is blocked during aversion or withdrawal, it tests subsets of those same proposed turns from the tip towards the base. Each trial is checked from the original pose rather than only from the preceding trial, so the final simultaneous movement remains safe. This is constrained local recovery, not global path search. blocked describes rejected movement even when the conflict decision still requests withdrawal.
scenario.py validates hazard severity/range, builds EscalationPolicy and ConflictPolicy, and passes them into the controller and arm signallers. Defaults are report severity 0.4, withdrawal severity 0.75, continuous exposure 2.0 seconds, report clearance 0.6 seconds, critical severity 0.95 and target tolerance 2 pixels. Local recoil has its own threshold of 0.4, duration 0.25 + 0.9 × severity, and 0.6-second reassessment interval; changing report policy does not rewrite those local rules.
Restart/load rebuild model state and reset episodes, reports, reservations and histories. Paused sensor refresh does not advance exposure or report-clearance timers. Deleting a hazard removes it from local steering memory when response sensing next runs; central knowledge persists until the reporting arm completes its clearance interval.
Two update interfaces serve different purposes:
| Interface | Purpose | Scene mutation and capture reporting |
|---|---|---|
CentralController.step(seconds, environment) |
Full interactive simulation | Supports grasping, ownership, carried-object movement and reports |
CentralController.update(seconds, objects) |
Isolated reaching/sensing against an immutable snapshot | Advances shared time, signalling, arbitration and arm movement, but not grasp creation or carried-object scene updates |
CentralController.refresh_sensing(objects) |
Reconcile scene edits and update displayed observations | Does not advance movement or dwell time; can release invalid grips and cancel invalid contact candidates |
Use step when simulating capture or carrying. update is useful for numerical experiments that need only reaching and contact observations, and for testing those behaviours independently. It is not an interchangeable way to advance a live carrying scene.
ArmParameters validates the segment count, segment length, maximum bend and turning speed. Arm stores its fixed base and relative joint angles. Its points property reconstructs a fresh list through geometry.pose_points, keeping connectivity and lengths exact without storing a second mutable representation of the pose.
ReachController owns the target and the previous SegmentRequest messages. In each movement update it:
- Updates
HazardResponseand filters solid collision candidates to obstacle circles; food and hazards are not arm collision walls. - Handles an enclosed target or a completed ordinary reach.
- Builds local endpoint goals from previous neighbour requests, using the withdrawal destination at the tip when present, otherwise the assigned target.
- Applies segment-local hazard steering and obstacle avoidance, then computes new requests.
- Proposes bounded joint turns from the fixed base outwards, rechecking local steering from each updated attachment.
- Accepts the proposed angles only when
avoidance.safe_motionapproves the swept movement. During hazard response or withdrawal, a rejected proposal also triggers a tip-to-base search over safe subsets of the proposed joint turns; every accepted combination is checked against the original pose’s complete sweep.
The controller stores new requests even when the geometric guard rejects the trial pose. This allows later requests to evolve without forcing unsafe movement. The guard examines the whole trial movement but only returns an accept/reject result; it does not generate a centrally planned pose.
The equations and coordinate conventions are described in Reach Controller and Arm Movement. segment_control.py contains the small numerical rules without access to the whole arm, while ReachController schedules those rules and materialises the connected chain.
WorldObject is an immutable dataclass containing a positive identity, centre, type and radius, plus severity and detection range for hazard behaviour. Environment owns a private identity-to-object dictionary. Moving an object replaces its record; objects returns an immutable tuple snapshot. Removing an object does not reuse its ID later.
detect_contacts returns immutable Contact records containing the segment index, object identity, object type and closest position on the segment. Each ControlledArm replaces its own contact tuple on refresh. Multiple observations of one object and multiple objects on one segment remain distinguishable.
The environment stores no separate grip-owner table. Existing Grip records provide ownership when CentralController.step builds the unavailable-ID set. A completed local capture is added immediately to that set for the rest of the step.
User object edits call release_object before directly moving or deleting a held object. Sensing also checks whether a grip's object still exists at its expected attachment position. This second check reconciles external scene edits and prevents a deleted or dragged object from being recreated or snapped back.
grasp_candidate groups food contacts by object identity and looks for consecutive segment indices. Two adjacent segments must maintain the same candidate for 0.25 simulation seconds. During that interval, the arm retains its pose. An interrupted candidate loses its accumulated time.
Grip.attach records the object's position relative to the final segment in the qualifying pair. Grip.centre applies that local offset to any subsequent pose. This is a rigid latch model: it does not solve friction forces or require both original contacts to remain present after capture.
When carrying, ControlledArm._carry translates the requested object destination into a changing tip objective. It saves the previous angles, lets the reach controller attempt a movement, and then calls Grip.safe_motion for the object's swept circle. If the payload check fails, it restores the previous angles and marks movement blocked. The scene object moves only after an accepted payload check.
Retraction is an ordinary carrying destination 40 pixels outward from the fixed attachment along the initial arm direction. Arrival within two pixels changes carrying to holding. Releasing clears the attachment and idles the arm. Geometry resets release held food before rebuilding the arm; speed-only edits preserve the attachment.
ArmState is derived from underlying flags, contacts and grip state rather than stored as an independently mutable state variable. Display priority is:
- Withdrawal status: holding under alert if a grip remains, otherwise Withdrawing or Withdrawal blocked.
- Yielding when resource arbitration suspends ordinary task movement.
- Blocked while active, followed by the local hazard state, then obstacle Avoiding.
- Holding, Carrying or Retracting when a grip exists.
- Grasping while a candidate is maturing, then Contact when observations exist.
- Idle, Reached or Reaching according to activity and reach progress.
A displayed restriction does not erase other state. For example, a blocked carry still has a grip, a carry destination and contact observations. ReachController.status separately provides the “Beyond reach” diagnosis used by the selected-arm display.
view.py reads these values but does not move objects, change angles or assign tasks. It draws the world, arm identities, contact locations, ownership rings and the three sidebar cards. The keypress/action table is a reference, not a set of clickable buttons. Fonts are created at startup and supplied to rendering; the smaller font is shared by settings contents and shortcut contents.
World bounds are shared with input handling so drag positions are clamped to the visible area. This is a presentation boundary, not an additional collision wall in the model.
Application owns ActivityHistory, observes the controller after each complete tick and after applicable UI edits, and resets the history when rebuilding a scene. activity.py has no Pygame dependency and never changes model decisions. It compares current state with its previous baseline to collect captures, releases, escalation, critical broadcasts, newly blocked arms and yielding decisions.
Related critical-alert, withdrawal and release changes are grouped into one ActivityEvent. Repeated matching category/arm/text groups retain a count and move to their latest occurrence; at most 256 groups are retained. This presentation history is distinct from raw diagnostic deques and omits routine discovery/clear chatter. Its release total counts observed detachments; the resolver's release counter specifically counts releases caused by withdrawal arbitration.
draw_messages renders a stable three-row summary beneath the scene. draw_history wraps and paginates filtered groups. Opening History saves the pause state and pauses playback; its event handler consumes input before normal scene handling, preventing clicks from assigning targets. Closing restores the saved pause state and clears fractional frame time. Explicit scrolling holds a row position; Jump to latest restores following.
SceneCamera.fit centres the body and fits full possible arm reach for empty, cleared and loaded scenes. Rendering projects model coordinates; pointer editing applies the inverse transform. Camera fitting does not rewrite scenario coordinates or affect sensing and collision calculations.
explorer/js mirrors the simulation layers in hazards.js, escalation.js, conflicts.js, organism.js and model.js. simulation.js owns fixed-step playback, pause, restart and speed multiplication independently of DOM rendering. app.js connects controls and readouts to that model; the Priority Communication and Conflict Resolution cards in explorer/index.html present the corresponding counters and bounded histories.
The desktop's ActivityHistory inspection overlay is a presentation feature; the web cards currently display their own report/conflict views. Equivalent model behaviour does not require identical interface layout.
tests/web/reference.py generates fresh Python snapshots for JavaScript comparisons, including angles, contacts, hazard metrics, reports, decisions, grips and scene objects. The extended reef trace includes the ten-second hazard-recovery checkpoint. Separate model tests check that accelerated playback executes the same sequence as normal speed, rather than increasing the solver timestep.
| Setting | Current value or distinction |
|---|---|
| Arms | Eight, with fixed radial attachments |
| Body radius | 32 pixels |
| Segments per arm | 20 by default; model accepts 2–100, interface adjusts 2–40 |
| Segment length | 12 pixels in the eight-arm scene; standalone ArmParameters defaults to 18 |
| Maximum relative bend | π/3 radians, displayed as 60 degrees |
| Turning speed | 1.5 radians per second per joint |
| Local steering response | 20 per second |
| Ordinary reach and carry tolerance | 2 pixels |
| Sensing radius and arm obstacle clearance | Both 3 pixels, but used for separate tests |
| Default scene object radius | 14 pixels |
| Obstacle lookahead | Up to three segment lengths |
| Grasp requirement | Two adjacent contacts on one food for 0.25 seconds |
| Retraction offset | 40 pixels from the fixed arm base along its initial heading |
These are model and interface defaults, not biological measurements. An arm can remain trapped by the local rules. Obstacles are circles; body collision, arm-to-arm collision, grasp-force modelling and reward-based planning are not implemented. Fixed-priority conflict arbitration is implemented in conflicts.py and mirrored in conflicts.js.
The package declares Python 3.14 or newer and Pygame Community Edition. The development dependencies are pytest and Ruff. NumPy is not required by the current implementation.
From the project root with the supplied environment activated:
python -m octopus_controllerThe installed octopus-controller command calls app.main, and scripts/run.sh activates the local environment before launching it. Package-relative icon loading avoids depending on the current working directory.
The automated checks are:
python -m pytest
python -m ruff check src tests
python -m ruff format --check src testsTests are organised by responsibility:
| Test file | Main coverage |
|---|---|
test_model.py |
Reaching, retargeting, geometry invariants, speed/bend limits and invalid inputs |
test_segment_control.py |
Local numerical edge cases and one-neighbour-per-tick propagation |
test_organism.py |
Independent arms, radial layout, assignments, idle/resume and isolation |
test_sensing.py |
Contact boundaries, object types, stable identity and contact clearing |
test_avoidance.py |
Detours, swept crossings, blocked targets and retained constraints |
test_grasping.py |
Dwell, ownership, reporting, carrying, retraction, release and payload safety |
test_app.py |
Input, pause/step/speed, camera transforms, history inspection, scene editing and headless rendering |
test_hazards.py |
Detection, recoil, retained tasks, exposure and safe escape from the reef hazard |
test_escalation.py |
Reporting thresholds, sustained exposure, deduplication, upgrades and clearance |
test_conflicts.py |
Safety precedence, releases, resource ownership/incumbency and incidental-food filtering |
test_activity.py |
Significant-event grouping, repetition counts, filters and bounded presentation history |
tests/web/*.test.js |
JavaScript model behaviour, Python parity and playback invariance |
tests/web/browser-check.mjs |
Browser controls, responsive layout, readouts and functional checks |
The numerical tests are deterministic. Graphical tests use SDL's dummy display and audio drivers, allowing interface and startup checks without opening a desktop window.
Web model checks run with npm test; formatting with npm run format:check. The separate npm run test:browser suite requires the explorer to be served locally and an available Playwright browser (or a configured browser executable).