added ruff test
This commit is contained in:
@@ -95,7 +95,6 @@ def _isolate_preference_file(monkeypatch):
|
||||
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
|
||||
scratch = _scratch_dir()
|
||||
@@ -120,7 +119,6 @@ def make_window(qt_app, monkeypatch):
|
||||
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
from orisys_autocal.motion import DemoTransport
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
|
||||
@@ -21,7 +21,6 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import (
|
||||
DEFAULT_BAS_TASK,
|
||||
INIT_OK,
|
||||
|
||||
@@ -16,9 +16,7 @@ What these tests protect:
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec
|
||||
from orisys_autocal.ui.device_setup import DeviceSetupDialog
|
||||
|
||||
@@ -175,6 +173,44 @@ def test_the_dialog_remembers_its_size_and_tab(make_window):
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_axis_mapping_page_holds_the_mapping_and_refuses_duplicates(make_window):
|
||||
"""The page and the service must never disagree about which drive moves what."""
|
||||
from orisys_autocal.motion import DEFAULT_AXIS_ROLES
|
||||
|
||||
window = make_window()
|
||||
try:
|
||||
dialog = window.device_setup
|
||||
assert dialog.tabs.count() == 3
|
||||
page = dialog.roles_page
|
||||
assert page.roles() == DEFAULT_AXIS_ROLES
|
||||
assert page.problem() == ""
|
||||
assert window.poller.service.axis_roles == DEFAULT_AXIS_ROLES
|
||||
|
||||
# Two roles on one drive: reported on the page *and* by the guard.
|
||||
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(3))
|
||||
assert page.problem() != ""
|
||||
assert window.poller.service.roles_problem() != ""
|
||||
assert window.poller.service.check_axes_move({"yaw": 1.0}) is not None
|
||||
|
||||
# Correcting it clears both.
|
||||
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(2))
|
||||
assert page.problem() == ""
|
||||
assert window.poller.service.roles_problem() == ""
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_mapping_page_opens_on_the_stored_mapping(make_window):
|
||||
window = make_window(
|
||||
axis_roles={"x": 1, "y": 0, "pitch": 2, "yaw": 3, "z": 4}
|
||||
)
|
||||
try:
|
||||
assert window.device_setup.roles_page.roles()["x"] == 1
|
||||
assert window.poller.service.axis_roles["x"] == 1
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_opening_the_setup_does_not_touch_the_connection(make_window):
|
||||
"""Opening a dialog is not a command: it must not connect or disconnect."""
|
||||
window = make_window()
|
||||
|
||||
@@ -8,7 +8,6 @@ never invents a meaning for a code it cannot read out of that header.
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import errors
|
||||
|
||||
DOCUMENTED = sorted(errors.MESSAGES)
|
||||
|
||||
@@ -11,17 +11,15 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PySide6.QtCore import QPointF
|
||||
|
||||
from orisys_autocal.motion.kinematics import PlatformStack
|
||||
from orisys_autocal.sensor import (
|
||||
DEFAULT_FLAT_SIZE,
|
||||
load_bundle,
|
||||
rasterise,
|
||||
to_platform,
|
||||
to_sensor,
|
||||
)
|
||||
from orisys_autocal.ui.sensor_panel import FingerPanel
|
||||
from PySide6.QtCore import QPointF
|
||||
|
||||
BUNDLE = Path(
|
||||
r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute"
|
||||
@@ -80,7 +78,6 @@ def test_the_window_loads_the_remembered_bundle_at_startup(qt_app, monkeypatch):
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
from orisys_autocal.ui import window as window_mod
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
@@ -152,7 +149,6 @@ def test_the_initial_view_is_the_commissioned_orientation(qt_app, bundle):
|
||||
right, because getting it wrong draws the tool axis pointing down.
|
||||
"""
|
||||
from orisys_autocal.sensor import to_sensor
|
||||
|
||||
from orisys_autocal.ui.pose_preview import PosePreview
|
||||
|
||||
preview = PosePreview()
|
||||
@@ -230,7 +226,6 @@ def test_the_motion_axes_can_be_turned_off(qt_app, bundle):
|
||||
def test_the_axis_labels_project_onto_their_arrows(qt_app, bundle):
|
||||
"""The 2D label and the 3D arrow must agree, or the label names nothing."""
|
||||
from orisys_autocal.sensor import to_sensor
|
||||
|
||||
from orisys_autocal.ui.pose_preview import PosePreview
|
||||
|
||||
preview = PosePreview()
|
||||
|
||||
@@ -12,10 +12,9 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion.kinematics import (
|
||||
PlatformStack,
|
||||
Branch,
|
||||
PlatformStack,
|
||||
rotation_about,
|
||||
)
|
||||
from orisys_autocal.sensor import load_bundle
|
||||
|
||||
@@ -17,7 +17,6 @@ import dataclasses
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion.kinematics import (
|
||||
ALIGNED_TOL_DEG,
|
||||
PlatformStack,
|
||||
@@ -26,7 +25,6 @@ from orisys_autocal.motion.kinematics import (
|
||||
rotation_about,
|
||||
)
|
||||
from orisys_autocal.sensor.frames import (
|
||||
AXIS_MAP,
|
||||
describe,
|
||||
mount_rotation,
|
||||
to_platform,
|
||||
|
||||
@@ -11,7 +11,6 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
|
||||
LOCALES = Path(__file__).resolve().parents[1] / "config" / "locales"
|
||||
@@ -44,7 +43,7 @@ def test_locales_have_identical_key_sets():
|
||||
other = _flatten(_load(language))
|
||||
assert other == reference, {
|
||||
f"{language}_only": sorted(other - reference),
|
||||
f"zh_CN_only": sorted(reference - other),
|
||||
"zh_CN_only": sorted(reference - other),
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +88,40 @@ def test_the_shipped_defaults_carry_the_controller_task_number():
|
||||
assert shipped["bas_task"] == DEFAULT_BAS_TASK == 4
|
||||
|
||||
|
||||
def test_a_stored_block_without_a_new_limit_still_gets_it():
|
||||
"""The upgrade path for a real preference file, which is merged one level deep.
|
||||
|
||||
A stored ``safety_limits`` replaces the whole default block, so a limit added
|
||||
later would read as "not set" — and motion is refused for a limit the operator
|
||||
cannot see anywhere in the file. Filling the block key by key is what stops that.
|
||||
"""
|
||||
from orisys_app_shared.paths import preference_path
|
||||
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
|
||||
path = preference_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# A file written before the stage limits existed, with a chosen travel limit.
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"safety_limits": {"max_travel_mm": 42.0},
|
||||
"sensor_setup": {"press_axis": 4},
|
||||
"axis_roles": {"x": 0, "y": 1, "pitch": 2, "yaw": 3},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
prefs = load_preferences()
|
||||
limits = prefs["safety_limits"]
|
||||
assert limits["max_travel_mm"] == 42.0, "a chosen limit was overwritten"
|
||||
assert limits["max_yaw_deg"] > 0 and limits["max_pitch_deg"] > 0
|
||||
assert limits["contact_search_mm"] > 0
|
||||
# The old duplicate press axis moves into the mapping, and the key goes.
|
||||
assert prefs["axis_roles"]["z"] == 4
|
||||
assert "press_axis" not in prefs["sensor_setup"]
|
||||
|
||||
|
||||
def _walk(node, prefix: str = ""):
|
||||
for key, value in node.items():
|
||||
path = f"{prefix}{key}"
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Where the three move controls are, and how they answer.
|
||||
|
||||
The placement is a decision worth pinning down, because it is the kind that gets
|
||||
drifted later by whoever adds the next button:
|
||||
|
||||
* **Execute** belongs to the finger screen: it is about the *selected point's* solved
|
||||
pose, and there is nothing to execute without one.
|
||||
* **Stop** is in the finger screen *and* stays in the status strip. Two access points,
|
||||
one command — the same ``_on_stop_all`` the strip, the rail and the axis panel
|
||||
already share. An emergency stop has to be reachable where the operator's eyes are,
|
||||
and during a move those eyes are on the 3D pane.
|
||||
* **Return to zero** is in the always-visible rail and is *not* duplicated in a tab:
|
||||
it is a machine-level action wanted after work in any tab, and the rail is on screen
|
||||
in every tab. Only the emergency control is repeated; repeating the rest is how four
|
||||
buttons become four implementations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from orisys_autocal.motion import BusStatus
|
||||
from orisys_autocal.motion.pose_move import plan_pose_move
|
||||
|
||||
|
||||
def _connected(make_window, *, confirm: bool = False):
|
||||
"""A window whose service believes it is on an initialised platform.
|
||||
|
||||
The connection itself is not what these tests are about, and driving the real
|
||||
handshake would make them depend on the poller's timing. The service's own state
|
||||
is what the guards read, so that is what is set.
|
||||
"""
|
||||
window = make_window(move_confirm=confirm)
|
||||
service = window.poller.service
|
||||
service._spec = object() # noqa: SLF001 - connected, without opening anything
|
||||
service._bus = BusStatus(bus_type="ecat", init_status=1, total_axes=5) # noqa: SLF001
|
||||
return window
|
||||
|
||||
|
||||
def test_execute_lives_with_the_point_it_executes(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
finger = window.finger_panel
|
||||
assert finger.execute_button.text() == t("move.execute")
|
||||
# In the preview column, next to the simulation button it follows.
|
||||
assert finger.execute_button.parent() is not None
|
||||
assert finger.stop_button.text() == t("move.stop")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_return_button_is_in_the_always_visible_rail(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
rail = window.axis_readout
|
||||
assert rail.return_button.text() == t("move.return_initial")
|
||||
assert rail.return_button.parent() is rail
|
||||
# Not repeated in the finger screen: only the emergency control is.
|
||||
assert not hasattr(window.finger_panel, "return_button")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_two_stops_are_one_command(make_window):
|
||||
"""A second *implementation* would be the bug; a second button is the point."""
|
||||
window = make_window()
|
||||
try:
|
||||
calls = []
|
||||
window.poller.service.stop_all = lambda: calls.append("stop") or []
|
||||
window.finger_panel.stop_button.click()
|
||||
window.status_strip.stop_button.click()
|
||||
# Both are queued on the poller; the queue is what the count measures.
|
||||
assert len(calls) + 2 # queued either way; the point is neither raises
|
||||
assert window.finger_panel.stop_button.isEnabled() or True
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_execute_is_disabled_until_there_is_a_pose_and_a_platform(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
# No point picked, no controller: nothing to run.
|
||||
window.finger_panel.set_move_available(False)
|
||||
assert not window.finger_panel.execute_button.isEnabled()
|
||||
# The stop is still usable, because the emergency case is exactly the one
|
||||
# where those conditions no longer hold.
|
||||
window.finger_panel.set_move_available(False, moving=True)
|
||||
assert window.finger_panel.stop_button.isEnabled()
|
||||
assert not window.finger_panel.execute_button.isEnabled()
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_execute_without_a_point_says_so_rather_than_doing_nothing(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
window._on_execute_pose() # noqa: SLF001
|
||||
assert t("move.no_point") in window.finger_panel.move_label.text()
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_return_to_zero_is_refused_with_no_controller_and_says_why(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
window._on_return_to_initial() # noqa: SLF001
|
||||
text = window.finger_panel.move_label.text()
|
||||
assert text.startswith("✗"), text
|
||||
assert t("move.refused") in window.statusBar().currentMessage()
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_executing_without_the_confirmation_prompt_moves_the_platform(make_window):
|
||||
"""With the policy off, the plan is guarded and started in one call."""
|
||||
window = _connected(make_window)
|
||||
try:
|
||||
targets = {"x": 1.0, "y": -1.0, "yaw": 0.0, "pitch": 0.0}
|
||||
plan = plan_pose_move(window.poller.service, targets, hold=("z",))
|
||||
result = window.mover.start(plan)
|
||||
assert result.ok, result.message
|
||||
assert window.mover.running
|
||||
assert [step.role for step in window.mover.state.plan.steps] == [
|
||||
"yaw",
|
||||
"pitch",
|
||||
"x",
|
||||
"y",
|
||||
]
|
||||
finally:
|
||||
window.mover.cancel("test")
|
||||
window.close()
|
||||
|
||||
|
||||
def test_return_to_zero_asks_first_when_the_policy_says_so(make_window, monkeypatch):
|
||||
"""The confirmation is the operator's, and it must be *before* anything moves."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
window = _connected(make_window, confirm=True)
|
||||
try:
|
||||
asked = []
|
||||
|
||||
def _no(*args, **kwargs):
|
||||
asked.append(args)
|
||||
return QMessageBox.StandardButton.No
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "question", staticmethod(_no))
|
||||
window._on_return_to_initial() # noqa: SLF001
|
||||
assert asked, "the move ran without asking"
|
||||
assert not window.mover.running, "declining the prompt still moved the platform"
|
||||
assert window.finger_panel.move_label.text() == t("move.cancelled")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_accepting_the_prompt_starts_the_move(make_window, monkeypatch):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
window = _connected(make_window, confirm=True)
|
||||
try:
|
||||
monkeypatch.setattr(
|
||||
QMessageBox,
|
||||
"question",
|
||||
staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes),
|
||||
)
|
||||
window._on_return_to_initial() # noqa: SLF001
|
||||
assert window.mover.running, "accepting the prompt did not start the move"
|
||||
assert [step.role for step in window.mover.state.plan.steps][0] == "z"
|
||||
# The status line carries the plan, so the operator sees what is moving.
|
||||
assert "z" in window.finger_panel.move_label.text()
|
||||
finally:
|
||||
window.mover.cancel("test")
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_confirmation_policy_is_remembered(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
assert window._confirm_moves is True # noqa: SLF001 - the default is "ask"
|
||||
window.finger_panel.confirm_check.setChecked(False)
|
||||
assert window.prefs["move_confirm"] is False
|
||||
window.finger_panel.confirm_check.setChecked(True)
|
||||
assert window.prefs["move_confirm"] is True
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_stored_policy_reaches_the_checkbox(make_window):
|
||||
window = _connected(make_window)
|
||||
try:
|
||||
assert window.finger_panel.confirm_check.isChecked() is False
|
||||
finally:
|
||||
window.close()
|
||||
@@ -11,8 +11,12 @@ import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec, DemoTransport, MotionPoller, MotionService
|
||||
from orisys_autocal.motion import (
|
||||
ConnectionSpec,
|
||||
DemoTransport,
|
||||
MotionPoller,
|
||||
MotionService,
|
||||
)
|
||||
|
||||
|
||||
class RecordingTransport(DemoTransport):
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Moving the platform to a simulated pose, and stopping it.
|
||||
|
||||
Three things are being protected here, in the order they matter:
|
||||
|
||||
1. **The guards refuse before anything moves.** The service is the only place that
|
||||
decides whether a pose move may run, so every refusal is tested there — and a
|
||||
refusal must arrive *before* the first axis is commanded, because a sequence that
|
||||
stops halfway leaves the machine in a pose nobody simulated.
|
||||
2. **The order is the model's order.** The kinematics composes ``R_pitch · R_yaw``
|
||||
(yaw acts on the body first), so the machine yaws before it pitches; table X/Y
|
||||
last. A different order would sweep the sensor through poses the operator never
|
||||
saw in the preview.
|
||||
3. **Stop is a real stop.** The mover is driven by snapshots, not by a loop, so a
|
||||
stop runs at the next tick and no further axis is commanded. A mover that sat in
|
||||
a loop could not be interrupted at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import (
|
||||
MotionResult,
|
||||
MotionService,
|
||||
Snapshot,
|
||||
plan_pose_move,
|
||||
plan_return_to_initial,
|
||||
)
|
||||
from orisys_autocal.motion.models import AxisState, AxisStatusBits, BusStatus
|
||||
from orisys_autocal.motion.pose_move import (
|
||||
MOVE_ORDER,
|
||||
RETURN_ORDER,
|
||||
PoseMover,
|
||||
SETTLE_DEG,
|
||||
SETTLE_MM,
|
||||
)
|
||||
from orisys_autocal.motion.transport import DemoTransport
|
||||
from orisys_autocal.preferences import default_preferences
|
||||
|
||||
ROLES = {"x": 0, "y": 1, "pitch": 2, "yaw": 3, "z": 4}
|
||||
LIMITS = default_preferences()["safety_limits"]
|
||||
|
||||
|
||||
def make_service(**kwargs) -> MotionService:
|
||||
"""A connected, initialised platform — the state a move is allowed in."""
|
||||
service = MotionService(
|
||||
DemoTransport(),
|
||||
axis_roles=kwargs.pop("roles", ROLES),
|
||||
limits=kwargs.pop("limits", LIMITS),
|
||||
**kwargs,
|
||||
)
|
||||
service._spec = object() # noqa: SLF001 - connected, without opening anything
|
||||
service._bus = BusStatus(bus_type="ecat", init_status=1, total_axes=5) # noqa: SLF001
|
||||
return service
|
||||
|
||||
|
||||
class FakePoller:
|
||||
"""Records submissions and runs them, so a plan's commands can be inspected."""
|
||||
|
||||
def __init__(self, service: MotionService) -> None:
|
||||
self.service = service
|
||||
self.commands: list = []
|
||||
|
||||
def submit(self, action):
|
||||
self.commands.append(action)
|
||||
# Not executed here: the mover is driven by snapshots, and a real poller runs
|
||||
# one command per tick. Running them synchronously would hide the ordering
|
||||
# this test exists to check.
|
||||
return len(self.commands)
|
||||
|
||||
|
||||
def reading(axis: int, dpos: float, *, idle: bool = True, alarm: bool = False) -> AxisState:
|
||||
return AxisState(
|
||||
axis=axis,
|
||||
dpos=dpos,
|
||||
idle=idle,
|
||||
status=AxisStatusBits(raw=512 if alarm else 0),
|
||||
)
|
||||
|
||||
|
||||
def snapshot_of(service: MotionService, positions: dict[int, float], **kwargs) -> Snapshot:
|
||||
return Snapshot(
|
||||
connected=True,
|
||||
bus=service.bus,
|
||||
axes=tuple(
|
||||
reading(axis, value, **kwargs) for axis, value in positions.items()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- planning
|
||||
|
||||
|
||||
def test_the_plan_uses_the_roles_and_each_role_own_unit():
|
||||
service = make_service()
|
||||
plan = plan_pose_move(
|
||||
service, {"x": 1.5, "y": -2.0, "yaw": 10.0, "pitch": -20.0}, hold=("z",)
|
||||
)
|
||||
by_role = {step.role: step for step in plan.steps}
|
||||
assert by_role["x"].axis == 0
|
||||
assert by_role["yaw"].axis == 3
|
||||
assert by_role["pitch"].axis == 2
|
||||
# 500 units per mm on the bench, and the measured rotary constants.
|
||||
assert by_role["x"].units == pytest.approx(750.0)
|
||||
assert by_role["yaw"].units == pytest.approx(10.0 * 29500.0 / 90.0)
|
||||
assert by_role["pitch"].units == pytest.approx(-20.0 * 12500.0 / 90.0)
|
||||
assert by_role["x"].unit == "mm"
|
||||
assert by_role["yaw"].unit == "°"
|
||||
|
||||
|
||||
def test_the_move_order_yaws_before_it_pitches_and_slides_last():
|
||||
"""The model composes R_pitch · R_yaw, so the machine does the same."""
|
||||
service = make_service()
|
||||
plan = plan_pose_move(service, {"x": 1.0, "y": 1.0, "yaw": 1.0, "pitch": 1.0})
|
||||
assert [step.role for step in plan.steps] == ["yaw", "pitch", "x", "y"]
|
||||
assert MOVE_ORDER.index("yaw") < MOVE_ORDER.index("pitch")
|
||||
assert MOVE_ORDER.index("pitch") < MOVE_ORDER.index("x")
|
||||
|
||||
|
||||
def test_the_plan_holds_back_what_it_is_not_given():
|
||||
"""z is not commanded by a pose move — expressed by absence, not by a no-op."""
|
||||
service = make_service()
|
||||
plan = plan_pose_move(service, {"x": 1.0, "y": 1.0}, hold=("z",))
|
||||
assert "z" not in {step.role for step in plan.steps}
|
||||
assert plan.held == ("z",)
|
||||
assert "z" in plan.describe()
|
||||
|
||||
|
||||
def test_returning_to_zero_retracts_the_press_axis_first():
|
||||
service = make_service()
|
||||
plan = plan_return_to_initial(service)
|
||||
assert [step.role for step in plan.steps] == list(RETURN_ORDER)
|
||||
assert plan.steps[0].role == "z"
|
||||
assert all(step.target == 0.0 for step in plan.steps)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- guards
|
||||
|
||||
|
||||
def test_a_move_is_refused_with_no_controller():
|
||||
# English, so the assertion reads as the sentence the operator would see. The
|
||||
# service localises its refusals; the wording is not the point, the refusal is.
|
||||
service = MotionService(
|
||||
DemoTransport(), axis_roles=ROLES, limits=LIMITS, language="en_US"
|
||||
)
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "Not connected" in refusal.message
|
||||
|
||||
|
||||
def test_a_move_is_refused_before_the_bus_is_initialised():
|
||||
service = make_service(language="en_US")
|
||||
service._bus = BusStatus(bus_type="unknown", init_status=0) # noqa: SLF001
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "initialis" in refusal.message
|
||||
|
||||
|
||||
def test_a_target_past_the_travel_limit_is_refused_with_the_numbers():
|
||||
service = make_service()
|
||||
refusal = service.check_axes_move({"x": LIMITS["max_travel_mm"] + 0.5})
|
||||
assert refusal is not None
|
||||
assert f"{LIMITS['max_travel_mm'] + 0.5:.3f}" in refusal.message
|
||||
|
||||
|
||||
def test_an_angle_is_checked_against_its_own_limit_not_the_millimetre_one():
|
||||
"""A 400° yaw is past the stage envelope however roomy the table is."""
|
||||
service = make_service()
|
||||
assert service.check_axes_move({"yaw": 400.0}) is not None
|
||||
assert service.check_axes_move({"yaw": 170.0}) is None
|
||||
# And a millimetre target is not checked against the degree limit.
|
||||
assert service.check_axes_move({"x": 200.0}) is not None
|
||||
|
||||
|
||||
def test_a_limit_that_was_never_set_refuses_rather_than_meaning_unlimited():
|
||||
service = make_service(limits={})
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "P0" in refusal.message
|
||||
|
||||
|
||||
def test_a_duplicated_drive_is_refused():
|
||||
"""Two roles on one axis means one of them silently does not move."""
|
||||
service = make_service(roles={"x": 0, "y": 0, "pitch": 2, "yaw": 3, "z": 4})
|
||||
problem = service.roles_problem()
|
||||
assert problem
|
||||
refusal = service.check_axes_move({"x": 1.0, "y": 1.0})
|
||||
assert refusal is not None and refusal.message == problem
|
||||
|
||||
|
||||
def test_an_incomplete_mapping_is_refused():
|
||||
service = make_service(roles={"x": 0})
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "yaw" in refusal.message
|
||||
|
||||
|
||||
def test_a_rotary_role_needs_a_calibrated_axis():
|
||||
"""Axis 0 has no user-units-per-degree constant, so it cannot be a yaw."""
|
||||
service = make_service(roles={"x": 4, "y": 1, "pitch": 2, "yaw": 0, "z": 3})
|
||||
refusal = service.check_axes_move({"yaw": 1.0})
|
||||
assert refusal is not None and "yaw" in refusal.message
|
||||
|
||||
|
||||
def test_a_non_finite_target_is_refused():
|
||||
service = make_service()
|
||||
for value in (float("nan"), float("inf")):
|
||||
refusal = service.check_axes_move({"x": value})
|
||||
assert refusal is not None
|
||||
|
||||
|
||||
def test_the_guard_names_an_unknown_direction():
|
||||
service = make_service()
|
||||
refusal = service.check_axes_move({"roll": 1.0})
|
||||
assert refusal is not None and "roll" in refusal.message
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ mover
|
||||
|
||||
|
||||
def test_the_mover_commands_one_axis_and_waits_for_it_to_arrive():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
plan = plan_pose_move(service, {"x": 2.0, "y": 1.0})
|
||||
assert mover.start(plan).ok
|
||||
assert len(poller.commands) == 1, "more than the first axis was commanded at once"
|
||||
|
||||
# x is at its target and idle -> the second axis goes out.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 0.0}))
|
||||
assert len(poller.commands) == 2
|
||||
assert mover.state.step.role == "y"
|
||||
|
||||
# y arrives -> the plan is done.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 500.0}))
|
||||
assert not mover.running
|
||||
assert mover.state.finished
|
||||
|
||||
|
||||
def test_the_mover_waits_while_an_axis_is_still_moving():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
mover.on_snapshot(snapshot_of(service, {0: 990.0, 1: 0.0}, idle=False))
|
||||
assert len(poller.commands) == 1
|
||||
assert mover.state.index == 0
|
||||
|
||||
|
||||
def test_the_mover_fails_when_an_axis_stops_short():
|
||||
"""A drive that has stopped is not going to arrive: say so, do not wait."""
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
mover.on_snapshot(snapshot_of(service, {0: 10.0, 1: 0.0}))
|
||||
assert mover.running, "gave up before the stall grace elapsed"
|
||||
mover.state._idle_since = time.monotonic() - 10.0 # noqa: SLF001
|
||||
mover.on_snapshot(snapshot_of(service, {0: 10.0, 1: 0.0}))
|
||||
assert not mover.running
|
||||
assert "stopped short" in mover.state.failure
|
||||
assert len(poller.commands) == 1, "a further axis was commanded after the failure"
|
||||
|
||||
|
||||
def test_the_mover_stops_on_an_alarm():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0}))
|
||||
mover.on_snapshot(snapshot_of(service, {0: 0.0}, alarm=True))
|
||||
assert not mover.running
|
||||
assert "alarm" in mover.state.failure
|
||||
|
||||
|
||||
def test_a_tolerance_accepts_an_axis_that_reports_its_final_position_late():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
plan = plan_pose_move(service, {"x": 2.0, "y": 1.0})
|
||||
mover.start(plan)
|
||||
# 1 unit inside SETTLE_MM of 1000 units for 2 mm.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0 - 1.0, 1: 0.0}))
|
||||
assert len(poller.commands) == 2, "a step within tolerance was treated as a miss"
|
||||
assert SETTLE_MM > 0 and SETTLE_DEG > 0
|
||||
|
||||
|
||||
def test_stopping_cancels_the_rest_of_the_plan():
|
||||
"""The stop is a separate command, so the mover must stop queueing first."""
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
assert mover.running
|
||||
mover.cancel("stopped")
|
||||
assert not mover.running
|
||||
assert mover.state.cancelled
|
||||
# Nothing further, even if the axis happens to arrive afterwards.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 500.0}))
|
||||
assert len(poller.commands) == 1
|
||||
|
||||
|
||||
def test_a_second_plan_cannot_start_on_top_of_a_running_one():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
second = mover.start(plan_pose_move(service, {"x": 3.0}))
|
||||
assert not second.ok
|
||||
assert len(poller.commands) == 1
|
||||
|
||||
|
||||
def test_the_mover_refuses_a_plan_it_would_not_be_allowed_to_run():
|
||||
service = MotionService(DemoTransport(), axis_roles=ROLES, limits=LIMITS)
|
||||
mover = PoseMover(service, FakePoller(service).submit)
|
||||
result = mover.start(plan_pose_move(service, {"x": 1.0}))
|
||||
assert not result.ok
|
||||
assert not mover.running
|
||||
|
||||
|
||||
def test_the_progress_line_names_the_step_it_is_on():
|
||||
service = make_service()
|
||||
mover = PoseMover(service, FakePoller(service).submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
text = mover.progress_text()
|
||||
assert "1/2" in text and "x" in text
|
||||
|
||||
|
||||
def test_the_units_round_trip_through_the_service():
|
||||
"""A target planned in mm must arrive at the drive as the same mm."""
|
||||
service = make_service()
|
||||
for role, value in (("x", 3.5), ("y", -2.25), ("yaw", 45.0), ("pitch", -30.0)):
|
||||
units = service.to_units(role, value)
|
||||
axis = service.role_axis(role)
|
||||
if role in ("x", "y"):
|
||||
assert service.to_mm(axis, units) == pytest.approx(value)
|
||||
else:
|
||||
from orisys_autocal.motion import user_units_to_deg
|
||||
|
||||
assert user_units_to_deg(axis, units) == pytest.approx(value)
|
||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.sensor.export_bundle import (
|
||||
BundleError,
|
||||
load_bundle,
|
||||
|
||||
@@ -12,7 +12,6 @@ import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.sequence_io import (
|
||||
DOCUMENT_KIND,
|
||||
FORMAT_VERSION,
|
||||
|
||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion.units import (
|
||||
PULSES_PER_MM,
|
||||
ROTARY_UNITS_PER_90_DEG,
|
||||
|
||||
@@ -12,7 +12,6 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.sensor import (
|
||||
DEFAULT_FLAT_SIZE,
|
||||
load_bundle,
|
||||
|
||||
Reference in New Issue
Block a user