Skip to content

Architecture

Dave Walker edited this page Oct 12, 2026 · 2 revisions

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.

Package structure and feature map

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
Loading

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.

Application lifecycle and timing

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:

  1. Handle pending input events.
  2. Advance as many complete simulation ticks as the accumulated time allows.
  3. Draw the current model state and present the frame.
  • Simulation ticks are 1 / 120 second
  • The display clock aims for at most 60 frames per second
  • Application.advance caps 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

Full simulation step

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"]
Loading

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 Grip when 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.

Hazard and conflict processing

State ownership

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.

Before any arm moves

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:

  1. Each arm refreshes contact observations and runs its signaller against the supplied object snapshot.
  2. 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.
  3. The controller derives the all-arm withdrawal flag from outstanding priority-100 reports at or above critical_severity.
  4. 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.
  5. ConflictResolver.resolve selects one ConflictDecision per arm from those intentions, the set of individually withdrawing arms and the broadcast flag.
  6. ControlledArm.apply_decision applies 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.

Arbitration and application are separate

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.

Local response and safe movement

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.

Configuration and lifecycle

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.

Snapshot updates versus scene updates

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.

Arm geometry and local movement

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:

  1. Updates HazardResponse and filters solid collision candidates to obstacle circles; food and hazards are not arm collision walls.
  2. Handles an enclosed target or a completed ordinary reach.
  3. Builds local endpoint goals from previous neighbour requests, using the withdrawal destination at the tip when present, otherwise the assigned target.
  4. Applies segment-local hazard steering and obstacle avoidance, then computes new requests.
  5. Proposes bounded joint turns from the fixed base outwards, rechecking local steering from each updated attachment.
  6. Accepts the proposed angles only when avoidance.safe_motion approves 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.

Sensing and scene ownership

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.

Grasping and carrying

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.

State and presentation

ArmState is derived from underlying flags, contacts and grip state rather than stored as an independently mutable state variable. Display priority is:

  1. Withdrawal status: holding under alert if a grip remains, otherwise Withdrawing or Withdrawal blocked.
  2. Yielding when resource arbitration suspends ordinary task movement.
  3. Blocked while active, followed by the local hazard state, then obstacle Avoiding.
  4. Holding, Carrying or Retracting when a grip exists.
  5. Grasping while a candidate is maturing, then Contact when observations exist.
  6. 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.

Significant-event history and camera

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.

Web implementation and parity

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.

Important defaults and boundaries

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.

Running and verification

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_controller

The 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 tests

Tests 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).