Skip to content

Commit 464ed92

Browse files
committed
Add cluster control web app
1 parent aef119d commit 464ed92

14 files changed

Lines changed: 1380 additions & 1 deletion
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Build Cluster Control
2+
3+
on:
4+
workflow_dispatch:
5+
6+
permissions:
7+
contents: read
8+
9+
jobs:
10+
build:
11+
name: ${{ matrix.name }}
12+
runs-on: ${{ matrix.runner }}
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
include:
17+
- name: Windows
18+
runner: windows-latest
19+
artifact: ClusterControl.exe
20+
mode: onefile
21+
- name: macOS
22+
runner: macos-latest
23+
artifact: ClusterControl.app
24+
mode: onedir
25+
- name: Linux
26+
runner: ubuntu-latest
27+
artifact: ClusterControl
28+
mode: onefile
29+
steps:
30+
- uses: actions/checkout@v4
31+
- uses: actions/setup-python@v5
32+
with:
33+
python-version: "3.11"
34+
- name: Install build dependencies
35+
run: python -m pip install '.[cluster-control]' 'pyinstaller>=6.0'
36+
- name: Build native application
37+
run: >-
38+
python -m PyInstaller --noconfirm --clean --windowed
39+
--${{ matrix.mode }} --name ClusterControl
40+
--collect-all fastapi --collect-all starlette --collect-all uvicorn
41+
--collect-data hex_maze_interface
42+
tools/cluster_control_launcher.py
43+
- uses: actions/upload-artifact@v4
44+
with:
45+
name: ClusterControl-${{ matrix.name }}
46+
path: dist/${{ matrix.artifact }}

documentation/cluster-control.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Cluster Control desktop application
2+
3+
Cluster Control is a small browser-based control surface for one cluster at a
4+
time. It is separate from the `maze` command-line interface: it uses the same
5+
public `HexMazeInterface` methods and does not change the interface used by
6+
the seven-cluster maze.
7+
8+
## Operator workflow
9+
10+
1. Connect the computer's Ethernet adapter to the maze network and assign it
11+
an unused `192.168.10.x` address with subnet mask `255.255.255.0`. Do not
12+
set a gateway or DNS server on that adapter.
13+
2. Launch **Cluster Control**. It opens in the default browser without using
14+
the internet.
15+
3. Select the one cluster address to operate (`10` normally means
16+
`192.168.10.10`), then select **Connect**.
17+
4. Select the shared maximum velocity, if needed. The application preserves
18+
the other controller parameters and verifies the requested value by reading
19+
it back from firmware.
20+
5. Select **Home all**. Motion remains disabled until all seven prisms report
21+
a successful terminal home outcome.
22+
6. Enter seven target positions in millimetres and select **Move to targets**.
23+
24+
**Pause** stops the selected cluster's motion. **Power off** turns off its
25+
prism power and requires another home before motion can be commanded. The
26+
browser's **Quit** button closes the local controller application.
27+
28+
The user interface deliberately exposes only one shared maximum velocity. The
29+
current firmware has one controller-parameter set per cluster, not one per
30+
prism.
31+
32+
## Validated defaults and limits
33+
34+
The operator app contains these conservative defaults:
35+
36+
- Home: 250 mm travel limit, 20 mm/s maximum velocity, 50% run current, and
37+
stall threshold 10.
38+
- Maximum velocity offered to an operator: 1–40 mm/s.
39+
- Position input: 0–550 mm, matching the firmware clamp.
40+
41+
Mechanical safe travel can be smaller than the firmware clamp. Validate the
42+
fixture-specific position range and speed profile before changing these values
43+
in `ClusterControlSettings`.
44+
45+
## Pixi installation for managed computers
46+
47+
Pixi is the recommended installation path for developers, technicians, and
48+
lab-managed computers. It creates the pinned environment described by
49+
`pixi.lock`, so Windows, macOS, and Linux use the same application
50+
dependencies:
51+
52+
```sh
53+
git clone https://github.com/janelia-python/hex_maze_interface_python.git
54+
cd hex_maze_interface_python
55+
pixi install
56+
pixi run cluster-control
57+
```
58+
59+
`pixi run cluster-control` starts the local application and opens the browser
60+
UI. A technician can make a desktop shortcut that runs this command from the
61+
checked-out repository. Use `git pull` followed by `pixi install` when
62+
updating the managed installation.
63+
64+
Git is not required on an operator computer. After a commit is pushed to
65+
GitHub, use **Code → Download ZIP**, extract it, open a terminal in the
66+
extracted directory, and run the same `pixi install` and
67+
`pixi run cluster-control` commands. To update that kind of installation,
68+
download and extract a fresh ZIP.
69+
70+
## Python development installation
71+
72+
The optional application dependencies do not affect existing users of the
73+
Python API or the `maze` CLI:
74+
75+
```sh
76+
python -m pip install 'hex-maze-interface[cluster-control]'
77+
cluster-control
78+
```
79+
80+
The program starts an HTTP server only on `127.0.0.1` and opens a
81+
per-launch, cookie-protected local URL. It never listens on the Ethernet maze
82+
network, and it does not require an internet connection.
83+
84+
## Native release artifacts
85+
86+
The repository workflow `.github/workflows/build-cluster-control.yml` creates
87+
native artifacts for Windows, macOS, and Linux. Trigger it from GitHub's
88+
**Actions** tab and download the artifact matching the operator's computer.
89+
90+
- Windows: extract the ZIP and launch `ClusterControl.exe`.
91+
- macOS: extract `ClusterControl.app`, move it to Applications if desired, and
92+
open it. An unsigned build may require Control-click → Open until the
93+
release is signed and notarized.
94+
- Linux: extract the archive, mark `ClusterControl` executable if needed, and
95+
launch it from the desktop or terminal.
96+
97+
For routine lab use, sign the Windows executable and sign/notarize the macOS
98+
application. That removes the operating-system trust warnings; it does not
99+
change the maze-control protocol.
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""Small, safety-oriented control surface for one seven-prism cluster.
2+
3+
This module intentionally uses the public :class:`HexMazeInterface` methods
4+
only. It is independent of any GUI toolkit so both the desktop application
5+
and automated tests use the same validation and command sequence.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import time
11+
from dataclasses import dataclass, field, replace
12+
from typing import Any
13+
14+
from .hex_maze_interface import (
15+
ControllerParameters,
16+
HexMazeInterface,
17+
HomeOutcome,
18+
HomeParameters,
19+
MazeException,
20+
)
21+
22+
23+
@dataclass(frozen=True, slots=True)
24+
class ClusterControlSettings:
25+
"""Limits and fixed homing parameters for the operator application.
26+
27+
The maximum velocity range is deliberately more conservative than the
28+
protocol's unsigned-byte encoding. Change these settings in code only
29+
after the corresponding values have been validated on the physical rig.
30+
"""
31+
32+
minimum_position_mm: int = 0
33+
maximum_position_mm: int = 550
34+
minimum_max_velocity_mm_s: int = 1
35+
maximum_max_velocity_mm_s: int = 40
36+
home_parameters: HomeParameters = field(
37+
default_factory=lambda: HomeParameters(
38+
travel_limit=250,
39+
max_velocity=20,
40+
run_current=50,
41+
stall_threshold=10,
42+
)
43+
)
44+
home_timeout_s: float = 30.0
45+
home_poll_interval_s: float = 0.25
46+
47+
def __post_init__(self) -> None:
48+
if self.minimum_position_mm > self.maximum_position_mm:
49+
raise ValueError("minimum_position_mm must not exceed maximum_position_mm")
50+
if self.minimum_max_velocity_mm_s > self.maximum_max_velocity_mm_s:
51+
raise ValueError("minimum_max_velocity_mm_s must not exceed maximum_max_velocity_mm_s")
52+
if self.home_timeout_s <= 0:
53+
raise ValueError("home_timeout_s must be positive")
54+
if self.home_poll_interval_s <= 0:
55+
raise ValueError("home_poll_interval_s must be positive")
56+
57+
58+
DEFAULT_CLUSTER_CONTROL_SETTINGS = ClusterControlSettings()
59+
60+
61+
@dataclass(frozen=True, slots=True)
62+
class ClusterState:
63+
"""The status shown by the single-cluster operator application."""
64+
65+
positions_mm: tuple[int, ...]
66+
homed: tuple[bool, ...]
67+
home_outcomes: tuple[HomeOutcome, ...]
68+
controller_parameters: ControllerParameters
69+
70+
71+
class ClusterControl:
72+
"""Validate and execute the limited operator workflow for one cluster."""
73+
74+
def __init__(
75+
self,
76+
hmi: HexMazeInterface,
77+
cluster_address: int,
78+
*,
79+
settings: ClusterControlSettings = DEFAULT_CLUSTER_CONTROL_SETTINGS,
80+
sleep_fn: Any = time.sleep,
81+
monotonic_fn: Any = time.monotonic,
82+
) -> None:
83+
HexMazeInterface._validate_cluster_address(cluster_address)
84+
self._hmi = hmi
85+
self.cluster_address = cluster_address
86+
self.settings = settings
87+
self._sleep_fn = sleep_fn
88+
self._monotonic_fn = monotonic_fn
89+
90+
def connect(self) -> ClusterState:
91+
"""Confirm that the selected cluster is reachable and read its state."""
92+
if not self._hmi.communicating_cluster(self.cluster_address):
93+
raise MazeException(f"cluster {self.cluster_address} did not respond over Ethernet")
94+
return self.read_state()
95+
96+
def read_state(self) -> ClusterState:
97+
"""Read all status that the GUI presents to the operator."""
98+
positions_mm = tuple(self._hmi.read_positions_cluster(self.cluster_address))
99+
homed = tuple(bool(value) for value in self._hmi.homed_cluster(self.cluster_address))
100+
home_outcomes = tuple(self._hmi.read_home_outcomes_cluster(self.cluster_address))
101+
controller_parameters = self._hmi.read_controller_parameters_cluster(self.cluster_address)
102+
return ClusterState(
103+
positions_mm=positions_mm,
104+
homed=homed,
105+
home_outcomes=home_outcomes,
106+
controller_parameters=controller_parameters,
107+
)
108+
109+
@staticmethod
110+
def home_succeeded(state: ClusterState) -> bool:
111+
"""Return whether every prism reports a successful home outcome.
112+
113+
Firmware can transiently report a false ``homed`` flag after a stall
114+
home even though its terminal outcome is successful. The terminal
115+
outcomes are therefore the authoritative signal for enabling motion.
116+
"""
117+
return all(
118+
outcome in (HomeOutcome.STALL, HomeOutcome.TARGET_REACHED)
119+
for outcome in state.home_outcomes
120+
)
121+
122+
def set_max_velocity(self, max_velocity_mm_s: int) -> ClusterState:
123+
"""Set the shared cluster maximum velocity and verify its readback."""
124+
self._validate_max_velocity(max_velocity_mm_s)
125+
current = self._hmi.read_controller_parameters_cluster(self.cluster_address)
126+
requested = replace(current, max_velocity=max_velocity_mm_s)
127+
if not self._hmi.write_controller_parameters_cluster(self.cluster_address, requested):
128+
raise MazeException("controller-parameter write failed")
129+
130+
actual = self._hmi.read_controller_parameters_cluster(self.cluster_address)
131+
if actual.max_velocity != max_velocity_mm_s:
132+
raise MazeException(
133+
"controller maximum velocity was not accepted: "
134+
f"requested {max_velocity_mm_s}, read back {actual.max_velocity}"
135+
)
136+
return self.read_state()
137+
138+
def home_all(self) -> ClusterState:
139+
"""Start homing all prisms and wait for a terminal outcome."""
140+
parameters = HomeParameters(*self.settings.home_parameters.to_tuple())
141+
if not self._hmi.home_cluster(self.cluster_address, parameters):
142+
raise MazeException("home command was not accepted")
143+
144+
deadline = self._monotonic_fn() + self.settings.home_timeout_s
145+
last_state = self.read_state()
146+
while True:
147+
if self.home_succeeded(last_state):
148+
return last_state
149+
if any(outcome == HomeOutcome.FAILED for outcome in last_state.home_outcomes):
150+
raise MazeException(self._home_error_message(last_state))
151+
if self._monotonic_fn() >= deadline:
152+
raise MazeException(
153+
"homing did not complete before the "
154+
f"{self.settings.home_timeout_s:g} s timeout; "
155+
f"last outcomes: {self._home_outcomes_text(last_state)}"
156+
)
157+
self._sleep_fn(self.settings.home_poll_interval_s)
158+
last_state = self.read_state()
159+
160+
def move_all(self, positions_mm: Any) -> None:
161+
"""Send all seven target positions after validating the operator input."""
162+
positions = self._validate_positions(positions_mm)
163+
if not self._hmi.write_targets_cluster(self.cluster_address, positions):
164+
raise MazeException("target-position write failed")
165+
166+
def pause(self) -> None:
167+
"""Immediately pause every prism in the selected cluster."""
168+
if not self._hmi.pause_cluster(self.cluster_address):
169+
raise MazeException("pause command was not accepted")
170+
171+
def power_off(self) -> None:
172+
"""Turn off the selected cluster's prism power."""
173+
if not self._hmi.power_off_cluster(self.cluster_address):
174+
raise MazeException("power-off command was not accepted")
175+
176+
def _validate_max_velocity(self, value: int) -> None:
177+
if not isinstance(value, int):
178+
raise MazeException("maximum velocity must be an integer")
179+
if not (
180+
self.settings.minimum_max_velocity_mm_s
181+
<= value
182+
<= self.settings.maximum_max_velocity_mm_s
183+
):
184+
raise MazeException(
185+
"maximum velocity must be between "
186+
f"{self.settings.minimum_max_velocity_mm_s} and "
187+
f"{self.settings.maximum_max_velocity_mm_s} mm/s"
188+
)
189+
190+
def _validate_positions(self, positions_mm: Any) -> tuple[int, ...]:
191+
try:
192+
positions = tuple(positions_mm)
193+
except TypeError as exc:
194+
raise MazeException("target positions must be iterable") from exc
195+
if len(positions) != HexMazeInterface.PRISM_COUNT:
196+
raise MazeException(
197+
f"exactly {HexMazeInterface.PRISM_COUNT} target positions are required"
198+
)
199+
for prism_index, position_mm in enumerate(positions, start=1):
200+
if not isinstance(position_mm, int):
201+
raise MazeException(f"prism {prism_index} target position must be an integer")
202+
if not (
203+
self.settings.minimum_position_mm
204+
<= position_mm
205+
<= self.settings.maximum_position_mm
206+
):
207+
raise MazeException(
208+
f"prism {prism_index} target must be between "
209+
f"{self.settings.minimum_position_mm} and "
210+
f"{self.settings.maximum_position_mm} mm"
211+
)
212+
return positions
213+
214+
@staticmethod
215+
def _home_outcomes_text(state: ClusterState) -> str:
216+
return ", ".join(outcome.name.lower() for outcome in state.home_outcomes)
217+
218+
def _home_error_message(self, state: ClusterState) -> str:
219+
return f"one or more prisms failed to home: {self._home_outcomes_text(state)}"

0 commit comments

Comments
 (0)