fix: add mx_bec dep
CI for pxii_bec / test (push) Successful in 1m9s

This commit is contained in:
perl_d committed 2026-11-03 13:11:30 +01:00
1 parent c85bde6127
commit abed6a7de6
5 files changed
+105 -170

No files matched your search

+32 -2
View File
@@ -24,6 +24,11 @@ on:
required: false
type: string
default: "main"
MX_BEC_REPO_BRANCH:
description: "Branch of the mx_bec core repo to install"
required: false
type: string
default: "main"
PYTHON_VERSION:
description: "Python version to use"
required: false
@@ -46,6 +51,29 @@ jobs:
with:
python-version: "${{ inputs.PYTHON_VERSION || '3.12' }}"
# Use the same-named branch of mx_bec if it exists (e.g. a feature developed in both repos), otherwise main.
- name: Resolve mx_bec branch
id: mx_bec
shell: bash
run: |
ref="${{ inputs.MX_BEC_REPO_BRANCH || github.head_ref || github.ref_name }}"
auth="$(printf 'x-access-token:%s' '${{ github.token }}' | base64 -w0)"
if git -c "http.extraheader=Authorization: Basic ${auth}" ls-remote --exit-code --heads \
"${{ github.server_url }}/bec/mx_bec.git" "refs/heads/${ref}" >/dev/null; then
echo "Using mx_bec branch '${ref}'"
else
echo "mx_bec has no branch '${ref}', using main"
ref=main
fi
echo "ref=${ref}" >> "$GITHUB_OUTPUT"
- name: Checkout MX BEC Repository
uses: actions/checkout@v4
with:
repository: bec/mx_bec
ref: "${{ steps.mx_bec.outputs.ref }}"
path: ./mx_bec
- name: Checkout BEC Plugin Repository
uses: actions/checkout@v4
with:
@@ -94,8 +122,10 @@ jobs:
uv pip install --system -e ./bec/bec_lib[dev]
uv pip install --system -e ./bec/bec_ipython_client
uv pip install --system -e ./bec/bec_server[dev]
uv pip install --system -e ./bec_widgets[dev,pyside6]
uv pip install --system -e ./pxii_bec[dev]
uv pip install --system -e ./bec_widgets[dev]
# MX-internal dependencies (aarecommon, aare_config, jfjoch_client, ...) come from gitea
uv pip install --system --extra-index-url https://gitea.psi.ch/api/packages/mx/pypi/simple/ -e ./mx_bec[dev]
uv pip install --system --extra-index-url https://gitea.psi.ch/api/packages/mx/pypi/simple/ -e ./pxii_bec[dev]
- name: Run Tests with Coverage
id: coverage
+1 -1
View File
@@ -12,7 +12,7 @@ classifiers = [
"Programming Language :: Python :: 3",
"Topic :: Scientific/Engineering",
]
dependencies = ["pandas~=2.0", "matplotlib"]
dependencies = ["mx_bec", "pandas~=2.0", "matplotlib"]
[project.optional-dependencies]
dev = [
-110
View File
@@ -1,110 +0,0 @@
"""A mock smargopolo REST interface with mock motoers, for testing devices against"""
import asyncio
import random
import time
from contextlib import asynccontextmanager
from typing import Iterable
import uvicorn
from fastapi import FastAPI, HTTPException, Query, Request
from pydantic import BaseModel
AXES = ["SHX", "SHY", "SHZ", "PHI", "CHI"]
class Motor:
def __init__(self, velocity: float = 1.0):
self.position = 0.0
self.target = 0.0
self.velocity = velocity
self.moving = False
self._last_update = time.monotonic()
def update(self):
now = time.monotonic()
dt = now - self._last_update
self._last_update = now
if not self.moving:
return
jitter_factor = random.random() * 0.05 - 0.025 # +- 2.5% jitter in step
distance = self.target - self.position
direction = 1 if distance > 0 else -1
step = direction * self.velocity * dt
if abs(step) >= abs(distance):
self.position = self.target + (step * jitter_factor)
self.moving = False
else:
self.position += step * (1 + jitter_factor)
motors: dict[str, Motor] = {
"SHX": Motor(velocity=3),
"SHY": Motor(velocity=2.5),
"SHZ": Motor(velocity=2),
"PHI": Motor(velocity=1.0),
"CHI": Motor(velocity=0.7),
}
class MoveRequest(BaseModel):
target: float
@asynccontextmanager
async def lifespan(app: FastAPI):
async def updater():
while True:
for motor in motors.values():
motor.update()
await asyncio.sleep(0.02) # 50 Hz update loop
task = asyncio.create_task(updater())
yield
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(lifespan=lifespan)
def validate_axes(axes: Iterable[str] | None) -> list[str]:
if axes is None:
return AXES
for a in axes:
if a not in AXES:
raise HTTPException(status_code=404, detail=f"Unknown axis: {a}")
return list(axes)
@app.get("/readbackSCS")
async def readback_scs(axis: list[str] | None = Query(None)):
selected_axes = validate_axes(axis)
return {ax: motors[ax].position for ax in selected_axes}
@app.put("/targetSCS")
async def target_scs(req: Request):
targets = {ax: float(t) for ax, t in req.query_params.items()}
if targets is None:
return {}
selected_axes = validate_axes(targets.keys())
for a in selected_axes:
motor = motors[a]
motor.update()
motor.target = targets[a]
motor.moving = True
return {"targets": targets, "message": "Move started"}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000, reload=False)
+39 -33
View File
@@ -1,58 +1,64 @@
"""Smoke tests of the Aerotech device as re-exported by pxii_bec; the device itself is tested in
mx_bec."""
from copy import copy
from threading import RLock
from unittest.mock import ANY
import pytest
from mx_bec.devices.aerotech import Aerotech as MxAerotech
from pxii_bec.devices import Aerotech
AXES = ("x", "y", "z", "u")
class MockServer:
"""Just enough of the AareScan REST API for reading and moving axes."""
def __init__(self) -> None:
self.lock = RLock()
self.mock_data = {
"x": {"pos": 1.0},
"y": {"pos": 1.0},
"z": {"pos": 1.0},
"u": {"pos": 1.0},
"vel_u_deg_s": {"pos": 1.0},
}
self.mock_data = {axis: {"pos": 1.0} for axis in AXES}
def get(self, endpoint):
with self.lock:
return copy(self.mock_data)
def put(self, params: dict | None = None, body: dict | None = None):
def post(self, params: dict | None = None, body: dict | None = None, endpoint=None):
with self.lock:
assert body is not None
for k, v in body.items():
self.mock_data[k]["pos"] = v
for axis, value in body.items():
if axis in self.mock_data: # the body also carries the velocity parameter
self.mock_data[axis]["pos"] = value
@pytest.fixture
def aerotech():
mock_server = MockServer()
from pxii_bec.devices import Aerotech
s = Aerotech(name="aerotech", prefix="http://test-aerotech.psi.ch")
s.controller._rest_get = mock_server.get
s.controller._rest_post = mock_server.put
yield s
s.controller._stop_monitor_readback_event.set()
device = Aerotech(name="aerotech", prefix="http://test-aerotech.psi.ch")
device.controller._rest_get = mock_server.get
device.controller._rest_post = mock_server.post
device.controller.validate = lambda endpoint, body: (True, "")
yield device
device.controller._stop_monitor_readback_event.set()
device.destroy()
class TestAerotech:
def test_aerotech_read(self, aerotech):
aerotech.wait_for_connection()
reading = aerotech.read()
assert dict(reading) == {
"aerotech_x": {"value": 1.0, "timestamp": ANY},
"aerotech_y": {"value": 1.0, "timestamp": ANY},
"aerotech_z": {"value": 1.0, "timestamp": ANY},
"aerotech_u": {"value": 1.0, "timestamp": ANY},
"aerotech_vel_u_deg_s": {"value": 1.0, "timestamp": ANY},
}
def test_is_the_mx_bec_device():
assert Aerotech is MxAerotech
def test_aerotech_set_with_status(self, aerotech):
aerotech.wait_for_connection()
st = aerotech.x.set(5.0)
st.wait(timeout=1)
assert aerotech.x.get() == 5.0
def test_aerotech_read(aerotech):
aerotech.wait_for_connection()
reading = dict(aerotech.read())
# besides the axes, read() reports the valid/message state of the scan parameter groups
assert {k: v for k, v in reading.items() if k.removeprefix("aerotech_") in AXES} == {
f"aerotech_{axis}": {"value": 1.0, "timestamp": ANY} for axis in AXES
}
assert reading["aerotech_gridscan_config_valid"]["value"] is True
def test_aerotech_set_with_status(aerotech):
aerotech.wait_for_connection()
aerotech.x.set(5.0).wait(timeout=1)
assert aerotech.x.get() == 5.0
+33 -24
View File
@@ -1,14 +1,24 @@
"""Smoke tests of the Smargon device as re-exported by pxii_bec; the device itself is tested in
mx_bec against a mock smargopolo server."""
from copy import copy
from threading import RLock
from unittest.mock import ANY
import pytest
from mx_bec.devices.smargopolo_smargon import Smargon as MxSmargon
from pxii_bec.devices import Smargon
AXES = ("SHX", "SHY", "SHZ", "PHI", "CHI")
class MockServer:
"""Just enough of the smargopolo REST API for reading and moving axes."""
def __init__(self) -> None:
self.lock = RLock()
self.mock_data = {"SHX": 1.0, "SHY": 1.0, "SHZ": 1.0, "PHI": 1.0, "CHI": 1.0}
self.mock_data = {axis: 1.0 for axis in AXES}
def get(self, endpoint):
with self.lock:
@@ -23,29 +33,28 @@ class MockServer:
@pytest.fixture
def smargon():
mock_server = MockServer()
from pxii_bec.devices.smargopolo_smargon import Smargon
s = Smargon(name="smargon", prefix="http://test-smargopolo.psi.ch")
s.controller._rest_get = mock_server.get
s.controller._rest_put = mock_server.put
yield s
s.controller._stop_monitor_readback_event.set()
device = Smargon(name="smargon", prefix="http://test-smargopolo.psi.ch")
device.controller._rest_get = mock_server.get
device.controller._rest_put = mock_server.put
yield device
device.controller._stop_monitor_readback_event.set()
device.destroy()
class TestSmargon:
def test_smargon_read(self, smargon):
smargon.wait_for_connection()
reading = smargon.read()
assert dict(reading) == {
"smargon_x": {"value": 1.0, "timestamp": ANY},
"smargon_y": {"value": 1.0, "timestamp": ANY},
"smargon_z": {"value": 1.0, "timestamp": ANY},
"smargon_phi": {"value": 1.0, "timestamp": ANY},
"smargon_chi": {"value": 1.0, "timestamp": ANY},
}
def test_is_the_mx_bec_device():
assert Smargon is MxSmargon
def test_smargon_set_with_status(self, smargon):
smargon.wait_for_connection()
st = smargon.x.set(5.0)
st.wait(timeout=1)
assert smargon.x.get() == 5.0
def test_smargon_read(smargon):
smargon.wait_for_connection()
reading = smargon.read()
assert dict(reading) == {
f"smargon_{axis}": {"value": 1.0, "timestamp": ANY}
for axis in ("x", "y", "z", "phi", "chi")
}
def test_smargon_set_with_status(smargon):
smargon.wait_for_connection()
smargon.x.set(5.0).wait(timeout=1)
assert smargon.x.get() == 5.0