Add configurable step size selector and motor velocity controls
F2 Control screen Manual mode now has a row of step-size buttons (0.1°, 0.5°, 1°, 5°, 10°) that scale arrow key nudges, plus AZ/EL velocity inputs with Apply button for runtime motor speed tuning via firmware mv/ma commands. Bridge: set_max_velocity(), set_max_acceleration() write methods. Demo: mutable dynamics + console mv/ma write handling. Tests: 8 Textual pilot tests covering button toggle, nudge math, velocity population, and apply round-trip.
This commit is contained in:
parent
5252d1d73c
commit
ce24f7c478
@ -315,6 +315,21 @@ class SerialBridge:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def set_max_velocity(self, motor_id: int, deg_per_sec: float) -> None:
|
||||||
|
"""Set max velocity for a motor axis (°/s). Firmware: MOT> mv [motor] [vel]."""
|
||||||
|
with self._lock:
|
||||||
|
self._ensure_menu(Menu.MOT)
|
||||||
|
self._send(f"mv {motor_id} {deg_per_sec:.1f}")
|
||||||
|
|
||||||
|
def set_max_acceleration(self, motor_id: int, accel: float) -> None:
|
||||||
|
"""Set max acceleration for a motor axis.
|
||||||
|
|
||||||
|
Firmware: MOT> ma [motor] [accel] (°/s²).
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._ensure_menu(Menu.MOT)
|
||||||
|
self._send(f"ma {motor_id} {accel:.1f}")
|
||||||
|
|
||||||
def get_motor_life(self) -> str:
|
def get_motor_life(self) -> str:
|
||||||
"""Read motor lifetime / usage statistics."""
|
"""Read motor lifetime / usage statistics."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
@ -206,6 +206,12 @@ class DemoDevice:
|
|||||||
self._target_el = 45.0
|
self._target_el = 45.0
|
||||||
self._last_move_time = time.monotonic()
|
self._last_move_time = time.monotonic()
|
||||||
|
|
||||||
|
# Motor dynamics (mutable in demo for velocity/accel controls).
|
||||||
|
self._az_max_vel = 65.0
|
||||||
|
self._el_max_vel = 45.0
|
||||||
|
self._az_accel = 400.0
|
||||||
|
self._el_accel = 400.0
|
||||||
|
|
||||||
# Submenu tracking for console simulation.
|
# Submenu tracking for console simulation.
|
||||||
self._menu = _DemoMenu.ROOT
|
self._menu = _DemoMenu.ROOT
|
||||||
|
|
||||||
@ -320,12 +326,24 @@ class DemoDevice:
|
|||||||
|
|
||||||
def get_motor_dynamics(self) -> dict[str, float]:
|
def get_motor_dynamics(self) -> dict[str, float]:
|
||||||
return {
|
return {
|
||||||
"az_max_vel": 65.0,
|
"az_max_vel": self._az_max_vel,
|
||||||
"el_max_vel": 45.0,
|
"el_max_vel": self._el_max_vel,
|
||||||
"az_accel": 400.0,
|
"az_accel": self._az_accel,
|
||||||
"el_accel": 400.0,
|
"el_accel": self._el_accel,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def set_max_velocity(self, motor_id: int, deg_per_sec: float) -> None:
|
||||||
|
if motor_id == 0:
|
||||||
|
self._az_max_vel = deg_per_sec
|
||||||
|
elif motor_id == 1:
|
||||||
|
self._el_max_vel = deg_per_sec
|
||||||
|
|
||||||
|
def set_max_acceleration(self, motor_id: int, accel: float) -> None:
|
||||||
|
if motor_id == 0:
|
||||||
|
self._az_accel = accel
|
||||||
|
elif motor_id == 1:
|
||||||
|
self._el_accel = accel
|
||||||
|
|
||||||
def get_motor_life(self) -> str:
|
def get_motor_life(self) -> str:
|
||||||
return _MOTOR_LIFE
|
return _MOTOR_LIFE
|
||||||
|
|
||||||
@ -574,10 +592,24 @@ class DemoDevice:
|
|||||||
self.home_motor(motor_id)
|
self.home_motor(motor_id)
|
||||||
return f"Homing motor {motor_id}\nMOT>"
|
return f"Homing motor {motor_id}\nMOT>"
|
||||||
return "Invalid parameters\nMOT>"
|
return "Invalid parameters\nMOT>"
|
||||||
if cmd == "mv":
|
if cmd == "mv" or cmd.startswith("mv "):
|
||||||
return "Max Vel [0] = 65.0 Max Vel [1] = 45.0\nMOT>"
|
parts = cmd.split()
|
||||||
if cmd == "ma":
|
if len(parts) >= 3:
|
||||||
return "Accel[0] = 400.0 Accel[1] = 400.0\nMOT>"
|
self.set_max_velocity(int(parts[1]), float(parts[2]))
|
||||||
|
return f"Max Vel [{parts[1]}] = {float(parts[2]):.1f}\nMOT>"
|
||||||
|
return (
|
||||||
|
f"Max Vel [0] = {self._az_max_vel:.1f}"
|
||||||
|
f" Max Vel [1] = {self._el_max_vel:.1f}\nMOT>"
|
||||||
|
)
|
||||||
|
if cmd == "ma" or cmd.startswith("ma "):
|
||||||
|
parts = cmd.split()
|
||||||
|
if len(parts) >= 3:
|
||||||
|
self.set_max_acceleration(int(parts[1]), float(parts[2]))
|
||||||
|
return f"Accel[{parts[1]}] = {float(parts[2]):.1f}\nMOT>"
|
||||||
|
return (
|
||||||
|
f"Accel[0] = {self._az_accel:.1f}"
|
||||||
|
f" Accel[1] = {self._el_accel:.1f}\nMOT>"
|
||||||
|
)
|
||||||
if cmd == "p":
|
if cmd == "p":
|
||||||
self._update_position()
|
self._update_position()
|
||||||
az_steps = int(self._az * 40000 / 360)
|
az_steps = int(self._az * 40000 / 360)
|
||||||
|
|||||||
@ -40,6 +40,9 @@ class ControlScreen(Container):
|
|||||||
Binding("r", "release_motors", "Release", show=False),
|
Binding("r", "release_motors", "Release", show=False),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Available step sizes for the nudge selector (degrees).
|
||||||
|
STEP_SIZES: list[float] = [0.1, 0.5, 1.0, 5.0, 10.0]
|
||||||
|
|
||||||
def __init__(self, **kwargs) -> None:
|
def __init__(self, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._device: object = None
|
self._device: object = None
|
||||||
@ -48,6 +51,7 @@ class ControlScreen(Container):
|
|||||||
self._poll_worker: Worker | None = None
|
self._poll_worker: Worker | None = None
|
||||||
self._last_az = 180.0
|
self._last_az = 180.0
|
||||||
self._last_el = 45.0
|
self._last_el = 45.0
|
||||||
|
self._step_size: float = 1.0
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Compose
|
# Compose
|
||||||
@ -99,6 +103,27 @@ class ControlScreen(Container):
|
|||||||
type="number",
|
type="number",
|
||||||
)
|
)
|
||||||
yield Button("Move", id="btn-ctrl-move", variant="primary")
|
yield Button("Move", id="btn-ctrl-move", variant="primary")
|
||||||
|
with Horizontal(classes="velocity-controls"):
|
||||||
|
yield Static("AZ Vel ", classes="label")
|
||||||
|
yield Input(
|
||||||
|
placeholder="65.0",
|
||||||
|
id="ctrl-az-vel",
|
||||||
|
type="number",
|
||||||
|
)
|
||||||
|
yield Static(" EL Vel ", classes="label")
|
||||||
|
yield Input(
|
||||||
|
placeholder="45.0",
|
||||||
|
id="ctrl-el-vel",
|
||||||
|
type="number",
|
||||||
|
)
|
||||||
|
yield Button("Apply", id="btn-ctrl-apply-vel")
|
||||||
|
with Horizontal(classes="step-size-bar"):
|
||||||
|
yield Static("Step ", classes="label")
|
||||||
|
for size in self.STEP_SIZES:
|
||||||
|
label = f"{size}°" if size >= 1 else f"{size:.1f}°"
|
||||||
|
btn_id = f"btn-step-{str(size).replace('.', '_')}"
|
||||||
|
classes = "step-btn selected" if size == 1.0 else "step-btn"
|
||||||
|
yield Button(label, id=btn_id, classes=classes)
|
||||||
with Horizontal(classes="bottom-controls"):
|
with Horizontal(classes="bottom-controls"):
|
||||||
yield Button("Home AZ", id="btn-ctrl-home-az")
|
yield Button("Home AZ", id="btn-ctrl-home-az")
|
||||||
yield Button("Home EL", id="btn-ctrl-home-el")
|
yield Button("Home EL", id="btn-ctrl-home-el")
|
||||||
@ -124,6 +149,7 @@ class ControlScreen(Container):
|
|||||||
"""Store the device reference and start the data poll."""
|
"""Store the device reference and start the data poll."""
|
||||||
self._device = device
|
self._device = device
|
||||||
self._start_data_poll()
|
self._start_data_poll()
|
||||||
|
self._load_motor_dynamics()
|
||||||
|
|
||||||
def on_show(self) -> None:
|
def on_show(self) -> None:
|
||||||
"""Resume polling when this screen becomes visible."""
|
"""Resume polling when this screen becomes visible."""
|
||||||
@ -195,6 +221,28 @@ class ControlScreen(Container):
|
|||||||
self._polling = True
|
self._polling = True
|
||||||
self._poll_worker = self._do_data_poll()
|
self._poll_worker = self._do_data_poll()
|
||||||
|
|
||||||
|
@work(thread=True, exclusive=True, group="load-dynamics")
|
||||||
|
def _load_motor_dynamics(self) -> None:
|
||||||
|
"""Fetch current velocity values and populate the input fields."""
|
||||||
|
if self._device is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
dynamics = self._device.get_motor_dynamics()
|
||||||
|
self.app.call_from_thread(
|
||||||
|
self._populate_velocity_inputs,
|
||||||
|
dynamics["az_max_vel"],
|
||||||
|
dynamics["el_max_vel"],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.debug("Failed to load motor dynamics", exc_info=True)
|
||||||
|
|
||||||
|
def _populate_velocity_inputs(self, az_vel: float, el_vel: float) -> None:
|
||||||
|
try:
|
||||||
|
self.query_one("#ctrl-az-vel", Input).value = f"{az_vel:.1f}"
|
||||||
|
self.query_one("#ctrl-el-vel", Input).value = f"{el_vel:.1f}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
@work(thread=True, exclusive=True, group="control-data-poll")
|
@work(thread=True, exclusive=True, group="control-data-poll")
|
||||||
def _do_data_poll(self) -> None:
|
def _do_data_poll(self) -> None:
|
||||||
"""Poll step positions and torque at ~2 Hz while active."""
|
"""Poll step positions and torque at ~2 Hz while active."""
|
||||||
@ -268,6 +316,10 @@ class ControlScreen(Container):
|
|||||||
self._handle_home(1)
|
self._handle_home(1)
|
||||||
elif button_id == "btn-ctrl-engage":
|
elif button_id == "btn-ctrl-engage":
|
||||||
self._handle_engage_toggle()
|
self._handle_engage_toggle()
|
||||||
|
elif button_id.startswith("btn-step-"):
|
||||||
|
self._handle_step_size(event.button)
|
||||||
|
elif button_id == "btn-ctrl-apply-vel":
|
||||||
|
self._handle_apply_velocity()
|
||||||
|
|
||||||
def _handle_move(self) -> None:
|
def _handle_move(self) -> None:
|
||||||
"""Read AZ/EL inputs and issue a move command."""
|
"""Read AZ/EL inputs and issue a move command."""
|
||||||
@ -319,6 +371,54 @@ class ControlScreen(Container):
|
|||||||
self._update_engaged(True)
|
self._update_engaged(True)
|
||||||
self.app.notify("Motors engaged")
|
self.app.notify("Motors engaged")
|
||||||
|
|
||||||
|
def _handle_step_size(self, pressed_btn: Button) -> None:
|
||||||
|
"""Update step size and highlight the selected button."""
|
||||||
|
# Parse the step size from the button ID: "btn-step-1_0" → 1.0
|
||||||
|
raw = (pressed_btn.id or "").replace("btn-step-", "").replace("_", ".")
|
||||||
|
try:
|
||||||
|
self._step_size = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update visual selection on all step buttons.
|
||||||
|
for btn in self.query(".step-btn"):
|
||||||
|
btn.remove_class("selected")
|
||||||
|
pressed_btn.add_class("selected")
|
||||||
|
|
||||||
|
def _handle_apply_velocity(self) -> None:
|
||||||
|
"""Apply velocity settings from the input fields."""
|
||||||
|
if self._device is None:
|
||||||
|
self.app.notify("No device connected", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
az_vel_input = self.query_one("#ctrl-az-vel", Input)
|
||||||
|
el_vel_input = self.query_one("#ctrl-el-vel", Input)
|
||||||
|
|
||||||
|
az_val = az_vel_input.value.strip()
|
||||||
|
el_val = el_vel_input.value.strip()
|
||||||
|
|
||||||
|
if not az_val and not el_val:
|
||||||
|
self.app.notify("Enter a velocity value", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
if az_val:
|
||||||
|
try:
|
||||||
|
az_vel = float(az_val)
|
||||||
|
self._run_motor_command(self._device.set_max_velocity, 0, az_vel)
|
||||||
|
except ValueError:
|
||||||
|
self.app.notify("Invalid AZ velocity", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
if el_val:
|
||||||
|
try:
|
||||||
|
el_vel = float(el_val)
|
||||||
|
self._run_motor_command(self._device.set_max_velocity, 1, el_vel)
|
||||||
|
except ValueError:
|
||||||
|
self.app.notify("Invalid EL velocity", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.app.notify("Velocity updated", severity="information")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Preset handlers
|
# Preset handlers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -396,17 +496,17 @@ class ControlScreen(Container):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def action_nudge_az(self, delta: int) -> None:
|
def action_nudge_az(self, delta: int) -> None:
|
||||||
"""Nudge azimuth by delta degrees."""
|
"""Nudge azimuth by delta * step_size degrees."""
|
||||||
if not self._is_manual_active() or self._device is None:
|
if not self._is_manual_active() or self._device is None:
|
||||||
return
|
return
|
||||||
new_az = self._last_az + delta
|
new_az = self._last_az + delta * self._step_size
|
||||||
self._run_motor_command(self._device.move_motor, 0, new_az)
|
self._run_motor_command(self._device.move_motor, 0, new_az)
|
||||||
|
|
||||||
def action_nudge_el(self, delta: int) -> None:
|
def action_nudge_el(self, delta: int) -> None:
|
||||||
"""Nudge elevation by delta degrees."""
|
"""Nudge elevation by delta * step_size degrees."""
|
||||||
if not self._is_manual_active() or self._device is None:
|
if not self._is_manual_active() or self._device is None:
|
||||||
return
|
return
|
||||||
new_el = self._last_el + delta
|
new_el = self._last_el + delta * self._step_size
|
||||||
self._run_motor_command(self._device.move_motor, 1, new_el)
|
self._run_motor_command(self._device.move_motor, 1, new_el)
|
||||||
|
|
||||||
def action_home_both(self) -> None:
|
def action_home_both(self) -> None:
|
||||||
|
|||||||
@ -644,6 +644,70 @@ ProgressBar PercentageStatus {
|
|||||||
text-style: bold;
|
text-style: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Step Size Selector ───────────────────────────── */
|
||||||
|
|
||||||
|
.step-size-bar {
|
||||||
|
layout: horizontal;
|
||||||
|
height: 3;
|
||||||
|
padding: 0 1;
|
||||||
|
dock: bottom;
|
||||||
|
background: #0e1420;
|
||||||
|
border-top: solid #1a2a38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-size-bar .label {
|
||||||
|
width: auto;
|
||||||
|
padding: 1 1 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-btn {
|
||||||
|
min-width: 8;
|
||||||
|
height: 3;
|
||||||
|
margin: 0 0 0 0;
|
||||||
|
background: #121c2a;
|
||||||
|
color: #7090a8;
|
||||||
|
border: round #1a3050;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-btn:hover {
|
||||||
|
background: #1a2a40;
|
||||||
|
color: #00d4aa;
|
||||||
|
border: round #00d4aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-btn.selected {
|
||||||
|
background: #0a3a3a;
|
||||||
|
color: #00d4aa;
|
||||||
|
border: round #00d4aa;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Velocity Controls ───────────────────────────── */
|
||||||
|
|
||||||
|
.velocity-controls {
|
||||||
|
layout: horizontal;
|
||||||
|
height: 3;
|
||||||
|
padding: 0 1;
|
||||||
|
dock: bottom;
|
||||||
|
background: #0e1420;
|
||||||
|
border-top: solid #1a2a38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velocity-controls .label {
|
||||||
|
width: auto;
|
||||||
|
padding: 1 1 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velocity-controls Input {
|
||||||
|
width: 10;
|
||||||
|
margin-right: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velocity-controls Button {
|
||||||
|
margin-left: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Scrollbar Styling ─────────────────────────────── */
|
/* ── Scrollbar Styling ─────────────────────────────── */
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
283
tui/tests/test_step_size_velocity.py
Normal file
283
tui/tests/test_step_size_velocity.py
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
"""Test step size selector and motor velocity controls on the F2 Control screen.
|
||||||
|
|
||||||
|
Uses Textual's run_test() and pilot to drive the TUI in demo mode.
|
||||||
|
Verifies step size button toggling, nudge multiplication, velocity
|
||||||
|
input population, and velocity apply round-trip through DemoDevice.
|
||||||
|
|
||||||
|
NOTE: pilot.click() on dock:bottom widgets can miss due to headless
|
||||||
|
coordinate mapping. We use post_message(Button.Pressed(...)) for reliable
|
||||||
|
button activation in CI, and pilot.press() for keyboard interaction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from textual.widgets import Button, Input
|
||||||
|
|
||||||
|
from birdcage_tui.app import BirdcageApp
|
||||||
|
from birdcage_tui.screens.control import ControlScreen
|
||||||
|
|
||||||
|
|
||||||
|
def _press_button(btn: Button) -> None:
|
||||||
|
"""Programmatically press a Button by posting its Pressed message.
|
||||||
|
|
||||||
|
Bypasses pilot.click() coordinate issues with docked widgets.
|
||||||
|
"""
|
||||||
|
btn.post_message(Button.Pressed(btn))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_step_size_default_is_one():
|
||||||
|
"""Default step size should be 1.0 degree."""
|
||||||
|
app = BirdcageApp()
|
||||||
|
app.demo_mode = True
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("f2")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
control = app.query_one("#control", ControlScreen)
|
||||||
|
assert control._step_size == 1.0
|
||||||
|
|
||||||
|
# The 1° button should have the "selected" class.
|
||||||
|
btn_1 = app.query_one("#btn-step-1_0", Button)
|
||||||
|
assert "selected" in btn_1.classes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_step_size_button_switches():
|
||||||
|
"""Pressing a step-size button should update _step_size and highlight."""
|
||||||
|
app = BirdcageApp()
|
||||||
|
app.demo_mode = True
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("f2")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
control = app.query_one("#control", ControlScreen)
|
||||||
|
|
||||||
|
# Press the 0.1° button.
|
||||||
|
btn_01 = app.query_one("#btn-step-0_1", Button)
|
||||||
|
_press_button(btn_01)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert control._step_size == 0.1
|
||||||
|
assert "selected" in btn_01.classes
|
||||||
|
|
||||||
|
# The old 1° button should no longer be selected.
|
||||||
|
btn_1 = app.query_one("#btn-step-1_0", Button)
|
||||||
|
assert "selected" not in btn_1.classes
|
||||||
|
|
||||||
|
# Press the 10° button.
|
||||||
|
btn_10 = app.query_one("#btn-step-10_0", Button)
|
||||||
|
_press_button(btn_10)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert control._step_size == 10.0
|
||||||
|
assert "selected" in btn_10.classes
|
||||||
|
assert "selected" not in btn_01.classes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_step_size_cycles_all():
|
||||||
|
"""Pressing each step button in sequence should set the correct value."""
|
||||||
|
app = BirdcageApp()
|
||||||
|
app.demo_mode = True
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("f2")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
control = app.query_one("#control", ControlScreen)
|
||||||
|
expected = [
|
||||||
|
("btn-step-0_1", 0.1),
|
||||||
|
("btn-step-0_5", 0.5),
|
||||||
|
("btn-step-1_0", 1.0),
|
||||||
|
("btn-step-5_0", 5.0),
|
||||||
|
("btn-step-10_0", 10.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
for btn_id, expected_size in expected:
|
||||||
|
btn = app.query_one(f"#{btn_id}", Button)
|
||||||
|
_press_button(btn)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert control._step_size == expected_size, (
|
||||||
|
f"After pressing #{btn_id}, step_size should be "
|
||||||
|
f"{expected_size}, got {control._step_size}"
|
||||||
|
)
|
||||||
|
assert "selected" in btn.classes
|
||||||
|
|
||||||
|
# All other buttons should NOT be selected.
|
||||||
|
for other_id, _ in expected:
|
||||||
|
if other_id != btn_id:
|
||||||
|
other = app.query_one(f"#{other_id}", Button)
|
||||||
|
assert "selected" not in other.classes, (
|
||||||
|
f"#{other_id} should not be selected when #{btn_id} is active"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nudge_uses_step_size():
|
||||||
|
"""Arrow key nudge should multiply delta by the selected step size."""
|
||||||
|
app = BirdcageApp()
|
||||||
|
app.demo_mode = True
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("f2")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
control = app.query_one("#control", ControlScreen)
|
||||||
|
|
||||||
|
# Wait for device to be set and position poll to run.
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
# Record starting position.
|
||||||
|
start_az = control._last_az
|
||||||
|
|
||||||
|
# Set step size to 5 degrees.
|
||||||
|
btn_5 = app.query_one("#btn-step-5_0", Button)
|
||||||
|
_press_button(btn_5)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert control._step_size == 5.0
|
||||||
|
|
||||||
|
# Focus the control screen for key bindings.
|
||||||
|
control.focus()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Press right arrow to nudge AZ +1 * 5.0 = +5.0.
|
||||||
|
await pilot.press("right")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# The demo device target should have moved by ~5 degrees.
|
||||||
|
target_az = app.device._target_az
|
||||||
|
expected = start_az + 5.0
|
||||||
|
assert abs(target_az - expected) < 0.1, (
|
||||||
|
f"Expected target AZ ~{expected}, got {target_az}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nudge_fine_step():
|
||||||
|
"""0.1 deg step size should produce sub-degree nudges."""
|
||||||
|
app = BirdcageApp()
|
||||||
|
app.demo_mode = True
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("f2")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
control = app.query_one("#control", ControlScreen)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
start_el = control._last_el
|
||||||
|
|
||||||
|
# Select 0.1° step size.
|
||||||
|
btn_01 = app.query_one("#btn-step-0_1", Button)
|
||||||
|
_press_button(btn_01)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
control.focus()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Press up arrow to nudge EL +1 * 0.1 = +0.1.
|
||||||
|
await pilot.press("up")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
target_el = app.device._target_el
|
||||||
|
expected = start_el + 0.1
|
||||||
|
assert abs(target_el - expected) < 0.05, (
|
||||||
|
f"Expected target EL ~{expected:.1f}, got {target_el:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_velocity_inputs_populated():
|
||||||
|
"""Velocity inputs should auto-populate from device motor dynamics."""
|
||||||
|
app = BirdcageApp()
|
||||||
|
app.demo_mode = True
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("f2")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Wait for the _load_motor_dynamics worker to complete.
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
az_vel_input = app.query_one("#ctrl-az-vel", Input)
|
||||||
|
el_vel_input = app.query_one("#ctrl-el-vel", Input)
|
||||||
|
|
||||||
|
assert az_vel_input.value == "65.0", (
|
||||||
|
f"AZ velocity should be '65.0', got {az_vel_input.value!r}"
|
||||||
|
)
|
||||||
|
assert el_vel_input.value == "45.0", (
|
||||||
|
f"EL velocity should be '45.0', got {el_vel_input.value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_velocity_apply():
|
||||||
|
"""Clicking Apply should update the demo device motor velocities."""
|
||||||
|
app = BirdcageApp()
|
||||||
|
app.demo_mode = True
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("f2")
|
||||||
|
await pilot.pause()
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
# Set new velocity values directly on the Input widgets.
|
||||||
|
az_vel_input = app.query_one("#ctrl-az-vel", Input)
|
||||||
|
el_vel_input = app.query_one("#ctrl-el-vel", Input)
|
||||||
|
az_vel_input.value = "55.0"
|
||||||
|
el_vel_input.value = "30.0"
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Press Apply via message posting.
|
||||||
|
apply_btn = app.query_one("#btn-ctrl-apply-vel", Button)
|
||||||
|
_press_button(apply_btn)
|
||||||
|
|
||||||
|
# Wait for the worker thread to run the commands.
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
# Verify the demo device stored the new velocities.
|
||||||
|
dynamics = app.device.get_motor_dynamics()
|
||||||
|
assert dynamics["az_max_vel"] == 55.0, (
|
||||||
|
f"AZ velocity should be 55.0, got {dynamics['az_max_vel']}"
|
||||||
|
)
|
||||||
|
assert dynamics["el_max_vel"] == 30.0, (
|
||||||
|
f"EL velocity should be 30.0, got {dynamics['el_max_vel']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_step_buttons_exist():
|
||||||
|
"""All five step size buttons should render in Manual mode."""
|
||||||
|
app = BirdcageApp()
|
||||||
|
app.demo_mode = True
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("f2")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
expected_ids = [
|
||||||
|
"btn-step-0_1",
|
||||||
|
"btn-step-0_5",
|
||||||
|
"btn-step-1_0",
|
||||||
|
"btn-step-5_0",
|
||||||
|
"btn-step-10_0",
|
||||||
|
]
|
||||||
|
for btn_id in expected_ids:
|
||||||
|
btn = app.query_one(f"#{btn_id}", Button)
|
||||||
|
assert btn is not None, f"Button #{btn_id} should exist"
|
||||||
|
|
||||||
|
app.save_screenshot("/tmp/birdcage_step_size_test.svg")
|
||||||
Loading…
x
Reference in New Issue
Block a user